From a69d53edea52de2860c97b2c6f1c6bb22adaaf61 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 5 May 2026 19:58:47 -0500 Subject: [PATCH 1/5] feat: add Mattermost channel integration with Testcontainers integration tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Implement full Mattermost channel support following the same 3-tier actor hierarchy pattern as Slack and Discord (Gateway → Conversation → Session Binding). Uses Mattermost.NET v4.0.4 for WebSocket event delivery and REST API operations. Production implementation (~3,400 LOC): - MattermostChannel (IChannel + IHostedService lifecycle) - MattermostGatewayActor (4096-entry LRU event dedup) - MattermostConversationActor (ACL, routing policy, session derivation) - MattermostSessionBindingActor (ReceivePersistentActor with cursor tracking) - MattermostNetGatewayClient (WebSocket transport wrapper) - MattermostNetReplyClient + MattermostNetOutboundClient (REST API) - MattermostThreadHistoryFetcher (thread backfill with content scanning) - MattermostApprovalPromptBuilder (letter-based text prompts) - SendMattermostMessageTool + LookupMattermostUserTool (channel tools) - ACL policy, routing policy, reminder target resolver - Config schema, DI wiring, ChannelType enum extension Unit tests (52 tests): - Routing policy, ACL contract, approval prompts, reminder resolution, config defaults, session ID parsing, user allowlist checks Integration tests (11 tests against real Mattermost container): - MattermostFixture with mattermost-preview Docker container - WebSocket event delivery, thread reply routing - Reply client posting, thread replies, message updates - Outbound client DM channel creation, new thread posting - Thread history fetching, root post inclusion, bot post visibility --- Directory.Packages.props | 3 + Netclaw.slnx | 2 + .../Contracts/MattermostAclContractTests.cs | 52 + .../MattermostApprovalPromptBuilderTests.cs | 146 +++ .../Channels/MattermostGatewayActorTests.cs | 63 + .../MattermostReminderTargetResolverTests.cs | 75 ++ .../Channels/MattermostRoutingPolicyTests.cs | 179 +++ .../MattermostChannelOptionsDefaultsTests.cs | 53 + .../Netclaw.Actors.Tests.csproj | 1 + src/Netclaw.Actors/Channels/ChannelType.cs | 7 +- .../Hosting/ActorRegistryKeys.cs | 8 + .../Reminders/ReminderExecutionActor.cs | 1 + .../Protos/netclaw_messages.proto | 1 + .../MattermostFixture.cs | 268 ++++ .../MattermostGatewayIntegrationTests.cs | 101 ++ .../MattermostReplyClientIntegrationTests.cs | 121 ++ ...MattermostThreadHistoryIntegrationTests.cs | 79 ++ ...hannels.Mattermost.IntegrationTests.csproj | 24 + .../IMattermostOutboundClient.cs | 21 + .../MattermostAclPolicy.cs | 78 ++ .../MattermostApprovalPromptBuilder.cs | 93 ++ .../MattermostAttachmentUrlTrust.cs | 18 + .../MattermostChannel.cs | 195 +++ .../MattermostChannelOptions.cs | 36 + .../MattermostConversationActor.cs | 322 +++++ .../MattermostGatewayActor.cs | 161 +++ .../MattermostIdentifiers.cs | 59 + .../MattermostIngressMessages.cs | 57 + .../MattermostReminderTargetResolver.cs | 74 ++ .../MattermostRoutingPolicy.cs | 86 ++ .../MattermostSessionBindingActor.cs | 1137 +++++++++++++++++ .../MattermostTransportContracts.cs | 115 ++ .../Netclaw.Channels.Mattermost.csproj | 28 + .../Tools/LookupMattermostUserTool.cs | 104 ++ .../Tools/SendMattermostMessageTool.cs | 123 ++ .../Transport/MattermostNetGatewayClient.cs | 150 +++ .../Transport/MattermostNetOutboundClient.cs | 37 + .../Transport/MattermostNetReplyClient.cs | 38 + .../MattermostThreadHistoryFetcher.cs | 599 +++++++++ .../Schemas/netclaw-config.v1.schema.json | 28 + ...MattermostChannelRegistrationExtensions.cs | 107 ++ src/Netclaw.Daemon/Netclaw.Daemon.csproj | 1 + src/Netclaw.Daemon/Program.cs | 1 + 43 files changed, 4851 insertions(+), 1 deletion(-) create mode 100644 src/Netclaw.Actors.Tests/Channels/Contracts/MattermostAclContractTests.cs create mode 100644 src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs create mode 100644 src/Netclaw.Actors.Tests/Channels/MattermostGatewayActorTests.cs create mode 100644 src/Netclaw.Actors.Tests/Channels/MattermostReminderTargetResolverTests.cs create mode 100644 src/Netclaw.Actors.Tests/Channels/MattermostRoutingPolicyTests.cs create mode 100644 src/Netclaw.Actors.Tests/Configuration/MattermostChannelOptionsDefaultsTests.cs create mode 100644 src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostFixture.cs create mode 100644 src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostGatewayIntegrationTests.cs create mode 100644 src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostReplyClientIntegrationTests.cs create mode 100644 src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostThreadHistoryIntegrationTests.cs create mode 100644 src/Netclaw.Channels.Mattermost.IntegrationTests/Netclaw.Channels.Mattermost.IntegrationTests.csproj create mode 100644 src/Netclaw.Channels.Mattermost/IMattermostOutboundClient.cs create mode 100644 src/Netclaw.Channels.Mattermost/MattermostAclPolicy.cs create mode 100644 src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs create mode 100644 src/Netclaw.Channels.Mattermost/MattermostAttachmentUrlTrust.cs create mode 100644 src/Netclaw.Channels.Mattermost/MattermostChannel.cs create mode 100644 src/Netclaw.Channels.Mattermost/MattermostChannelOptions.cs create mode 100644 src/Netclaw.Channels.Mattermost/MattermostConversationActor.cs create mode 100644 src/Netclaw.Channels.Mattermost/MattermostGatewayActor.cs create mode 100644 src/Netclaw.Channels.Mattermost/MattermostIdentifiers.cs create mode 100644 src/Netclaw.Channels.Mattermost/MattermostIngressMessages.cs create mode 100644 src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs create mode 100644 src/Netclaw.Channels.Mattermost/MattermostRoutingPolicy.cs create mode 100644 src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs create mode 100644 src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs create mode 100644 src/Netclaw.Channels.Mattermost/Netclaw.Channels.Mattermost.csproj create mode 100644 src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs create mode 100644 src/Netclaw.Channels.Mattermost/Tools/SendMattermostMessageTool.cs create mode 100644 src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs create mode 100644 src/Netclaw.Channels.Mattermost/Transport/MattermostNetOutboundClient.cs create mode 100644 src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs create mode 100644 src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs create mode 100644 src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs diff --git a/Directory.Packages.props b/Directory.Packages.props index 06e4357c..c6b574ae 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -9,6 +9,7 @@ 1.15.3 0.17.10 3.19.1 + 4.0.4 10.5.0 10.0.7 @@ -49,6 +50,7 @@ + @@ -67,6 +69,7 @@ + diff --git a/Netclaw.slnx b/Netclaw.slnx index 1d75d817..a5a3bd7f 100644 --- a/Netclaw.slnx +++ b/Netclaw.slnx @@ -10,6 +10,7 @@ + @@ -26,5 +27,6 @@ + diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/MattermostAclContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/MattermostAclContractTests.cs new file mode 100644 index 00000000..34d9b3a1 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/MattermostAclContractTests.cs @@ -0,0 +1,52 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Tests.Channels.TestHelpers; +using Netclaw.Channels; +using Netclaw.Channels.Mattermost; + +namespace Netclaw.Actors.Tests.Channels.Contracts; + +public sealed class MattermostAclContractTests : AclPolicyContractTests +{ + protected override string ExpectedSourceKind => "mattermost"; + + protected override IAclDecision EvaluateDm(string userId, ChannelOptionsBuilder options) + => EvaluateMessage("dm-channel", userId, isDm: true, options); + + protected override IAclDecision EvaluateChannel( + string channelId, string userId, ChannelOptionsBuilder options) + => EvaluateMessage(channelId, userId, isDm: false, options); + + protected override IAclDecision EvaluateMessage( + string channelId, string userId, bool isDm, ChannelOptionsBuilder options) + { + var mattermostOptions = new MattermostChannelOptions + { + AllowDirectMessages = options.AllowDirectMessages, + AllowedChannelIds = options.AllowedChannelIds, + AllowedUserIds = options.AllowedUserIds, + ChannelAudiences = options.ChannelAudiences + }; + + var message = new MattermostGatewayMessage( + EventId: new MattermostEventId("evt-1"), + ChannelId: new MattermostChannelId(channelId), + PostId: new MattermostPostId("post-1"), + RootPostId: new MattermostRootPostId(string.Empty), + SenderId: new MattermostUserId(userId), + IsBotMessage: false, + IsDirectMessage: isDm, + ContainsBotMention: false, + Text: "test", + ReceivedAt: TimeProvider.System.GetUtcNow()); + + var defaultChannelId = options.DefaultChannelId is not null + ? new MattermostChannelId(options.DefaultChannelId) + : (MattermostChannelId?)null; + + return MattermostAclPolicy.EvaluateInbound(message, mattermostOptions, defaultChannelId); + } +} diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs new file mode 100644 index 00000000..91ca256c --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs @@ -0,0 +1,146 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Protocol; +using Netclaw.Channels.Mattermost; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels; + +public sealed class MattermostApprovalPromptBuilderTests +{ + [Fact] + public void BuildTextPrompt_contains_tool_name_and_options() + { + var request = new ToolInteractionRequest + { + SessionId = new SessionId("test/session"), + Kind = "approval", + CallId = "call-1", + ToolName = "git_push", + DisplayText = "push to origin/main", + Patterns = ["origin/main"], + Options = [ + new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveSession, ApprovalOptionKeys.ApproveSessionLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveAlways, ApprovalOptionKeys.ApproveAlwaysLabel), + new ToolInteractionOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) + ] + }; + + var prompt = MattermostApprovalPromptBuilder.BuildTextPrompt(request); + + Assert.Contains("git_push", prompt); + Assert.Contains("push to origin/main", prompt); + Assert.Contains("origin/main", prompt); + Assert.Contains("A)", prompt); + Assert.Contains("B)", prompt); + Assert.Contains("C)", prompt); + Assert.Contains("D)", prompt); + } + + [Fact] + public void BuildTextPrompt_omits_pattern_when_empty() + { + var request = new ToolInteractionRequest + { + SessionId = new SessionId("test/session"), + Kind = "approval", + CallId = "call-2", + ToolName = "read_file", + DisplayText = "read config.json", + Patterns = [], + Options = [ + new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), + new ToolInteractionOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) + ] + }; + + var prompt = MattermostApprovalPromptBuilder.BuildTextPrompt(request); + + Assert.DoesNotContain("Pattern:", prompt); + } + + [Fact] + public void BuildDecisionStatus_formats_known_keys() + { + Assert.Contains("Approve once", MattermostApprovalPromptBuilder.BuildDecisionStatus(ApprovalOptionKeys.ApproveOnce)); + Assert.Contains("Approve always", MattermostApprovalPromptBuilder.BuildDecisionStatus(ApprovalOptionKeys.ApproveAlways)); + Assert.Contains("Deny", MattermostApprovalPromptBuilder.BuildDecisionStatus(ApprovalOptionKeys.Deny)); + } + + [Fact] + public void BuildDecisionStatus_passes_through_unknown_key() + { + var status = MattermostApprovalPromptBuilder.BuildDecisionStatus("custom_key"); + Assert.Contains("custom_key", status); + } + + [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 = MattermostApprovalPromptBuilder.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 = MattermostApprovalPromptBuilder.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 = MattermostApprovalPromptBuilder.BuildResolvedPromptText( + request, ApprovalOptionKeys.ApproveOnce, "user-1"); + + Assert.DoesNotContain("Pattern", text); + } +} diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostGatewayActorTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostGatewayActorTests.cs new file mode 100644 index 00000000..535d9b8b --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/MattermostGatewayActorTests.cs @@ -0,0 +1,63 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Channels; +using Netclaw.Actors.Protocol; +using Netclaw.Channels.Mattermost; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels; + +public sealed class MattermostGatewayActorTests +{ + [Fact] + public void TryParseSessionId_valid_session() + { + var sessionId = new SessionId("channelid1234567890123456/rootpost1234567890123456"); + + var result = MattermostGatewayActor.TryParseMattermostSessionId( + sessionId, out var channelId, out var rootPostId); + + Assert.True(result); + Assert.Equal("channelid1234567890123456", channelId.Value); + Assert.Equal("rootpost1234567890123456", rootPostId.Value); + } + + [Theory] + [InlineData("")] + [InlineData("no-slash-here")] + [InlineData("/missing-channel")] + [InlineData("missing-root/")] + public void TryParseSessionId_rejects_invalid_formats(string raw) + { + var sessionId = new SessionId(raw); + + var result = MattermostGatewayActor.TryParseMattermostSessionId( + sessionId, out _, out _); + + Assert.False(result); + } + + [Fact] + public void IsAllowedUser_empty_allowlist_permits_all() + { + var options = new MattermostChannelOptions { AllowedUserIds = [] }; + Assert.True(MattermostAclPolicy.IsAllowedUser(new MattermostUserId("any-user"), options)); + } + + [Fact] + public void IsAllowedUser_rejects_unlisted_user() + { + var options = new MattermostChannelOptions { AllowedUserIds = ["allowed-user"] }; + Assert.False(MattermostAclPolicy.IsAllowedUser(new MattermostUserId("other-user"), options)); + } + + [Fact] + public void IsAllowedUser_permits_listed_user() + { + var options = new MattermostChannelOptions { AllowedUserIds = ["allowed-user"] }; + Assert.True(MattermostAclPolicy.IsAllowedUser(new MattermostUserId("allowed-user"), options)); + } +} diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostReminderTargetResolverTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostReminderTargetResolverTests.cs new file mode 100644 index 00000000..3d7f910a --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/MattermostReminderTargetResolverTests.cs @@ -0,0 +1,75 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Reminders; +using Netclaw.Channels.Mattermost; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels; + +public sealed class MattermostReminderTargetResolverTests +{ + private readonly MattermostReminderTargetResolver _resolver = new(); + + [Theory] + [InlineData("abcdefghijklmnopqrstuvwxyz")] + [InlineData("@abcdefghijklmnopqrstuvwxyz")] + public async Task Resolves_user_targets_to_canonical_user_id(string input) + { + var result = await _resolver.ResolveAsync(input, TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(ReminderTargetKind.User, result.Kind); + Assert.Equal("abcdefghijklmnopqrstuvwxyz", result.ResolvedId); + Assert.Null(result.ErrorMessage); + } + + [Fact] + public async Task Resolves_channel_prefix_to_channel_target() + { + var result = await _resolver.ResolveAsync( + "channel:abcdefghijklmnopqrstuvwxyz", + TestContext.Current.CancellationToken); + + Assert.True(result.Success); + Assert.Equal(ReminderTargetKind.Channel, result.Kind); + Assert.Equal("abcdefghijklmnopqrstuvwxyz", result.ResolvedId); + } + + [Fact] + public async Task Rejects_short_non_mattermost_ids() + { + var result = await _resolver.ResolveAsync("@aaron", TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Equal(ReminderTargetKind.Unknown, result.Kind); + Assert.Null(result.ResolvedId); + Assert.Contains("Could not resolve Mattermost target", result.ErrorMessage); + } + + [Fact] + public async Task Rejects_empty_target() + { + var result = await _resolver.ResolveAsync("", TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains("required", result.ErrorMessage); + } + + [Fact] + public async Task Rejects_invalid_channel_id() + { + var result = await _resolver.ResolveAsync("channel:short", TestContext.Current.CancellationToken); + + Assert.False(result.Success); + Assert.Contains("Invalid Mattermost channel ID", result.ErrorMessage); + } + + [Fact] + public void Transport_is_mattermost() + { + Assert.Equal("mattermost", _resolver.Transport); + } +} diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostRoutingPolicyTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostRoutingPolicyTests.cs new file mode 100644 index 00000000..18eba482 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/MattermostRoutingPolicyTests.cs @@ -0,0 +1,179 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Channels.Mattermost; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels; + +public class MattermostRoutingPolicyTests +{ + [Fact] + public void MessageWithoutMention_DoesNotStartThread_WhenMentionOnly() + { + var message = CreateMessage(text: "hello", rootPostId: "rootpost123456789012345678"); + + var decision = MattermostRoutingPolicy.Evaluate( + message, + mentionOnly: true, + allowDirectMessages: true, + mentionRequiredInDm: false, + threadExists: false, + containsBotMention: false); + + Assert.Equal(MattermostRoutingDecisionKind.StartOrContinue, decision.Kind); + Assert.Null(decision.IgnoreReason); + } + + [Fact] + public void TopLevelMessage_WithoutMention_Ignored_WhenMentionOnly() + { + var message = CreateMessage(text: "hello"); + + var decision = MattermostRoutingPolicy.Evaluate( + message, + mentionOnly: true, + allowDirectMessages: true, + mentionRequiredInDm: false, + threadExists: false, + containsBotMention: false); + + Assert.Equal(MattermostRoutingDecisionKind.Ignore, decision.Kind); + Assert.Equal(MattermostRoutingIgnoreReason.ChannelMentionRequired, decision.IgnoreReason); + } + + [Fact] + public void MessageWithMention_StartsThread_WhenMentionOnly() + { + var message = CreateMessage(text: "@bot hello"); + + var decision = MattermostRoutingPolicy.Evaluate( + message, + mentionOnly: true, + allowDirectMessages: true, + mentionRequiredInDm: false, + threadExists: false, + containsBotMention: true); + + Assert.Equal(MattermostRoutingDecisionKind.StartOrContinue, decision.Kind); + Assert.Null(decision.IgnoreReason); + } + + [Fact] + public void ExistingThread_ContinuesWithoutMention() + { + var message = CreateMessage(text: "follow up", rootPostId: "rootpost123456789012345678"); + + var decision = MattermostRoutingPolicy.Evaluate( + message, + mentionOnly: true, + allowDirectMessages: true, + mentionRequiredInDm: false, + threadExists: true, + containsBotMention: false); + + Assert.Equal(MattermostRoutingDecisionKind.ContinueOnly, decision.Kind); + Assert.Null(decision.IgnoreReason); + } + + [Fact] + public void ThreadReply_RehydratesSession_WhenNoExistingActor() + { + var message = CreateMessage(text: "follow up", rootPostId: "rootpost123456789012345678"); + + var decision = MattermostRoutingPolicy.Evaluate( + message, + mentionOnly: true, + allowDirectMessages: true, + mentionRequiredInDm: false, + threadExists: false, + containsBotMention: false); + + Assert.Equal(MattermostRoutingDecisionKind.StartOrContinue, decision.Kind); + Assert.Null(decision.IgnoreReason); + } + + [Theory] + [InlineData(true, false, false, MattermostRoutingDecisionKind.StartOrContinue, null)] + [InlineData(false, false, false, MattermostRoutingDecisionKind.Ignore, MattermostRoutingIgnoreReason.DmNotAllowed)] + [InlineData(true, true, false, MattermostRoutingDecisionKind.Ignore, MattermostRoutingIgnoreReason.DmMentionRequired)] + [InlineData(true, true, true, MattermostRoutingDecisionKind.StartOrContinue, null)] + internal void DirectMessage_routing_decision( + bool allowDirectMessages, + bool mentionRequiredInDm, + bool containsBotMention, + MattermostRoutingDecisionKind expectedKind, + MattermostRoutingIgnoreReason? expectedReason) + { + var message = CreateMessage(text: "hey", isDirectMessage: true); + + var decision = MattermostRoutingPolicy.Evaluate( + message, + mentionOnly: true, + allowDirectMessages: allowDirectMessages, + mentionRequiredInDm: mentionRequiredInDm, + threadExists: false, + containsBotMention: containsBotMention); + + Assert.Equal(expectedKind, decision.Kind); + Assert.Equal(expectedReason, decision.IgnoreReason); + } + + [Theory] + [InlineData("")] + [InlineData(" ")] + internal void EmptyContent_Ignored(string text) + { + var message = CreateMessage(text: text); + + var decision = MattermostRoutingPolicy.Evaluate( + message, + mentionOnly: false, + allowDirectMessages: false, + mentionRequiredInDm: false, + threadExists: false, + containsBotMention: false); + + Assert.Equal(MattermostRoutingDecisionKind.Ignore, decision.Kind); + Assert.Equal(MattermostRoutingIgnoreReason.NoContent, decision.IgnoreReason); + } + + [Fact] + public void MentionOnly_false_StartsWithoutMention() + { + var message = CreateMessage(text: "hello"); + + var decision = MattermostRoutingPolicy.Evaluate( + message, + mentionOnly: false, + allowDirectMessages: true, + mentionRequiredInDm: false, + threadExists: false, + containsBotMention: false); + + Assert.Equal(MattermostRoutingDecisionKind.StartOrContinue, decision.Kind); + Assert.Null(decision.IgnoreReason); + } + + private static MattermostGatewayMessage CreateMessage( + string text, + string? rootPostId = null, + bool isDirectMessage = false) + { + return new MattermostGatewayMessage( + EventId: new MattermostEventId("ev-1"), + ChannelId: new MattermostChannelId(isDirectMessage ? "dm-ch-1" : "ch-1"), + PostId: new MattermostPostId("post-1"), + RootPostId: rootPostId is not null + ? new MattermostRootPostId(rootPostId) + : new MattermostRootPostId(string.Empty), + SenderId: new MattermostUserId("u-1"), + IsBotMessage: false, + IsDirectMessage: isDirectMessage, + ContainsBotMention: false, + Text: text, + ReceivedAt: DateTimeOffset.UtcNow); + } +} diff --git a/src/Netclaw.Actors.Tests/Configuration/MattermostChannelOptionsDefaultsTests.cs b/src/Netclaw.Actors.Tests/Configuration/MattermostChannelOptionsDefaultsTests.cs new file mode 100644 index 00000000..66a6ec6d --- /dev/null +++ b/src/Netclaw.Actors.Tests/Configuration/MattermostChannelOptionsDefaultsTests.cs @@ -0,0 +1,53 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Configuration; +using Netclaw.Channels.Mattermost; +using Xunit; + +namespace Netclaw.Actors.Tests.Configuration; + +public sealed class MattermostChannelOptionsDefaultsTests +{ + [Fact] + public void BindsSecureDefaults_WhenMattermostSectionMissing() + { + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection([]) + .Build(); + + var options = configuration.GetSection("Mattermost").Get() ?? new MattermostChannelOptions(); + + Assert.False(options.Enabled); + Assert.False(options.AllowDirectMessages); + Assert.Null(options.ServerUrl); + Assert.Empty(options.AllowedChannelIds); + Assert.Empty(options.AllowedUserIds); + } + + [Fact] + public void KeepsSecureDefaults_WhenMattermostSectionPartiallyConfigured() + { + var values = new Dictionary + { + ["Mattermost:Enabled"] = "true", + ["Mattermost:ServerUrl"] = "https://mattermost.example.com", + ["Mattermost:DefaultChannelId"] = "abcdefghij1234567890abcdef" + }; + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(values) + .Build(); + + var options = configuration.GetSection("Mattermost").Get() ?? new MattermostChannelOptions(); + + Assert.True(options.Enabled); + Assert.Equal("https://mattermost.example.com", options.ServerUrl); + Assert.Equal("abcdefghij1234567890abcdef", options.DefaultChannelId); + Assert.False(options.AllowDirectMessages); + Assert.Empty(options.AllowedChannelIds); + Assert.Empty(options.AllowedUserIds); + } +} diff --git a/src/Netclaw.Actors.Tests/Netclaw.Actors.Tests.csproj b/src/Netclaw.Actors.Tests/Netclaw.Actors.Tests.csproj index b3da0825..febc6225 100644 --- a/src/Netclaw.Actors.Tests/Netclaw.Actors.Tests.csproj +++ b/src/Netclaw.Actors.Tests/Netclaw.Actors.Tests.csproj @@ -22,6 +22,7 @@ + diff --git a/src/Netclaw.Actors/Channels/ChannelType.cs b/src/Netclaw.Actors/Channels/ChannelType.cs index 229c4666..3eda68d2 100644 --- a/src/Netclaw.Actors/Channels/ChannelType.cs +++ b/src/Netclaw.Actors/Channels/ChannelType.cs @@ -16,7 +16,8 @@ public enum ChannelType SignalR, Reminder, Webhook, - Discord + Discord, + Mattermost } public static class ChannelTypeExtensions @@ -30,6 +31,7 @@ public static class ChannelTypeExtensions ChannelType.Reminder => "reminder", ChannelType.Webhook => "webhook", ChannelType.Discord => "discord", + ChannelType.Mattermost => "mattermost", _ => throw new ArgumentOutOfRangeException(nameof(value), value, null) }; @@ -37,6 +39,7 @@ public static class ChannelTypeExtensions { ChannelType.Slack => true, ChannelType.Discord => true, + ChannelType.Mattermost => true, ChannelType.Tui => true, ChannelType.SignalR => true, _ => false @@ -58,6 +61,8 @@ public static bool TryFromWireValue(string? wire, out ChannelType value) { value = ChannelType.Webhook; return true; } if (string.Equals(wire, "discord", StringComparison.OrdinalIgnoreCase)) { value = ChannelType.Discord; return true; } + if (string.Equals(wire, "mattermost", StringComparison.OrdinalIgnoreCase)) + { value = ChannelType.Mattermost; return true; } value = default; return false; } diff --git a/src/Netclaw.Actors/Hosting/ActorRegistryKeys.cs b/src/Netclaw.Actors/Hosting/ActorRegistryKeys.cs index 430b65d0..cafb0494 100644 --- a/src/Netclaw.Actors/Hosting/ActorRegistryKeys.cs +++ b/src/Netclaw.Actors/Hosting/ActorRegistryKeys.cs @@ -62,3 +62,11 @@ public sealed class BackgroundJobManagerActorKey; /// the Discord channel's existing routing hierarchy. /// public sealed class DiscordGatewayActorKey; + +/// +/// Marker type for lookup of the +/// Mattermost gateway parent actor (MattermostGatewayActor -> MattermostSessionBindingActor). +/// Resolved by the reminder dispatcher to deliver Mode B reminder turns through +/// the Mattermost channel's existing routing hierarchy. +/// +public sealed class MattermostGatewayActorKey; diff --git a/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs b/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs index 60d9f53d..ec8643d0 100644 --- a/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs +++ b/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs @@ -356,6 +356,7 @@ private async Task TryAckEnvelopeAsync() { ChannelType.Slack => registry.TryGet(out var slack) ? slack : null, ChannelType.Discord => registry.TryGet(out var discord) ? discord : null, + ChannelType.Mattermost => registry.TryGet(out var mattermost) ? mattermost : null, ChannelType.Tui => registry.TryGet(out var signalr) ? signalr : null, ChannelType.SignalR => registry.TryGet(out var signalr2) ? signalr2 : null, _ => null diff --git a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto index e91785bb..2a1011f3 100644 --- a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto +++ b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto @@ -37,6 +37,7 @@ enum ChannelType { CHANNEL_TYPE_REMINDER = 4; CHANNEL_TYPE_WEBHOOK = 5; CHANNEL_TYPE_DISCORD = 6; + CHANNEL_TYPE_MATTERMOST = 7; } // ── Value types ── diff --git a/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostFixture.cs b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostFixture.cs new file mode 100644 index 00000000..ed15121c --- /dev/null +++ b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostFixture.cs @@ -0,0 +1,268 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Net; +using System.Net.Http.Headers; +using System.Net.Http.Json; +using System.Text.Json; +using DotNet.Testcontainers.Builders; +using DotNet.Testcontainers.Configurations; +using DotNet.Testcontainers.Containers; +using Xunit; + +namespace Netclaw.Channels.Mattermost.IntegrationTests; + +/// +/// Manages a real Mattermost server container for integration testing. +/// Creates admin user, bot account with token, test team, channel, and test user. +/// +public sealed class MattermostFixture : IAsyncLifetime +{ + private const string AdminEmail = "admin@test.local"; + private const string AdminUsername = "admin"; + private const string AdminPassword = "Admin1234!"; + private const string BotUsername = "testbot"; + private const string TestUserEmail = "testuser@test.local"; + private const string TestUserUsername = "testuser"; + private const string TestUserPassword = "TestUser1234!"; + private const string TeamName = "test-team"; + private const string ChannelName = "test-channel"; + + private IContainer? _container; + + public string ServerUrl { get; private set; } = string.Empty; + public string AdminToken { get; private set; } = string.Empty; + public string BotToken { get; private set; } = string.Empty; + public string BotUserId { get; private set; } = string.Empty; + public string TeamId { get; private set; } = string.Empty; + public string ChannelId { get; private set; } = string.Empty; + public string TestUserId { get; private set; } = string.Empty; + + public async ValueTask InitializeAsync() + { + _container = new ContainerBuilder("mattermost/mattermost-preview") + .WithPortBinding(8065, true) + .WithEnvironment("MM_SERVICESETTINGS_ENABLEOPENSERVER", "true") + .WithEnvironment("MM_SERVICESETTINGS_ENABLEBOTACCOUNTCREATION", "true") + .WithEnvironment("MM_SERVICESETTINGS_ENABLEUSERACCESSTOKENS", "true") + .WithEnvironment("MM_TEAMSETTINGS_ENABLEOPENSERVER", "true") + .WithEnvironment("MM_SERVICESETTINGS_ENABLETESTING", "true") + .WithWaitStrategy(Wait.ForUnixContainer() + .UntilHttpRequestIsSucceeded(r => r + .ForPort(8065) + .ForPath("/api/v4/system/ping") + .ForStatusCode(HttpStatusCode.OK)) + .AddCustomWaitStrategy(new WaitUntilApiReady())) + .Build(); + + await _container.StartAsync(); + + var port = _container.GetMappedPublicPort(8065); + ServerUrl = $"http://localhost:{port}"; + + using var http = CreateHttpClient(); + + // Create admin user (first user gets admin privileges) + var adminUserId = await CreateUserAsync(http, AdminEmail, AdminUsername, AdminPassword); + + // Login as admin + AdminToken = await LoginAsync(http, AdminUsername, AdminPassword); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", AdminToken); + + // Create team + TeamId = await CreateTeamAsync(http, TeamName); + + // Create bot + (BotUserId, BotToken) = await CreateBotAsync(http, BotUsername); + + // Add bot to team + await AddUserToTeamAsync(http, TeamId, BotUserId); + + // Create test channel + ChannelId = await CreateChannelAsync(http, TeamId, ChannelName); + + // Add bot to channel + await AddUserToChannelAsync(http, ChannelId, BotUserId); + + // Create test user + TestUserId = await CreateUserAsync(http, TestUserEmail, TestUserUsername, TestUserPassword); + await AddUserToTeamAsync(http, TeamId, TestUserId); + await AddUserToChannelAsync(http, ChannelId, TestUserId); + } + + public async ValueTask DisposeAsync() + { + if (_container is not null) + await _container.DisposeAsync(); + } + + public HttpClient CreateHttpClient() + { + return new HttpClient { BaseAddress = new Uri(ServerUrl) }; + } + + /// + /// Creates an authenticated HttpClient that can act as the test user. + /// + public async Task<(HttpClient Client, string Token)> CreateTestUserClientAsync() + { + var http = CreateHttpClient(); + var token = await LoginAsync(http, TestUserUsername, TestUserPassword); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token); + return (http, token); + } + + private static async Task CreateUserAsync(HttpClient http, string email, string username, string password) + { + var response = await http.PostAsJsonAsync("/api/v4/users", new + { + email, + username, + password + }); + response.EnsureSuccessStatusCode(); + var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return doc.RootElement.GetProperty("id").GetString()!; + } + + private static async Task LoginAsync(HttpClient http, string loginId, string password) + { + var response = await http.PostAsJsonAsync("/api/v4/users/login", new + { + login_id = loginId, + password + }); + response.EnsureSuccessStatusCode(); + + // Token is returned in the response header + if (response.Headers.TryGetValues("Token", out var tokens)) + return tokens.First(); + + throw new InvalidOperationException("Mattermost login did not return a Token header."); + } + + private static async Task CreateTeamAsync(HttpClient http, string name) + { + var response = await http.PostAsJsonAsync("/api/v4/teams", new + { + name, + display_name = name, + type = "O" // Open team + }); + response.EnsureSuccessStatusCode(); + var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return doc.RootElement.GetProperty("id").GetString()!; + } + + private static async Task<(string BotUserId, string Token)> CreateBotAsync(HttpClient http, string username) + { + // Create bot + var botResponse = await http.PostAsJsonAsync("/api/v4/bots", new + { + username, + display_name = "Test Bot" + }); + botResponse.EnsureSuccessStatusCode(); + var botDoc = await JsonDocument.ParseAsync(await botResponse.Content.ReadAsStreamAsync()); + var botUserId = botDoc.RootElement.GetProperty("user_id").GetString()!; + + // Create personal access token for bot + var tokenResponse = await http.PostAsJsonAsync($"/api/v4/users/{botUserId}/tokens", new + { + description = "integration-test-token" + }); + tokenResponse.EnsureSuccessStatusCode(); + var tokenDoc = await JsonDocument.ParseAsync(await tokenResponse.Content.ReadAsStreamAsync()); + var token = tokenDoc.RootElement.GetProperty("token").GetString()!; + + return (botUserId, token); + } + + private static async Task CreateChannelAsync(HttpClient http, string teamId, string name) + { + var response = await http.PostAsJsonAsync("/api/v4/channels", new + { + team_id = teamId, + name, + display_name = name, + type = "O" // Public channel + }); + response.EnsureSuccessStatusCode(); + var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return doc.RootElement.GetProperty("id").GetString()!; + } + + private static async Task AddUserToTeamAsync(HttpClient http, string teamId, string userId) + { + var response = await http.PostAsJsonAsync($"/api/v4/teams/{teamId}/members", new + { + team_id = teamId, + user_id = userId + }); + response.EnsureSuccessStatusCode(); + } + + private static async Task AddUserToChannelAsync(HttpClient http, string channelId, string userId) + { + var response = await http.PostAsJsonAsync($"/api/v4/channels/{channelId}/members", new + { + user_id = userId + }); + response.EnsureSuccessStatusCode(); + } + + /// + /// Posts a message as the test user. Returns the post ID. + /// + public async Task PostAsTestUserAsync(string channelId, string text, string? rootId = null) + { + var (http, _) = await CreateTestUserClientAsync(); + using (http) + { + var payload = new Dictionary + { + ["channel_id"] = channelId, + ["message"] = text + }; + if (!string.IsNullOrEmpty(rootId)) + payload["root_id"] = rootId; + + var response = await http.PostAsJsonAsync("/api/v4/posts", payload); + response.EnsureSuccessStatusCode(); + var doc = await JsonDocument.ParseAsync(await response.Content.ReadAsStreamAsync()); + return doc.RootElement.GetProperty("id").GetString()!; + } + } +} + +/// +/// Additional wait strategy that ensures the API is actually ready to accept user registration, +/// not just returning 200 on /ping. +/// +internal sealed class WaitUntilApiReady : IWaitUntil +{ + public async Task UntilAsync(IContainer container) + { + try + { + var port = container.GetMappedPublicPort(8065); + using var http = new HttpClient { BaseAddress = new Uri($"http://localhost:{port}") }; + + // The /ping endpoint returns 200 early, but the API may not be ready + // for user creation yet. Try the users endpoint to confirm. + var response = await http.GetAsync("/api/v4/users/me"); + + // 401 means the API is up and rejecting unauthenticated requests — ready + return response.StatusCode == HttpStatusCode.Unauthorized; + } + catch + { + return false; + } + } +} + +[CollectionDefinition("Mattermost")] +public class MattermostCollection : ICollectionFixture; diff --git a/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostGatewayIntegrationTests.cs b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostGatewayIntegrationTests.cs new file mode 100644 index 00000000..cbc2826a --- /dev/null +++ b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostGatewayIntegrationTests.cs @@ -0,0 +1,101 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Mattermost; +using Microsoft.Extensions.Logging.Abstractions; +using Netclaw.Channels.Mattermost.Transport; +using Xunit; + +namespace Netclaw.Channels.Mattermost.IntegrationTests; + +/// +/// Tests the gateway client against a real Mattermost server. +/// Validates WebSocket event delivery, message normalization, and connection lifecycle. +/// +[Collection("Mattermost")] +public sealed class MattermostGatewayIntegrationTests : IAsyncLifetime +{ + private readonly MattermostFixture _fixture; + private MattermostClient? _botClient; + private MattermostNetGatewayClient? _gateway; + + public MattermostGatewayIntegrationTests(MattermostFixture fixture) + { + _fixture = fixture; + } + + public async ValueTask InitializeAsync() + { + _botClient = new MattermostClient(_fixture.ServerUrl, _fixture.BotToken); + _gateway = new MattermostNetGatewayClient( + _botClient, + TimeProvider.System, + NullLogger.Instance); + + await _gateway.ConnectAsync(_fixture.ServerUrl, _fixture.BotToken, + TestContext.Current.CancellationToken); + } + + public async ValueTask DisposeAsync() + { + if (_gateway is not null) + { + await _gateway.DisconnectAsync(); + _gateway.Dispose(); + } + } + + [Fact] + public void BotUserId_is_resolved_after_connect() + { + Assert.NotNull(_gateway!.BotUserId); + Assert.Equal(_fixture.BotUserId, _gateway.BotUserId!.Value.Value); + } + + [Fact] + public async Task Receives_message_posted_by_test_user() + { + var ct = TestContext.Current.CancellationToken; + var receivedTcs = new TaskCompletionSource(); + + _gateway!.MessageReceived += msg => + { + receivedTcs.TrySetResult(msg); + return Task.CompletedTask; + }; + + await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "Hello from integration test"); + + var received = await receivedTcs.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + + Assert.Equal(_fixture.ChannelId, received.ChannelId.Value); + Assert.Contains("Hello from integration test", received.Text); + Assert.False(received.IsBotMessage); + Assert.False(received.IsDirectMessage); + } + + [Fact] + public async Task Thread_reply_has_root_post_id() + { + var ct = TestContext.Current.CancellationToken; + var replyTcs = new TaskCompletionSource(); + + _gateway!.MessageReceived += msg => + { + if (!msg.RootPostId.IsEmpty) + replyTcs.TrySetResult(msg); + return Task.CompletedTask; + }; + + var rootPostId = await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "Thread root message"); + + await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "Thread reply message", rootId: rootPostId); + + var reply = await replyTcs.Task.WaitAsync(TimeSpan.FromSeconds(10), ct); + + Assert.Equal(rootPostId, reply.RootPostId.Value); + Assert.Contains("Thread reply message", reply.Text); + } +} diff --git a/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostReplyClientIntegrationTests.cs b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostReplyClientIntegrationTests.cs new file mode 100644 index 00000000..8f434485 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostReplyClientIntegrationTests.cs @@ -0,0 +1,121 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Mattermost; +using Netclaw.Channels.Mattermost.Transport; +using Xunit; + +namespace Netclaw.Channels.Mattermost.IntegrationTests; + +/// +/// Tests the reply client and outbound client against a real Mattermost server. +/// Validates message posting, thread replies, and DM channel creation. +/// +[Collection("Mattermost")] +public sealed class MattermostReplyClientIntegrationTests +{ + private readonly MattermostFixture _fixture; + + public MattermostReplyClientIntegrationTests(MattermostFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task PostReplyAsync_creates_top_level_post() + { + var ct = TestContext.Current.CancellationToken; + using var botClient = new MattermostClient(_fixture.ServerUrl, _fixture.BotToken); + var replyClient = new MattermostNetReplyClient(botClient); + + var result = await replyClient.PostReplyAsync(new MattermostPostMessage( + ChannelId: new MattermostChannelId(_fixture.ChannelId), + Text: "Top-level post from reply client test"), ct); + + Assert.NotNull(result.PostId); + Assert.False(string.IsNullOrEmpty(result.PostId!.Value.Value)); + + var post = await botClient.GetPostAsync(result.PostId.Value.Value); + Assert.Contains("Top-level post from reply client test", post.Text); + } + + [Fact] + public async Task PostReplyAsync_creates_thread_reply() + { + var ct = TestContext.Current.CancellationToken; + using var botClient = new MattermostClient(_fixture.ServerUrl, _fixture.BotToken); + var replyClient = new MattermostNetReplyClient(botClient); + + var root = await replyClient.PostReplyAsync(new MattermostPostMessage( + ChannelId: new MattermostChannelId(_fixture.ChannelId), + Text: "Thread root for reply test"), ct); + Assert.NotNull(root.PostId); + + var reply = await replyClient.PostReplyAsync(new MattermostPostMessage( + ChannelId: new MattermostChannelId(_fixture.ChannelId), + Text: "Thread reply from reply client test", + RootPostId: root.PostId), ct); + Assert.NotNull(reply.PostId); + + var replyPost = await botClient.GetPostAsync(reply.PostId!.Value.Value); + Assert.Equal(root.PostId!.Value.Value, replyPost.RootId); + } + + [Fact] + public async Task UpdatePostAsync_modifies_message_text() + { + var ct = TestContext.Current.CancellationToken; + using var botClient = new MattermostClient(_fixture.ServerUrl, _fixture.BotToken); + var replyClient = new MattermostNetReplyClient(botClient); + + var result = await replyClient.PostReplyAsync(new MattermostPostMessage( + ChannelId: new MattermostChannelId(_fixture.ChannelId), + Text: "Original message text"), ct); + Assert.NotNull(result.PostId); + + await replyClient.UpdatePostAsync(result.PostId!.Value, "Updated message text", ct); + + var updated = await botClient.GetPostAsync(result.PostId.Value.Value); + Assert.Contains("Updated message text", updated.Text); + } + + [Fact] + public async Task PostNewThreadAsync_creates_top_level_post_and_returns_root_id() + { + var ct = TestContext.Current.CancellationToken; + using var botClient = new MattermostClient(_fixture.ServerUrl, _fixture.BotToken); + var outboundClient = new MattermostNetOutboundClient(botClient); + + var result = await outboundClient.PostNewThreadAsync( + new MattermostChannelId(_fixture.ChannelId), + "Outbound client new thread test", ct); + + Assert.Equal(_fixture.ChannelId, result.ChannelId.Value); + Assert.False(string.IsNullOrEmpty(result.RootPostId.Value)); + + var post = await botClient.GetPostAsync(result.RootPostId.Value); + Assert.Contains("Outbound client new thread test", post.Text); + } + + [Fact] + public async Task OpenDmChannelAsync_creates_dm_channel_with_test_user() + { + var ct = TestContext.Current.CancellationToken; + using var botClient = new MattermostClient(_fixture.ServerUrl, _fixture.BotToken); + // Initialize CurrentUserInfo — in production this is done by the shared gateway client + await botClient.GetMeAsync(); + var outboundClient = new MattermostNetOutboundClient(botClient); + + var dmChannelId = await outboundClient.OpenDmChannelAsync( + new MattermostUserId(_fixture.TestUserId), ct); + + Assert.False(string.IsNullOrEmpty(dmChannelId.Value)); + + var post = await botClient.CreatePostAsync( + channelId: dmChannelId.Value, + message: "DM from bot in integration test"); + Assert.Equal(dmChannelId.Value, post.ChannelId); + } +} diff --git a/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostThreadHistoryIntegrationTests.cs b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostThreadHistoryIntegrationTests.cs new file mode 100644 index 00000000..f2d14cdd --- /dev/null +++ b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostThreadHistoryIntegrationTests.cs @@ -0,0 +1,79 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Mattermost; +using Xunit; + +namespace Netclaw.Channels.Mattermost.IntegrationTests; + +/// +/// Tests thread history fetching against a real Mattermost server. +/// Validates pagination, message ordering, and thread structure. +/// +[Collection("Mattermost")] +public sealed class MattermostThreadHistoryIntegrationTests +{ + private readonly MattermostFixture _fixture; + + public MattermostThreadHistoryIntegrationTests(MattermostFixture fixture) + { + _fixture = fixture; + } + + [Fact] + public async Task GetThreadPostsAsync_returns_thread_messages_in_order() + { + var ct = TestContext.Current.CancellationToken; + using var botClient = new MattermostClient(_fixture.ServerUrl, _fixture.BotToken); + + var rootPostId = await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "History root message"); + await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "History reply 1", rootId: rootPostId); + await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "History reply 2", rootId: rootPostId); + + await Task.Delay(500, ct); + + var threadPosts = await botClient.GetThreadPostsAsync(rootPostId); + + Assert.NotNull(threadPosts); + Assert.True(threadPosts.Posts.Count >= 3, $"Expected at least 3 posts, got {threadPosts.Posts.Count}"); + } + + [Fact] + public async Task GetThreadPostsAsync_includes_root_post() + { + var ct = TestContext.Current.CancellationToken; + using var botClient = new MattermostClient(_fixture.ServerUrl, _fixture.BotToken); + + var rootPostId = await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "Unique root content for history test"); + await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "Reply to unique root", rootId: rootPostId); + + await Task.Delay(500, ct); + + var threadPosts = await botClient.GetThreadPostsAsync(rootPostId); + + Assert.True(threadPosts.Posts.ContainsKey(rootPostId), + "Thread history should include the root post"); + Assert.Contains("Unique root content for history test", threadPosts.Posts[rootPostId].Text); + } + + [Fact] + public async Task Bot_can_read_its_own_posts_in_thread() + { + var ct = TestContext.Current.CancellationToken; + using var botClient = new MattermostClient(_fixture.ServerUrl, _fixture.BotToken); + + var rootPostId = await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "Thread with bot participation"); + + await botClient.CreatePostAsync(_fixture.ChannelId, "Bot reply in thread", replyToPostId: rootPostId); + + await Task.Delay(500, ct); + + var threadPosts = await botClient.GetThreadPostsAsync(rootPostId); + var botPosts = threadPosts.Posts.Values.Where(p => p.UserId == _fixture.BotUserId).ToList(); + + Assert.Single(botPosts); + Assert.Contains("Bot reply in thread", botPosts[0].Text); + } +} diff --git a/src/Netclaw.Channels.Mattermost.IntegrationTests/Netclaw.Channels.Mattermost.IntegrationTests.csproj b/src/Netclaw.Channels.Mattermost.IntegrationTests/Netclaw.Channels.Mattermost.IntegrationTests.csproj new file mode 100644 index 00000000..750ce2fe --- /dev/null +++ b/src/Netclaw.Channels.Mattermost.IntegrationTests/Netclaw.Channels.Mattermost.IntegrationTests.csproj @@ -0,0 +1,24 @@ + + + + net10.0 + enable + enable + false + true + + + + + + + + + + + + + + + + diff --git a/src/Netclaw.Channels.Mattermost/IMattermostOutboundClient.cs b/src/Netclaw.Channels.Mattermost/IMattermostOutboundClient.cs new file mode 100644 index 00000000..3637e7aa --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/IMattermostOutboundClient.cs @@ -0,0 +1,21 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +namespace Netclaw.Channels.Mattermost; + +public readonly record struct MattermostNewThread(MattermostChannelId ChannelId, MattermostRootPostId RootPostId); + +/// +/// Thin abstraction over the Mattermost API for proactive outbound operations: +/// opening DM channels and posting new threads. +/// +public interface IMattermostOutboundClient +{ + /// Open or retrieve a DM channel with a user. Returns the DM channel ID. + Task OpenDmChannelAsync(MattermostUserId userId, CancellationToken ct = default); + + /// Post a new top-level message to a channel. Returns the thread root identifiers. + Task PostNewThreadAsync(MattermostChannelId channelId, string text, CancellationToken ct = default); +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostAclPolicy.cs b/src/Netclaw.Channels.Mattermost/MattermostAclPolicy.cs new file mode 100644 index 00000000..113903c0 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostAclPolicy.cs @@ -0,0 +1,78 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Configuration; +using Netclaw.Actors.Channels; + +namespace Netclaw.Channels.Mattermost; + +public static class MattermostAclPolicy +{ + public static ChannelAclDecision EvaluateInbound( + MattermostGatewayMessage message, + MattermostChannelOptions options, + MattermostChannelId? defaultChannelId) + { + if (string.IsNullOrWhiteSpace(message.SenderId.Value)) + return ChannelAclDecision.Deny(AclDenyReasons.MissingUserId); + + if (message.IsDirectMessage && !options.AllowDirectMessages) + return ChannelAclDecision.Deny(AclDenyReasons.DirectMessagesDisabled); + + if (!message.IsDirectMessage + && !IsAllowedChannel(message.ChannelId, options, defaultChannelId)) + return ChannelAclDecision.Deny(AclDenyReasons.ChannelNotAllowed); + + var isExplicitUser = options.AllowedUserIds.Contains(message.SenderId.Value, StringComparer.Ordinal); + if (options.AllowedUserIds.Length > 0 && !isExplicitUser) + return ChannelAclDecision.Deny(AclDenyReasons.UserNotAllowed); + + var isExplicitChannel = options.AllowedChannelIds.Contains(message.ChannelId.Value, StringComparer.Ordinal); + + var audienceResult = AudienceResult.Resolve( + message.ChannelId.Value, message.IsDirectMessage, + options.ChannelAudiences, isExplicitUser, isExplicitChannel); + if (audienceResult.Error is not null) + return ChannelAclDecision.Deny(audienceResult.Error); + + var audience = audienceResult.Audience; + var principal = isExplicitUser + ? PrincipalClassification.TrustedInternal + : PrincipalClassification.UntrustedExternal; + + return ChannelAclDecision.Allow( + audience, + principal, + new SourceProvenance + { + TransportAuthenticity = TransportAuthenticity.Verified, + PayloadTaint = PayloadTaint.Public, + SourceKind = "mattermost", + SourceScope = message.ChannelId.Value + }); + } + + public static bool IsAllowedChannel( + MattermostChannelId channelId, + MattermostChannelOptions options, + MattermostChannelId? defaultChannelId) + { + if (defaultChannelId is { } expected + && string.Equals(channelId.Value, expected.Value, StringComparison.Ordinal)) + return true; + + return options.AllowedChannelIds.Contains(channelId.Value, StringComparer.Ordinal); + } + + public static bool IsAllowedUser( + MattermostUserId userId, + MattermostChannelOptions options) + { + if (options.AllowedUserIds.Length == 0) + return true; + + return options.AllowedUserIds.Contains(userId.Value, StringComparer.Ordinal); + } +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs b/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs new file mode 100644 index 00000000..31d1cd4c --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs @@ -0,0 +1,93 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text; +using Netclaw.Actors.Protocol; + +namespace Netclaw.Channels.Mattermost; + +internal static class MattermostApprovalPromptBuilder +{ + public static string BuildTextPrompt(ToolInteractionRequest request) + { + var sb = new StringBuilder(); + sb.AppendLine(":lock: **Tool approval required**"); + AppendToolSummary(sb, request); + + sb.AppendLine(); + sb.AppendLine("Reply with:"); + sb.Append("**A)** ").AppendLine(ApprovalOptionKeys.ApproveOnceLabel); + sb.Append("**B)** ").AppendLine(ApprovalOptionKeys.ApproveSessionLabel); + sb.Append("**C)** ").AppendLine(ApprovalOptionKeys.ApproveAlwaysLabel); + sb.Append("**D)** ").AppendLine(ApprovalOptionKeys.DenyLabel); + return sb.ToString().TrimEnd(); + } + + public static string BuildDecisionStatus(string selectedKey) + { + 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**"); + 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("`"); + + 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("`"); + } + } + + AppendAdoptedContextSummary(sb, request); + } + + private static void AppendAdoptedContextSummary(StringBuilder sb, ToolInteractionRequest request) + { + if (!request.HasAdoptedContext) + return; + + sb.Append("**Adopted context:** present").AppendLine(); + sb.Append("**Speakers:** `").Append(string.Join(", ", request.AdoptedSpeakerIds)).AppendLine("`"); + } + + private static string GetDecisionLabel(string selectedKey) + => selectedKey switch + { + ApprovalOptionKeys.ApproveOnce => ApprovalOptionKeys.ApproveOnceLabel, + ApprovalOptionKeys.ApproveSession => ApprovalOptionKeys.ApproveSessionLabel, + ApprovalOptionKeys.ApproveAlways => ApprovalOptionKeys.ApproveAlwaysLabel, + ApprovalOptionKeys.Deny => ApprovalOptionKeys.DenyLabel, + _ => selectedKey + }; +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostAttachmentUrlTrust.cs b/src/Netclaw.Channels.Mattermost/MattermostAttachmentUrlTrust.cs new file mode 100644 index 00000000..a5d73a52 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostAttachmentUrlTrust.cs @@ -0,0 +1,18 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +namespace Netclaw.Channels.Mattermost; + +internal static class MattermostAttachmentUrlTrust +{ + /// + /// Mattermost file URLs originate from the configured server, so we trust + /// any URL whose authority matches the server URL provided at startup. + /// + public static bool IsAllowedAttachmentUrl(string url, string serverUrl) + { + return url.StartsWith(serverUrl, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs new file mode 100644 index 00000000..3c17db70 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs @@ -0,0 +1,195 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Akka.Actor; +using Akka.Hosting; +using Akka.Pattern; +using Microsoft.Extensions.Logging; +using Netclaw.Actors.Channels; +using Netclaw.Actors.Hosting; +using Netclaw.Configuration; +using Netclaw.Security; + +namespace Netclaw.Channels.Mattermost; + +public sealed class MattermostChannel : IChannel +{ + private readonly ActorSystem _system; + private readonly ISessionPipeline _pipeline; + private readonly SessionIngressGate _ingressGate; + private readonly IMattermostGatewayClient _gatewayClient; + private readonly IMattermostReplyClient _replyClient; + private readonly IContentScanner _contentScanner; + private readonly IPromptInjectionDetector _promptInjectionDetector; + private readonly IHttpClientFactory _httpClientFactory; + private readonly IThreadHistoryFetcher? _threadHistoryFetcher; + private readonly IOperationalNotificationSink _notificationSink; + private readonly TimeProvider _timeProvider; + private readonly MattermostChannelOptions _options; + private readonly ILogger _logger; + private readonly ToolAudienceProfiles _audienceProfiles; + private readonly ModelCapabilities _modelCapabilities; + private readonly NetclawPaths _paths; + + private IActorRef? _gateway; + + internal IActorRef? Gateway => _gateway; + + internal MattermostChannelId? DefaultChannelId => + !string.IsNullOrWhiteSpace(_options.DefaultChannelId) + ? new MattermostChannelId(_options.DefaultChannelId) + : null; + + public MattermostChannel( + ActorSystem system, + ISessionPipeline pipeline, + SessionIngressGate ingressGate, + IMattermostGatewayClient gatewayClient, + IMattermostReplyClient replyClient, + IContentScanner contentScanner, + IPromptInjectionDetector? promptInjectionDetector, + IHttpClientFactory httpClientFactory, + IThreadHistoryFetcher? threadHistoryFetcher, + IOperationalNotificationSink notificationSink, + TimeProvider timeProvider, + MattermostChannelOptions options, + ILogger logger, + ToolConfig toolConfig, + ModelCapabilities modelCapabilities, + NetclawPaths paths) + { + _system = system; + _pipeline = pipeline; + _ingressGate = ingressGate; + _gatewayClient = gatewayClient; + _replyClient = replyClient; + _contentScanner = contentScanner; + _promptInjectionDetector = promptInjectionDetector ?? new NullPromptInjectionDetector(); + _httpClientFactory = httpClientFactory; + _threadHistoryFetcher = threadHistoryFetcher; + _notificationSink = notificationSink; + _timeProvider = timeProvider; + _options = options; + _logger = logger; + _audienceProfiles = toolConfig.AudienceProfiles; + _modelCapabilities = modelCapabilities; + _paths = paths; + } + + public ChannelType ChannelType => ChannelType.Mattermost; + + public string DisplayName => "Mattermost"; + + public ValueTask GetHealthAsync(CancellationToken cancellationToken = default) + { + if (!_options.Enabled) + return ValueTask.FromResult(new ChannelHealth(ChannelHealthStatus.Degraded, "Mattermost channel disabled.")); + + if (_gatewayClient.IsConnected) + return ValueTask.FromResult(new ChannelHealth(ChannelHealthStatus.Healthy)); + + return ValueTask.FromResult(new ChannelHealth(ChannelHealthStatus.Disconnected, "Mattermost WebSocket disconnected.")); + } + + public async Task StartAsync(CancellationToken cancellationToken) + { + if (!_options.Enabled) + { + _logger.LogInformation("Mattermost channel disabled by configuration."); + return; + } + + var serverUrl = _options.ServerUrl + ?? throw new InvalidOperationException("Mattermost:ServerUrl is required when Mattermost channel is enabled."); + var botToken = _options.BotToken.RequireValid("Mattermost:BotToken"); + + try + { + await _gatewayClient.ConnectAsync(serverUrl, botToken.Value, cancellationToken); + + _gatewayClient.MessageReceived += HandleMessageReceivedAsync; + _gatewayClient.InteractionReceived += HandleInteractionReceivedAsync; + + var httpClient = _httpClientFactory.CreateClient("mattermost-files"); + + _gateway = _system.ActorOf( + MattermostGatewayActor.CreateProps(new MattermostGatewayDependencies( + Pipeline: _pipeline, + IngressGate: _ingressGate, + TimeProvider: _timeProvider, + Options: _options, + DefaultChannelId: !string.IsNullOrWhiteSpace(_options.DefaultChannelId) + ? new MattermostChannelId(_options.DefaultChannelId) + : null, + ReplyClient: _replyClient, + ContentScanner: _contentScanner, + AudienceProfiles: _audienceProfiles, + ModelCapabilities: _modelCapabilities, + Paths: _paths, + ServerUrl: serverUrl, + BotUserId: _gatewayClient.BotUserId, + PromptInjectionDetector: _promptInjectionDetector, + ThreadHistoryFetcher: _threadHistoryFetcher, + HttpClient: httpClient)), + "mattermost-gateway"); + + ActorRegistry.For(_system).Register(_gateway); + + _logger.LogInformation("Mattermost channel connected."); + } + catch (Exception ex) + { + _gatewayClient.MessageReceived -= HandleMessageReceivedAsync; + _gatewayClient.InteractionReceived -= HandleInteractionReceivedAsync; + + _notificationSink.Emit(OperationalAlert.Create( + _timeProvider, + "channel.disconnected", + AlertType.ChannelDisconnected, + $"Mattermost channel failed to connect: {ex.Message}", + AlertSeverity.Warning, + source: "mattermost", + context: new Dictionary { ["channel"] = "mattermost" })); + throw; + } + } + + public async Task StopAsync(CancellationToken cancellationToken) + { + _gatewayClient.MessageReceived -= HandleMessageReceivedAsync; + _gatewayClient.InteractionReceived -= HandleInteractionReceivedAsync; + + if (_gateway is not null) + { + try + { + await _gateway.GracefulStop(TimeSpan.FromSeconds(5)); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Mattermost gateway actor did not stop gracefully; forcing stop"); + _system.Stop(_gateway); + } + + _gateway = null; + } + + await _gatewayClient.DisconnectAsync(cancellationToken); + if (_gatewayClient is IDisposable disposable) + disposable.Dispose(); + } + + private Task HandleMessageReceivedAsync(MattermostGatewayMessage message) + { + _gateway?.Tell(message); + return Task.CompletedTask; + } + + private Task HandleInteractionReceivedAsync(MattermostGatewayInteraction interaction) + { + _gateway?.Tell(interaction); + return Task.CompletedTask; + } +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostChannelOptions.cs b/src/Netclaw.Channels.Mattermost/MattermostChannelOptions.cs new file mode 100644 index 00000000..9c667204 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostChannelOptions.cs @@ -0,0 +1,36 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Configuration; + +namespace Netclaw.Channels.Mattermost; + +public sealed class MattermostChannelOptions +{ + public bool Enabled { get; init; } + + public string? ServerUrl { get; init; } + + public SensitiveString? BotToken { get; init; } + + public string? DefaultChannelId { get; init; } + + public bool AllowDirectMessages { get; init; } + + public bool MentionOnly { get; init; } = true; + + public bool MentionRequiredInDm { get; init; } + + public string[] AllowedChannelIds { get; init; } = []; + + public string[] AllowedUserIds { get; init; } = []; + + /// + /// Per-channel audience overrides. Keys are Mattermost channel IDs or the + /// special key "dm" for direct messages. Values are + /// "personal", "team", or "public". + /// + public Dictionary ChannelAudiences { get; init; } = new(StringComparer.Ordinal); +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostConversationActor.cs b/src/Netclaw.Channels.Mattermost/MattermostConversationActor.cs new file mode 100644 index 00000000..16528d51 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostConversationActor.cs @@ -0,0 +1,322 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Akka.Actor; +using Akka.Event; +using Netclaw.Actors.Channels; +using Netclaw.Actors.Protocol; +using Netclaw.Channels.Telemetry; +using Netclaw.Configuration; + +namespace Netclaw.Channels.Mattermost; + +/// +/// Per-channel actor that serves as the security boundary for Mattermost messages. +/// Performs ACL checks, routing policy evaluation, and ingress gating. +/// Uses blind-write routing: session IDs are derived deterministically from +/// channel and root post identifiers with no routing state. +/// +internal sealed class MattermostConversationActor : ReceiveActor +{ + private const int MaxInboundTextLength = 4000; + + private readonly MattermostChannelId _channelId; + private readonly MattermostGatewayDependencies _dependencies; + private readonly string? _botMentionTag; + private readonly ILoggingAdapter _log; + + public MattermostConversationActor(MattermostChannelId channelId, MattermostGatewayDependencies dependencies) + { + _channelId = channelId; + _dependencies = dependencies; + _botMentionTag = dependencies.BotUserId is { } botId ? $"@{botId.Value}" : null; + _log = Context.GetLogger() + .WithContext("Adapter", "mattermost") + .WithContext("MattermostChannelId", _channelId.Value); + + Context.SetReceiveTimeout(TimeSpan.FromHours(2)); + + Receive(_ => + { + _log.Info("Mattermost conversation idle for 2 hours, passivating"); + Context.Stop(Self); + }); + + Receive(HandleGatewayMessage); + Receive(HandleGatewayInteraction); + Receive(HandleProactiveThread); + Receive(HandleTrustedSessionTurn); + Receive(HandleTerminated); + } + + protected override SupervisorStrategy SupervisorStrategy() + => new OneForOneStrategy(ex => + { + _log.Error(ex, "Session binding child failed; stopping to allow re-creation"); + return Directive.Stop; + }); + + public static Props CreateProps(MattermostChannelId channelId, MattermostGatewayDependencies dependencies) + => Props.Create(() => new MattermostConversationActor(channelId, dependencies)); + + private void HandleGatewayMessage(MattermostGatewayMessage message) + { + var options = _dependencies.Options; + + // --- ACL gate --- + var aclDecision = MattermostAclPolicy.EvaluateInbound( + message, + options, + _dependencies.DefaultChannelId); + + if (!aclDecision.IsAllowed) + { + var reason = aclDecision.DenyReason ?? "acl_denied"; + _log.Info("mattermost_event_dropped event={0} reason={1}", message.EventId.Value, reason); + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventDropped(reason); + return; + } + + // --- Bot self-loop filter --- + if (message.IsBotMessage) + { + _log.Info("mattermost_event_filtered event={0} reason=bot_message", message.EventId.Value); + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventFiltered("bot_message"); + return; + } + + // --- Ingress gate --- + if (_dependencies.IngressGate?.ClosedReason is { } closedReason) + { + _log.Info("mattermost_event_filtered event={0} reason=restart_drain_active", message.EventId.Value); + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventFiltered("restart_drain_active"); + _ = PostIngressClosedReplyAsync(message.ChannelId, message.PostId, closedReason); + return; + } + + // --- Derive session key --- + // For threaded messages, session key is channelId/rootPostId. + // For top-level messages, use channelId/postId (the post becomes the thread root). + var sessionRootId = message.RootPostId.IsEmpty + ? new MattermostRootPostId(message.PostId.Value) + : message.RootPostId; + + var actorName = BuildActorName(_channelId, sessionRootId); + var existingBinding = Context.Child(actorName); + var threadExists = !existingBinding.IsNobody(); + + // --- Routing policy --- + var decision = MattermostRoutingPolicy.Evaluate( + message, + options.MentionOnly, + options.AllowDirectMessages, + options.MentionRequiredInDm, + threadExists, + message.ContainsBotMention); + + if (decision.Kind is MattermostRoutingDecisionKind.Ignore) + { + var ignoreReason = decision.IgnoreReason!.Value; + _log.Info( + "mattermost_event_filtered event={0} reason=routing_policy_ignore ignoreReason={1}", + message.EventId.Value, + ignoreReason); + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventFiltered( + MattermostRoutingDecision.TelemetryLabelFor(ignoreReason)); + return; + } + + if (decision.Kind is MattermostRoutingDecisionKind.ContinueOnly && !threadExists) + { + _log.Info("mattermost_event_dropped event={0} reason=thread_not_initialized", message.EventId.Value); + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventDropped("thread_not_initialized"); + return; + } + + // --- Empty text filter --- + var normalizedText = NormalizeInboundText(message.Text); + if (normalizedText.Length > MaxInboundTextLength) + { + _log.Warning("mattermost_inbound_text_truncated original={OriginalLength} clamped={MaxLength}", + normalizedText.Length, MaxInboundTextLength); + normalizedText = normalizedText[..MaxInboundTextLength]; + } + var hasAttachments = message.Attachments is { Count: > 0 }; + if (string.IsNullOrWhiteSpace(normalizedText) && !hasAttachments) + { + _log.Info("mattermost_event_filtered event={0} reason=empty_text", message.EventId.Value); + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventFiltered("empty_text"); + return; + } + + // --- Build session and forward --- + var sessionId = new SessionId($"{_channelId.Value}/{sessionRootId.Value}"); + var sessionBinding = threadExists + ? existingBinding + : GetOrCreateSessionBinding(sessionId, _channelId, sessionRootId); + + var turnId = string.IsNullOrWhiteSpace(message.EventId.Value) + ? IdGen.ShortId() + : message.EventId.Value; + + var log = _log + .WithContext("MattermostRootPostId", sessionRootId.Value) + .WithContext("SessionId", sessionId.Value) + .WithContext("TurnId", turnId) + .WithContext("MattermostEventId", message.EventId.Value); + + log.Info("mattermost_turn_routed event={EventId} textChars={TextLength}", + message.EventId.Value, + normalizedText.Length); + + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventRouted("message"); + sessionBinding.Forward(new MattermostThreadInbound( + SessionId: sessionId, + ChannelId: _channelId, + PostId: message.PostId, + RootPostId: sessionRootId, + EventId: message.EventId, + SenderId: message.SenderId, + Audience: aclDecision.Audience, + Principal: aclDecision.Principal, + Provenance: aclDecision.Provenance, + Text: normalizedText, + ReceivedAt: message.ReceivedAt, + Attachments: message.Attachments)); + } + + private void HandleGatewayInteraction(MattermostGatewayInteraction interaction) + { + var actorName = BuildActorName(_channelId, interaction.RootPostId); + var sessionBinding = Context.Child(actorName); + if (sessionBinding.IsNobody()) + { + _log.Info( + "Ignoring Mattermost interaction for missing session binding channel={0} rootPost={1}", + _channelId.Value, + interaction.RootPostId.Value); + ChannelTelemetry.For(ChannelType.Mattermost).RecordExtra("interactionErrors", "missing_session_binding"); + return; + } + + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventRouted("interaction"); + sessionBinding.Forward(new MattermostApprovalResponse( + ChannelId: _channelId, + RootPostId: interaction.RootPostId, + CallId: interaction.CallId, + SelectedKey: interaction.SelectedKey, + SenderId: interaction.SenderId, + RequesterSenderId: interaction.RequesterSenderId)); + } + + private void HandleProactiveThread(StartMattermostProactiveThread message) + { + if (!MattermostAclPolicy.IsAllowedChannel( + message.ChannelId, + _dependencies.Options, + _dependencies.DefaultChannelId)) + { + _log.Warning( + "Rejecting proactive thread for disallowed channel {Channel}", + message.ChannelId.Value); + Sender.Tell(CommandNack.For( + message.SessionId, + $"Channel {message.ChannelId.Value} is not in the allowed channels list")); + return; + } + + var sessionBinding = GetOrCreateSessionBinding( + message.SessionId, + message.ChannelId, + message.RootPostId); + + _log.Info( + "mattermost_proactive_thread session={Session} channel={Channel} rootPost={RootPost}", + message.SessionId.Value, message.ChannelId.Value, message.RootPostId.Value); + Sender.Tell(new MattermostProactiveThreadAck(message.SessionId)); + } + + private void HandleTrustedSessionTurn(DeliverTrustedSessionTurn message) + { + if (!MattermostGatewayActor.TryParseMattermostSessionId( + message.SessionId, + out var parsedChannelId, + out var rootPostId)) + { + _log.Warning( + "Dropping DeliverTrustedSessionTurn with unparseable Mattermost SessionId {SessionId}", + message.SessionId.Value); + Sender.Tell(CommandNack.For(message.SessionId, "Invalid Mattermost SessionId format")); + return; + } + + if (parsedChannelId != _channelId) + { + _log.Warning( + "Dropping DeliverTrustedSessionTurn for wrong conversation session={Session} expected_channel={Channel}", + message.SessionId.Value, _channelId.Value); + Sender.Tell(CommandNack.For(message.SessionId, "Conversation mismatch")); + return; + } + + var sessionBinding = GetOrCreateSessionBinding( + message.SessionId, + _channelId, + rootPostId); + + _log.Debug( + "Routing DeliverTrustedSessionTurn session={Session} channel={Channel} rootPost={RootPost}", + message.SessionId.Value, parsedChannelId.Value, rootPostId.Value); + sessionBinding.Forward(message); + } + + private void HandleTerminated(Terminated msg) + { + _log.Debug("Session binding stopped: {0}", msg.ActorRef.Path.Name); + } + + private string NormalizeInboundText(string text) + { + if (_botMentionTag is null) + return text.Trim(); + + return text.Replace(_botMentionTag, string.Empty, StringComparison.OrdinalIgnoreCase).Trim(); + } + + private IActorRef GetOrCreateSessionBinding( + SessionId sessionId, + MattermostChannelId channelId, + MattermostRootPostId rootPostId) + { + var actorName = BuildActorName(channelId, rootPostId); + var existing = Context.Child(actorName); + if (!existing.IsNobody()) + return existing; + + var props = _dependencies.SessionPropsFactory?.Invoke( + sessionId, channelId, rootPostId, _dependencies) + ?? MattermostSessionBindingActor.CreateProps( + sessionId, channelId, rootPostId, _dependencies); + var child = Context.ActorOf(props, actorName); + Context.Watch(child); + return child; + } + + private async Task PostIngressClosedReplyAsync(MattermostChannelId channelId, MattermostPostId rootPostId, string message) + { + try + { + await _dependencies.ReplyClient.PostReplyAsync( + new MattermostPostMessage(channelId, message, rootPostId)); + } + catch (Exception ex) + { + _log.Warning(ex, "Failed to post restart-drain reply to Mattermost channel {0}", channelId.Value); + } + } + + private static string BuildActorName(MattermostChannelId channelId, MattermostRootPostId rootPostId) + => Uri.EscapeDataString($"{channelId.Value}:{rootPostId.Value}"); +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostGatewayActor.cs b/src/Netclaw.Channels.Mattermost/MattermostGatewayActor.cs new file mode 100644 index 00000000..227534ac --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostGatewayActor.cs @@ -0,0 +1,161 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Akka.Actor; +using Akka.Event; +using Netclaw.Actors.Channels; +using Netclaw.Actors.Protocol; +using Netclaw.Channels.Telemetry; +using Netclaw.Configuration; +using Netclaw.Security; + +namespace Netclaw.Channels.Mattermost; + +public sealed class MattermostGatewayActor : ReceiveActor +{ + private const int MaxProcessedEventIds = 4096; + + private readonly MattermostGatewayDependencies _dependencies; + private readonly ILoggingAdapter _log; + private readonly Dictionary _processedEventIds = []; + private readonly Queue _processedEventOrder = new(); + + public MattermostGatewayActor(MattermostGatewayDependencies dependencies) + { + _dependencies = dependencies; + _log = Context.GetLogger().WithContext("Adapter", "mattermost"); + + Receive(message => + { + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventReceived("message"); + + if (!TryMarkEventProcessed(message.EventId)) + { + _log.Debug("Dropping duplicate Mattermost event {0}", message.EventId.Value); + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventFiltered("duplicate_event"); + return; + } + + var conversation = GetOrCreateConversationActor(message.ChannelId); + + _log.Debug("Routing Mattermost event {0} to conversation {1}", message.EventId.Value, message.ChannelId); + conversation.Forward(message); + }); + + Receive(interaction => + { + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventReceived("interaction"); + + var conversation = GetOrCreateConversationActor(interaction.ChannelId); + + _log.Debug("Routing Mattermost interaction to conversation {0}", interaction.ChannelId); + conversation.Forward(interaction); + }); + + Receive(message => + { + var conversation = GetOrCreateConversationActor(message.ChannelId); + + _log.Debug( + "Routing StartMattermostProactiveThread session={Session} channel={Channel} rootPost={RootPost}", + message.SessionId.Value, message.ChannelId.Value, message.RootPostId.Value); + conversation.Forward(message); + }); + + Receive(message => + { + if (!TryParseMattermostSessionId(message.SessionId, out var channelId, out _)) + { + _log.Warning( + "Dropping DeliverTrustedSessionTurn with unparseable Mattermost SessionId {SessionId}", + message.SessionId.Value); + Sender.Tell(CommandNack.For(message.SessionId, "Invalid Mattermost SessionId format")); + return; + } + + var conversation = GetOrCreateConversationActor(channelId); + + _log.Debug( + "Routing DeliverTrustedSessionTurn session={Session} channel={Channel}", + message.SessionId.Value, channelId.Value); + conversation.Forward(message); + }); + } + + public static Props CreateProps(MattermostGatewayDependencies dependencies) => + Props.Create(() => new MattermostGatewayActor(dependencies)); + + internal static bool TryParseMattermostSessionId( + SessionId sessionId, + out MattermostChannelId channelId, + out MattermostRootPostId rootPostId) + { + channelId = default; + rootPostId = default; + + var value = sessionId.Value; + if (string.IsNullOrEmpty(value)) + return false; + + var slashIdx = value.IndexOf('/', StringComparison.Ordinal); + if (slashIdx <= 0 || slashIdx == value.Length - 1) + return false; + + channelId = new MattermostChannelId(value[..slashIdx]); + rootPostId = new MattermostRootPostId(value[(slashIdx + 1)..]); + return true; + } + + private IActorRef GetOrCreateConversationActor(MattermostChannelId channelId) + { + var actorName = Uri.EscapeDataString(channelId.Value); + var existing = Context.Child(actorName); + if (!existing.IsNobody()) + return existing; + + var props = _dependencies.ConversationPropsFactory?.Invoke(channelId, _dependencies) + ?? MattermostConversationActor.CreateProps(channelId, _dependencies); + return Context.ActorOf(props, actorName); + } + + private bool TryMarkEventProcessed(MattermostEventId eventId) + { + if (string.IsNullOrWhiteSpace(eventId.Value)) + { + _log.Warning("Rejecting Mattermost event with empty EventId — cannot deduplicate"); + return false; + } + + if (!_processedEventIds.TryAdd(eventId, 0)) + return false; + + _processedEventOrder.Enqueue(eventId); + + while (_processedEventIds.Count > MaxProcessedEventIds + && _processedEventOrder.TryDequeue(out var oldestEventId)) + _processedEventIds.Remove(oldestEventId); + + return true; + } +} + +public sealed record MattermostGatewayDependencies( + ISessionPipeline Pipeline, + SessionIngressGate? IngressGate, + TimeProvider TimeProvider, + MattermostChannelOptions Options, + MattermostChannelId? DefaultChannelId, + IMattermostReplyClient ReplyClient, + IContentScanner ContentScanner, + ToolAudienceProfiles AudienceProfiles, + ModelCapabilities ModelCapabilities, + NetclawPaths Paths, + string? ServerUrl = null, + MattermostUserId? BotUserId = null, + IPromptInjectionDetector? PromptInjectionDetector = null, + IThreadHistoryFetcher? ThreadHistoryFetcher = null, + HttpClient? HttpClient = null, + Func? ConversationPropsFactory = null, + Func? SessionPropsFactory = null); diff --git a/src/Netclaw.Channels.Mattermost/MattermostIdentifiers.cs b/src/Netclaw.Channels.Mattermost/MattermostIdentifiers.cs new file mode 100644 index 00000000..b639ac74 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostIdentifiers.cs @@ -0,0 +1,59 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +namespace Netclaw.Channels.Mattermost; + +/// +/// Mattermost channel identifier. +/// +public readonly record struct MattermostChannelId(string Value) +{ + public static explicit operator MattermostChannelId(string value) => new(value); + + public override string ToString() => Value; +} + +/// +/// Mattermost post identifier. +/// +public readonly record struct MattermostPostId(string Value) +{ + public static explicit operator MattermostPostId(string value) => new(value); + + public override string ToString() => Value; +} + +/// +/// Root post identifier for thread-based session identity. +/// Empty when the message is a top-level post (not in a thread). +/// +public readonly record struct MattermostRootPostId(string Value) +{ + public static explicit operator MattermostRootPostId(string value) => new(value); + + public bool IsEmpty => string.IsNullOrEmpty(Value); + + public override string ToString() => Value; +} + +/// +/// Deduplication key for Mattermost WebSocket events. +/// +public readonly record struct MattermostEventId(string Value) +{ + public static explicit operator MattermostEventId(string value) => new(value); + + public override string ToString() => Value; +} + +/// +/// Mattermost user identifier. +/// +public readonly record struct MattermostUserId(string Value) +{ + public static explicit operator MattermostUserId(string value) => new(value); + + public override string ToString() => Value; +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostIngressMessages.cs b/src/Netclaw.Channels.Mattermost/MattermostIngressMessages.cs new file mode 100644 index 00000000..b0bd1a9e --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostIngressMessages.cs @@ -0,0 +1,57 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Protocol; +using Netclaw.Actors.Channels; +using Netclaw.Configuration; + +namespace Netclaw.Channels.Mattermost; + +public sealed record MattermostFileReference( + string Name, + string MimeType, + long Size, + string Url); + +public sealed record MattermostThreadInbound( + SessionId SessionId, + MattermostChannelId ChannelId, + MattermostPostId PostId, + MattermostRootPostId RootPostId, + MattermostEventId EventId, + MattermostUserId SenderId, + TrustAudience Audience, + PrincipalClassification Principal, + SourceProvenance Provenance, + string Text, + DateTimeOffset ReceivedAt, + IReadOnlyList? Attachments = null); + +public sealed record MattermostApprovalResponse( + MattermostChannelId ChannelId, + MattermostRootPostId RootPostId, + string CallId, + string SelectedKey, + MattermostUserId SenderId, + MattermostUserId? RequesterSenderId = null); + +public sealed record StartMattermostProactiveThread( + MattermostChannelId ChannelId, + MattermostRootPostId RootPostId, + SessionId SessionId); + +public sealed record MattermostProactiveThreadAck(SessionId SessionId); + +internal sealed class PendingApprovalRequest(ToolInteractionRequest request) +{ + public ToolInteractionRequest Request { get; } = request; + public string CallId => Request.CallId; + + public MattermostUserId? RequesterSenderId { get; } = + request.RequesterSenderId is not null ? new MattermostUserId(request.RequesterSenderId) : null; + + public PrincipalClassification? RequesterPrincipal => Request.RequesterPrincipal; + public MattermostPostId? PromptPostId { get; set; } +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs b/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs new file mode 100644 index 00000000..c5d608fc --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs @@ -0,0 +1,74 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Reminders; + +namespace Netclaw.Channels.Mattermost; + +/// +/// Resolves Mattermost reminder targets to canonical IDs. +/// Supported inputs: +/// - @username +/// - raw user ID (26-char alphanumeric Mattermost ID) +/// - channel:channelId +/// +public sealed class MattermostReminderTargetResolver : IReminderTargetResolver +{ + public string Transport => "mattermost"; + + public Task ResolveAsync(string target, CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(target)) + { + return Task.FromResult(new ReminderTargetResolution( + Success: false, + ResolvedId: null, + Kind: ReminderTargetKind.Unknown, + ErrorMessage: "Target is required.")); + } + + var raw = target.Trim(); + + if (raw.StartsWith("channel:", StringComparison.OrdinalIgnoreCase)) + { + var channelId = raw[8..].Trim(); + if (IsMattermostId(channelId)) + { + return Task.FromResult(new ReminderTargetResolution( + Success: true, + ResolvedId: channelId, + Kind: ReminderTargetKind.Channel, + ErrorMessage: null)); + } + + return Task.FromResult(new ReminderTargetResolution( + Success: false, + ResolvedId: null, + Kind: ReminderTargetKind.Unknown, + ErrorMessage: "Invalid Mattermost channel ID. Use channel:.")); + } + + if (raw.StartsWith('@')) + raw = raw[1..].Trim(); + + if (IsMattermostId(raw)) + { + return Task.FromResult(new ReminderTargetResolution( + Success: true, + ResolvedId: raw, + Kind: ReminderTargetKind.User, + ErrorMessage: null)); + } + + return Task.FromResult(new ReminderTargetResolution( + Success: false, + ResolvedId: null, + Kind: ReminderTargetKind.Unknown, + ErrorMessage: $"Could not resolve Mattermost target '{target}'. Use a Mattermost user ID, @userId, or channel:.")); + } + + private static bool IsMattermostId(string value) + => value.Length == 26 && value.All(c => char.IsLetterOrDigit(c)); +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostRoutingPolicy.cs b/src/Netclaw.Channels.Mattermost/MattermostRoutingPolicy.cs new file mode 100644 index 00000000..b16ab66c --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostRoutingPolicy.cs @@ -0,0 +1,86 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +namespace Netclaw.Channels.Mattermost; + +internal static class MattermostRoutingPolicy +{ + public static MattermostRoutingDecision Evaluate( + MattermostGatewayMessage message, + bool mentionOnly, + bool allowDirectMessages, + bool mentionRequiredInDm, + bool threadExists, + bool containsBotMention) + { + var hasAttachments = message.Attachments is { Count: > 0 }; + if (string.IsNullOrWhiteSpace(message.Text) && !hasAttachments) + return MattermostRoutingDecision.Ignore(MattermostRoutingIgnoreReason.NoContent); + + if (message.IsDirectMessage) + { + if (!allowDirectMessages) + return MattermostRoutingDecision.Ignore(MattermostRoutingIgnoreReason.DmNotAllowed); + if (mentionRequiredInDm && !containsBotMention) + return MattermostRoutingDecision.Ignore(MattermostRoutingIgnoreReason.DmMentionRequired); + return MattermostRoutingDecision.StartOrContinue; + } + + if (threadExists) + return MattermostRoutingDecision.ContinueOnly; + + // Thread reply where the actor was lost (e.g. daemon restart): + // the message has a root_id, so re-create the session binding + // and continue the persisted session. + if (!message.RootPostId.IsEmpty) + return MattermostRoutingDecision.StartOrContinue; + + if (!mentionOnly) + return MattermostRoutingDecision.StartOrContinue; + + return containsBotMention + ? MattermostRoutingDecision.StartOrContinue + : MattermostRoutingDecision.Ignore(MattermostRoutingIgnoreReason.ChannelMentionRequired); + } +} + +internal enum MattermostRoutingDecisionKind +{ + Ignore, + ContinueOnly, + StartOrContinue +} + +internal enum MattermostRoutingIgnoreReason +{ + NoContent, + DmNotAllowed, + DmMentionRequired, + ChannelMentionRequired +} + +internal sealed record MattermostRoutingDecision( + MattermostRoutingDecisionKind Kind, + MattermostRoutingIgnoreReason? IgnoreReason) +{ + public static readonly MattermostRoutingDecision StartOrContinue = + new(MattermostRoutingDecisionKind.StartOrContinue, null); + + public static readonly MattermostRoutingDecision ContinueOnly = + new(MattermostRoutingDecisionKind.ContinueOnly, null); + + public static MattermostRoutingDecision Ignore(MattermostRoutingIgnoreReason reason) => + new(MattermostRoutingDecisionKind.Ignore, reason); + + public static string TelemetryLabelFor(MattermostRoutingIgnoreReason reason) => + reason switch + { + MattermostRoutingIgnoreReason.NoContent => "routing_policy_ignore:NoContent", + MattermostRoutingIgnoreReason.DmNotAllowed => "routing_policy_ignore:DmNotAllowed", + MattermostRoutingIgnoreReason.DmMentionRequired => "routing_policy_ignore:DmMentionRequired", + MattermostRoutingIgnoreReason.ChannelMentionRequired => "routing_policy_ignore:ChannelMentionRequired", + _ => "routing_policy_ignore", + }; +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs new file mode 100644 index 00000000..d5e7fd26 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs @@ -0,0 +1,1137 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text; +using System.Threading.Channels; +using Akka.Actor; +using Akka.Event; +using Akka.Persistence; +using Microsoft.Extensions.AI; +using Netclaw.Actors.Channels; +using Netclaw.Actors.Protocol; +using Netclaw.Actors.Reminders; +using Netclaw.Channels; +using Netclaw.Channels.Telemetry; +using Netclaw.Configuration; +using Netclaw.Security; +using IOPath = System.IO.Path; + +namespace Netclaw.Channels.Mattermost; + +internal sealed class MattermostSessionBindingActor : ReceivePersistentActor, IWithTimers +{ + private readonly SessionId _sessionId; + private readonly MattermostChannelId _channelId; + private readonly MattermostRootPostId _rootPostId; + + private const string EmptyTurnFallbackText = + ":warning: I didn't manage to produce a reply. Please try rephrasing or sending your message again."; + private const string LiveInjectionBlockedWarning = + ":warning: Message blocked by prompt-injection policy."; + private const string LiveDetectorUnavailableWarning = + ":warning: I couldn't safely analyze your message -- please try again in a moment."; + private const string WrongRequesterWarning = + ":warning: Only the requesting user can approve this tool action."; + private const string BackfillDetectorWarning = + ":warning: I couldn't safely analyze some earlier thread messages, so they were excluded from context."; + + private readonly MattermostGatewayDependencies _dependencies; + private readonly IPromptInjectionDetector _promptInjectionDetector; + private readonly SessionPipelineHandle _handle; + private readonly ILoggingAdapter _log; + private readonly List _pendingApprovalRequests = []; + + private static readonly TimeSpan PipelineInitTimeout = TimeSpan.FromSeconds(15); + private static readonly TimeSpan ReinitializeDelay = TimeSpan.FromSeconds(2); + private static readonly object ReinitializeTimerKey = new(); + private static readonly TimeSpan IdlePassivationTimeout = TimeSpan.FromHours(1); + private bool _deliveredThisTurn; + private int _turnNumber; + private string? _cursorPostId; + private string? _pendingCursorPostId; + + public ITimerScheduler Timers { get; set; } = null!; + + public MattermostSessionBindingActor( + SessionId sessionId, + MattermostChannelId channelId, + MattermostRootPostId rootPostId, + MattermostGatewayDependencies dependencies) + { + _sessionId = sessionId; + _channelId = channelId; + _rootPostId = rootPostId; + _dependencies = dependencies; + _promptInjectionDetector = dependencies.PromptInjectionDetector ?? new NullPromptInjectionDetector(); + + _log = Context.GetLogger() + .WithContext("Adapter", "mattermost") + .WithContext("SessionId", _sessionId.Value) + .WithContext("MattermostChannelId", _channelId.Value) + .WithContext("MattermostRootPostId", _rootPostId.Value); + + _handle = new SessionPipelineHandle(_dependencies.Pipeline, _log, "mattermost-session"); + + Recover(ApplyCursorAdvanced); + + Initializing(); + } + + public override string PersistenceId => $"mattermost-session-cursor-{Uri.EscapeDataString(_sessionId.Value)}"; + + public static Props CreateProps( + SessionId sessionId, + MattermostChannelId channelId, + MattermostRootPostId rootPostId, + MattermostGatewayDependencies dependencies) + => Props.Create(() => new MattermostSessionBindingActor( + sessionId, + channelId, + rootPostId, + dependencies)); + + protected override void PreStart() + { + Self.Tell(InitializePipeline.Instance); + base.PreStart(); + } + + protected override void PostStop() + { + _handle.Dispose(); + base.PostStop(); + } + + private SessionPipelineOptions BuildOptions() => new() + { + ChannelType = ChannelType.Mattermost, + DefaultAudience = TrustAudience.Team, + DefaultBoundary = SecurityPolicyDefaults.TrustedInstanceBoundary, + DefaultPrincipal = PrincipalClassification.UntrustedExternal, + DefaultProvenance = new SourceProvenance + { + TransportAuthenticity = TransportAuthenticity.Verified, + PayloadTaint = PayloadTaint.Public, + SourceKind = "mattermost", + SourceScope = _channelId.Value + }, + Filter = OutputFilter.Text | OutputFilter.Files + }; + + private void Initializing() + { + CommandAsync(async _ => + { + try + { + await EnsureInitializedAsync(); + Become(Active); + Stash.UnstashAll(); + } + catch (Exception ex) + { + _log.Error(ex, "Failed to initialize Mattermost session pipeline; stopping actor"); + Context.Stop(Self); + } + }); + + CommandAny(msg => + { + if (msg is not InitializePipeline) + Stash.Stash(); + }); + } + + private void Active() + { + CommandAsync(HandleInboundAsync); + CommandAsync(HandleApprovalResponseAsync); + CommandAsync(HandleTrustedReminderAsync); + CommandAsync(HandleOutputReceivedAsync); + + Command(msg => + { + if (msg.Generation != _handle.Generation) + return; + + var reason = msg.Cause is null + ? "completed" + : $"faulted: {msg.Cause.Message}"; + + _log.Warning("Mattermost output stream terminated ({Reason}); reinitializing pipeline", reason); + Self.Tell(new ReinitializePipeline(reason)); + }); + + CommandAsync(async msg => + { + _deliveredThisTurn = false; + await _handle.ReinitializeAsync( + msg.Reason, + () => Timers.StartSingleTimer( + ReinitializeTimerKey, + new ReinitializePipeline("retry after failed reinit"), + ReinitializeDelay)); + }); + + Command(_ => + { + if (_pendingApprovalRequests.Count > 0) + { + _log.Info("Mattermost session idle but {0} approval(s) pending; deferring passivation", _pendingApprovalRequests.Count); + return; + } + + _log.Info("Mattermost session idle for 1 hour, passivating"); + Context.Stop(Self); + }); + + Context.SetReceiveTimeout(IdlePassivationTimeout); + } + + private async Task EnsureInitializedAsync() + { + if (_handle.IsInitialized) + return; + + var self = Self; + using var initCts = new CancellationTokenSource(PipelineInitTimeout); + await _handle.InitializeWithChannelAsync( + Context, + _sessionId, + BuildOptions(), + output => self.Tell(new OutputReceived(output)), + (generation, cause) => self.Tell(new OutputStreamTerminated(generation, cause)), + initCts.Token); + } + + private static readonly TimeSpan InboundProcessingTimeout = TimeSpan.FromSeconds(30); + + private async Task HandleInboundAsync(MattermostThreadInbound message) + { + if (_dependencies.IngressGate?.ClosedReason is { } ingressClosedReason) + { + _log.Info("Rejecting Mattermost inbound message while restart drain is active"); + await SafeReplyAsync(ingressClosedReason); + return; + } + + var hasAttachments = message.Attachments is { Count: > 0 }; + if (string.IsNullOrWhiteSpace(message.Text) && !hasAttachments) + return; + + if (!string.IsNullOrWhiteSpace(message.Text) + && ToolInteractionResponseParser.TryParseApprovalResponse(message.Text, out var selectedKey) + && selectedKey is not null + && await TryHandleTextApprovalResponseAsync(message, selectedKey)) + { + return; + } + + using var inboundCts = new CancellationTokenSource(InboundProcessingTimeout); + + if (!string.IsNullOrWhiteSpace(message.Text)) + { + var classification = await PromptClassifier.ClassifyAsync( + _promptInjectionDetector, message.Text, "mattermost-live", _log, inboundCts.Token); + switch (classification.Outcome) + { + case ClassificationOutcome.Block: + _log.Warning("Blocked Mattermost message due to prompt injection risk: {Reason}", classification.Reason); + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventDropped("prompt_injection_high"); + await SafeReplyAsync(LiveInjectionBlockedWarning); + return; + + case ClassificationOutcome.DetectorUnavailable: + _log.Warning("Prompt injection detector unavailable for live message -- dropping"); + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventDropped("prompt_injection_detector_unavailable"); + await SafeReplyAsync(LiveDetectorUnavailableWarning); + return; + + case ClassificationOutcome.Allow: + break; + } + } + + var writer = _handle.InputQueue; + if (writer is null) + { + _log.Warning("Mattermost input queue is not initialized; dropping inbound message"); + return; + } + + var liveContents = new List(); + if (!string.IsNullOrWhiteSpace(message.Text)) + liveContents.Add(new TextContent(message.Text)); + + if (hasAttachments) + await ProcessInboundAttachmentsAsync(message.Attachments!, message.Audience, liveContents, inboundCts.Token); + + if (liveContents.Count == 0) + return; + + var (mergedContents, backfillDetectorUnavailable, adoptedSpeakerIds, projection, adoptedEntries) = await BuildInputContentsAsync(message, liveContents, inboundCts.Token); + + if (backfillDetectorUnavailable) + await SafeReplyAsync(BackfillDetectorWarning); + + var input = new ChannelInput + { + SenderId = message.SenderId.Value, + ChannelId = message.ChannelId.Value, + MessageId = message.EventId.Value, + Audience = message.Audience, + Boundary = SecurityPolicyDefaults.TrustedInstanceBoundary, + Principal = message.Principal, + Provenance = message.Provenance, + Contents = mergedContents, + ReceivedAt = message.ReceivedAt, + ExecutableText = message.Text, + HasAdoptedContext = adoptedSpeakerIds.Count > 0, + AdoptedSpeakerIds = adoptedSpeakerIds, + AdoptedContextProjection = projection, + AdoptedContextLowerBound = _cursorPostId, + AdoptedContextUpperBound = message.EventId.Value, + AdoptedContextEntries = adoptedEntries + }; + + try + { + using var writeCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await writer.WriteAsync(input, writeCts.Token); + ChannelTelemetry.For(ChannelType.Mattermost).RecordMessageEnqueued(); + + var eventId = message.EventId.Value; + if (!string.IsNullOrEmpty(eventId)) + { + if (_pendingCursorPostId is null + || string.CompareOrdinal(eventId, _pendingCursorPostId) > 0) + _pendingCursorPostId = eventId; + } + } + catch (OperationCanceledException) + { + _log.Warning("Timed out enqueueing Mattermost message for session {0}", _sessionId.Value); + Self.Tell(new ReinitializePipeline("input queue write timeout")); + } + catch (ChannelClosedException) + { + _log.Warning("Mattermost input queue closed for session {0}", _sessionId.Value); + Self.Tell(new ReinitializePipeline("input queue closed")); + } + } + + private async Task<(IReadOnlyList Contents, bool BackfillDetectorUnavailable, IReadOnlyList AdoptedSpeakerIds, string? Projection, IReadOnlyList AdoptedEntries)> BuildInputContentsAsync( + MattermostThreadInbound message, + List liveContents, + CancellationToken cancellationToken) + { + if (_dependencies.ThreadHistoryFetcher is not { } fetcher) + return (liveContents, false, [], null, []); + + IReadOnlyList history; + try + { + history = await fetcher.FetchThreadHistoryAsync(_sessionId, cancellationToken); + } + catch (Exception ex) + { + _log.Warning(ex, "Thread history fetch failed for session {0}", _sessionId.Value); + return (liveContents, false, [], null, []); + } + + if (history.Count == 0) + return (liveContents, false, [], null, []); + + var cursor = _cursorPostId; + + // Phase 1: filter by cursor bounds (cheap, sync). + // Mattermost post IDs are lexicographically sortable strings. + var candidates = new List(history.Count); + foreach (var item in history) + { + var itemId = item.MessageId ?? string.Empty; + if (string.IsNullOrEmpty(itemId)) + continue; + + // Keep the cursor message itself during fresh-runtime hydration. + if (cursor is not null && string.CompareOrdinal(itemId, cursor) < 0) + continue; + + candidates.Add(item); + } + + if (candidates.Count == 0) + { + _log.Info( + "Thread history hydrated fetched={FetchedCount} gapCount=0 cursor={Cursor} session={Session}", + history.Count, cursor ?? "none", _sessionId.Value); + return (liveContents, false, [], null, []); + } + + // Phase 2: classify candidates in parallel for prompt injection risk. + var classifications = await Task.WhenAll( + candidates.Select(c => ClassifyGapMessageAsync(c, cancellationToken))); + + // Phase 3: assemble gap preserving chronological order. + var safe = new List(candidates.Count); + var blockedForRisk = 0; + var detectorUnavailable = false; + + for (var i = 0; i < candidates.Count; i++) + { + switch (classifications[i].Outcome) + { + case ClassificationOutcome.Allow: + var authority = _dependencies.Options.AllowedUserIds.Length == 0 + || _dependencies.Options.AllowedUserIds.Contains(candidates[i].SenderId, StringComparer.Ordinal) + ? AdoptedMessageAuthority.Authorized + : AdoptedMessageAuthority.Pending; + safe.Add(new AdoptedContextMessage(candidates[i], authority)); + break; + + case ClassificationOutcome.Block: + blockedForRisk++; + _log.Warning( + "Dropped backfill message due to prompt injection risk sender={SenderId} messageId={MessageId} reason={Reason}", + candidates[i].SenderId, + candidates[i].MessageId ?? "none", + classifications[i].Reason ?? "high-risk pattern detected"); + break; + + case ClassificationOutcome.DetectorUnavailable: + blockedForRisk++; + detectorUnavailable = true; + break; + } + } + + _log.Info( + "Thread history hydrated fetched={FetchedCount} gapCount={GapCount} allowed={AllowedCount} blockedHighRisk={BlockedHighRiskCount} cursor={Cursor} session={Session}", + history.Count, candidates.Count, safe.Count, blockedForRisk, cursor ?? "none", _sessionId.Value); + + if (safe.Count == 0) + return (liveContents, detectorUnavailable, [], null, []); + + var merged = MergeHistoryWithLiveContents(safe, liveContents, message); + + return ( + merged.Contents, + detectorUnavailable, + merged.SpeakerIds, + merged.Projection, + merged.Entries); + } + + private Task ClassifyGapMessageAsync(ChannelInput input, CancellationToken cancellationToken) + { + var text = string.Join("\n", input.Contents + .OfType() + .Select(t => t.Text) + .Where(t => !string.IsNullOrWhiteSpace(t))); + + return PromptClassifier.ClassifyAsync( + _promptInjectionDetector, text, "mattermost-backfill", _log, cancellationToken); + } + + private static AdoptedContextMergeResult MergeHistoryWithLiveContents( + IReadOnlyList history, + IReadOnlyList liveContents, + MattermostThreadInbound message) + => AdoptedContextContentBuilder.MergeWithCurrentMessage( + history, + liveContents, + message.SenderId.Value, + message.ReceivedAt); + + private async Task TryHandleTextApprovalResponseAsync(MattermostThreadInbound message, string selectedKey) + { + var (result, pending) = ResolvePendingRequest(message.SenderId, callId: null); + + if (result is ApprovalLookupResult.NotFound) + return false; + + if (result is ApprovalLookupResult.WrongRequester) + { + await SafeReplyAsync(WrongRequesterWarning); + return true; + } + + _pendingApprovalRequests.Remove(pending!); + + await _dependencies.Pipeline.SendFeedbackAsync(new ToolInteractionResponse + { + SessionId = _sessionId, + CallId = pending!.CallId, + SelectedKey = selectedKey, + SenderId = message.SenderId.Value + }); + + await TryResolveApprovalPromptAsync(pending!, selectedKey, message.SenderId.Value); + return true; + } + + private async Task HandleApprovalResponseAsync(MattermostApprovalResponse message) + { + var (result, pending) = ResolvePendingRequest(message.SenderId, message.CallId); + + if (result is ApprovalLookupResult.WrongRequester) + { + await SafeReplyAsync(WrongRequesterWarning); + return; + } + + if (result is ApprovalLookupResult.NotFound) + { + _log.Info("Ignoring Mattermost approval response for unknown call id {0}", message.CallId); + ChannelTelemetry.For(ChannelType.Mattermost).RecordExtra("interactionErrors", "unknown_call_id"); + return; + } + + _pendingApprovalRequests.Remove(pending!); + + await _dependencies.Pipeline.SendFeedbackAsync(new ToolInteractionResponse + { + SessionId = _sessionId, + CallId = message.CallId, + SelectedKey = message.SelectedKey, + SenderId = message.SenderId.Value + }); + + await TryResolveApprovalPromptAsync(pending!, message.SelectedKey, message.SenderId.Value); + } + + private async Task TryResolveApprovalPromptAsync( + PendingApprovalRequest pending, + string selectedKey, + string senderId) + { + if (pending.PromptPostId is not { } promptPostId) + return; + + try + { + var resolvedText = BuildResolvedApprovalText( + pending.Request, + selectedKey, + senderId); + + using var cts = new CancellationTokenSource(OperationTimeout); + await _dependencies.ReplyClient.UpdatePostAsync( + promptPostId, + resolvedText, + cts.Token); + } + catch (Exception ex) + { + _log.Warning( + ex, + "Failed to update resolved approval prompt for call {CallId} postId={PostId}", + pending.CallId, + promptPostId.Value); + } + } + + private async Task HandleTrustedReminderAsync(DeliverTrustedSessionTurn message) + { + var ackTarget = Sender; + + if (message.SessionId != _sessionId) + { + _log.Warning( + "Dropping DeliverTrustedSessionTurn with mismatching session id actual={Actual} expected={Expected}", + message.SessionId.Value, _sessionId.Value); + ackTarget.Tell(CommandNack.For(_sessionId, "Session id mismatch")); + return; + } + + if (_dependencies.IngressGate?.ClosedReason is { } ingressClosedReason) + { + _log.Info("Rejecting Mode B reminder while restart drain is active"); + ackTarget.Tell(CommandNack.For(_sessionId, ingressClosedReason)); + return; + } + + var writer = _handle.InputQueue; + if (writer is null) + { + _log.Warning("Mattermost input queue is not initialized; rejecting Mode B reminder"); + ackTarget.Tell(CommandNack.For(_sessionId, "Mattermost session pipeline not initialized")); + return; + } + + var input = new ChannelInput + { + SenderId = message.Source.SenderId, + ChannelId = _channelId.Value, + MessageId = message.Source.MessageId, + Audience = message.Source.Audience, + Boundary = message.Source.Boundary, + Principal = message.Source.Principal, + Provenance = message.Source.Provenance, + Contents = [new TextContent(message.Content)], + ReceivedAt = _dependencies.TimeProvider.GetUtcNow(), + ReminderId = message.Source.ReminderId, + AckTarget = ackTarget + }; + + try + { + using var writeCts = new CancellationTokenSource(TimeSpan.FromSeconds(10)); + await writer.WriteAsync(input, writeCts.Token); + _log.Debug( + "reminder_mode_b_dispatch session={Session} reminder={Reminder}", + _sessionId.Value, message.Source.ReminderId); + } + catch (OperationCanceledException) + { + _log.Warning("Timed out enqueueing Mode B reminder for session {0}", _sessionId.Value); + ackTarget.Tell(CommandNack.For(_sessionId, "Pipeline enqueue timeout")); + } + catch (ChannelClosedException) + { + _log.Warning("Mattermost input queue closed; rejecting Mode B reminder for session {0}", _sessionId.Value); + ackTarget.Tell(CommandNack.For(_sessionId, "Pipeline input queue closed")); + } + } + + private enum ApprovalLookupResult { Matched, WrongRequester, NotFound } + + private (ApprovalLookupResult Result, PendingApprovalRequest? Pending) ResolvePendingRequest( + MattermostUserId senderId, string? callId) + { + if (callId is not null) + { + var byCallId = _pendingApprovalRequests.LastOrDefault(p => + string.Equals(p.CallId, callId, StringComparison.Ordinal)); + if (byCallId is null) + return (ApprovalLookupResult.NotFound, null); + if (!ApprovalButtonValueCodec.CanApprove(byCallId.RequesterPrincipal, byCallId.RequesterSenderId?.Value, senderId.Value)) + return (ApprovalLookupResult.WrongRequester, null); + return (ApprovalLookupResult.Matched, byCallId); + } + + if (_pendingApprovalRequests.Count == 0) + return (ApprovalLookupResult.NotFound, null); + + var bySender = _pendingApprovalRequests.LastOrDefault(p => + ApprovalButtonValueCodec.CanApprove(p.RequesterPrincipal, p.RequesterSenderId?.Value, senderId.Value)); + return bySender is not null + ? (ApprovalLookupResult.Matched, bySender) + : (ApprovalLookupResult.WrongRequester, null); + } + + private async Task HandleOutputReceivedAsync(OutputReceived msg) + { + switch (msg.Output) + { + case TextOutput textOutput: + await SafeReplyAsync(textOutput.Text); + _deliveredThisTurn = true; + break; + + case ErrorOutput error: + await SafeReplyAsync($":warning: {error.Message}"); + _deliveredThisTurn = true; + break; + + case FileOutput file: + await SafeReplyAsync($":paperclip: Produced file `{file.FileName}` ({file.MimeType})."); + _deliveredThisTurn = true; + break; + + case ToolInteractionRequest request when string.Equals(request.Kind, "approval", StringComparison.OrdinalIgnoreCase): + var pendingApproval = new PendingApprovalRequest(request); + _pendingApprovalRequests.Add(pendingApproval); + + var promptPostId = await SafeReplyWithApprovalPromptAsync(request); + if (promptPostId is not null) + { + pendingApproval.PromptPostId = promptPostId; + } + else + { + _pendingApprovalRequests.Remove(pendingApproval); + } + break; + + // Mattermost threads don't support renaming, so SessionTitleOutput is ignored. + + case TurnCompleted completed: + if (completed.Outcome == TurnOutcome.Completed && _pendingCursorPostId is { } pendingCursor) + AdvanceCursor(pendingCursor); + _pendingCursorPostId = null; + + if (!string.IsNullOrWhiteSpace(completed.SourceReminderId) && _deliveredThisTurn) + { + Context.System.EventStream.Publish(new ReminderDeliveryObserved( + completed.SourceReminderId, + ChannelType.Mattermost, + completed.TimestampMs)); + } + + if (!_deliveredThisTurn) + await SafeReplyAsync(EmptyTurnFallbackText); + + _turnNumber = completed.TurnNumber; + _pendingApprovalRequests.Clear(); + _deliveredThisTurn = false; + break; + } + } + + private async Task SafeReplyWithApprovalPromptAsync(ToolInteractionRequest request) + { + var promptText = BuildApprovalPromptText(request); + var startedAt = _dependencies.TimeProvider.GetTimestamp(); + try + { + var postMessage = BuildPostMessage(promptText); + var result = await _dependencies.ReplyClient.PostReplyAsync(postMessage); + var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; + ChannelTelemetry.For(ChannelType.Mattermost).RecordReplyPosted(duration); + return result.PostId; + } + catch (Exception ex) + { + _log.Error(ex, "Failed posting Mattermost approval prompt; auto-denying request"); + ChannelTelemetry.For(ChannelType.Mattermost).RecordExtra("approvalFallbackActivated", "auto_deny"); + await SendApprovalDenyOnFailureAsync(request.CallId); + return null; + } + } + + /// + /// Builds a simple markdown-formatted approval prompt. Interactive + /// buttons will be added via a dedicated MattermostApprovalPromptBuilder + /// in a follow-up change. + /// + private static string BuildApprovalPromptText(ToolInteractionRequest request) + { + 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("`"); + } + } + + if (request.HasAdoptedContext) + { + sb.Append("**Adopted context:** present").AppendLine(); + sb.Append("**Speakers:** `").Append(string.Join(", ", request.AdoptedSpeakerIds)).AppendLine("`"); + } + + sb.AppendLine(); + sb.AppendLine("Reply with:"); + sb.Append("A) ").AppendLine(ApprovalOptionKeys.ApproveOnceLabel); + sb.Append("B) ").AppendLine(ApprovalOptionKeys.ApproveSessionLabel); + sb.Append("C) ").AppendLine(ApprovalOptionKeys.ApproveAlwaysLabel); + sb.Append("D) ").AppendLine(ApprovalOptionKeys.DenyLabel); + return sb.ToString().TrimEnd(); + } + + private static string BuildResolvedApprovalText( + ToolInteractionRequest request, + string selectedKey, + string senderId) + { + var statusEmoji = selectedKey == ApprovalOptionKeys.Deny + ? ":no_entry:" + : ":white_check_mark:"; + var decisionLabel = selectedKey switch + { + ApprovalOptionKeys.ApproveOnce => ApprovalOptionKeys.ApproveOnceLabel, + ApprovalOptionKeys.ApproveSession => ApprovalOptionKeys.ApproveSessionLabel, + ApprovalOptionKeys.ApproveAlways => ApprovalOptionKeys.ApproveAlwaysLabel, + ApprovalOptionKeys.Deny => ApprovalOptionKeys.DenyLabel, + _ => 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("`"); + sb.Append("**Decision:** ").Append(decisionLabel); + sb.Append(" (by @").Append(senderId).Append(')'); + return sb.ToString(); + } + + private async Task SafeReplyAsync(string text) + { + var startedAt = _dependencies.TimeProvider.GetTimestamp(); + try + { + var postMessage = BuildPostMessage(text); + await _dependencies.ReplyClient.PostReplyAsync(postMessage); + var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; + ChannelTelemetry.For(ChannelType.Mattermost).RecordReplyPosted(duration); + } + catch (Exception ex) + { + var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; + _log.Warning(ex, "Failed posting Mattermost reply for session {0}", _sessionId.Value); + ChannelTelemetry.For(ChannelType.Mattermost).RecordReplyFailed(duration); + await NotifyDeliveryFailedAsync(DeliveryFailureKind.TransportFailure, ex.Message); + } + } + + private MattermostPostMessage BuildPostMessage(string text) + => new( + ChannelId: _channelId, + Text: text, + RootPostId: _rootPostId.IsEmpty ? null : new MattermostPostId(_rootPostId.Value)); + + private async Task NotifyDeliveryFailedAsync(DeliveryFailureKind failureKind, string errorMessage) + { + try + { + await _dependencies.Pipeline.SendFeedbackAsync(new DeliveryFailed + { + SessionId = _sessionId, + TurnNumber = _turnNumber, + ChannelType = ChannelType.Mattermost, + FailureKind = failureKind, + ErrorMessage = errorMessage + }); + } + catch (Exception ex) + { + _log.Error(ex, "Failed to send delivery feedback to session"); + } + } + + private async Task SendApprovalDenyOnFailureAsync(string callId) + { + var pending = _pendingApprovalRequests.LastOrDefault(p => + string.Equals(p.CallId, callId, StringComparison.Ordinal)); + if (pending is not null) + _pendingApprovalRequests.Remove(pending); + + try + { + await _dependencies.Pipeline.SendFeedbackAsync(new ToolInteractionResponse + { + SessionId = _sessionId, + CallId = callId, + SelectedKey = ApprovalOptionKeys.Deny, + SenderId = "system" + }); + } + catch (Exception ex) + { + _log.Error(ex, "Failed to send auto-deny feedback for call {CallId}", callId); + } + } + + private static readonly TimeSpan OperationTimeout = TimeSpan.FromSeconds(10); + + private async Task ProcessInboundAttachmentsAsync( + IReadOnlyList files, + TrustAudience audience, + List contents, + CancellationToken cancellationToken) + { + if (_dependencies.HttpClient is null) + { + _log.Warning( + "Mattermost HTTP client is not configured; rejecting {Count} inbound attachment(s)", + files.Count); + await SafeReplyAsync(":warning: I can't download attachments right now -- HTTP client is not configured."); + return; + } + + var profile = ToolAudienceProfileDefaults.GetResolvedProfile(_dependencies.AudienceProfiles, audience); + var policy = profile.ChannelAttachments ?? ChannelAttachmentPolicy.Empty; + + if (files.Count > policy.MaxFilesPerMessage) + { + _log.Warning( + "mattermost_attachments_rejected count={Count} limit={Limit} audience={Audience} reason=too-many-files", + files.Count, + policy.MaxFilesPerMessage, + audience); + await SafeReplyAsync( + $":warning: I can only accept up to {policy.MaxFilesPerMessage} attachments per message. " + + "Please split your upload and try again. Text content was delivered."); + return; + } + + var modelCapabilities = _dependencies.ModelCapabilities; + var inlineImages = modelCapabilities.InputModalities.HasFlag(ModelModality.Image); + + var acceptedLines = new List(files.Count); + var dataContents = new List(); + var rejections = new List(); + + var inboxDir = SessionDirectoryHelper.GetOrCreateInboxDirectory(_sessionId, _dependencies.Paths.SessionsDirectory); + var stagingDir = SessionDirectoryHelper.GetOrCreateAttachmentStagingDirectory(_sessionId, _dependencies.Paths.SessionsDirectory); + + foreach (var file in files) + { + var attachmentResult = await TryIngestSingleAttachmentAsync( + file, audience, policy, inlineImages, inboxDir, stagingDir, cancellationToken); + + switch (attachmentResult) + { + case AttachmentIngestResult.Accepted accepted: + acceptedLines.Add(accepted.Line); + if (accepted.Inline is { } inline) + dataContents.Add(inline); + break; + + case AttachmentIngestResult.Rejected rejected: + rejections.Add(rejected.UserFacingReason); + break; + } + } + + if (acceptedLines.Count > 0) + { + contents.Add(new TextContent(string.Join('\n', acceptedLines))); + contents.AddRange(dataContents); + } + + if (rejections.Count > 0) + { + var joined = rejections.Count == 1 + ? rejections[0] + : ":warning: Some attachments were not accepted:\n - " + string.Join("\n - ", rejections); + await SafeReplyAsync(joined); + } + } + + private async Task TryIngestSingleAttachmentAsync( + MattermostFileReference file, + TrustAudience audience, + ChannelAttachmentPolicy policy, + bool inlineImages, + string inboxDir, + string stagingDir, + CancellationToken cancellationToken) + { + var category = AttachmentCategories.FromMime(file.MimeType); + + if (!policy.Allows(category)) + { + _log.Warning( + "mattermost_attachment_rejected name={Name} mime={Mime} audience={Audience} category={Category} reason=category-not-allowed", + file.Name, file.MimeType, audience, category); + return new AttachmentIngestResult.Rejected( + $"`{file.Name}` ({category}) isn't allowed in {audience} channels. " + + "Please DM me if you want to share this class of file."); + } + + if (file.Size > policy.MaxFileBytes) + { + _log.Warning( + "mattermost_attachment_rejected name={Name} mime={Mime} audience={Audience} size={Size} limit={Limit} reason=too-large", + file.Name, file.MimeType, audience, file.Size, policy.MaxFileBytes); + return new AttachmentIngestResult.Rejected( + $"`{file.Name}` ({FormatBytes(file.Size)}) exceeds the {FormatBytes(policy.MaxFileBytes)} per-file limit."); + } + + // Mattermost attachment URLs must originate from the configured server. + var serverUrl = _dependencies.ServerUrl ?? string.Empty; + if (!string.IsNullOrEmpty(serverUrl) && !MattermostAttachmentUrlTrust.IsAllowedAttachmentUrl(file.Url, serverUrl)) + { + _log.Warning( + "mattermost_attachment_rejected name={Name} url={Url} reason=untrusted-url", + file.Name, file.Url); + return new AttachmentIngestResult.Rejected( + $"`{file.Name}` has an untrusted URL and was skipped."); + } + + AttachmentDownloadResult downloadResult; + try + { + using var downloadCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + downloadCts.CancelAfter(OperationTimeout); + downloadResult = await StreamingAttachmentDownloader.DownloadToFileAsync( + _dependencies.HttpClient!, file.Url, configureRequest: null, + stagingDir, policy.MaxFileBytes, downloadCts.Token, + (ex, path) => _log.Error(ex, "Failed to clean up staged download file {0}", path)); + } + catch (AttachmentTooLargeException ex) + { + _log.Warning( + "mattermost_attachment_rejected name={Name} mime={Mime} audience={Audience} size={Size} limit={Limit} reason=too-large-during-download", + file.Name, file.MimeType, audience, ex.BytesReceived, ex.MaxBytes); + return new AttachmentIngestResult.Rejected( + $"`{file.Name}` ({FormatBytes(ex.BytesReceived)}) exceeds the {FormatBytes(ex.MaxBytes)} per-file limit."); + } + catch (OperationCanceledException ex) + { + _log.Warning(ex, + "mattermost_attachment_rejected name={Name} mime={Mime} reason=download-timeout", + file.Name, file.MimeType); + return new AttachmentIngestResult.Rejected( + $"Timed out downloading `{file.Name}`. Please try again."); + } + catch (Exception ex) + { + _log.Warning(ex, + "mattermost_attachment_rejected name={Name} mime={Mime} reason=download-failed", + file.Name, file.MimeType); + return new AttachmentIngestResult.Rejected( + $"Couldn't download `{file.Name}` -- please try again later."); + } + + if (downloadResult.BytesWritten == 0) + { + _log.Warning( + "mattermost_attachment_rejected name={Name} mime={Mime} reason=empty-download", + file.Name, file.MimeType); + TryDeleteTemp(downloadResult.FilePath); + return new AttachmentIngestResult.Rejected( + $"`{file.Name}` downloaded as zero bytes."); + } + + ContentScanResult scanResult; + try + { + using var scanCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + scanCts.CancelAfter(OperationTimeout); + scanResult = await _dependencies.ContentScanner.ScanFileAsync( + downloadResult.FilePath, file.Name, file.MimeType, scanCts.Token); + } + catch (Exception ex) + { + _log.Warning(ex, + "mattermost_attachment_rejected name={Name} mime={Mime} reason=scan-exception", + file.Name, file.MimeType); + TryDeleteTemp(downloadResult.FilePath); + return new AttachmentIngestResult.Rejected( + $"Couldn't scan `{file.Name}` -- please try again later."); + } + + if (!scanResult.IsAllowed) + { + _log.Warning( + "mattermost_attachment_rejected name={Name} mime={Mime} reason=scan-blocked error={ScanError} message={ScanMessage}", + file.Name, file.MimeType, scanResult.Error?.ToString(), scanResult.Message ?? scanResult.Error?.ToString()); + + TryDeleteTemp(downloadResult.FilePath); + + if (scanResult.Error == ContentScanError.ScanFailure) + { + return new AttachmentIngestResult.Rejected( + $"Couldn't scan `{file.Name}` -- please try again later."); + } + + return new AttachmentIngestResult.Rejected( + $"Content scanner rejected `{file.Name}`: {scanResult.Message ?? scanResult.Error?.ToString()}."); + } + + string inboxPath; + try + { + inboxPath = InboxWriter.SanitizeReserveAndMove( + inboxDir, file.Name, downloadResult.FilePath); + } + catch (InboxWriter.CollisionExhaustedException ex) + { + _log.Warning(ex, + "mattermost_attachment_rejected name={Name} reason=collision-exhausted", + file.Name); + TryDeleteTemp(downloadResult.FilePath); + return new AttachmentIngestResult.Rejected( + $"Too many attachments named `{file.Name}` in this session -- please rename and try again."); + } + catch (Exception ex) + { + _log.Error(ex, + "mattermost_attachment_rejected name={Name} reason=inbox-write-failed", + file.Name); + TryDeleteTemp(downloadResult.FilePath); + return new AttachmentIngestResult.Rejected( + $"Couldn't save `{file.Name}` -- please try again later."); + } + + var (inlined, note) = AttachmentIngressFormatting.ResolveInlineDecision(category, inlineImages); + + var relativePath = $"{SessionDirectoryHelper.InboxSubdirectory}/{IOPath.GetFileName(inboxPath)}"; + var line = AttachmentIngressFormatting.BuildAttachmentLine( + file.Name, file.MimeType, downloadResult.BytesWritten, relativePath, inlined, note); + + DataContent? inlineContent = null; + if (inlined) + { + var inlineBytes = await File.ReadAllBytesAsync(inboxPath, cancellationToken); + inlineContent = new DataContent(inlineBytes, file.MimeType); + } + + _log.Info( + "mattermost_attachment_accepted name={Name} mime={Mime} size={Size} audience={Audience} category={Category} inlined={Inlined}", + file.Name, file.MimeType, downloadResult.BytesWritten, audience, category, inlined); + + return new AttachmentIngestResult.Accepted(line, inlineContent); + } + + private void TryDeleteTemp(string tempPath) + { + try + { + if (File.Exists(tempPath)) + File.Delete(tempPath); + } + catch (Exception ex) + { + _log.Error(ex, "Failed to clean up staged attachment file {Path}", tempPath); + } + } + + private static string FormatBytes(long size) => AttachmentIngressFormatting.FormatBytes(size); + + private abstract record AttachmentIngestResult + { + public sealed record Accepted(string Line, DataContent? Inline) : AttachmentIngestResult; + + public sealed record Rejected(string UserFacingReason) : AttachmentIngestResult; + } + + private void AdvanceCursor(string candidatePostId) + { + if (_cursorPostId is not null && string.CompareOrdinal(candidatePostId, _cursorPostId) <= 0) + { + _log.Debug("Mattermost session cursor did not advance session={Session} postId={PostId}", + _sessionId.Value, candidatePostId); + return; + } + + Persist(new CursorAdvanced(candidatePostId), ApplyCursorAdvanced); + } + + private void ApplyCursorAdvanced(CursorAdvanced advanced) + { + _cursorPostId = advanced.CursorPostId; + + if (!IsRecovering && LastSequenceNr > 1 && LastSequenceNr % 10 == 0) + DeleteMessages(LastSequenceNr - 1); + } + + private readonly record struct CursorAdvanced(string CursorPostId); + + private sealed record InitializePipeline + { + public static readonly InitializePipeline Instance = new(); + } + + private sealed record OutputReceived(SessionOutput Output); + + private sealed record OutputStreamTerminated(int Generation, Exception? Cause); + + private sealed record ReinitializePipeline(string Reason); +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs b/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs new file mode 100644 index 00000000..57ca6a87 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs @@ -0,0 +1,115 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +namespace Netclaw.Channels.Mattermost; + +/// +/// Normalized inbound Mattermost message payload emitted by the transport client. +/// +public sealed record MattermostGatewayMessage( + MattermostEventId EventId, + MattermostChannelId ChannelId, + MattermostPostId PostId, + MattermostRootPostId RootPostId, + MattermostUserId SenderId, + bool IsBotMessage, + bool IsDirectMessage, + bool ContainsBotMention, + string Text, + DateTimeOffset ReceivedAt, + IReadOnlyList? Attachments = null); + +/// +/// Normalized Mattermost interactive action response emitted by the transport client. +/// +public sealed record MattermostGatewayInteraction( + MattermostChannelId ChannelId, + MattermostRootPostId RootPostId, + string CallId, + string SelectedKey, + MattermostUserId SenderId, + MattermostUserId? RequesterSenderId, + DateTimeOffset ReceivedAt); + +public interface IMattermostGatewayClient +{ + event Func? MessageReceived; + + event Func? InteractionReceived; + + bool IsConnected { get; } + + MattermostUserId? BotUserId { get; } + + Task ConnectAsync(string serverUrl, string botToken, CancellationToken cancellationToken = default); + + Task DisconnectAsync(CancellationToken cancellationToken = default); +} + +public interface IMattermostReplyClient +{ + Task PostReplyAsync(MattermostPostMessage message, CancellationToken cancellationToken = default); + + Task UpdatePostAsync( + MattermostPostId postId, + string text, + CancellationToken cancellationToken = default); +} + +public sealed record MattermostPostMessage( + MattermostChannelId ChannelId, + string Text, + MattermostPostId? RootPostId = null, + IReadOnlyList? FileIds = null); + +public sealed record MattermostPostResult( + MattermostPostId? PostId = null) +{ + public static readonly MattermostPostResult Default = new(); +} + +/// +/// Placeholder transport client that fails loud until the real Mattermost +/// gateway wiring is added. +/// +public sealed class UnconfiguredMattermostGatewayClient : IMattermostGatewayClient +{ + public event Func? MessageReceived + { + add { } + remove { } + } + + public event Func? InteractionReceived + { + add { } + remove { } + } + + public bool IsConnected => false; + + public MattermostUserId? BotUserId => null; + + public Task ConnectAsync(string serverUrl, string botToken, CancellationToken cancellationToken = default) + => throw new InvalidOperationException( + "Mattermost channel is enabled, but no Mattermost gateway client is configured."); + + public Task DisconnectAsync(CancellationToken cancellationToken = default) + => Task.CompletedTask; +} + +/// +/// Placeholder reply client that fails loud until Mattermost outbound delivery is wired. +/// +public sealed class UnconfiguredMattermostReplyClient : IMattermostReplyClient +{ + public Task PostReplyAsync(MattermostPostMessage message, CancellationToken cancellationToken = default) + => throw new InvalidOperationException( + "Mattermost channel attempted outbound delivery, but no Mattermost reply client is configured."); + + public Task UpdatePostAsync(MattermostPostId postId, string text, CancellationToken cancellationToken = default) + => throw new InvalidOperationException( + "Mattermost channel attempted to update a post, but no Mattermost reply client is configured."); +} diff --git a/src/Netclaw.Channels.Mattermost/Netclaw.Channels.Mattermost.csproj b/src/Netclaw.Channels.Mattermost/Netclaw.Channels.Mattermost.csproj new file mode 100644 index 00000000..e38ba743 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/Netclaw.Channels.Mattermost.csproj @@ -0,0 +1,28 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs b/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs new file mode 100644 index 00000000..3da51b3c --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs @@ -0,0 +1,104 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.ComponentModel; +using System.Text; +using Mattermost; +using Netclaw.Tools; + +namespace Netclaw.Channels.Mattermost.Tools; + +/// +/// LLM tool that looks up Mattermost users by username or email. +/// Returns user IDs suitable for use with . +/// +[NetclawTool("lookup_mattermost_user", + "Look up a Mattermost user by username or email. " + + "Returns their user ID for use with send_mattermost_message.", + Grant = "builtin")] +public sealed partial class LookupMattermostUserTool : NetclawTool, IChannelTool +{ + private readonly MattermostClient _client; + private readonly MattermostChannelOptions _options; + + public record Params( + [property: Description("Username or email address to search for")] + string Query); + + public LookupMattermostUserTool(MattermostClient client, MattermostChannelOptions options) + { + _client = client; + _options = options; + } + + protected override async Task ExecuteAsync(Params args, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(args.Query)) + return "Error: 'query' parameter is required."; + + var query = args.Query.Trim(); + + // Strip leading @ if present (users often type @username) + if (query.StartsWith('@')) + query = query[1..]; + + var sb = new StringBuilder(); + + // Try username lookup first + try + { + var user = await _client.GetUserByUsernameAsync(query); + if (user is not null && !IsFilteredOut(user)) + { + AppendUser(sb, user); + return sb.ToString().TrimEnd(); + } + } + catch + { + // Username not found — fall through to email lookup + } + + // Try email lookup + if (query.Contains('@', StringComparison.Ordinal)) + { + try + { + var user = await _client.GetUserByEmailAsync(query); + if (user is not null && !IsFilteredOut(user)) + { + AppendUser(sb, user); + return sb.ToString().TrimEnd(); + } + } + catch + { + // Email not found either + } + } + + return "No matching user found. Try an exact username (without @) or email address."; + } + + private bool IsFilteredOut(global::Mattermost.Models.Users.User user) + { + if (_options.AllowedUserIds.Length > 0 + && !_options.AllowedUserIds.Contains(user.Id, StringComparer.Ordinal)) + return true; + + return false; + } + + private static void AppendUser(StringBuilder sb, global::Mattermost.Models.Users.User user) + { + sb.AppendLine("Found user:"); + sb.Append($" {user.Id} (@{user.Username})"); + if (!string.IsNullOrWhiteSpace(user.FirstName) || !string.IsNullOrWhiteSpace(user.LastName)) + sb.Append($" — {user.FirstName} {user.LastName}".TrimEnd()); + if (!string.IsNullOrWhiteSpace(user.Email)) + sb.Append($" — {user.Email}"); + sb.AppendLine(); + } +} diff --git a/src/Netclaw.Channels.Mattermost/Tools/SendMattermostMessageTool.cs b/src/Netclaw.Channels.Mattermost/Tools/SendMattermostMessageTool.cs new file mode 100644 index 00000000..e9741f6c --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/Tools/SendMattermostMessageTool.cs @@ -0,0 +1,123 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.ComponentModel; +using Akka.Actor; +using Netclaw.Actors.Protocol; +using Netclaw.Tools; + +namespace Netclaw.Channels.Mattermost.Tools; + +/// +/// LLM tool that sends a proactive message to a Mattermost channel or DMs a user, +/// creating a new conversation thread. The new thread is wired into the actor +/// hierarchy so user replies route back to a live session. +/// +[NetclawTool("send_mattermost_message", + "Send a message to a Mattermost channel or DM a user, creating a new conversation thread. " + + "Use this to proactively notify users or start discussions. " + + "Provide exactly one of channel_id or user_id.", + Grant = "builtin")] +public sealed partial class SendMattermostMessageTool : NetclawTool, IChannelTool +{ + private readonly IMattermostOutboundClient _outboundClient; + private readonly MattermostChannelOptions _options; + private readonly Func _defaultChannelIdAccessor; + private readonly Func _gatewayAccessor; + + public record Params( + [property: Description("The message text to send")] + string Message, + [property: Description("Mattermost channel ID to post to. Mutually exclusive with user_id.")] + string? ChannelId = null, + [property: Description("Mattermost user ID to DM. Mutually exclusive with channel_id.")] + string? UserId = null); + + public SendMattermostMessageTool( + IMattermostOutboundClient outboundClient, + MattermostChannelOptions options, + Func defaultChannelIdAccessor, + Func gatewayAccessor) + { + _outboundClient = outboundClient; + _options = options; + _defaultChannelIdAccessor = defaultChannelIdAccessor; + _gatewayAccessor = gatewayAccessor; + } + + protected override async Task ExecuteAsync(Params args, CancellationToken ct) + { + if (string.IsNullOrWhiteSpace(args.Message)) + return "Error: 'message' parameter is required."; + + var hasChannel = !string.IsNullOrWhiteSpace(args.ChannelId); + var hasUser = !string.IsNullOrWhiteSpace(args.UserId); + + if (hasChannel == hasUser) + return "Error: Provide exactly one of 'channel_id' or 'user_id'."; + + var gateway = _gatewayAccessor(); + if (gateway is null) + return "Error: Mattermost gateway is not connected."; + + MattermostChannelId targetChannelId; + + if (hasUser) + { + if (!_options.AllowDirectMessages) + return "Error: Direct messages are disabled. Enable AllowDirectMessages in Mattermost configuration to send DMs."; + + var userId = new MattermostUserId(args.UserId!); + + if (!MattermostAclPolicy.IsAllowedUser(userId, _options)) + return $"Error: User {userId.Value} is not in the allowed users list."; + + try + { + targetChannelId = await _outboundClient.OpenDmChannelAsync(userId, ct); + } + catch (Exception ex) + { + return $"Error: Failed to open DM channel: {ex.Message}"; + } + } + else + { + targetChannelId = new MattermostChannelId(args.ChannelId!); + + if (!MattermostAclPolicy.IsAllowedChannel(targetChannelId, _options, _defaultChannelIdAccessor())) + return $"Error: Channel {targetChannelId.Value} is not in the allowed channels list."; + } + + MattermostNewThread result; + try + { + result = await _outboundClient.PostNewThreadAsync(targetChannelId, args.Message, ct); + } + catch (Exception ex) + { + return $"Error: Failed to post message to Mattermost: {ex.Message}"; + } + + var sessionId = new SessionId($"{result.ChannelId.Value}/{result.RootPostId.Value}"); + + try + { + await gateway.Ask( + new StartMattermostProactiveThread(result.ChannelId, result.RootPostId, sessionId), + TimeSpan.FromSeconds(30), + ct); + } + catch (Exception) + { + var target = hasUser ? $"user {args.UserId}" : $"channel {args.ChannelId}"; + return $"Message sent to {target} but session pipeline failed to initialize. " + + $"Thread: {result.ChannelId.Value}/{result.RootPostId.Value}"; + } + + var successTarget = hasUser ? $"user {args.UserId}" : $"channel {args.ChannelId}"; + return $"Message sent to {successTarget}. Thread: {result.ChannelId.Value}/{result.RootPostId.Value}"; + } +} diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs new file mode 100644 index 00000000..f3141ba3 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs @@ -0,0 +1,150 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Mattermost; +using Mattermost.Events; +using Microsoft.Extensions.Logging; + +namespace Netclaw.Channels.Mattermost.Transport; + +internal sealed class MattermostNetGatewayClient : IMattermostGatewayClient, IDisposable +{ + private readonly MattermostClient _client; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + + private string? _serverUrl; + + public event Func? MessageReceived; + + // Interactive message actions will be wired in a follow-up when + // Mattermost attachment action support is implemented. +#pragma warning disable CS0067 + public event Func? InteractionReceived; +#pragma warning restore CS0067 + + public bool IsConnected => _client.IsConnected; + public MattermostUserId? BotUserId { get; private set; } + + public MattermostNetGatewayClient( + MattermostClient client, + TimeProvider timeProvider, + ILogger logger) + { + _client = client; + _timeProvider = timeProvider; + _logger = logger; + } + + public async Task ConnectAsync(string serverUrl, string botToken, CancellationToken cancellationToken = default) + { + _serverUrl = serverUrl.TrimEnd('/'); + _client.Options.IgnoreOwnMessages = true; + + _client.OnMessageReceived += OnMessageReceived; + _client.OnConnected += OnConnected; + _client.OnDisconnected += OnDisconnected; + _client.OnLogMessage += OnLogMessage; + + var me = await _client.GetMeAsync(); + BotUserId = new MattermostUserId(me.Id); + _logger.LogInformation("Mattermost bot identity resolved: {BotUserId} (@{Username})", + me.Id, me.Username); + + await _client.StartReceivingAsync(cancellationToken); + } + + public async Task DisconnectAsync(CancellationToken cancellationToken = default) + { + await _client.StopReceivingAsync(); + } + + private void OnMessageReceived(object? sender, MessageEventArgs e) + { + var handler = MessageReceived; + if (handler is null) + return; + + var post = e.Message.Post; + var channelType = e.Message.ChannelType; + var isDm = string.Equals(channelType, "D", StringComparison.Ordinal); + + var botId = BotUserId?.Value; + var containsMention = botId is not null + && !string.IsNullOrEmpty(post.Text) + && post.Text.Contains($"@{e.Client.CurrentUserInfo.Username}", StringComparison.OrdinalIgnoreCase); + + // Mentions field is a JSON array of user IDs + if (!containsMention && botId is not null && !string.IsNullOrEmpty(e.Message.Mentions)) + { + containsMention = e.Message.Mentions.Contains(botId, StringComparison.Ordinal); + } + + var rootPostId = string.IsNullOrEmpty(post.RootId) + ? new MattermostRootPostId(string.Empty) + : new MattermostRootPostId(post.RootId); + + IReadOnlyList? attachments = null; + if (post.FileIdentifiers.Count > 0) + { + attachments = post.FileIdentifiers + .Select(fileId => new MattermostFileReference( + Name: fileId, + MimeType: "application/octet-stream", + Size: 0, + Url: $"{_serverUrl}/api/v4/files/{fileId}")) + .ToList(); + } + + var gatewayMessage = new MattermostGatewayMessage( + EventId: new MattermostEventId(post.Id), + ChannelId: new MattermostChannelId(post.ChannelId), + PostId: new MattermostPostId(post.Id), + RootPostId: rootPostId, + SenderId: new MattermostUserId(post.UserId), + IsBotMessage: false, // Mattermost.NET already filters bot's own messages + IsDirectMessage: isDm, + ContainsBotMention: containsMention, + Text: post.Text ?? string.Empty, + ReceivedAt: _timeProvider.GetUtcNow(), + Attachments: attachments); + + _ = Task.Run(async () => + { + try + { + await handler(gatewayMessage); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling Mattermost message {PostId}", post.Id); + } + }); + } + + private void OnConnected(object? sender, ConnectionEventArgs e) + { + _logger.LogInformation("Connected to Mattermost WebSocket at {Uri}", e.Uri); + } + + private void OnDisconnected(object? sender, DisconnectionEventArgs e) + { + _logger.LogWarning("Disconnected from Mattermost WebSocket: {Reason}", e.CloseStatusDescription); + } + + private void OnLogMessage(object? sender, LogEventArgs e) + { + _logger.LogDebug("[Mattermost.NET] {Message}", e.Message); + } + + public void Dispose() + { + _client.OnMessageReceived -= OnMessageReceived; + _client.OnConnected -= OnConnected; + _client.OnDisconnected -= OnDisconnected; + _client.OnLogMessage -= OnLogMessage; + // Do not dispose the MattermostClient — it's owned by the DI container. + } +} diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetOutboundClient.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetOutboundClient.cs new file mode 100644 index 00000000..312fccb8 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetOutboundClient.cs @@ -0,0 +1,37 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Mattermost; + +namespace Netclaw.Channels.Mattermost.Transport; + +internal sealed class MattermostNetOutboundClient : IMattermostOutboundClient +{ + private readonly MattermostClient _client; + + public MattermostNetOutboundClient(MattermostClient client) + { + _client = client; + } + + public async Task OpenDmChannelAsync(MattermostUserId userId, CancellationToken ct = default) + { + var channel = await _client.CreateDirectChannelAsync(userId.Value); + return new MattermostChannelId(channel.Id); + } + + public async Task PostNewThreadAsync(MattermostChannelId channelId, string text, CancellationToken ct = default) + { + var post = await _client.CreatePostAsync( + channelId: channelId.Value, + message: text); + + if (string.IsNullOrEmpty(post.Id)) + throw new InvalidOperationException( + "Mattermost returned no post ID — the message was not delivered"); + + return new MattermostNewThread(channelId, new MattermostRootPostId(post.Id)); + } +} diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs new file mode 100644 index 00000000..c5c2d548 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs @@ -0,0 +1,38 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Mattermost; + +namespace Netclaw.Channels.Mattermost.Transport; + +internal sealed class MattermostNetReplyClient : IMattermostReplyClient +{ + private readonly MattermostClient _client; + + public MattermostNetReplyClient(MattermostClient client) + { + _client = client; + } + + public async Task PostReplyAsync(MattermostPostMessage message, CancellationToken cancellationToken = default) + { + var post = await _client.CreatePostAsync( + channelId: message.ChannelId.Value, + message: message.Text, + replyToPostId: message.RootPostId?.Value ?? string.Empty, + files: message.FileIds); + + return new MattermostPostResult( + PostId: new MattermostPostId(post.Id)); + } + + public async Task UpdatePostAsync( + MattermostPostId postId, + string text, + CancellationToken cancellationToken = default) + { + await _client.UpdatePostAsync(postId.Value, text); + } +} diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs new file mode 100644 index 00000000..a5e88963 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs @@ -0,0 +1,599 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Mattermost; +using Mattermost.Models; +using Mattermost.Models.Posts; +using Mattermost.Models.Responses; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; +using Netclaw.Actors.Channels; +using Netclaw.Actors.Protocol; +using Netclaw.Channels; +using Netclaw.Configuration; +using Netclaw.Security; +using IOFile = System.IO.File; + +namespace Netclaw.Channels.Mattermost.Transport; + +public sealed class MattermostThreadHistoryFetcher : IThreadHistoryFetcher +{ + private static readonly TimeSpan FileDownloadTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan ContentScanTimeout = TimeSpan.FromSeconds(5); + + internal sealed record HistoricalMessage( + string MessageId, + string SenderId, + bool IsBot, + string Text, + DateTimeOffset Timestamp, + IReadOnlyList Attachments); + + internal delegate Task> MessageFetcher( + string rootPostId, + CancellationToken cancellationToken); + + /// + /// Downloads a file by its Mattermost file ID and writes it to the staging directory. + /// Returns the staging file path and byte count, or null on failure. + /// + internal delegate Task<(string FilePath, long BytesWritten)?> FileDownloader( + string fileId, + string stagingDir, + long maxBytes, + CancellationToken cancellationToken); + + private readonly MessageFetcher _messageFetcher; + private readonly FileDownloader _fileDownloader; + private readonly IContentScanner _contentScanner; + private readonly IPromptInjectionDetector _promptInjectionDetector; + private readonly MattermostChannelOptions _options; + private readonly string _serverUrl; + private readonly string? _botUserId; + private readonly ToolAudienceProfiles _audienceProfiles; + private readonly ModelCapabilities _modelCapabilities; + private readonly NetclawPaths _paths; + private readonly ILogger _logger; + + public MattermostThreadHistoryFetcher( + MattermostClient client, + IContentScanner contentScanner, + IPromptInjectionDetector promptInjectionDetector, + MattermostChannelOptions options, + string serverUrl, + string? botUserId, + ToolAudienceProfiles audienceProfiles, + ModelCapabilities modelCapabilities, + NetclawPaths paths, + ILogger logger) + : this( + (rootPostId, cancellationToken) => FetchRawMessagesAsync(client, rootPostId, botUserId, serverUrl, cancellationToken, logger), + (fileId, stagingDir, maxBytes, ct) => DownloadFileViaSdkAsync(client, fileId, stagingDir, maxBytes, ct), + contentScanner, + promptInjectionDetector, + options, + serverUrl, + botUserId, + audienceProfiles, + modelCapabilities, + paths, + logger) + { + } + + internal MattermostThreadHistoryFetcher( + MessageFetcher messageFetcher, + FileDownloader fileDownloader, + IContentScanner contentScanner, + IPromptInjectionDetector promptInjectionDetector, + MattermostChannelOptions options, + string serverUrl, + string? botUserId, + ToolAudienceProfiles audienceProfiles, + ModelCapabilities modelCapabilities, + NetclawPaths paths, + ILogger logger) + { + _messageFetcher = messageFetcher; + _fileDownloader = fileDownloader; + _contentScanner = contentScanner; + _promptInjectionDetector = promptInjectionDetector; + _options = options; + _serverUrl = serverUrl.TrimEnd('/'); + _botUserId = botUserId; + _audienceProfiles = audienceProfiles; + _modelCapabilities = modelCapabilities; + _paths = paths; + _logger = logger; + } + + public async Task> FetchThreadHistoryAsync( + SessionId sessionId, + CancellationToken cancellationToken = default) + { + if (!MattermostGatewayActor.TryParseMattermostSessionId(sessionId, out var channelId, out var rootPostId)) + { + _logger.LogWarning("Cannot extract channel/thread from session ID {SessionId}", sessionId.Value); + return []; + } + + var audienceResult = ResolveHistoricalAudience(channelId); + if (audienceResult.Error is { } audienceError) + { + _logger.LogWarning( + "Invalid Mattermost audience configuration while fetching history for {SessionId}: {Error}", + sessionId.Value, + audienceError); + return []; + } + + var audience = audienceResult.Audience; + var profile = ToolAudienceProfileDefaults.GetResolvedProfile(_audienceProfiles, audience); + var attachmentPolicy = profile.ChannelAttachments ?? ChannelAttachmentPolicy.Empty; + var inlineImages = _modelCapabilities.InputModalities.HasFlag(ModelModality.Image); + var inboxDir = SessionDirectoryHelper.GetOrCreateInboxDirectory(sessionId, _paths.SessionsDirectory); + var stagingDir = SessionDirectoryHelper.GetOrCreateAttachmentStagingDirectory(sessionId, _paths.SessionsDirectory); + + try + { + var history = await _messageFetcher(rootPostId.Value, cancellationToken); + var results = new List(history.Count); + + foreach (var message in history) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (message.IsBot) + continue; + + var input = await ConvertMessageAsync( + message, + channelId, + rootPostId, + audience, + attachmentPolicy, + inlineImages, + inboxDir, + stagingDir, + cancellationToken); + if (input is not null) + results.Add(input); + } + + _logger.LogInformation( + "Fetched {Count} thread history messages for Mattermost thread {RootPostId}", + results.Count, rootPostId.Value); + return results; + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + _logger.LogWarning(ex, "Failed to fetch thread history for {SessionId}", sessionId.Value); + return []; + } + } + + private async Task ConvertMessageAsync( + HistoricalMessage message, + MattermostChannelId channelId, + MattermostRootPostId rootPostId, + TrustAudience audience, + ChannelAttachmentPolicy attachmentPolicy, + bool inlineImages, + string inboxDir, + string stagingDir, + CancellationToken cancellationToken) + { + var contents = new List(); + + if (!string.IsNullOrWhiteSpace(message.Text)) + contents.Add(new TextContent(message.Text)); + + if (message.Attachments.Count > 0) + { + if (message.Attachments.Count > attachmentPolicy.MaxFilesPerMessage) + { + _logger.LogWarning( + "Skipping {Count} historical Mattermost attachments on thread {RootPostId}; limit is {Limit} for audience {Audience}", + message.Attachments.Count, + rootPostId.Value, + attachmentPolicy.MaxFilesPerMessage, + audience); + contents.Add(BuildHistoricalAttachmentRejected( + $"{message.Attachments.Count} historical attachments exceed the {attachmentPolicy.MaxFilesPerMessage} per-message limit")); + } + else + { + var attachmentTasks = message.Attachments.Select(file => DownloadAndProjectAttachmentAsync( + message.MessageId, + file, + audience, + attachmentPolicy, + inlineImages, + inboxDir, + stagingDir, + cancellationToken)); + var attachmentResults = await Task.WhenAll(attachmentTasks); + + foreach (var result in attachmentResults) + contents.AddRange(result); + } + } + + if (contents.Count == 0) + return null; + + return new ChannelInput + { + SenderId = message.SenderId, + ChannelId = channelId.Value, + MessageId = message.MessageId, + Audience = audience, + Principal = PrincipalClassification.UntrustedExternal, + Provenance = new SourceProvenance + { + TransportAuthenticity = TransportAuthenticity.Verified, + PayloadTaint = PayloadTaint.Public, + SourceKind = "mattermost", + SourceScope = rootPostId.Value + }, + Contents = contents, + ReceivedAt = message.Timestamp + }; + } + + private async Task> DownloadAndProjectAttachmentAsync( + string messageId, + MattermostFileReference file, + TrustAudience audience, + ChannelAttachmentPolicy policy, + bool inlineImages, + string inboxDir, + string stagingDir, + CancellationToken cancellationToken) + { + var category = AttachmentCategories.FromMime(file.MimeType); + var sourceKey = BuildHistoricalAttachmentSourceKey(messageId, file); + + if (!policy.Allows(category)) + { + _logger.LogWarning( + "Historical Mattermost attachment {Name} rejected: category {Category} not allowed for {Audience}", + file.Name, + category, + audience); + return [BuildHistoricalAttachmentRejected( + $"historical attachment ({file.MimeType}) category not allowed in {audience}")]; + } + + if (file.Size > policy.MaxFileBytes) + { + _logger.LogWarning( + "Historical Mattermost attachment {Name} rejected: size {Size} exceeds {Limit}", + file.Name, + file.Size, + policy.MaxFileBytes); + return [BuildHistoricalAttachmentRejected( + $"historical attachment \"{AttachmentIngressFormatting.EscapeQuoted(file.Name)}\" exceeds the {AttachmentIngressFormatting.FormatBytes(policy.MaxFileBytes)} per-file limit")]; + } + + if (HistoricalAttachmentInbox.TryGetExistingFile(inboxDir, file.Name, sourceKey, out var existingPath, out var existingSize)) + return await BuildAcceptedAttachmentContentsAsync( + existingPath, + file.Name, + file.MimeType, + category, + inlineImages, + existingSize, + cancellationToken); + + if (!MattermostAttachmentUrlTrust.IsAllowedAttachmentUrl(file.Url, _serverUrl)) + { + _logger.LogWarning( + "Historical Mattermost attachment {Name} rejected: untrusted URL {Url}", + file.Name, + file.Url); + return [BuildHistoricalAttachmentRejected( + $"historical attachment \"{AttachmentIngressFormatting.EscapeQuoted(file.Name)}\" has an untrusted download URL")]; + } + + // Extract file ID from the URL for SDK-based download. + var fileId = ExtractFileId(file.Url); + if (fileId is null) + { + _logger.LogWarning( + "Historical Mattermost attachment {Name} rejected: could not extract file ID from URL {Url}", + file.Name, file.Url); + return [BuildHistoricalAttachmentRejected( + $"historical attachment \"{AttachmentIngressFormatting.EscapeQuoted(file.Name)}\" has an unrecognized URL format")]; + } + + (string FilePath, long BytesWritten)? downloadResult; + try + { + using var downloadCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + downloadCts.CancelAfter(FileDownloadTimeout); + downloadResult = await _fileDownloader(fileId, stagingDir, policy.MaxFileBytes, downloadCts.Token); + } + catch (OperationCanceledException) + { + _logger.LogWarning("Timed out downloading historical Mattermost attachment {Name}", file.Name); + return [BuildHistoricalAttachmentRejected( + $"historical attachment \"{AttachmentIngressFormatting.EscapeQuoted(file.Name)}\" timed out during download")]; + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed downloading historical Mattermost attachment {Name}", file.Name); + return [BuildHistoricalAttachmentRejected( + $"historical attachment \"{AttachmentIngressFormatting.EscapeQuoted(file.Name)}\" could not be downloaded")]; + } + + if (downloadResult is null || downloadResult.Value.BytesWritten == 0) + { + if (downloadResult is not null) + AttachmentStagingCleanup.TryDelete(downloadResult.Value.FilePath, _logger); + return [BuildHistoricalAttachmentRejected( + $"historical attachment \"{AttachmentIngressFormatting.EscapeQuoted(file.Name)}\" downloaded as zero bytes")]; + } + + var (stagedPath, bytesWritten) = downloadResult.Value; + + if (bytesWritten > policy.MaxFileBytes) + { + _logger.LogWarning( + "Historical Mattermost attachment {Name} rejected during download: {Size} exceeds {Limit}", + file.Name, bytesWritten, policy.MaxFileBytes); + AttachmentStagingCleanup.TryDelete(stagedPath, _logger); + return [BuildHistoricalAttachmentRejected( + $"historical attachment \"{AttachmentIngressFormatting.EscapeQuoted(file.Name)}\" exceeded the {AttachmentIngressFormatting.FormatBytes(policy.MaxFileBytes)} per-file limit during download")]; + } + + ContentScanResult scanResult; + try + { + using var scanCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + scanCts.CancelAfter(ContentScanTimeout); + scanResult = await _contentScanner.ScanFileAsync( + stagedPath, + file.Name, + file.MimeType, + scanCts.Token); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Historical Mattermost attachment scan threw for {Name}", file.Name); + AttachmentStagingCleanup.TryDelete(stagedPath, _logger); + return [BuildHistoricalAttachmentRejected( + $"historical attachment \"{AttachmentIngressFormatting.EscapeQuoted(file.Name)}\" could not be scanned")]; + } + + if (!scanResult.IsAllowed) + { + _logger.LogWarning( + "Historical Mattermost attachment {Name} rejected by scanner: {Error} {Message}", + file.Name, + scanResult.Error?.ToString(), + scanResult.Message ?? string.Empty); + AttachmentStagingCleanup.TryDelete(stagedPath, _logger); + return [BuildHistoricalAttachmentRejected( + $"historical attachment \"{AttachmentIngressFormatting.EscapeQuoted(file.Name)}\" was rejected by content scanning: {AttachmentIngressFormatting.EscapeQuoted(scanResult.Message ?? scanResult.Error?.ToString() ?? "unknown error")}")]; + } + + string inboxPath; + try + { + inboxPath = HistoricalAttachmentInbox.PromoteOrReuse( + inboxDir, + file.Name, + sourceKey, + stagedPath); + } + catch (Exception ex) + { + _logger.LogWarning(ex, "Failed to promote historical Mattermost attachment {Name} into inbox", file.Name); + AttachmentStagingCleanup.TryDelete(stagedPath, _logger); + return [BuildHistoricalAttachmentRejected( + $"historical attachment \"{AttachmentIngressFormatting.EscapeQuoted(file.Name)}\" could not be saved to the session inbox")]; + } + + return await BuildAcceptedAttachmentContentsAsync( + inboxPath, + file.Name, + file.MimeType, + category, + inlineImages, + bytesWritten, + cancellationToken); + } + + private async Task> BuildAcceptedAttachmentContentsAsync( + string inboxPath, + string filename, + string mimeType, + AttachmentCategory category, + bool inlineImages, + long size, + CancellationToken cancellationToken) + { + var relativePath = $"{SessionDirectoryHelper.InboxSubdirectory}/{Path.GetFileName(inboxPath)}"; + var (inlined, note) = AttachmentIngressFormatting.ResolveInlineDecision(category, inlineImages); + var line = new TextContent(AttachmentIngressFormatting.BuildAttachmentLine( + filename, + mimeType, + size, + relativePath, + inlined, + note)); + + if (!inlined) + { + return [line]; + } + + var bytes = await IOFile.ReadAllBytesAsync(inboxPath, cancellationToken); + return [line, new DataContent(bytes, mimeType)]; + } + + private AudienceResult ResolveHistoricalAudience(MattermostChannelId channelId) + { + // DM detection is not available from thread history context, so default to false. + var isExplicitChannel = _options.AllowedChannelIds.Contains(channelId.Value, StringComparer.Ordinal); + + return AudienceResult.Resolve( + channelId.Value, isDirectMessage: false, + _options.ChannelAudiences, + isExplicitUser: false, + isExplicitChannel: isExplicitChannel); + } + + private static async Task> FetchRawMessagesAsync( + MattermostClient client, + string rootPostId, + string? botUserId, + string serverUrl, + CancellationToken cancellationToken, + ILogger logger) + { + ChannelPostsResponse threadResponse; + try + { + threadResponse = await client.GetThreadPostsAsync(rootPostId); + } + catch (Exception ex) when (ex is not OperationCanceledException) + { + logger.LogWarning(ex, "Failed to fetch Mattermost thread posts for root {RootPostId}", rootPostId); + return []; + } + + if (threadResponse.Posts.Count == 0) + { + logger.LogDebug("Mattermost thread {RootPostId} returned no posts", rootPostId); + return []; + } + + var results = new List(threadResponse.Order.Count); + var normalizedServerUrl = serverUrl.TrimEnd('/'); + + // Order list is provided by the API in chronological order + foreach (var postId in threadResponse.Order) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (!threadResponse.Posts.TryGetValue(postId, out var post)) + continue; + + // Skip deleted posts + if (post.DeletedAt > 0) + continue; + + var isBotMessage = botUserId is not null + && string.Equals(post.UserId, botUserId, StringComparison.Ordinal); + + if (isBotMessage) + continue; + + if (!HasUsableContent(post)) + continue; + + var attachments = BuildFileReferences(post, normalizedServerUrl); + var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(post.CreatedAt); + + results.Add(new HistoricalMessage( + MessageId: post.Id, + SenderId: post.UserId, + IsBot: false, + Text: post.Text ?? string.Empty, + Timestamp: timestamp, + Attachments: attachments)); + } + + return results; + } + + private static IReadOnlyList BuildFileReferences(Post post, string serverUrl) + { + if (post.FileIdentifiers.Count == 0) + return []; + + return post.FileIdentifiers + .Select(fileId => new MattermostFileReference( + Name: fileId, + MimeType: "application/octet-stream", + Size: 0, + Url: $"{serverUrl}/api/v4/files/{fileId}")) + .ToArray(); + } + + private static async Task<(string FilePath, long BytesWritten)?> DownloadFileViaSdkAsync( + MattermostClient client, + string fileId, + string stagingDir, + long maxBytes, + CancellationToken cancellationToken) + { + await using var sourceStream = await client.GetFileStreamAsync(fileId); + var stagingPath = Path.Combine(stagingDir, $"{Guid.NewGuid():N}.tmp"); + long totalBytes = 0; + + try + { + await using var fileStream = new FileStream( + stagingPath, FileMode.Create, FileAccess.Write, FileShare.None, + bufferSize: 81920, useAsync: true); + + var buffer = new byte[81920]; + int bytesRead; + while ((bytesRead = await sourceStream.ReadAsync(buffer, cancellationToken)) > 0) + { + totalBytes += bytesRead; + if (totalBytes > maxBytes) + { + // Exceeded size limit during streaming download + await fileStream.DisposeAsync(); + IOFile.Delete(stagingPath); + return null; + } + + await fileStream.WriteAsync(buffer.AsMemory(0, bytesRead), cancellationToken); + } + } + catch + { + if (IOFile.Exists(stagingPath)) + IOFile.Delete(stagingPath); + throw; + } + + return (stagingPath, totalBytes); + } + + /// + /// Extracts the Mattermost file ID from a /api/v4/files/{fileId} URL. + /// + internal static string? ExtractFileId(string url) + { + const string marker = "/api/v4/files/"; + var idx = url.IndexOf(marker, StringComparison.Ordinal); + if (idx < 0) + return null; + + var start = idx + marker.Length; + if (start >= url.Length) + return null; + + // File ID runs until the next '/' or '?' or end of string + var end = url.IndexOfAny(['/', '?'], start); + var fileId = end < 0 ? url[start..] : url[start..end]; + return string.IsNullOrEmpty(fileId) ? null : fileId; + } + + private static bool HasUsableContent(Post post) + => !string.IsNullOrWhiteSpace(post.Text) || post.FileIdentifiers.Count > 0; + + private static TextContent BuildHistoricalAttachmentRejected(string detail) + => new($"[attachment rejected: {detail}]"); + + private static string BuildHistoricalAttachmentSourceKey(string messageId, MattermostFileReference file) + => $"mattermost:{messageId}:{file.Url}"; +} diff --git a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json index 618d89b7..e8720ed9 100644 --- a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json +++ b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json @@ -65,6 +65,34 @@ }, "additionalProperties": false }, + "Mattermost": { + "type": "object", + "properties": { + "Enabled": { "type": "boolean" }, + "ServerUrl": { "type": "string", "format": "uri", "description": "Base URL of the Mattermost server (e.g. https://mm.example.com)." }, + "DefaultChannelId": { "type": ["string", "null"] }, + "AllowDirectMessages": { "type": "boolean" }, + "MentionOnly": { "type": "boolean", "default": true }, + "MentionRequiredInDm": { "type": "boolean", "default": false }, + "AllowedChannelIds": { + "type": "array", + "items": { "type": "string" } + }, + "AllowedUserIds": { + "type": "array", + "items": { "type": "string" } + }, + "ChannelAudiences": { + "type": "object", + "description": "Per-channel audience overrides. Keys are channel IDs or 'dm'. Values are 'personal', 'team', or 'public'.", + "additionalProperties": { + "type": "string", + "enum": ["personal", "team", "public"] + } + } + }, + "additionalProperties": false + }, "Logging": { "type": "object", "properties": { diff --git a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs new file mode 100644 index 00000000..553fa1cc --- /dev/null +++ b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs @@ -0,0 +1,107 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Mattermost; +using Netclaw.Actors.Channels; +using Netclaw.Actors.Reminders; +using Netclaw.Channels; +using Netclaw.Channels.Mattermost; +using Netclaw.Channels.Mattermost.Tools; +using Netclaw.Channels.Mattermost.Transport; +using Netclaw.Configuration; +using Netclaw.Security; +using Netclaw.Tools; + +namespace Netclaw.Daemon.Configuration; + +public static class MattermostChannelRegistrationExtensions +{ + private const string MattermostChannelKey = "mattermost"; + + public static void AddMattermostChannelIntegration(this IServiceCollection services, IConfiguration configuration) + { + var mattermostOptions = configuration.GetSection("Mattermost").Get() ?? new MattermostChannelOptions(); + services.AddSingleton(mattermostOptions); + + if (!mattermostOptions.Enabled) + return; + + mattermostOptions.BotToken.RequireValid("Mattermost:BotToken"); + var serverUrl = mattermostOptions.ServerUrl + ?? throw new InvalidOperationException("Mattermost:ServerUrl is required when Mattermost channel is enabled."); + + services.AddSingleton(_ => new MattermostClient(serverUrl, mattermostOptions.BotToken!.Value)); + + services.AddHttpClient("mattermost-files"); + services.AddSingleton(); + services.AddSingleton(sp => + { + var client = sp.GetRequiredService(); + return new MattermostNetReplyClient(client); + }); + services.AddSingleton(sp => + { + var client = sp.GetRequiredService(); + var contentScanner = sp.GetRequiredService(); + var promptInjectionDetector = sp.GetService() ?? new NullPromptInjectionDetector(); + var toolConfig = sp.GetRequiredService(); + var modelCapabilities = sp.GetRequiredService(); + var paths = sp.GetRequiredService(); + var logger = sp.GetRequiredService().CreateLogger(); + + var gatewayClient = sp.GetRequiredService(); + + return new MattermostThreadHistoryFetcher( + client, + contentScanner, + promptInjectionDetector, + mattermostOptions, + serverUrl, + gatewayClient.BotUserId?.Value, + toolConfig.AudienceProfiles, + modelCapabilities, + paths, + logger); + }); + services.AddSingleton(); + + services.AddSingleton(sp => + { + var client = sp.GetRequiredService(); + return new MattermostNetOutboundClient(client); + }); + + services.AddKeyedSingleton(MattermostChannelKey); + services.AddSingleton(sp => + sp.GetRequiredKeyedService(MattermostChannelKey)); + services.AddSingleton(sp => + (MattermostChannel)sp.GetRequiredKeyedService(MattermostChannelKey)); + + // Channel-specific LLM tools: registered as IChannelTool singletons. + // The gateway actor ref and default channel ID are resolved lazily via + // MattermostChannel since they're not available until StartAsync completes. + services.AddSingleton(sp => + { + var outbound = sp.GetRequiredService(); + var channel = sp.GetRequiredService(); + return new SendMattermostMessageTool( + outbound, + mattermostOptions, + () => channel.DefaultChannelId, + () => channel.Gateway); + }); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddSingleton(sp => + { + var client = sp.GetRequiredService(); + return new LookupMattermostUserTool(client, mattermostOptions); + }); + services.AddSingleton(sp => sp.GetRequiredService()); + + services.AddSingleton(sp => + (IHostedService)sp.GetRequiredKeyedService(MattermostChannelKey)); + } +} diff --git a/src/Netclaw.Daemon/Netclaw.Daemon.csproj b/src/Netclaw.Daemon/Netclaw.Daemon.csproj index 8101e2df..d12d77a3 100644 --- a/src/Netclaw.Daemon/Netclaw.Daemon.csproj +++ b/src/Netclaw.Daemon/Netclaw.Daemon.csproj @@ -45,6 +45,7 @@ + diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index 1b672cdc..1ff5b41c 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -1094,6 +1094,7 @@ static void ConfigureDaemonServices( services.AddSlackChannelIntegration(configuration); services.AddDiscordChannelIntegration(configuration); + services.AddMattermostChannelIntegration(configuration); // Config hot-reload watcher services.AddSingleton(); From 7d3eca63f0c4a22f98facb5fe1869f5fa81c2849 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 5 May 2026 20:16:58 -0500 Subject: [PATCH 2/5] fix: address code review findings in Mattermost channel implementation Remove duplicated approval prompt builder methods from SessionBindingActor (now delegates to MattermostApprovalPromptBuilder). Fix BotUserId being captured too early at DI time by switching to lazy Func resolution. Fail loud when ServerUrl is absent on the attachment URL trust path. Add missing BotToken to JSON config schema. Replace Task.Delay in integration tests with a polling helper. Cache test user token to avoid re-authenticating per call. Remove TOCTOU File.Exists before File.Delete, redundant BotToken.RequireValid, inline DefaultChannelId duplication, and LINQ allocation in IsMattermostId. --- .../Channels/MattermostRoutingPolicyTests.cs | 2 +- .../MattermostFixture.cs | 8 +- ...MattermostThreadHistoryIntegrationTests.cs | 37 ++++++-- .../MattermostChannel.cs | 7 +- .../MattermostReminderTargetResolver.cs | 13 ++- .../MattermostSessionBindingActor.cs | 86 +++---------------- .../Tools/LookupMattermostUserTool.cs | 9 +- .../MattermostThreadHistoryFetcher.cs | 8 +- .../Schemas/netclaw-config.v1.schema.json | 1 + ...MattermostChannelRegistrationExtensions.cs | 2 +- 10 files changed, 69 insertions(+), 104 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostRoutingPolicyTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostRoutingPolicyTests.cs index 18eba482..18c1354d 100644 --- a/src/Netclaw.Actors.Tests/Channels/MattermostRoutingPolicyTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/MattermostRoutingPolicyTests.cs @@ -174,6 +174,6 @@ private static MattermostGatewayMessage CreateMessage( IsDirectMessage: isDirectMessage, ContainsBotMention: false, Text: text, - ReceivedAt: DateTimeOffset.UtcNow); + ReceivedAt: TimeProvider.System.GetUtcNow()); } } diff --git a/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostFixture.cs b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostFixture.cs index ed15121c..00d73b4f 100644 --- a/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostFixture.cs +++ b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostFixture.cs @@ -31,6 +31,7 @@ public sealed class MattermostFixture : IAsyncLifetime private const string ChannelName = "test-channel"; private IContainer? _container; + private string? _testUserToken; public string ServerUrl { get; private set; } = string.Empty; public string AdminToken { get; private set; } = string.Empty; @@ -86,10 +87,11 @@ public async ValueTask InitializeAsync() // Add bot to channel await AddUserToChannelAsync(http, ChannelId, BotUserId); - // Create test user + // Create test user and cache their auth token TestUserId = await CreateUserAsync(http, TestUserEmail, TestUserUsername, TestUserPassword); await AddUserToTeamAsync(http, TeamId, TestUserId); await AddUserToChannelAsync(http, ChannelId, TestUserId); + _testUserToken = await LoginAsync(http, TestUserUsername, TestUserPassword); } public async ValueTask DisposeAsync() @@ -218,8 +220,8 @@ private static async Task AddUserToChannelAsync(HttpClient http, string channelI /// public async Task PostAsTestUserAsync(string channelId, string text, string? rootId = null) { - var (http, _) = await CreateTestUserClientAsync(); - using (http) + using var http = CreateHttpClient(); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _testUserToken); { var payload = new Dictionary { diff --git a/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostThreadHistoryIntegrationTests.cs b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostThreadHistoryIntegrationTests.cs index f2d14cdd..ba1c30aa 100644 --- a/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostThreadHistoryIntegrationTests.cs +++ b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostThreadHistoryIntegrationTests.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using Mattermost; +using Mattermost.Models.Responses; using Xunit; namespace Netclaw.Channels.Mattermost.IntegrationTests; @@ -15,6 +16,9 @@ namespace Netclaw.Channels.Mattermost.IntegrationTests; [Collection("Mattermost")] public sealed class MattermostThreadHistoryIntegrationTests { + private static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(100); + private readonly MattermostFixture _fixture; public MattermostThreadHistoryIntegrationTests(MattermostFixture fixture) @@ -32,9 +36,7 @@ public async Task GetThreadPostsAsync_returns_thread_messages_in_order() await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "History reply 1", rootId: rootPostId); await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "History reply 2", rootId: rootPostId); - await Task.Delay(500, ct); - - var threadPosts = await botClient.GetThreadPostsAsync(rootPostId); + var threadPosts = await PollThreadPostsAsync(botClient, rootPostId, minCount: 3, ct); Assert.NotNull(threadPosts); Assert.True(threadPosts.Posts.Count >= 3, $"Expected at least 3 posts, got {threadPosts.Posts.Count}"); @@ -49,9 +51,7 @@ public async Task GetThreadPostsAsync_includes_root_post() var rootPostId = await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "Unique root content for history test"); await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "Reply to unique root", rootId: rootPostId); - await Task.Delay(500, ct); - - var threadPosts = await botClient.GetThreadPostsAsync(rootPostId); + var threadPosts = await PollThreadPostsAsync(botClient, rootPostId, minCount: 2, ct); Assert.True(threadPosts.Posts.ContainsKey(rootPostId), "Thread history should include the root post"); @@ -68,12 +68,31 @@ public async Task Bot_can_read_its_own_posts_in_thread() await botClient.CreatePostAsync(_fixture.ChannelId, "Bot reply in thread", replyToPostId: rootPostId); - await Task.Delay(500, ct); - - var threadPosts = await botClient.GetThreadPostsAsync(rootPostId); + var threadPosts = await PollThreadPostsAsync(botClient, rootPostId, minCount: 2, ct); var botPosts = threadPosts.Posts.Values.Where(p => p.UserId == _fixture.BotUserId).ToList(); Assert.Single(botPosts); Assert.Contains("Bot reply in thread", botPosts[0].Text); } + + private static async Task PollThreadPostsAsync( + MattermostClient client, + string rootPostId, + int minCount, + CancellationToken ct) + { + using var cts = CancellationTokenSource.CreateLinkedTokenSource(ct); + cts.CancelAfter(PollTimeout); + + while (!cts.Token.IsCancellationRequested) + { + var result = await client.GetThreadPostsAsync(rootPostId); + if (result.Posts.Count >= minCount) + return result; + + await Task.Delay(PollInterval, cts.Token); + } + + return await client.GetThreadPostsAsync(rootPostId); + } } diff --git a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs index 3c17db70..77729453 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs @@ -103,11 +103,10 @@ public async Task StartAsync(CancellationToken cancellationToken) var serverUrl = _options.ServerUrl ?? throw new InvalidOperationException("Mattermost:ServerUrl is required when Mattermost channel is enabled."); - var botToken = _options.BotToken.RequireValid("Mattermost:BotToken"); try { - await _gatewayClient.ConnectAsync(serverUrl, botToken.Value, cancellationToken); + await _gatewayClient.ConnectAsync(serverUrl, _options.BotToken!.Value, cancellationToken); _gatewayClient.MessageReceived += HandleMessageReceivedAsync; _gatewayClient.InteractionReceived += HandleInteractionReceivedAsync; @@ -120,9 +119,7 @@ public async Task StartAsync(CancellationToken cancellationToken) IngressGate: _ingressGate, TimeProvider: _timeProvider, Options: _options, - DefaultChannelId: !string.IsNullOrWhiteSpace(_options.DefaultChannelId) - ? new MattermostChannelId(_options.DefaultChannelId) - : null, + DefaultChannelId: DefaultChannelId, ReplyClient: _replyClient, ContentScanner: _contentScanner, AudienceProfiles: _audienceProfiles, diff --git a/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs b/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs index c5d608fc..014c93fd 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs @@ -70,5 +70,16 @@ public Task ResolveAsync(string target, CancellationTo } private static bool IsMattermostId(string value) - => value.Length == 26 && value.All(c => char.IsLetterOrDigit(c)); + { + if (value.Length != 26) + return false; + + for (var i = 0; i < value.Length; i++) + { + if (!char.IsAsciiLetterOrDigit(value[i])) + return false; + } + + return true; + } } diff --git a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs index d5e7fd26..b8b3f9db 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs @@ -512,7 +512,7 @@ private async Task TryResolveApprovalPromptAsync( try { - var resolvedText = BuildResolvedApprovalText( + var resolvedText = MattermostApprovalPromptBuilder.BuildResolvedPromptText( pending.Request, selectedKey, senderId); @@ -683,7 +683,7 @@ private async Task HandleOutputReceivedAsync(OutputReceived msg) private async Task SafeReplyWithApprovalPromptAsync(ToolInteractionRequest request) { - var promptText = BuildApprovalPromptText(request); + var promptText = MattermostApprovalPromptBuilder.BuildTextPrompt(request); var startedAt = _dependencies.TimeProvider.GetTimestamp(); try { @@ -702,73 +702,6 @@ private async Task HandleOutputReceivedAsync(OutputReceived msg) } } - /// - /// Builds a simple markdown-formatted approval prompt. Interactive - /// buttons will be added via a dedicated MattermostApprovalPromptBuilder - /// in a follow-up change. - /// - private static string BuildApprovalPromptText(ToolInteractionRequest request) - { - 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("`"); - } - } - - if (request.HasAdoptedContext) - { - sb.Append("**Adopted context:** present").AppendLine(); - sb.Append("**Speakers:** `").Append(string.Join(", ", request.AdoptedSpeakerIds)).AppendLine("`"); - } - - sb.AppendLine(); - sb.AppendLine("Reply with:"); - sb.Append("A) ").AppendLine(ApprovalOptionKeys.ApproveOnceLabel); - sb.Append("B) ").AppendLine(ApprovalOptionKeys.ApproveSessionLabel); - sb.Append("C) ").AppendLine(ApprovalOptionKeys.ApproveAlwaysLabel); - sb.Append("D) ").AppendLine(ApprovalOptionKeys.DenyLabel); - return sb.ToString().TrimEnd(); - } - - private static string BuildResolvedApprovalText( - ToolInteractionRequest request, - string selectedKey, - string senderId) - { - var statusEmoji = selectedKey == ApprovalOptionKeys.Deny - ? ":no_entry:" - : ":white_check_mark:"; - var decisionLabel = selectedKey switch - { - ApprovalOptionKeys.ApproveOnce => ApprovalOptionKeys.ApproveOnceLabel, - ApprovalOptionKeys.ApproveSession => ApprovalOptionKeys.ApproveSessionLabel, - ApprovalOptionKeys.ApproveAlways => ApprovalOptionKeys.ApproveAlwaysLabel, - ApprovalOptionKeys.Deny => ApprovalOptionKeys.DenyLabel, - _ => 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("`"); - sb.Append("**Decision:** ").Append(decisionLabel); - sb.Append(" (by @").Append(senderId).Append(')'); - return sb.ToString(); - } - private async Task SafeReplyAsync(string text) { var startedAt = _dependencies.TimeProvider.GetTimestamp(); @@ -944,8 +877,16 @@ private async Task TryIngestSingleAttachmentAsync( } // Mattermost attachment URLs must originate from the configured server. - var serverUrl = _dependencies.ServerUrl ?? string.Empty; - if (!string.IsNullOrEmpty(serverUrl) && !MattermostAttachmentUrlTrust.IsAllowedAttachmentUrl(file.Url, serverUrl)) + if (string.IsNullOrEmpty(_dependencies.ServerUrl)) + { + _log.Warning( + "mattermost_attachment_rejected name={Name} reason=no-server-url-configured", + file.Name); + return new AttachmentIngestResult.Rejected( + $"`{file.Name}` was rejected because no Mattermost server URL is configured for URL trust validation."); + } + + if (!MattermostAttachmentUrlTrust.IsAllowedAttachmentUrl(file.Url, _dependencies.ServerUrl)) { _log.Warning( "mattermost_attachment_rejected name={Name} url={Url} reason=untrusted-url", @@ -1084,8 +1025,7 @@ private void TryDeleteTemp(string tempPath) { try { - if (File.Exists(tempPath)) - File.Delete(tempPath); + File.Delete(tempPath); } catch (Exception ex) { diff --git a/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs b/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs index 3da51b3c..b0c647e8 100644 --- a/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs +++ b/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs @@ -83,13 +83,8 @@ protected override async Task ExecuteAsync(Params args, CancellationToke } private bool IsFilteredOut(global::Mattermost.Models.Users.User user) - { - if (_options.AllowedUserIds.Length > 0 - && !_options.AllowedUserIds.Contains(user.Id, StringComparer.Ordinal)) - return true; - - return false; - } + => _options.AllowedUserIds.Length > 0 + && !_options.AllowedUserIds.Contains(user.Id, StringComparer.Ordinal); private static void AppendUser(StringBuilder sb, global::Mattermost.Models.Users.User user) { diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs index a5e88963..9be24ae9 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs @@ -63,19 +63,19 @@ public MattermostThreadHistoryFetcher( IPromptInjectionDetector promptInjectionDetector, MattermostChannelOptions options, string serverUrl, - string? botUserId, + Func botUserIdFactory, ToolAudienceProfiles audienceProfiles, ModelCapabilities modelCapabilities, NetclawPaths paths, ILogger logger) : this( - (rootPostId, cancellationToken) => FetchRawMessagesAsync(client, rootPostId, botUserId, serverUrl, cancellationToken, logger), + (rootPostId, cancellationToken) => FetchRawMessagesAsync(client, rootPostId, botUserIdFactory(), serverUrl, cancellationToken, logger), (fileId, stagingDir, maxBytes, ct) => DownloadFileViaSdkAsync(client, fileId, stagingDir, maxBytes, ct), contentScanner, promptInjectionDetector, options, serverUrl, - botUserId, + botUserIdFactory(), audienceProfiles, modelCapabilities, paths, @@ -473,7 +473,7 @@ private static async Task> FetchRawMessagesAsyn } var results = new List(threadResponse.Order.Count); - var normalizedServerUrl = serverUrl.TrimEnd('/'); + var normalizedServerUrl = serverUrl.TrimEnd('/'); // serverUrl may not be pre-normalized when called from test delegates // Order list is provided by the API in chronological order foreach (var postId in threadResponse.Order) diff --git a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json index e8720ed9..64aec9a3 100644 --- a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json +++ b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json @@ -70,6 +70,7 @@ "properties": { "Enabled": { "type": "boolean" }, "ServerUrl": { "type": "string", "format": "uri", "description": "Base URL of the Mattermost server (e.g. https://mm.example.com)." }, + "BotToken": { "type": ["string", "null"] }, "DefaultChannelId": { "type": ["string", "null"] }, "AllowDirectMessages": { "type": "boolean" }, "MentionOnly": { "type": "boolean", "default": true }, diff --git a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs index 553fa1cc..6ef70326 100644 --- a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs @@ -59,7 +59,7 @@ public static void AddMattermostChannelIntegration(this IServiceCollection servi promptInjectionDetector, mattermostOptions, serverUrl, - gatewayClient.BotUserId?.Value, + () => gatewayClient.BotUserId?.Value, toolConfig.AudienceProfiles, modelCapabilities, paths, From 6c365ad278c874e1628ee0220b462fff2c36d44e Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 5 May 2026 20:50:15 -0500 Subject: [PATCH 3/5] feat: add interactive button-based approval prompts for Mattermost channel Replace text-only approval prompts with Mattermost interactive message attachments using action buttons. Add HTTP callback endpoint for button clicks and fix several security/correctness issues found in deep review. Interactive buttons: - BuildButtonPrompt produces attachments with styled action buttons - HTTP POST /api/mattermost/actions receives Mattermost button callbacks - Graceful degradation to text-only when CallbackUrl not configured - Buttons cleared on resolution via UpdatePostAsync with colored attachment Security fixes: - Fix URL trust subdomain bypass (StartsWith without trailing slash) - Add Bearer auth to mattermost-files HttpClient (was missing) - Whitelist selectedKey values in callback endpoint - Fix RootPostId routing in button context (was empty, broke actor lookup) --- .../MattermostApprovalPromptBuilderTests.cs | 104 ++++++++++++ .../MattermostFixture.cs | 7 + .../MattermostReplyClientIntegrationTests.cs | 9 +- .../MattermostApprovalPromptBuilder.cs | 56 ++++++ .../MattermostAttachmentUrlTrust.cs | 7 +- .../MattermostChannel.cs | 2 + .../MattermostChannelOptions.cs | 8 + .../MattermostGatewayActor.cs | 1 + .../MattermostReminderTargetResolver.cs | 2 +- .../MattermostSessionBindingActor.cs | 48 +++++- .../MattermostTransportContracts.cs | 32 +++- .../Transport/MattermostNetGatewayClient.cs | 11 +- .../Transport/MattermostNetReplyClient.cs | 159 +++++++++++++++++- .../Schemas/netclaw-config.v1.schema.json | 1 + .../MattermostActionEndpointExtensions.cs | 127 ++++++++++++++ ...MattermostChannelRegistrationExtensions.cs | 16 +- src/Netclaw.Daemon/Program.cs | 1 + 17 files changed, 574 insertions(+), 17 deletions(-) create mode 100644 src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs index 91ca256c..f336bb42 100644 --- a/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs @@ -143,4 +143,108 @@ public void BuildResolvedPromptText_omits_patterns_when_empty() Assert.DoesNotContain("Pattern", text); } + + [Fact] + public void BuildButtonPrompt_produces_attachment_with_four_buttons() + { + var request = CreateStandardRequest(); + + var (text, attachments) = MattermostApprovalPromptBuilder.BuildButtonPrompt( + request, "http://localhost:5199/api/mattermost/actions", "root-post-1"); + + Assert.Contains("Tool approval required", text); + Assert.Contains("git_push", text); + Assert.Contains("reply with `A`, `B`, `C`, or `D`", text); + + Assert.Single(attachments); + var attachment = attachments[0]; + Assert.NotNull(attachment.Actions); + Assert.Equal(4, attachment.Actions!.Count); + } + + [Fact] + public void BuildButtonPrompt_buttons_encode_context_correctly() + { + var request = CreateStandardRequest(); + + var (_, attachments) = MattermostApprovalPromptBuilder.BuildButtonPrompt( + request, "http://callback:5199/api/mattermost/actions", "root-post-1"); + + var approveOnce = attachments[0].Actions![0]; + Assert.Equal("tool_approval_approve_once", approveOnce.Id); + Assert.Equal(ApprovalOptionKeys.ApproveOnceLabel, approveOnce.Name); + Assert.Equal("http://callback:5199/api/mattermost/actions", approveOnce.IntegrationUrl); + Assert.Equal("call-btn-1", approveOnce.Context["call_id"]); + Assert.Equal(ApprovalOptionKeys.ApproveOnce, approveOnce.Context["selected_key"]); + Assert.Equal("requester-1", approveOnce.Context["requester_sender_id"]); + Assert.Equal("root-post-1", approveOnce.Context["root_post_id"]); + } + + [Fact] + public void BuildButtonPrompt_deny_button_has_danger_style() + { + var request = CreateStandardRequest(); + + var (_, attachments) = MattermostApprovalPromptBuilder.BuildButtonPrompt( + request, "http://localhost/api/mattermost/actions", "root-post-1"); + + var denyButton = attachments[0].Actions!.Single(a => a.Id == "tool_approval_deny"); + Assert.Equal("danger", denyButton.Style); + } + + [Fact] + public void BuildButtonPrompt_approve_once_has_primary_style() + { + var request = CreateStandardRequest(); + + var (_, attachments) = MattermostApprovalPromptBuilder.BuildButtonPrompt( + request, "http://localhost/api/mattermost/actions", "root-post-1"); + + var approveOnce = attachments[0].Actions!.Single(a => a.Id == "tool_approval_approve_once"); + Assert.Equal("primary", approveOnce.Style); + } + + [Fact] + public void BuildResolvedAttachment_approve_shows_green_color() + { + var request = CreateStandardRequest(); + var attachment = MattermostApprovalPromptBuilder.BuildResolvedAttachment( + request, ApprovalOptionKeys.ApproveOnce, "user-42"); + + Assert.Equal("#2EA44F", attachment.Color); + Assert.Contains(":white_check_mark:", attachment.Text!); + Assert.Contains("git_push", attachment.Text!); + Assert.Contains("@user-42", attachment.Text!); + Assert.Null(attachment.Actions); + } + + [Fact] + public void BuildResolvedAttachment_deny_shows_red_color() + { + var request = CreateStandardRequest(); + var attachment = MattermostApprovalPromptBuilder.BuildResolvedAttachment( + request, ApprovalOptionKeys.Deny, "user-99"); + + Assert.Equal("#CC0000", attachment.Color); + Assert.Contains(":no_entry:", attachment.Text!); + Assert.Null(attachment.Actions); + } + + private static ToolInteractionRequest CreateStandardRequest() + => new() + { + SessionId = new SessionId("test/session"), + Kind = "approval", + CallId = "call-btn-1", + ToolName = "git_push", + DisplayText = "push to origin/main", + RequesterSenderId = "requester-1", + Patterns = ["origin/main"], + Options = [ + new ToolInteractionOption(ApprovalOptionKeys.ApproveOnce, ApprovalOptionKeys.ApproveOnceLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveSession, ApprovalOptionKeys.ApproveSessionLabel), + new ToolInteractionOption(ApprovalOptionKeys.ApproveAlways, ApprovalOptionKeys.ApproveAlwaysLabel), + new ToolInteractionOption(ApprovalOptionKeys.Deny, ApprovalOptionKeys.DenyLabel) + ] + }; } diff --git a/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostFixture.cs b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostFixture.cs index 00d73b4f..5f0f5871 100644 --- a/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostFixture.cs +++ b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostFixture.cs @@ -105,6 +105,13 @@ public HttpClient CreateHttpClient() return new HttpClient { BaseAddress = new Uri(ServerUrl) }; } + public HttpClient CreateBotApiClient() + { + var http = CreateHttpClient(); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", BotToken); + return http; + } + /// /// Creates an authenticated HttpClient that can act as the test user. /// diff --git a/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostReplyClientIntegrationTests.cs b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostReplyClientIntegrationTests.cs index 8f434485..4c565293 100644 --- a/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostReplyClientIntegrationTests.cs +++ b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostReplyClientIntegrationTests.cs @@ -28,7 +28,8 @@ public async Task PostReplyAsync_creates_top_level_post() { var ct = TestContext.Current.CancellationToken; using var botClient = new MattermostClient(_fixture.ServerUrl, _fixture.BotToken); - var replyClient = new MattermostNetReplyClient(botClient); + using var apiClient = _fixture.CreateBotApiClient(); + var replyClient = new MattermostNetReplyClient(botClient, apiClient); var result = await replyClient.PostReplyAsync(new MattermostPostMessage( ChannelId: new MattermostChannelId(_fixture.ChannelId), @@ -46,7 +47,8 @@ public async Task PostReplyAsync_creates_thread_reply() { var ct = TestContext.Current.CancellationToken; using var botClient = new MattermostClient(_fixture.ServerUrl, _fixture.BotToken); - var replyClient = new MattermostNetReplyClient(botClient); + using var apiClient = _fixture.CreateBotApiClient(); + var replyClient = new MattermostNetReplyClient(botClient, apiClient); var root = await replyClient.PostReplyAsync(new MattermostPostMessage( ChannelId: new MattermostChannelId(_fixture.ChannelId), @@ -68,7 +70,8 @@ public async Task UpdatePostAsync_modifies_message_text() { var ct = TestContext.Current.CancellationToken; using var botClient = new MattermostClient(_fixture.ServerUrl, _fixture.BotToken); - var replyClient = new MattermostNetReplyClient(botClient); + using var apiClient = _fixture.CreateBotApiClient(); + var replyClient = new MattermostNetReplyClient(botClient, apiClient); var result = await replyClient.PostReplyAsync(new MattermostPostMessage( ChannelId: new MattermostChannelId(_fixture.ChannelId), diff --git a/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs b/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs index 31d1cd4c..1516d2ed 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs @@ -10,6 +10,40 @@ namespace Netclaw.Channels.Mattermost; internal static class MattermostApprovalPromptBuilder { + public static (string Text, IReadOnlyList Attachments) BuildButtonPrompt( + ToolInteractionRequest request, + string callbackUrl, + string rootPostId) + { + var sb = new StringBuilder(); + sb.AppendLine(":lock: **Tool approval required**"); + AppendToolSummary(sb, request); + sb.AppendLine(); + sb.Append("You can also reply with `A`, `B`, `C`, or `D` in this thread."); + + var actions = request.Options + .Select(option => new MattermostAttachmentAction( + Id: $"tool_approval_{option.Key}", + Name: option.Label, + IntegrationUrl: callbackUrl, + Context: new Dictionary + { + ["call_id"] = request.CallId, + ["selected_key"] = option.Key, + ["requester_sender_id"] = request.RequesterSenderId ?? string.Empty, + ["root_post_id"] = rootPostId + }, + Style: GetButtonStyle(option.Key))) + .ToList(); + + var attachment = new MattermostAttachment( + Fallback: "Tool approval required — reply with A, B, C, or D", + Color: "#3AA3E3", + Actions: actions); + + return (sb.ToString().TrimEnd(), [attachment]); + } + public static string BuildTextPrompt(ToolInteractionRequest request) { var sb = new StringBuilder(); @@ -50,6 +84,20 @@ public static string BuildResolvedPromptText( return sb.ToString(); } + public static MattermostAttachment BuildResolvedAttachment( + ToolInteractionRequest request, + string selectedKey, + string senderId) + { + var resolvedText = BuildResolvedPromptText(request, selectedKey, senderId); + var color = selectedKey == ApprovalOptionKeys.Deny ? "#CC0000" : "#2EA44F"; + + return new MattermostAttachment( + Fallback: resolvedText, + Color: color, + Text: resolvedText); + } + private static void AppendToolSummary(StringBuilder sb, ToolInteractionRequest request) { sb.Append("**Tool:** `").Append(request.ToolName).AppendLine("`"); @@ -90,4 +138,12 @@ private static string GetDecisionLabel(string selectedKey) ApprovalOptionKeys.Deny => ApprovalOptionKeys.DenyLabel, _ => selectedKey }; + + private static string GetButtonStyle(string optionKey) + => optionKey switch + { + ApprovalOptionKeys.Deny => "danger", + ApprovalOptionKeys.ApproveOnce => "primary", + _ => "default" + }; } diff --git a/src/Netclaw.Channels.Mattermost/MattermostAttachmentUrlTrust.cs b/src/Netclaw.Channels.Mattermost/MattermostAttachmentUrlTrust.cs index a5d73a52..c3a5fea2 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostAttachmentUrlTrust.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostAttachmentUrlTrust.cs @@ -13,6 +13,11 @@ internal static class MattermostAttachmentUrlTrust /// public static bool IsAllowedAttachmentUrl(string url, string serverUrl) { - return url.StartsWith(serverUrl, StringComparison.OrdinalIgnoreCase); + // Append trailing slash to prevent subdomain bypass: + // "https://mm.example.com" must not match "https://mm.example.com.evil.com/..." + var normalized = serverUrl.EndsWith('/') + ? serverUrl + : serverUrl + '/'; + return url.StartsWith(normalized, StringComparison.OrdinalIgnoreCase); } } diff --git a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs index 77729453..24c607f4 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs @@ -36,6 +36,7 @@ public sealed class MattermostChannel : IChannel private IActorRef? _gateway; internal IActorRef? Gateway => _gateway; + internal IMattermostGatewayClient GatewayClient => _gatewayClient; internal MattermostChannelId? DefaultChannelId => !string.IsNullOrWhiteSpace(_options.DefaultChannelId) @@ -126,6 +127,7 @@ public async Task StartAsync(CancellationToken cancellationToken) ModelCapabilities: _modelCapabilities, Paths: _paths, ServerUrl: serverUrl, + CallbackUrl: _options.CallbackUrl, BotUserId: _gatewayClient.BotUserId, PromptInjectionDetector: _promptInjectionDetector, ThreadHistoryFetcher: _threadHistoryFetcher, diff --git a/src/Netclaw.Channels.Mattermost/MattermostChannelOptions.cs b/src/Netclaw.Channels.Mattermost/MattermostChannelOptions.cs index 9c667204..efc83493 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostChannelOptions.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostChannelOptions.cs @@ -15,6 +15,14 @@ public sealed class MattermostChannelOptions public SensitiveString? BotToken { get; init; } + /// + /// URL that Mattermost can reach to deliver interactive button callbacks. + /// Required for button-based approval prompts. Falls back to text-only + /// prompts when not configured. + /// Example: http://netclaw-host:5199/api/mattermost/actions + /// + public string? CallbackUrl { get; init; } + public string? DefaultChannelId { get; init; } public bool AllowDirectMessages { get; init; } diff --git a/src/Netclaw.Channels.Mattermost/MattermostGatewayActor.cs b/src/Netclaw.Channels.Mattermost/MattermostGatewayActor.cs index 227534ac..12aadca2 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostGatewayActor.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostGatewayActor.cs @@ -153,6 +153,7 @@ public sealed record MattermostGatewayDependencies( ModelCapabilities ModelCapabilities, NetclawPaths Paths, string? ServerUrl = null, + string? CallbackUrl = null, MattermostUserId? BotUserId = null, IPromptInjectionDetector? PromptInjectionDetector = null, IThreadHistoryFetcher? ThreadHistoryFetcher = null, diff --git a/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs b/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs index 014c93fd..f221ce52 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs @@ -10,8 +10,8 @@ namespace Netclaw.Channels.Mattermost; /// /// Resolves Mattermost reminder targets to canonical IDs. /// Supported inputs: -/// - @username /// - raw user ID (26-char alphanumeric Mattermost ID) +/// - @userId (same, with @ prefix stripped) /// - channel:channelId /// public sealed class MattermostReminderTargetResolver : IReminderTargetResolver diff --git a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs index b8b3f9db..890a3e66 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs @@ -512,7 +512,7 @@ private async Task TryResolveApprovalPromptAsync( try { - var resolvedText = MattermostApprovalPromptBuilder.BuildResolvedPromptText( + var resolvedAttachment = MattermostApprovalPromptBuilder.BuildResolvedAttachment( pending.Request, selectedKey, senderId); @@ -520,7 +520,8 @@ private async Task TryResolveApprovalPromptAsync( using var cts = new CancellationTokenSource(OperationTimeout); await _dependencies.ReplyClient.UpdatePostAsync( promptPostId, - resolvedText, + resolvedAttachment.Text ?? string.Empty, + [resolvedAttachment], cts.Token); } catch (Exception ex) @@ -682,6 +683,42 @@ private async Task HandleOutputReceivedAsync(OutputReceived msg) } private async Task SafeReplyWithApprovalPromptAsync(ToolInteractionRequest request) + { + var callbackUrl = _dependencies.CallbackUrl; + + if (!string.IsNullOrEmpty(callbackUrl)) + { + return await TryPostButtonPromptAsync(request, callbackUrl); + } + + return await TryPostTextPromptAsync(request); + } + + private async Task TryPostButtonPromptAsync( + ToolInteractionRequest request, + string callbackUrl) + { + var (promptText, attachments) = MattermostApprovalPromptBuilder.BuildButtonPrompt( + request, callbackUrl, _rootPostId.Value); + var startedAt = _dependencies.TimeProvider.GetTimestamp(); + try + { + var postMessage = BuildPostMessage(promptText, attachments: attachments); + var result = await _dependencies.ReplyClient.PostReplyAsync(postMessage); + var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; + ChannelTelemetry.For(ChannelType.Mattermost).RecordReplyPosted(duration); + ChannelTelemetry.For(ChannelType.Mattermost).RecordExtra("approvalFallbackActivated", "button_prompt"); + return result.PostId; + } + catch (Exception ex) + { + _log.Warning(ex, "Failed posting Mattermost button prompt; falling back to text-only"); + ChannelTelemetry.For(ChannelType.Mattermost).RecordExtra("approvalFallbackActivated", "text_prompt"); + return await TryPostTextPromptAsync(request); + } + } + + private async Task TryPostTextPromptAsync(ToolInteractionRequest request) { var promptText = MattermostApprovalPromptBuilder.BuildTextPrompt(request); var startedAt = _dependencies.TimeProvider.GetTimestamp(); @@ -721,11 +758,14 @@ private async Task SafeReplyAsync(string text) } } - private MattermostPostMessage BuildPostMessage(string text) + private MattermostPostMessage BuildPostMessage( + string text, + IReadOnlyList? attachments = null) => new( ChannelId: _channelId, Text: text, - RootPostId: _rootPostId.IsEmpty ? null : new MattermostPostId(_rootPostId.Value)); + RootPostId: _rootPostId.IsEmpty ? null : new MattermostPostId(_rootPostId.Value), + Attachments: attachments); private async Task NotifyDeliveryFailedAsync(DeliveryFailureKind failureKind, string errorMessage) { diff --git a/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs b/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs index 57ca6a87..8bb3f6aa 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs @@ -46,6 +46,8 @@ public interface IMattermostGatewayClient Task ConnectAsync(string serverUrl, string botToken, CancellationToken cancellationToken = default); Task DisconnectAsync(CancellationToken cancellationToken = default); + + Task HandleActionCallbackAsync(MattermostGatewayInteraction interaction); } public interface IMattermostReplyClient @@ -56,13 +58,33 @@ Task UpdatePostAsync( MattermostPostId postId, string text, CancellationToken cancellationToken = default); + + Task UpdatePostAsync( + MattermostPostId postId, + string text, + IReadOnlyList? attachments, + CancellationToken cancellationToken = default); } public sealed record MattermostPostMessage( MattermostChannelId ChannelId, string Text, MattermostPostId? RootPostId = null, - IReadOnlyList? FileIds = null); + IReadOnlyList? FileIds = null, + IReadOnlyList? Attachments = null); + +public sealed record MattermostAttachment( + string? Fallback = null, + string? Color = null, + string? Text = null, + IReadOnlyList? Actions = null); + +public sealed record MattermostAttachmentAction( + string Id, + string Name, + string IntegrationUrl, + Dictionary Context, + string Style = "default"); public sealed record MattermostPostResult( MattermostPostId? PostId = null) @@ -98,6 +120,10 @@ public Task ConnectAsync(string serverUrl, string botToken, CancellationToken ca public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task HandleActionCallbackAsync(MattermostGatewayInteraction interaction) + => throw new InvalidOperationException( + "Mattermost channel is enabled, but no Mattermost gateway client is configured."); } /// @@ -112,4 +138,8 @@ public Task PostReplyAsync(MattermostPostMessage message, public Task UpdatePostAsync(MattermostPostId postId, string text, CancellationToken cancellationToken = default) => throw new InvalidOperationException( "Mattermost channel attempted to update a post, but no Mattermost reply client is configured."); + + public Task UpdatePostAsync(MattermostPostId postId, string text, IReadOnlyList? attachments, CancellationToken cancellationToken = default) + => throw new InvalidOperationException( + "Mattermost channel attempted to update a post, but no Mattermost reply client is configured."); } diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs index f3141ba3..3471ac34 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs @@ -19,11 +19,7 @@ internal sealed class MattermostNetGatewayClient : IMattermostGatewayClient, IDi public event Func? MessageReceived; - // Interactive message actions will be wired in a follow-up when - // Mattermost attachment action support is implemented. -#pragma warning disable CS0067 public event Func? InteractionReceived; -#pragma warning restore CS0067 public bool IsConnected => _client.IsConnected; public MattermostUserId? BotUserId { get; private set; } @@ -139,6 +135,13 @@ private void OnLogMessage(object? sender, LogEventArgs e) _logger.LogDebug("[Mattermost.NET] {Message}", e.Message); } + public async Task HandleActionCallbackAsync(MattermostGatewayInteraction interaction) + { + var handler = InteractionReceived; + if (handler is not null) + await handler(interaction); + } + public void Dispose() { _client.OnMessageReceived -= OnMessageReceived; diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs index c5c2d548..9acb08a5 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs @@ -3,21 +3,35 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; using Mattermost; namespace Netclaw.Channels.Mattermost.Transport; internal sealed class MattermostNetReplyClient : IMattermostReplyClient { + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull + }; + private readonly MattermostClient _client; + private readonly HttpClient _httpClient; - public MattermostNetReplyClient(MattermostClient client) + public MattermostNetReplyClient(MattermostClient client, HttpClient httpClient) { _client = client; + _httpClient = httpClient; } public async Task PostReplyAsync(MattermostPostMessage message, CancellationToken cancellationToken = default) { + if (message.Attachments is { Count: > 0 }) + return await PostWithAttachmentsAsync(message, cancellationToken); + var post = await _client.CreatePostAsync( channelId: message.ChannelId.Value, message: message.Text, @@ -35,4 +49,147 @@ public async Task UpdatePostAsync( { await _client.UpdatePostAsync(postId.Value, text); } + + public async Task UpdatePostAsync( + MattermostPostId postId, + string text, + IReadOnlyList? attachments, + CancellationToken cancellationToken = default) + { + if (attachments is null or { Count: 0 }) + { + await _client.UpdatePostAsync(postId.Value, text); + return; + } + + var attachmentPayloads = attachments + .Select(a => new AttachmentPayload + { + Fallback = a.Fallback, + Color = a.Color, + Text = a.Text, + Actions = a.Actions?.Select(act => new ActionPayload + { + Id = act.Id, + Name = act.Name, + Type = "button", + Style = act.Style, + Integration = new IntegrationPayload + { + Url = act.IntegrationUrl, + Context = act.Context + } + }).ToList() + }) + .ToList(); + + var payload = new UpdatePostPayload + { + Id = postId.Value, + Message = text, + Props = new PropsPayload { Attachments = attachmentPayloads } + }; + + var response = await _httpClient.PutAsJsonAsync( + $"/api/v4/posts/{postId.Value}", + payload, + JsonOptions, + cancellationToken); + response.EnsureSuccessStatusCode(); + } + + private async Task PostWithAttachmentsAsync( + MattermostPostMessage message, + CancellationToken cancellationToken) + { + var attachments = message.Attachments! + .Select(a => new AttachmentPayload + { + Fallback = a.Fallback, + Color = a.Color, + Text = a.Text, + Actions = a.Actions?.Select(act => new ActionPayload + { + Id = act.Id, + Name = act.Name, + Type = "button", + Style = act.Style, + Integration = new IntegrationPayload + { + Url = act.IntegrationUrl, + Context = act.Context + } + }).ToList() + }) + .ToList(); + + var payload = new CreatePostPayload + { + ChannelId = message.ChannelId.Value, + Message = message.Text, + RootId = message.RootPostId?.Value, + Props = new PropsPayload + { + Attachments = attachments + } + }; + + var response = await _httpClient.PostAsJsonAsync( + "/api/v4/posts", + payload, + JsonOptions, + cancellationToken); + response.EnsureSuccessStatusCode(); + + var doc = await JsonDocument.ParseAsync( + await response.Content.ReadAsStreamAsync(cancellationToken), + cancellationToken: cancellationToken); + var postId = doc.RootElement.GetProperty("id").GetString()!; + + return new MattermostPostResult(PostId: new MattermostPostId(postId)); + } + + // JSON payload types for Mattermost REST API + private sealed class UpdatePostPayload + { + public string Id { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + public PropsPayload? Props { get; init; } + } + + private sealed class CreatePostPayload + { + public string ChannelId { get; init; } = string.Empty; + public string Message { get; init; } = string.Empty; + public string? RootId { get; init; } + public PropsPayload? Props { get; init; } + } + + private sealed class PropsPayload + { + public List? Attachments { get; init; } + } + + private sealed class AttachmentPayload + { + public string? Fallback { get; init; } + public string? Color { get; init; } + public string? Text { get; init; } + public List? Actions { get; init; } + } + + private sealed class ActionPayload + { + public string Id { get; init; } = string.Empty; + public string Name { get; init; } = string.Empty; + public string Type { get; init; } = "button"; + public string? Style { get; init; } + public IntegrationPayload? Integration { get; init; } + } + + private sealed class IntegrationPayload + { + public string Url { get; init; } = string.Empty; + public Dictionary? Context { get; init; } + } } diff --git a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json index 64aec9a3..0e614e59 100644 --- a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json +++ b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json @@ -71,6 +71,7 @@ "Enabled": { "type": "boolean" }, "ServerUrl": { "type": "string", "format": "uri", "description": "Base URL of the Mattermost server (e.g. https://mm.example.com)." }, "BotToken": { "type": ["string", "null"] }, + "CallbackUrl": { "type": ["string", "null"], "format": "uri", "description": "URL that Mattermost can reach for interactive button callbacks (e.g. http://netclaw-host:5199/api/mattermost/actions)." }, "DefaultChannelId": { "type": ["string", "null"] }, "AllowDirectMessages": { "type": "boolean" }, "MentionOnly": { "type": "boolean", "default": true }, diff --git a/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs new file mode 100644 index 00000000..92b81ecb --- /dev/null +++ b/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs @@ -0,0 +1,127 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Channels.Mattermost; + +namespace Netclaw.Daemon.Configuration; + +public static class MattermostActionEndpointExtensions +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower, + PropertyNameCaseInsensitive = true + }; + + public static void MapMattermostActionEndpoint(this WebApplication app) + { + app.MapPost("/api/mattermost/actions", async ( + HttpContext httpContext, + IServiceProvider sp, + TimeProvider timeProvider, + ILogger logger, + CancellationToken ct) => + { + var channel = sp.GetService(); + if (channel is null) + return Results.NotFound("Mattermost channel is not configured."); + + ActionCallbackPayload? payload; + try + { + payload = await JsonSerializer.DeserializeAsync( + httpContext.Request.Body, + JsonOptions, + ct); + } + catch (JsonException) + { + return Results.BadRequest("Invalid JSON payload."); + } + + if (payload is null + || string.IsNullOrEmpty(payload.UserId) + || string.IsNullOrEmpty(payload.PostId) + || string.IsNullOrEmpty(payload.ChannelId)) + { + return Results.BadRequest("Missing required fields: user_id, post_id, channel_id."); + } + + if (payload.Context is null + || !payload.Context.TryGetValue("call_id", out var callId) + || !payload.Context.TryGetValue("selected_key", out var selectedKey) + || string.IsNullOrEmpty(callId) + || string.IsNullOrEmpty(selectedKey)) + { + return Results.BadRequest("Missing required context fields: call_id, selected_key."); + } + + if (!IsValidApprovalKey(selectedKey)) + return Results.BadRequest("Invalid selected_key value."); + + payload.Context.TryGetValue("requester_sender_id", out var requesterSenderId); + if (string.IsNullOrEmpty(requesterSenderId)) + requesterSenderId = null; + + payload.Context.TryGetValue("root_post_id", out var rootPostId); + + var interaction = new MattermostGatewayInteraction( + ChannelId: new MattermostChannelId(payload.ChannelId), + RootPostId: new MattermostRootPostId(rootPostId ?? string.Empty), + CallId: callId, + SelectedKey: selectedKey, + SenderId: new MattermostUserId(payload.UserId), + RequesterSenderId: requesterSenderId is not null + ? new MattermostUserId(requesterSenderId) + : null, + ReceivedAt: timeProvider.GetUtcNow()); + + try + { + await channel.GatewayClient.HandleActionCallbackAsync(interaction); + } + catch (Exception ex) + { + logger.LogError(ex, "Failed routing Mattermost action callback for call {CallId}", callId); + return Results.StatusCode(500); + } + + var decisionLabel = selectedKey switch + { + "approve_once" => "Approve Once", + "approve_session" => "Approve for Session", + "approve_always" => "Always Approve", + "deny" => "Deny", + _ => selectedKey + }; + + var response = new ActionCallbackResponse + { + EphemeralText = $"You selected: **{decisionLabel}**" + }; + + return Results.Json(response, JsonOptions); + }); + } + + private sealed class ActionCallbackPayload + { + public string? UserId { get; set; } + public string? UserName { get; set; } + public string? ChannelId { get; set; } + public string? PostId { get; set; } + public string? TriggerId { get; set; } + public Dictionary? Context { get; set; } + } + + private sealed class ActionCallbackResponse + { + public string? EphemeralText { get; set; } + } + + private static bool IsValidApprovalKey(string key) + => key is "approve_once" or "approve_session" or "approve_always" or "deny"; +} diff --git a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs index 6ef70326..40919ca7 100644 --- a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs @@ -34,12 +34,24 @@ public static void AddMattermostChannelIntegration(this IServiceCollection servi services.AddSingleton(_ => new MattermostClient(serverUrl, mattermostOptions.BotToken!.Value)); - services.AddHttpClient("mattermost-files"); + services.AddHttpClient("mattermost-files", client => + { + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", mattermostOptions.BotToken!.Value); + }); + services.AddHttpClient("mattermost-api", client => + { + client.BaseAddress = new Uri(serverUrl.TrimEnd('/')); + client.DefaultRequestHeaders.Authorization = + new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", mattermostOptions.BotToken!.Value); + }); services.AddSingleton(); services.AddSingleton(sp => { var client = sp.GetRequiredService(); - return new MattermostNetReplyClient(client); + var httpClientFactory = sp.GetRequiredService(); + var httpClient = httpClientFactory.CreateClient("mattermost-api"); + return new MattermostNetReplyClient(client, httpClient); }); services.AddSingleton(sp => { diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index 1ff5b41c..53c9a01f 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -210,6 +210,7 @@ static async Task RunDaemonAsync(string[] args, DaemonRestartSignal restartSigna app.MapGet("/api/stats/skills", async (DaemonStatsService statsService, int? days, CancellationToken ct) => Results.Ok(await statsService.GetSkillUsageStatsAsync(days, ct))).RequireAuthorization(); app.MapWebhookEndpoints(); + app.MapMattermostActionEndpoint(); // Device pairing exchange — unauthenticated, rate-limited, with per-IP lockout guard. // Accepts a time-limited pairing code and a device name; returns a bearer token on success. From e8276c6a97859705e56d1357e5a668c8a09062dd Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 5 May 2026 22:50:27 -0500 Subject: [PATCH 4/5] fix: harden Mattermost channel for production readiness - Add HMAC-SHA256 callback signing for interactive button webhooks with ephemeral per-daemon keys and constant-time signature verification - Add ACL enforcement on callback endpoint and conversation actor for interaction messages - Add message chunking at 16,000 chars with newline-aware splitting to stay under Mattermost's 16,383-char post limit - Fix bot mention stripping to use @username format instead of @userId - Resolve file attachment metadata (name, MIME type, size) via GetFileDetailsAsync instead of using raw file IDs - Remove BotToken from config schema for consistency with other channels - Add comprehensive test coverage: 123 Mattermost tests across conversation actor (22), session binding contracts (26), gateway contracts (3), URL trust (5), chunking (4), approval signing (5), and existing test suites --- .../MattermostGatewayContractTests.cs | 80 +++ .../MattermostSessionBindingContractTests.cs | 195 ++++++ .../MattermostApprovalPromptBuilderTests.cs | 94 +++ .../MattermostAttachmentUrlTrustTests.cs | 52 ++ .../MattermostConversationActorTests.cs | 577 ++++++++++++++++++ .../MattermostMessageChunkingTests.cs | 62 ++ .../RecordingMattermostReplyClient.cs | 39 ++ .../Channels/TestMattermostGatewayDeps.cs | 39 ++ .../MattermostApprovalPromptBuilder.cs | 31 +- .../MattermostCallbackSigner.cs | 49 ++ .../MattermostChannel.cs | 7 +- .../MattermostConversationActor.cs | 12 +- .../MattermostGatewayActor.cs | 2 + .../MattermostSessionBindingActor.cs | 62 +- .../MattermostTransportContracts.cs | 4 + .../Transport/MattermostNetGatewayClient.cs | 74 ++- .../MattermostThreadHistoryFetcher.cs | 37 +- .../Schemas/netclaw-config.v1.schema.json | 1 - .../MattermostActionEndpointExtensions.cs | 29 +- ...MattermostChannelRegistrationExtensions.cs | 8 + 20 files changed, 1393 insertions(+), 61 deletions(-) create mode 100644 src/Netclaw.Actors.Tests/Channels/Contracts/MattermostGatewayContractTests.cs create mode 100644 src/Netclaw.Actors.Tests/Channels/Contracts/MattermostSessionBindingContractTests.cs create mode 100644 src/Netclaw.Actors.Tests/Channels/MattermostAttachmentUrlTrustTests.cs create mode 100644 src/Netclaw.Actors.Tests/Channels/MattermostConversationActorTests.cs create mode 100644 src/Netclaw.Actors.Tests/Channels/MattermostMessageChunkingTests.cs create mode 100644 src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingMattermostReplyClient.cs create mode 100644 src/Netclaw.Actors.Tests/Channels/TestMattermostGatewayDeps.cs create mode 100644 src/Netclaw.Channels.Mattermost/MattermostCallbackSigner.cs diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/MattermostGatewayContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/MattermostGatewayContractTests.cs new file mode 100644 index 00000000..36d6dcff --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/MattermostGatewayContractTests.cs @@ -0,0 +1,80 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Akka.Actor; +using Akka.Hosting; +using Netclaw.Actors.Tests.Channels.TestHelpers; +using Netclaw.Channels.Mattermost; +using Netclaw.Security; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels.Contracts; + +public sealed class MattermostGatewayContractTests(ITestOutputHelper output) + : GatewayRoutingContractTests(output) +{ + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + protected override IActorRef CreateGateway(ChannelOptionsBuilder options) + { + var mattermostOptions = new MattermostChannelOptions + { + AllowedChannelIds = options.AllowedChannelIds, + AllowedUserIds = options.AllowedUserIds, + AllowDirectMessages = options.AllowDirectMessages, + ChannelAudiences = options.ChannelAudiences + }; + + var defaultChannelId = options.DefaultChannelId is not null + ? new MattermostChannelId(options.DefaultChannelId) + : (MattermostChannelId?)null; + + var deps = new MattermostGatewayDependencies( + Pipeline: new FailingSessionPipeline(new InvalidOperationException("not used")), + IngressGate: null, + TimeProvider: TimeProvider.System, + Options: mattermostOptions, + DefaultChannelId: defaultChannelId, + ReplyClient: new RecordingMattermostReplyClient(), + ContentScanner: new NullContentScanner(), + AudienceProfiles: TestMattermostGatewayDeps.DefaultAudienceProfiles, + ModelCapabilities: TestMattermostGatewayDeps.DefaultVisionCapableModel, + Paths: TestMattermostGatewayDeps.NewTestPaths(), + SessionPropsFactory: (sid, chId, rootPostId, d) => + Props.Create(() => new ForwardActor(TestActor))); + + return Sys.ActorOf(MattermostGatewayActor.CreateProps(deps)); + } + + protected override object CreateAllowedMessage( + string channelId, string threadId, string userId, string text, string eventId) + => new MattermostGatewayMessage( + EventId: new MattermostEventId(eventId), + ChannelId: new MattermostChannelId(channelId), + PostId: new MattermostPostId("post-1"), + RootPostId: new MattermostRootPostId(threadId), + SenderId: new MattermostUserId(userId), + IsBotMessage: false, + IsDirectMessage: false, + ContainsBotMention: true, + Text: text, + ReceivedAt: TimeProvider.System.GetUtcNow()); + + protected override object CreateDeniedMessage( + string channelId, string userId, string eventId) + => new MattermostGatewayMessage( + EventId: new MattermostEventId(eventId), + ChannelId: new MattermostChannelId(channelId), + PostId: new MattermostPostId("post-1"), + RootPostId: new MattermostRootPostId("thread-1"), + SenderId: new MattermostUserId(userId), + IsBotMessage: false, + IsDirectMessage: false, + ContainsBotMention: true, + Text: "denied", + ReceivedAt: TimeProvider.System.GetUtcNow()); +} diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/MattermostSessionBindingContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/MattermostSessionBindingContractTests.cs new file mode 100644 index 00000000..cf72b2f3 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/MattermostSessionBindingContractTests.cs @@ -0,0 +1,195 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Akka.Actor; +using Akka.Hosting; +using Akka.Persistence.Hosting; +using Microsoft.Extensions.AI; +using Netclaw.Actors.Channels; +using Netclaw.Actors.Protocol; +using Netclaw.Actors.Tests.Channels.TestHelpers; +using Netclaw.Channels.Mattermost; +using Netclaw.Configuration; +using Netclaw.Security; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels.Contracts; + +public sealed class MattermostSessionBindingContractTests(ITestOutputHelper output) + : SessionBindingContractTests(output) +{ + private RecordingMattermostReplyClient _replyClient = new(); + private int _actorCounter; + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + builder.WithInMemoryJournal().WithInMemorySnapshotStore(); + } + + protected override IActorRef CreateBindingActor( + SessionId sessionId, + RecordingSessionPipeline pipeline, + ConfigurablePromptInjectionDetector detector) + { + ResetReplyClient(); + return CreateActorCore(sessionId, pipeline, detector); + } + + protected override IActorRef CreateBindingActorWithPipeline( + SessionId sessionId, + ISessionPipeline pipeline, + ConfigurablePromptInjectionDetector detector) + { + ResetReplyClient(); + var options = new MattermostChannelOptions(); + var deps = new MattermostGatewayDependencies( + Pipeline: pipeline, + IngressGate: null, + TimeProvider: TimeProvider.System, + Options: options, + DefaultChannelId: null, + ReplyClient: _replyClient, + ContentScanner: new NullContentScanner(), + AudienceProfiles: TestMattermostGatewayDeps.DefaultAudienceProfiles, + ModelCapabilities: TestMattermostGatewayDeps.DefaultVisionCapableModel, + Paths: TestMattermostGatewayDeps.NewTestPaths(), + PromptInjectionDetector: detector); + + var name = $"mm-session-fail-{Interlocked.Increment(ref _actorCounter)}"; + return Sys.ActorOf(MattermostSessionBindingActor.CreateProps( + sessionId, + new MattermostChannelId("ch-test"), + new MattermostRootPostId("root-test"), + deps), name); + } + + protected override object CreateInboundMessage(string text, string senderId) + => new MattermostThreadInbound( + SessionId: new SessionId("ignored"), + ChannelId: new MattermostChannelId("ch-test"), + PostId: new MattermostPostId($"post-{Guid.NewGuid():N}"), + RootPostId: new MattermostRootPostId("root-test"), + EventId: new MattermostEventId($"evt-{Guid.NewGuid():N}"), + SenderId: new MattermostUserId(senderId), + Audience: TrustAudience.Team, + Principal: PrincipalClassification.UntrustedExternal, + Provenance: new SourceProvenance + { + TransportAuthenticity = TransportAuthenticity.Verified, + SourceKind = "mattermost" + }, + Text: text, + ReceivedAt: TimeProvider.System.GetUtcNow()); + + protected override object CreateApprovalResponse(string callId, string selectedKey, string senderId) + => new MattermostApprovalResponse( + ChannelId: new MattermostChannelId("ch-test"), + RootPostId: new MattermostRootPostId("root-test"), + CallId: callId, + SelectedKey: selectedKey, + SenderId: new MattermostUserId(senderId)); + + protected override IReadOnlyList GetPostedTexts() + => _replyClient.Posts.Select(p => p.Text).ToList(); + + protected override void ClearPostedTexts() + => _replyClient.Posts.Clear(); + + protected override void SetReplyClientThrows(Exception ex) + => _replyClient.ThrowOnPost = ex; + + protected override void ClearReplyClientThrows() + => _replyClient.ThrowOnPost = null; + + protected override ChannelType ExpectedChannelType => ChannelType.Mattermost; + + protected override bool SupportsThreadHydration => true; + + private long _hydrationEventCounter; + + protected override IActorRef CreateBindingActorWithHydration( + SessionId sessionId, + RecordingSessionPipeline pipeline, + ConfigurablePromptInjectionDetector detector, + IThreadHistoryFetcher historyFetcher) + { + ResetReplyClient(); + return CreateActorCore(sessionId, pipeline, detector, historyFetcher: historyFetcher); + } + + protected override IReadOnlyList CreateHistoryItems(int count) + { + var items = new List(); + for (var i = 0; i < count; i++) + { + items.Add(new ChannelInput + { + SenderId = $"history-user-{i}", + ChannelId = "ch-test", + MessageId = $"post-history-{900_000 + i}", + Contents = [new TextContent($"history message {i}")], + ReceivedAt = TimeProvider.System.GetUtcNow().AddMinutes(-count + i) + }); + } + + return items; + } + + protected override object CreateHydrationTriggerInboundMessage(string text, string senderId) + { + var postId = $"post-live-{1_000_000 + Interlocked.Increment(ref _hydrationEventCounter)}"; + return new MattermostThreadInbound( + SessionId: new SessionId("ignored"), + ChannelId: new MattermostChannelId("ch-test"), + PostId: new MattermostPostId(postId), + RootPostId: new MattermostRootPostId("root-test"), + EventId: new MattermostEventId(postId), + SenderId: new MattermostUserId(senderId), + Audience: TrustAudience.Team, + Principal: PrincipalClassification.UntrustedExternal, + Provenance: new SourceProvenance + { + TransportAuthenticity = TransportAuthenticity.Verified, + SourceKind = "mattermost" + }, + Text: text, + ReceivedAt: TimeProvider.System.GetUtcNow()); + } + + private void ResetReplyClient() + { + var pendingThrow = _replyClient.ThrowOnPost; + _replyClient = new RecordingMattermostReplyClient { ThrowOnPost = pendingThrow }; + } + + private IActorRef CreateActorCore( + SessionId sessionId, + ISessionPipeline pipeline, + ConfigurablePromptInjectionDetector detector, + IThreadHistoryFetcher? historyFetcher = null) + { + var options = new MattermostChannelOptions(); + var deps = new MattermostGatewayDependencies( + Pipeline: pipeline, + IngressGate: null, + TimeProvider: TimeProvider.System, + Options: options, + DefaultChannelId: null, + ReplyClient: _replyClient, + ContentScanner: new NullContentScanner(), + AudienceProfiles: TestMattermostGatewayDeps.DefaultAudienceProfiles, + ModelCapabilities: TestMattermostGatewayDeps.DefaultVisionCapableModel, + Paths: TestMattermostGatewayDeps.NewTestPaths(), + PromptInjectionDetector: detector, + ThreadHistoryFetcher: historyFetcher); + + var name = $"mm-session-contract-{Interlocked.Increment(ref _actorCounter)}"; + return Sys.ActorOf(MattermostSessionBindingActor.CreateProps( + sessionId, + new MattermostChannelId("ch-test"), + new MattermostRootPostId("root-test"), + deps), name); + } +} diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs index f336bb42..e2cfbf11 100644 --- a/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs @@ -230,6 +230,100 @@ public void BuildResolvedAttachment_deny_shows_red_color() Assert.Null(attachment.Actions); } + [Fact] + public void BuildButtonPrompt_with_signing_key_includes_signature() + { + var request = CreateStandardRequest(); + var signingKey = MattermostCallbackSigner.GenerateKey(); + + var (_, attachments) = MattermostApprovalPromptBuilder.BuildButtonPrompt( + request, "http://localhost/api/mattermost/actions", "root-post-1", signingKey); + + foreach (var action in attachments[0].Actions!) + { + Assert.True(action.Context.ContainsKey("signature"), $"Button '{action.Id}' missing signature"); + Assert.NotEmpty(action.Context["signature"]); + } + } + + [Fact] + public void BuildButtonPrompt_without_signing_key_omits_signature() + { + var request = CreateStandardRequest(); + + var (_, attachments) = MattermostApprovalPromptBuilder.BuildButtonPrompt( + request, "http://localhost/api/mattermost/actions", "root-post-1"); + + foreach (var action in attachments[0].Actions!) + { + Assert.False(action.Context.ContainsKey("signature"), $"Button '{action.Id}' should not have signature"); + } + } + + [Fact] + public void BuildButtonPrompt_signatures_are_verifiable() + { + var request = CreateStandardRequest(); + var signingKey = MattermostCallbackSigner.GenerateKey(); + + var (_, attachments) = MattermostApprovalPromptBuilder.BuildButtonPrompt( + request, "http://localhost/api/mattermost/actions", "root-post-1", signingKey); + + var approveOnce = attachments[0].Actions![0]; + var verified = MattermostCallbackSigner.Verify( + signingKey, + approveOnce.Context["call_id"], + approveOnce.Context["selected_key"], + approveOnce.Context["requester_sender_id"], + approveOnce.Context["root_post_id"], + approveOnce.Context["signature"]); + + Assert.True(verified); + } + + [Fact] + public void BuildButtonPrompt_signature_rejects_tampered_selected_key() + { + var request = CreateStandardRequest(); + var signingKey = MattermostCallbackSigner.GenerateKey(); + + var (_, attachments) = MattermostApprovalPromptBuilder.BuildButtonPrompt( + request, "http://localhost/api/mattermost/actions", "root-post-1", signingKey); + + var approveOnce = attachments[0].Actions![0]; + var verified = MattermostCallbackSigner.Verify( + signingKey, + approveOnce.Context["call_id"], + "approve_always", // tampered: was approve_once + approveOnce.Context["requester_sender_id"], + approveOnce.Context["root_post_id"], + approveOnce.Context["signature"]); + + Assert.False(verified); + } + + [Fact] + public void BuildButtonPrompt_signature_rejects_wrong_key() + { + var request = CreateStandardRequest(); + var signingKey = MattermostCallbackSigner.GenerateKey(); + var wrongKey = MattermostCallbackSigner.GenerateKey(); + + var (_, attachments) = MattermostApprovalPromptBuilder.BuildButtonPrompt( + request, "http://localhost/api/mattermost/actions", "root-post-1", signingKey); + + var approveOnce = attachments[0].Actions![0]; + var verified = MattermostCallbackSigner.Verify( + wrongKey, + approveOnce.Context["call_id"], + approveOnce.Context["selected_key"], + approveOnce.Context["requester_sender_id"], + approveOnce.Context["root_post_id"], + approveOnce.Context["signature"]); + + Assert.False(verified); + } + private static ToolInteractionRequest CreateStandardRequest() => new() { diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostAttachmentUrlTrustTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostAttachmentUrlTrustTests.cs new file mode 100644 index 00000000..ce6eef26 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/MattermostAttachmentUrlTrustTests.cs @@ -0,0 +1,52 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Channels.Mattermost; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels; + +public sealed class MattermostAttachmentUrlTrustTests +{ + [Fact] + public void Allows_url_matching_server_url() + { + Assert.True(MattermostAttachmentUrlTrust.IsAllowedAttachmentUrl( + "https://mm.example.com/api/v4/files/abc123", + "https://mm.example.com")); + } + + [Fact] + public void Rejects_url_from_different_domain() + { + Assert.False(MattermostAttachmentUrlTrust.IsAllowedAttachmentUrl( + "https://evil.com/api/v4/files/abc123", + "https://mm.example.com")); + } + + [Fact] + public void Rejects_subdomain_bypass() + { + Assert.False(MattermostAttachmentUrlTrust.IsAllowedAttachmentUrl( + "https://mm.example.com.evil.com/api/v4/files/abc123", + "https://mm.example.com")); + } + + [Fact] + public void Handles_trailing_slash_on_server_url() + { + Assert.True(MattermostAttachmentUrlTrust.IsAllowedAttachmentUrl( + "https://mm.example.com/api/v4/files/abc123", + "https://mm.example.com/")); + } + + [Fact] + public void Case_insensitive_comparison() + { + Assert.True(MattermostAttachmentUrlTrust.IsAllowedAttachmentUrl( + "HTTPS://MM.EXAMPLE.COM/api/v4/files/abc123", + "https://mm.example.com")); + } +} diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostConversationActorTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostConversationActorTests.cs new file mode 100644 index 00000000..7b01a005 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/MattermostConversationActorTests.cs @@ -0,0 +1,577 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Akka.Actor; +using Akka.Configuration; +using Akka.Hosting; +using Akka.Hosting.TestKit; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Netclaw.Actors.Channels; +using Netclaw.Actors.Protocol; +using Netclaw.Actors.Tests.Channels.TestHelpers; +using Netclaw.Channels.Mattermost; +using Netclaw.Configuration; +using Netclaw.Security; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels; + +public sealed class MattermostConversationActorTests(ITestOutputHelper output) : TestKit(output: output) +{ + protected override Config? Config => + ConfigurationFactory.ParseString("akka.test.default-timeout = 5s"); + + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) + { + } + + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + [Fact] + public async Task Routes_messages_to_session_binding_by_thread_id() + { + var sink = CreateTestProbe("route-by-thread"); + var conversation = CreateConversation("ch-1", sink); + + conversation.Tell(CreateMessage(channelId: "ch-1", rootPostId: "root-42", text: "hello")); + + var inbound = await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("ch-1/root-42", inbound.SessionId.Value); + Assert.Equal("hello", inbound.Text); + } + + [Fact] + public async Task Creates_new_session_binding_for_top_level_messages() + { + var sink = CreateTestProbe("top-level"); + var conversation = CreateConversation("ch-1", sink); + + // Top-level message has empty RootPostId, so PostId becomes the root + conversation.Tell(CreateMessage( + channelId: "ch-1", postId: "post-100", rootPostId: "", text: "new conversation")); + + var inbound = await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("ch-1/post-100", inbound.SessionId.Value); + } + + [Fact] + public async Task Reuses_existing_session_binding_for_same_thread() + { + var sink = CreateTestProbe("same-thread"); + var conversation = CreateConversation("ch-1", sink); + + conversation.Tell(CreateMessage( + channelId: "ch-1", rootPostId: "root-42", text: "first")); + var first = await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + + conversation.Tell(CreateMessage( + channelId: "ch-1", rootPostId: "root-42", text: "second", eventId: "ev-2")); + var second = await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + + Assert.Equal(first.SessionId, second.SessionId); + } + + [Fact] + public async Task Filters_bot_messages() + { + var sink = CreateTestProbe("bot-filter"); + var conversation = CreateConversation("ch-1", sink); + + conversation.Tell(new MattermostGatewayMessage( + EventId: new MattermostEventId("ev-bot"), + ChannelId: new MattermostChannelId("ch-1"), + PostId: new MattermostPostId("p-bot"), + RootPostId: new MattermostRootPostId("p-bot"), + SenderId: new MattermostUserId("u-bot"), + IsBotMessage: true, + IsDirectMessage: false, + ContainsBotMention: false, + Text: "bot output", + ReceivedAt: TimeProvider.System.GetUtcNow())); + + await sink.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(250), + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Filters_empty_text_messages() + { + var sink = CreateTestProbe("empty-text"); + var conversation = CreateConversation("ch-1", sink); + + conversation.Tell(CreateMessage(channelId: "ch-1", text: " ")); + + await sink.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(250), + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Truncates_oversized_inbound_text() + { + var sink = CreateTestProbe("truncate"); + var conversation = CreateConversation("ch-1", sink); + + var longText = new string('x', 5000); + conversation.Tell(CreateMessage(channelId: "ch-1", text: longText)); + + var inbound = await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal(4000, inbound.Text.Length); + } + + [Fact] + public async Task Enforces_ACL_denies_non_allowed_users() + { + var sink = CreateTestProbe("acl-user-denied"); + var options = new MattermostChannelOptions + { + Enabled = true, + AllowDirectMessages = true, + MentionOnly = false, + AllowedChannelIds = ["ch-1"], + AllowedUserIds = ["u-allowed"] + }; + var conversation = CreateConversation("ch-1", sink, options); + + conversation.Tell(CreateMessage( + channelId: "ch-1", senderId: "u-denied", text: "should be denied")); + + await sink.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(250), + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Enforces_ACL_denies_non_allowed_channels() + { + var sink = CreateTestProbe("acl-channel-denied"); + // ch-99 is not in AllowedChannelIds + var conversation = CreateConversation("ch-99", sink); + + conversation.Tell(CreateMessage(channelId: "ch-99", text: "should be denied")); + + await sink.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(250), + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Enforces_ACL_denies_DMs_when_disabled() + { + var sink = CreateTestProbe("dm-denied"); + var options = new MattermostChannelOptions + { + Enabled = true, + AllowDirectMessages = false, + MentionOnly = false, + AllowedChannelIds = ["ch-1"] + }; + var conversation = CreateConversation("ch-1", sink, options); + + conversation.Tell(new MattermostGatewayMessage( + EventId: new MattermostEventId("ev-dm"), + ChannelId: new MattermostChannelId("ch-1"), + PostId: new MattermostPostId("p-dm"), + RootPostId: new MattermostRootPostId(""), + SenderId: new MattermostUserId("u-1"), + IsBotMessage: false, + IsDirectMessage: true, + ContainsBotMention: false, + Text: "hi from DM", + ReceivedAt: TimeProvider.System.GetUtcNow())); + + await sink.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(250), + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Enforces_routing_policy_mention_only() + { + var sink = CreateTestProbe("mention-filter"); + var options = new MattermostChannelOptions + { + Enabled = true, + MentionOnly = true, + AllowedChannelIds = ["ch-1"] + }; + var conversation = CreateConversation("ch-1", sink, options); + + // Top-level message without mention and no existing thread + conversation.Tell(CreateMessage( + channelId: "ch-1", postId: "p-1", rootPostId: "", + text: "no mention here", containsBotMention: false)); + + await sink.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(250), + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task MentionOnly_allows_mention_messages() + { + var sink = CreateTestProbe("mention-allow"); + var options = new MattermostChannelOptions + { + Enabled = true, + MentionOnly = true, + AllowedChannelIds = ["ch-1"] + }; + var conversation = CreateConversation("ch-1", sink, options, botUsername: "netclaw"); + + conversation.Tell(CreateMessage( + channelId: "ch-1", postId: "p-1", rootPostId: "", + text: "@netclaw hello", containsBotMention: true)); + + var inbound = await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains("hello", inbound.Text); + } + + [Fact] + public async Task MentionOnly_allows_existing_thread_without_mention() + { + var sink = CreateTestProbe("mention-thread-continue"); + var options = new MattermostChannelOptions + { + Enabled = true, + MentionOnly = true, + AllowedChannelIds = ["ch-1"] + }; + var conversation = CreateConversation("ch-1", sink, options, botUsername: "netclaw"); + + // Start thread with mention + conversation.Tell(CreateMessage( + channelId: "ch-1", rootPostId: "root-1", + text: "@netclaw start", containsBotMention: true)); + await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // Follow-up in same thread without mention (ContinueOnly) + conversation.Tell(CreateMessage( + channelId: "ch-1", rootPostId: "root-1", + text: "follow up without mention", eventId: "ev-2", + containsBotMention: false)); + + var second = await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("follow up without mention", second.Text); + } + + [Fact] + public async Task Strips_bot_mention_tag_from_text() + { + var sink = CreateTestProbe("mention-strip"); + var conversation = CreateConversation("ch-1", sink, botUsername: "netclaw"); + + conversation.Tell(CreateMessage( + channelId: "ch-1", text: "@netclaw what is the weather?", + containsBotMention: true)); + + var inbound = await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("what is the weather?", inbound.Text); + } + + [Fact] + public async Task Routes_interactions_to_correct_session_binding() + { + var sink = CreateTestProbe("interaction-route"); + var conversation = CreateConversation("ch-1", sink); + + // Create the session binding with a threaded message + conversation.Tell(CreateMessage( + channelId: "ch-1", rootPostId: "root-500", text: "start")); + await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // Send an interaction using the same root post ID + conversation.Tell(new MattermostGatewayInteraction( + ChannelId: new MattermostChannelId("ch-1"), + RootPostId: new MattermostRootPostId("root-500"), + CallId: "call-1", + SelectedKey: ApprovalOptionKeys.ApproveOnce, + SenderId: new MattermostUserId("u-1"), + RequesterSenderId: new MattermostUserId("u-1"), + ReceivedAt: TimeProvider.System.GetUtcNow())); + + var approval = await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("call-1", approval.CallId); + } + + [Fact] + public async Task Rejects_interactions_for_missing_session_bindings() + { + var sink = CreateTestProbe("interaction-missing"); + var conversation = CreateConversation("ch-1", sink); + + conversation.Tell(new MattermostGatewayInteraction( + ChannelId: new MattermostChannelId("ch-1"), + RootPostId: new MattermostRootPostId("nonexistent"), + CallId: "call-1", + SelectedKey: ApprovalOptionKeys.ApproveOnce, + SenderId: new MattermostUserId("u-1"), + RequesterSenderId: null, + ReceivedAt: TimeProvider.System.GetUtcNow())); + + await sink.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(250), + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Rejects_interactions_from_non_allowed_users() + { + var sink = CreateTestProbe("interaction-user-denied"); + var options = new MattermostChannelOptions + { + Enabled = true, + AllowDirectMessages = true, + MentionOnly = false, + AllowedChannelIds = ["ch-1"], + AllowedUserIds = ["u-allowed"] + }; + var conversation = CreateConversation("ch-1", sink, options); + + // Create the session binding first (from allowed user) + conversation.Tell(CreateMessage( + channelId: "ch-1", rootPostId: "root-600", text: "setup", + senderId: "u-allowed")); + await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // Send interaction from non-allowed user + conversation.Tell(new MattermostGatewayInteraction( + ChannelId: new MattermostChannelId("ch-1"), + RootPostId: new MattermostRootPostId("root-600"), + CallId: "call-1", + SelectedKey: ApprovalOptionKeys.ApproveOnce, + SenderId: new MattermostUserId("u-denied"), + RequesterSenderId: null, + ReceivedAt: TimeProvider.System.GetUtcNow())); + + await sink.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(250), + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task DeliverTrustedSessionTurn_routes_to_existing_session() + { + var sink = CreateTestProbe("trusted-turn"); + var conversation = CreateConversation("ch-1", sink); + + // Create a session binding first + conversation.Tell(CreateMessage(channelId: "ch-1", rootPostId: "root-50", text: "setup")); + await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + + // Deliver trusted turn + conversation.Tell(new DeliverTrustedSessionTurn( + SessionId: new SessionId("ch-1/root-50"), + Content: "reminder content", + Source: CreateReminderSource())); + + var forwarded = await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("ch-1/root-50", forwarded.SessionId.Value); + } + + [Fact] + public async Task DeliverTrustedSessionTurn_recreates_passivated_binding() + { + var sink = CreateTestProbe("trusted-turn-recreate"); + var conversation = CreateConversation("ch-1", sink); + + // Deliver trusted turn WITHOUT an existing session binding — should re-create + conversation.Tell(new DeliverTrustedSessionTurn( + SessionId: new SessionId("ch-1/root-99"), + Content: "reminder for passivated session", + Source: CreateReminderSource())); + + var forwarded = await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("ch-1/root-99", forwarded.SessionId.Value); + } + + [Fact] + public async Task DeliverTrustedSessionTurn_nacks_channel_mismatch() + { + var sink = CreateTestProbe("trusted-turn-mismatch"); + var conversation = CreateConversation("ch-1", sink); + + var probe = CreateTestProbe("nack-receiver"); + conversation.Tell( + new DeliverTrustedSessionTurn( + SessionId: new SessionId("ch-99/root-50"), + Content: "wrong channel", + Source: CreateReminderSource()), + probe.Ref); + + var nack = await probe.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains("mismatch", nack.Reason, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Ingress_gate_blocks_messages() + { + var sink = CreateTestProbe("ingress-gate"); + var gate = new SessionIngressGate(); + gate.TryClose("test-drain"); + var conversation = CreateConversation("ch-1", sink, ingressGate: gate); + + conversation.Tell(CreateMessage(channelId: "ch-1", text: "should be blocked")); + + await sink.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(250), + TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Ingress_gate_posts_drain_reply() + { + var replyClient = new RecordingMattermostReplyClient(); + var gate = new SessionIngressGate(); + gate.TryClose("restarting"); + + var deps = CreateDependencies( + ingressGate: gate, + replyClient: replyClient, + sessionPropsFactory: (_, _, _, _) => + Props.Create(() => new ForwardActor(TestActor))); + + var conversation = Sys.ActorOf( + MattermostConversationActor.CreateProps(new MattermostChannelId("ch-1"), deps), + $"conv-gate-reply-{Guid.NewGuid():N}"); + + conversation.Tell(CreateMessage(channelId: "ch-1", text: "blocked")); + + await AwaitAssertAsync(() => + { + Assert.Single(replyClient.Posts); + Assert.Contains("restarting", replyClient.Posts[0].Text, StringComparison.OrdinalIgnoreCase); + }, cancellationToken: TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Ingress_gate_reply_failure_does_not_crash_actor() + { + var replyClient = new RecordingMattermostReplyClient + { + ThrowOnPost = new InvalidOperationException("API down") + }; + var gate = new SessionIngressGate(); + gate.TryClose("restarting"); + + var deps = CreateDependencies( + ingressGate: gate, + replyClient: replyClient, + sessionPropsFactory: (_, _, _, _) => + Props.Create(() => new ForwardActor(TestActor))); + + var conversation = Sys.ActorOf( + MattermostConversationActor.CreateProps(new MattermostChannelId("ch-1"), deps), + $"conv-gate-fail-{Guid.NewGuid():N}"); + + conversation.Tell(CreateMessage(channelId: "ch-1", text: "blocked")); + + // Actor should survive the reply failure + var probe = CreateTestProbe(); + probe.Watch(conversation); + + await AwaitAssertAsync(() => + { + Assert.False(probe.HasMessages, "Actor should not have terminated"); + }, cancellationToken: TestContext.Current.CancellationToken); + } + + private IActorRef CreateConversation( + string channelId, + Akka.TestKit.TestProbe sink, + MattermostChannelOptions? options = null, + SessionIngressGate? ingressGate = null, + string? botUsername = null) + { + var deps = CreateDependencies( + options: options, + ingressGate: ingressGate, + botUsername: botUsername, + sessionPropsFactory: (_, _, _, _) => + Props.Create(() => new ForwardActor(sink.Ref))); + + return Sys.ActorOf( + MattermostConversationActor.CreateProps(new MattermostChannelId(channelId), deps), + $"mm-conv-{channelId}-{Guid.NewGuid():N}"); + } + + private static MattermostGatewayDependencies CreateDependencies( + MattermostChannelOptions? options = null, + SessionIngressGate? ingressGate = null, + string? botUsername = null, + IMattermostReplyClient? replyClient = null, + Func? sessionPropsFactory = null) + { + return new MattermostGatewayDependencies( + Pipeline: null!, + IngressGate: ingressGate, + TimeProvider: TimeProvider.System, + Options: options ?? new MattermostChannelOptions + { + Enabled = true, + MentionOnly = false, + AllowDirectMessages = true, + AllowedChannelIds = ["ch-1"] + }, + DefaultChannelId: null, + ReplyClient: replyClient ?? new UnconfiguredMattermostReplyClient(), + ContentScanner: new NullContentScanner(), + AudienceProfiles: TestMattermostGatewayDeps.DefaultAudienceProfiles, + ModelCapabilities: TestMattermostGatewayDeps.DefaultVisionCapableModel, + Paths: TestMattermostGatewayDeps.NewTestPaths(), + BotUsername: botUsername, + SessionPropsFactory: sessionPropsFactory); + } + + private static MattermostGatewayMessage CreateMessage( + string channelId, + string text, + string eventId = "ev-1", + string postId = "p-1", + string rootPostId = "root-1", + string senderId = "u-1", + bool containsBotMention = false, + bool isDirectMessage = false) + { + return new MattermostGatewayMessage( + EventId: new MattermostEventId(eventId), + ChannelId: new MattermostChannelId(channelId), + PostId: new MattermostPostId(postId), + RootPostId: new MattermostRootPostId(rootPostId), + SenderId: new MattermostUserId(senderId), + IsBotMessage: false, + IsDirectMessage: isDirectMessage, + ContainsBotMention: containsBotMention, + Text: text, + ReceivedAt: TimeProvider.System.GetUtcNow()); + } + + private static MessageSource CreateReminderSource() => new() + { + ChannelType = ChannelType.Mattermost, + SenderId = "reminder-system", + MessageId = "reminder-1", + Audience = TrustAudience.Team, + Boundary = "trusted-instance", + Principal = PrincipalClassification.TrustedInternal, + Provenance = new SourceProvenance + { + TransportAuthenticity = TransportAuthenticity.Verified, + SourceKind = "reminder" + }, + ReminderId = "rem-1" + }; +} diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostMessageChunkingTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostMessageChunkingTests.cs new file mode 100644 index 00000000..4e92d9f7 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/MattermostMessageChunkingTests.cs @@ -0,0 +1,62 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Channels.Mattermost; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels; + +public sealed class MattermostMessageChunkingTests +{ + [Fact] + public void ShortMessage_returns_single_chunk() + { + var text = new string('a', 100); + var chunks = MattermostSessionBindingActor.ChunkMessage(text); + Assert.Single(chunks); + Assert.Equal(text, chunks[0]); + } + + [Fact] + public void LongMessage_splits_at_limit() + { + // 32001 chars should produce 3 chunks: 16000 + 16000 + 1 + var text = new string('x', 32_001); + var chunks = MattermostSessionBindingActor.ChunkMessage(text); + Assert.Equal(3, chunks.Count); + Assert.True(chunks.All(c => c.Length <= 16_000)); + Assert.Equal(32_001, chunks.Sum(c => c.Length)); + } + + [Fact] + public void Splits_at_newline_when_available() + { + // Place a newline near the boundary so the split prefers it + var before = new string('a', 15_990); + var after = new string('b', 5_000); + var text = before + "\n" + after; + + var chunks = MattermostSessionBindingActor.ChunkMessage(text); + Assert.Equal(2, chunks.Count); + + // First chunk should end with the newline (inclusive) + Assert.Equal(before.Length + 1, chunks[0].Length); + Assert.EndsWith("\n", chunks[0]); + Assert.Equal(after, chunks[1]); + } + + [Fact] + public void Handles_text_with_no_newlines() + { + // Continuous text with no newlines splits at exactly MaxMattermostPostLength + var text = new string('z', 40_000); + var chunks = MattermostSessionBindingActor.ChunkMessage(text); + Assert.Equal(3, chunks.Count); + Assert.Equal(16_000, chunks[0].Length); + Assert.Equal(16_000, chunks[1].Length); + Assert.Equal(8_000, chunks[2].Length); + Assert.Equal(40_000, chunks.Sum(c => c.Length)); + } +} diff --git a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingMattermostReplyClient.cs b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingMattermostReplyClient.cs new file mode 100644 index 00000000..af37b008 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingMattermostReplyClient.cs @@ -0,0 +1,39 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Channels.Mattermost; + +namespace Netclaw.Actors.Tests.Channels.TestHelpers; + +internal sealed class RecordingMattermostReplyClient : IMattermostReplyClient +{ + public List Posts { get; } = []; + public List<(MattermostPostId PostId, string Text, IReadOnlyList? Attachments)> Updates { get; } = []; + public Exception? ThrowOnPost { get; set; } + + private int _messageCounter; + + public Task PostReplyAsync(MattermostPostMessage message, CancellationToken cancellationToken = default) + { + if (ThrowOnPost is { } ex) + throw ex; + + Posts.Add(message); + var postId = new MattermostPostId($"post-{Interlocked.Increment(ref _messageCounter)}"); + return Task.FromResult(new MattermostPostResult(PostId: postId)); + } + + public Task UpdatePostAsync(MattermostPostId postId, string text, CancellationToken cancellationToken = default) + { + Updates.Add((postId, text, null)); + return Task.CompletedTask; + } + + public Task UpdatePostAsync(MattermostPostId postId, string text, IReadOnlyList? attachments, CancellationToken cancellationToken = default) + { + Updates.Add((postId, text, attachments)); + return Task.CompletedTask; + } +} diff --git a/src/Netclaw.Actors.Tests/Channels/TestMattermostGatewayDeps.cs b/src/Netclaw.Actors.Tests/Channels/TestMattermostGatewayDeps.cs new file mode 100644 index 00000000..d7c8fadf --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/TestMattermostGatewayDeps.cs @@ -0,0 +1,39 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Configuration; + +namespace Netclaw.Actors.Tests.Channels; + +internal static class TestMattermostGatewayDeps +{ + public static ToolAudienceProfiles DefaultAudienceProfiles + => ToolAudienceProfileDefaults.CreateProfiles(); + + public static ModelCapabilities DefaultVisionCapableModel + => new() + { + ModelId = "test-vision-model", + ContextWindowTokens = 128_000, + InputModalities = ModelModality.Text | ModelModality.Image, + OutputModalities = ModelModality.Text + }; + + public static ModelCapabilities DefaultTextOnlyModel + => new() + { + ModelId = "test-text-only-model", + ContextWindowTokens = 128_000, + InputModalities = ModelModality.Text, + OutputModalities = ModelModality.Text + }; + + public static NetclawPaths NewTestPaths() + { + var path = new NetclawPaths(Path.Combine(Path.GetTempPath(), $"netclaw-mattermost-test-{Guid.NewGuid():N}")); + path.EnsureDirectoriesExist(); + return path; + } +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs b/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs index 1516d2ed..a3fee494 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs @@ -13,7 +13,8 @@ internal static class MattermostApprovalPromptBuilder public static (string Text, IReadOnlyList Attachments) BuildButtonPrompt( ToolInteractionRequest request, string callbackUrl, - string rootPostId) + string rootPostId, + byte[]? signingKey = null) { var sb = new StringBuilder(); sb.AppendLine(":lock: **Tool approval required**"); @@ -21,19 +22,31 @@ public static (string Text, IReadOnlyList Attachments) Bui sb.AppendLine(); sb.Append("You can also reply with `A`, `B`, `C`, or `D` in this thread."); + var requesterSenderId = request.RequesterSenderId ?? string.Empty; var actions = request.Options - .Select(option => new MattermostAttachmentAction( - Id: $"tool_approval_{option.Key}", - Name: option.Label, - IntegrationUrl: callbackUrl, - Context: new Dictionary + .Select(option => + { + var context = new Dictionary { ["call_id"] = request.CallId, ["selected_key"] = option.Key, - ["requester_sender_id"] = request.RequesterSenderId ?? string.Empty, + ["requester_sender_id"] = requesterSenderId, ["root_post_id"] = rootPostId - }, - Style: GetButtonStyle(option.Key))) + }; + + if (signingKey is not null) + { + context["signature"] = MattermostCallbackSigner.Sign( + signingKey, request.CallId, option.Key, requesterSenderId, rootPostId); + } + + return new MattermostAttachmentAction( + Id: $"tool_approval_{option.Key}", + Name: option.Label, + IntegrationUrl: callbackUrl, + Context: context, + Style: GetButtonStyle(option.Key)); + }) .ToList(); var attachment = new MattermostAttachment( diff --git a/src/Netclaw.Channels.Mattermost/MattermostCallbackSigner.cs b/src/Netclaw.Channels.Mattermost/MattermostCallbackSigner.cs new file mode 100644 index 00000000..b8b21c08 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostCallbackSigner.cs @@ -0,0 +1,49 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Security.Cryptography; +using System.Text; + +namespace Netclaw.Channels.Mattermost; + +/// +/// HMAC-SHA256 signing and verification for interactive button callback context. +/// The signing key is ephemeral — generated per daemon lifetime — so buttons +/// from a previous process are automatically rejected on restart. +/// +internal static class MattermostCallbackSigner +{ + public static byte[] GenerateKey() + { + var key = new byte[32]; + RandomNumberGenerator.Fill(key); + return key; + } + + public static string Sign(byte[] key, string callId, string selectedKey, string requesterSenderId, string rootPostId) + { + var message = $"{callId}\n{selectedKey}\n{requesterSenderId}\n{rootPostId}"; + var messageBytes = Encoding.UTF8.GetBytes(message); + var hash = HMACSHA256.HashData(key, messageBytes); + return Convert.ToHexString(hash).ToLowerInvariant(); + } + + public static bool Verify(byte[] key, string callId, string selectedKey, string requesterSenderId, string rootPostId, string signature) + { + var expected = Sign(key, callId, selectedKey, requesterSenderId, rootPostId); + return CryptographicOperations.FixedTimeEquals( + Encoding.UTF8.GetBytes(expected), + Encoding.UTF8.GetBytes(signature)); + } +} + +/// +/// Holds the ephemeral HMAC signing key for Mattermost callback verification. +/// Generated once per daemon lifetime; registered as a singleton. +/// +public sealed class MattermostCallbackSigningKey(byte[] key) +{ + public byte[] Key { get; } = key; +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs index 24c607f4..912c3ca4 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs @@ -32,6 +32,7 @@ public sealed class MattermostChannel : IChannel private readonly ToolAudienceProfiles _audienceProfiles; private readonly ModelCapabilities _modelCapabilities; private readonly NetclawPaths _paths; + private readonly byte[]? _callbackSigningKey; private IActorRef? _gateway; @@ -59,7 +60,8 @@ public MattermostChannel( ILogger logger, ToolConfig toolConfig, ModelCapabilities modelCapabilities, - NetclawPaths paths) + NetclawPaths paths, + MattermostCallbackSigningKey? callbackSigningKey = null) { _system = system; _pipeline = pipeline; @@ -77,6 +79,7 @@ public MattermostChannel( _audienceProfiles = toolConfig.AudienceProfiles; _modelCapabilities = modelCapabilities; _paths = paths; + _callbackSigningKey = callbackSigningKey?.Key; } public ChannelType ChannelType => ChannelType.Mattermost; @@ -129,6 +132,8 @@ public async Task StartAsync(CancellationToken cancellationToken) ServerUrl: serverUrl, CallbackUrl: _options.CallbackUrl, BotUserId: _gatewayClient.BotUserId, + BotUsername: _gatewayClient.BotUsername, + CallbackSigningKey: _callbackSigningKey, PromptInjectionDetector: _promptInjectionDetector, ThreadHistoryFetcher: _threadHistoryFetcher, HttpClient: httpClient)), diff --git a/src/Netclaw.Channels.Mattermost/MattermostConversationActor.cs b/src/Netclaw.Channels.Mattermost/MattermostConversationActor.cs index 16528d51..f0756135 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostConversationActor.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostConversationActor.cs @@ -31,7 +31,7 @@ public MattermostConversationActor(MattermostChannelId channelId, MattermostGate { _channelId = channelId; _dependencies = dependencies; - _botMentionTag = dependencies.BotUserId is { } botId ? $"@{botId.Value}" : null; + _botMentionTag = !string.IsNullOrEmpty(dependencies.BotUsername) ? $"@{dependencies.BotUsername}" : null; _log = Context.GetLogger() .WithContext("Adapter", "mattermost") .WithContext("MattermostChannelId", _channelId.Value); @@ -189,6 +189,16 @@ private void HandleGatewayMessage(MattermostGatewayMessage message) private void HandleGatewayInteraction(MattermostGatewayInteraction interaction) { + // ACL: verify the clicking user is allowed to interact + if (!MattermostAclPolicy.IsAllowedUser(interaction.SenderId, _dependencies.Options)) + { + _log.Info( + "mattermost_interaction_denied sender={0} reason=user_not_allowed", + interaction.SenderId.Value); + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventDropped("interaction_user_not_allowed"); + return; + } + var actorName = BuildActorName(_channelId, interaction.RootPostId); var sessionBinding = Context.Child(actorName); if (sessionBinding.IsNobody()) diff --git a/src/Netclaw.Channels.Mattermost/MattermostGatewayActor.cs b/src/Netclaw.Channels.Mattermost/MattermostGatewayActor.cs index 12aadca2..e02ab09a 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostGatewayActor.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostGatewayActor.cs @@ -155,8 +155,10 @@ public sealed record MattermostGatewayDependencies( string? ServerUrl = null, string? CallbackUrl = null, MattermostUserId? BotUserId = null, + string? BotUsername = null, IPromptInjectionDetector? PromptInjectionDetector = null, IThreadHistoryFetcher? ThreadHistoryFetcher = null, + byte[]? CallbackSigningKey = null, HttpClient? HttpClient = null, Func? ConversationPropsFactory = null, Func? SessionPropsFactory = null); diff --git a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs index 890a3e66..68ec1193 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs @@ -37,6 +37,8 @@ internal sealed class MattermostSessionBindingActor : ReceivePersistentActor, IW private const string BackfillDetectorWarning = ":warning: I couldn't safely analyze some earlier thread messages, so they were excluded from context."; + private const int MaxMattermostPostLength = 16_000; + private readonly MattermostGatewayDependencies _dependencies; private readonly IPromptInjectionDetector _promptInjectionDetector; private readonly SessionPipelineHandle _handle; @@ -699,7 +701,7 @@ private async Task HandleOutputReceivedAsync(OutputReceived msg) string callbackUrl) { var (promptText, attachments) = MattermostApprovalPromptBuilder.BuildButtonPrompt( - request, callbackUrl, _rootPostId.Value); + request, callbackUrl, _rootPostId.Value, _dependencies.CallbackSigningKey); var startedAt = _dependencies.TimeProvider.GetTimestamp(); try { @@ -741,20 +743,25 @@ private async Task HandleOutputReceivedAsync(OutputReceived msg) private async Task SafeReplyAsync(string text) { - var startedAt = _dependencies.TimeProvider.GetTimestamp(); - try - { - var postMessage = BuildPostMessage(text); - await _dependencies.ReplyClient.PostReplyAsync(postMessage); - var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; - ChannelTelemetry.For(ChannelType.Mattermost).RecordReplyPosted(duration); - } - catch (Exception ex) + var chunks = ChunkMessage(text); + foreach (var chunk in chunks) { - var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; - _log.Warning(ex, "Failed posting Mattermost reply for session {0}", _sessionId.Value); - ChannelTelemetry.For(ChannelType.Mattermost).RecordReplyFailed(duration); - await NotifyDeliveryFailedAsync(DeliveryFailureKind.TransportFailure, ex.Message); + var startedAt = _dependencies.TimeProvider.GetTimestamp(); + try + { + var postMessage = BuildPostMessage(chunk); + await _dependencies.ReplyClient.PostReplyAsync(postMessage); + var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; + ChannelTelemetry.For(ChannelType.Mattermost).RecordReplyPosted(duration); + } + catch (Exception ex) + { + var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; + _log.Warning(ex, "Failed posting Mattermost reply for session {0}", _sessionId.Value); + ChannelTelemetry.For(ChannelType.Mattermost).RecordReplyFailed(duration); + await NotifyDeliveryFailedAsync(DeliveryFailureKind.TransportFailure, ex.Message); + return; + } } } @@ -1082,6 +1089,33 @@ public sealed record Accepted(string Line, DataContent? Inline) : AttachmentInge public sealed record Rejected(string UserFacingReason) : AttachmentIngestResult; } + internal static List ChunkMessage(string text) + { + if (text.Length <= MaxMattermostPostLength) + return [text]; + + var chunks = new List(); + var remaining = text.AsSpan(); + while (remaining.Length > 0) + { + if (remaining.Length <= MaxMattermostPostLength) + { + chunks.Add(remaining.ToString()); + break; + } + + var splitAt = MaxMattermostPostLength; + var newlineIdx = remaining[..splitAt].LastIndexOf('\n'); + if (newlineIdx > 0) + splitAt = newlineIdx + 1; + + chunks.Add(remaining[..splitAt].ToString()); + remaining = remaining[splitAt..]; + } + + return chunks; + } + private void AdvanceCursor(string candidatePostId) { if (_cursorPostId is not null && string.CompareOrdinal(candidatePostId, _cursorPostId) <= 0) diff --git a/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs b/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs index 8bb3f6aa..2ca48a09 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs @@ -43,6 +43,8 @@ public interface IMattermostGatewayClient MattermostUserId? BotUserId { get; } + string? BotUsername { get; } + Task ConnectAsync(string serverUrl, string botToken, CancellationToken cancellationToken = default); Task DisconnectAsync(CancellationToken cancellationToken = default); @@ -114,6 +116,8 @@ public event Func? InteractionReceived public MattermostUserId? BotUserId => null; + public string? BotUsername => null; + public Task ConnectAsync(string serverUrl, string botToken, CancellationToken cancellationToken = default) => throw new InvalidOperationException( "Mattermost channel is enabled, but no Mattermost gateway client is configured."); diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs index 3471ac34..4bf38e76 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs @@ -23,6 +23,7 @@ internal sealed class MattermostNetGatewayClient : IMattermostGatewayClient, IDi public bool IsConnected => _client.IsConnected; public MattermostUserId? BotUserId { get; private set; } + public string? BotUsername { get; private set; } public MattermostNetGatewayClient( MattermostClient client, @@ -46,6 +47,7 @@ public async Task ConnectAsync(string serverUrl, string botToken, CancellationTo var me = await _client.GetMeAsync(); BotUserId = new MattermostUserId(me.Id); + BotUsername = me.Username; _logger.LogInformation("Mattermost bot identity resolved: {BotUserId} (@{Username})", me.Id, me.Username); @@ -82,35 +84,31 @@ private void OnMessageReceived(object? sender, MessageEventArgs e) ? new MattermostRootPostId(string.Empty) : new MattermostRootPostId(post.RootId); - IReadOnlyList? attachments = null; - if (post.FileIdentifiers.Count > 0) - { - attachments = post.FileIdentifiers - .Select(fileId => new MattermostFileReference( - Name: fileId, - MimeType: "application/octet-stream", - Size: 0, - Url: $"{_serverUrl}/api/v4/files/{fileId}")) - .ToList(); - } - - var gatewayMessage = new MattermostGatewayMessage( - EventId: new MattermostEventId(post.Id), - ChannelId: new MattermostChannelId(post.ChannelId), - PostId: new MattermostPostId(post.Id), - RootPostId: rootPostId, - SenderId: new MattermostUserId(post.UserId), - IsBotMessage: false, // Mattermost.NET already filters bot's own messages - IsDirectMessage: isDm, - ContainsBotMention: containsMention, - Text: post.Text ?? string.Empty, - ReceivedAt: _timeProvider.GetUtcNow(), - Attachments: attachments); + IReadOnlyList fileIds = post.FileIdentifiers as IReadOnlyList ?? post.FileIdentifiers.ToList(); + var serverUrl = _serverUrl!; + var receivedAt = _timeProvider.GetUtcNow(); _ = Task.Run(async () => { try { + IReadOnlyList? attachments = null; + if (fileIds.Count > 0) + attachments = await ResolveFileReferencesAsync(fileIds, serverUrl); + + var gatewayMessage = new MattermostGatewayMessage( + EventId: new MattermostEventId(post.Id), + ChannelId: new MattermostChannelId(post.ChannelId), + PostId: new MattermostPostId(post.Id), + RootPostId: rootPostId, + SenderId: new MattermostUserId(post.UserId), + IsBotMessage: false, // Mattermost.NET already filters bot's own messages + IsDirectMessage: isDm, + ContainsBotMention: containsMention, + Text: post.Text ?? string.Empty, + ReceivedAt: receivedAt, + Attachments: attachments); + await handler(gatewayMessage); } catch (Exception ex) @@ -135,6 +133,34 @@ private void OnLogMessage(object? sender, LogEventArgs e) _logger.LogDebug("[Mattermost.NET] {Message}", e.Message); } + private async Task> ResolveFileReferencesAsync( + IReadOnlyList fileIds, string serverUrl) + { + var tasks = fileIds.Select(async fileId => + { + try + { + var details = await _client.GetFileDetailsAsync(fileId); + return new MattermostFileReference( + Name: details.Name ?? fileId, + MimeType: details.MimeType ?? "application/octet-stream", + Size: details.Size, + Url: $"{serverUrl}/api/v4/files/{fileId}"); + } + catch (Exception ex) + { + _logger.LogDebug(ex, "Failed to resolve file details for {FileId}; using fallback metadata", fileId); + return new MattermostFileReference( + Name: fileId, + MimeType: "application/octet-stream", + Size: 0, + Url: $"{serverUrl}/api/v4/files/{fileId}"); + } + }); + + return await Task.WhenAll(tasks); + } + public async Task HandleActionCallbackAsync(MattermostGatewayInteraction interaction) { var handler = InteractionReceived; diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs index 9be24ae9..13bc703f 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs @@ -496,7 +496,7 @@ private static async Task> FetchRawMessagesAsyn if (!HasUsableContent(post)) continue; - var attachments = BuildFileReferences(post, normalizedServerUrl); + var attachments = await ResolveFileReferencesAsync(client, post.FileIdentifiers, normalizedServerUrl, logger); var timestamp = DateTimeOffset.FromUnixTimeMilliseconds(post.CreatedAt); results.Add(new HistoricalMessage( @@ -511,18 +511,35 @@ private static async Task> FetchRawMessagesAsyn return results; } - private static IReadOnlyList BuildFileReferences(Post post, string serverUrl) + private static async Task> ResolveFileReferencesAsync( + MattermostClient client, IList fileIds, string serverUrl, ILogger logger) { - if (post.FileIdentifiers.Count == 0) + if (fileIds.Count == 0) return []; - return post.FileIdentifiers - .Select(fileId => new MattermostFileReference( - Name: fileId, - MimeType: "application/octet-stream", - Size: 0, - Url: $"{serverUrl}/api/v4/files/{fileId}")) - .ToArray(); + var tasks = fileIds.Select(async fileId => + { + try + { + var details = await client.GetFileDetailsAsync(fileId); + return new MattermostFileReference( + Name: details.Name ?? fileId, + MimeType: details.MimeType ?? "application/octet-stream", + Size: details.Size, + Url: $"{serverUrl}/api/v4/files/{fileId}"); + } + catch (Exception ex) + { + logger.LogDebug(ex, "Failed to resolve file details for {FileId}; using fallback metadata", fileId); + return new MattermostFileReference( + Name: fileId, + MimeType: "application/octet-stream", + Size: 0, + Url: $"{serverUrl}/api/v4/files/{fileId}"); + } + }); + + return await Task.WhenAll(tasks); } private static async Task<(string FilePath, long BytesWritten)?> DownloadFileViaSdkAsync( diff --git a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json index 0e614e59..08a73ea9 100644 --- a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json +++ b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json @@ -70,7 +70,6 @@ "properties": { "Enabled": { "type": "boolean" }, "ServerUrl": { "type": "string", "format": "uri", "description": "Base URL of the Mattermost server (e.g. https://mm.example.com)." }, - "BotToken": { "type": ["string", "null"] }, "CallbackUrl": { "type": ["string", "null"], "format": "uri", "description": "URL that Mattermost can reach for interactive button callbacks (e.g. http://netclaw-host:5199/api/mattermost/actions)." }, "DefaultChannelId": { "type": ["string", "null"] }, "AllowDirectMessages": { "type": "boolean" }, diff --git a/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs index 92b81ecb..2bc65265 100644 --- a/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs @@ -67,10 +67,36 @@ public static void MapMattermostActionEndpoint(this WebApplication app) requesterSenderId = null; payload.Context.TryGetValue("root_post_id", out var rootPostId); + if (string.IsNullOrEmpty(rootPostId)) + return Results.BadRequest("Missing required context field: root_post_id."); + + // Verify HMAC signature to prove we created these buttons + var signingKey = sp.GetService(); + if (signingKey?.Key is { } key) + { + payload.Context.TryGetValue("signature", out var signature); + if (string.IsNullOrEmpty(signature) + || !MattermostCallbackSigner.Verify(key, callId, selectedKey, requesterSenderId ?? string.Empty, rootPostId, signature)) + { + logger.LogWarning("Rejected Mattermost action callback with invalid HMAC signature for call {CallId}", callId); + return Results.Unauthorized(); + } + } + + // ACL: verify the clicking user is allowed to interact + var options = sp.GetRequiredService(); + if (!MattermostAclPolicy.IsAllowedUser(new MattermostUserId(payload.UserId), options)) + { + logger.LogWarning("Rejected Mattermost action callback from non-allowed user {UserId}", payload.UserId); + return Results.Json(new ActionCallbackResponse + { + EphemeralText = "You are not authorized to respond to tool approval prompts." + }, JsonOptions); + } var interaction = new MattermostGatewayInteraction( ChannelId: new MattermostChannelId(payload.ChannelId), - RootPostId: new MattermostRootPostId(rootPostId ?? string.Empty), + RootPostId: new MattermostRootPostId(rootPostId), CallId: callId, SelectedKey: selectedKey, SenderId: new MattermostUserId(payload.UserId), @@ -125,3 +151,4 @@ private sealed class ActionCallbackResponse private static bool IsValidApprovalKey(string key) => key is "approve_once" or "approve_session" or "approve_always" or "deny"; } + diff --git a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs index 40919ca7..899ed774 100644 --- a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs @@ -45,6 +45,14 @@ public static void AddMattermostChannelIntegration(this IServiceCollection servi client.DefaultRequestHeaders.Authorization = new System.Net.Http.Headers.AuthenticationHeaderValue("Bearer", mattermostOptions.BotToken!.Value); }); + // Ephemeral signing key for HMAC verification of button callbacks. + // Regenerated each daemon start — stale buttons from prior runs are rejected. + if (!string.IsNullOrEmpty(mattermostOptions.CallbackUrl)) + { + services.AddSingleton(new MattermostCallbackSigningKey( + MattermostCallbackSigner.GenerateKey())); + } + services.AddSingleton(); services.AddSingleton(sp => { From 1ad9a034edee56037de3365ed36f124375ea54f0 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 5 May 2026 23:34:52 -0500 Subject: [PATCH 5/5] fix: correct approval labels and clean up Mattermost channel code - Fix wrong ephemeral approval labels in action callback endpoint (used "Approve Once"/"Approve for Session"/"Always Approve" instead of canonical ApprovalOptionKeys labels) - Replace raw string literals in IsValidApprovalKey with ApprovalOptionKeys constants to prevent drift - Extract duplicated attachment-to-payload mapping in MattermostNetReplyClient into shared MapAttachments helper - Add Contains guard before Replace in NormalizeInboundText to avoid unnecessary string allocation on the per-message path - Remove TOCTOU File.Exists check before File.Delete in DownloadFileViaSdkAsync (Delete is a no-op for absent files) - Remove section-divider and narration comments that duplicate what well-named identifiers already communicate --- .../MattermostConversationActor.cs | 15 ++--- .../Transport/MattermostNetReplyClient.cs | 65 +++++++------------ .../MattermostThreadHistoryFetcher.cs | 8 +-- .../MattermostActionEndpointExtensions.cs | 15 +++-- 4 files changed, 40 insertions(+), 63 deletions(-) diff --git a/src/Netclaw.Channels.Mattermost/MattermostConversationActor.cs b/src/Netclaw.Channels.Mattermost/MattermostConversationActor.cs index f0756135..8b943f64 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostConversationActor.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostConversationActor.cs @@ -65,7 +65,6 @@ private void HandleGatewayMessage(MattermostGatewayMessage message) { var options = _dependencies.Options; - // --- ACL gate --- var aclDecision = MattermostAclPolicy.EvaluateInbound( message, options, @@ -79,7 +78,6 @@ private void HandleGatewayMessage(MattermostGatewayMessage message) return; } - // --- Bot self-loop filter --- if (message.IsBotMessage) { _log.Info("mattermost_event_filtered event={0} reason=bot_message", message.EventId.Value); @@ -87,7 +85,6 @@ private void HandleGatewayMessage(MattermostGatewayMessage message) return; } - // --- Ingress gate --- if (_dependencies.IngressGate?.ClosedReason is { } closedReason) { _log.Info("mattermost_event_filtered event={0} reason=restart_drain_active", message.EventId.Value); @@ -96,9 +93,6 @@ private void HandleGatewayMessage(MattermostGatewayMessage message) return; } - // --- Derive session key --- - // For threaded messages, session key is channelId/rootPostId. - // For top-level messages, use channelId/postId (the post becomes the thread root). var sessionRootId = message.RootPostId.IsEmpty ? new MattermostRootPostId(message.PostId.Value) : message.RootPostId; @@ -107,7 +101,6 @@ private void HandleGatewayMessage(MattermostGatewayMessage message) var existingBinding = Context.Child(actorName); var threadExists = !existingBinding.IsNobody(); - // --- Routing policy --- var decision = MattermostRoutingPolicy.Evaluate( message, options.MentionOnly, @@ -135,7 +128,6 @@ private void HandleGatewayMessage(MattermostGatewayMessage message) return; } - // --- Empty text filter --- var normalizedText = NormalizeInboundText(message.Text); if (normalizedText.Length > MaxInboundTextLength) { @@ -151,7 +143,6 @@ private void HandleGatewayMessage(MattermostGatewayMessage message) return; } - // --- Build session and forward --- var sessionId = new SessionId($"{_channelId.Value}/{sessionRootId.Value}"); var sessionBinding = threadExists ? existingBinding @@ -189,7 +180,6 @@ private void HandleGatewayMessage(MattermostGatewayMessage message) private void HandleGatewayInteraction(MattermostGatewayInteraction interaction) { - // ACL: verify the clicking user is allowed to interact if (!MattermostAclPolicy.IsAllowedUser(interaction.SenderId, _dependencies.Options)) { _log.Info( @@ -292,7 +282,10 @@ private string NormalizeInboundText(string text) if (_botMentionTag is null) return text.Trim(); - return text.Replace(_botMentionTag, string.Empty, StringComparison.OrdinalIgnoreCase).Trim(); + if (text.Contains(_botMentionTag, StringComparison.OrdinalIgnoreCase)) + text = text.Replace(_botMentionTag, string.Empty, StringComparison.OrdinalIgnoreCase); + + return text.Trim(); } private IActorRef GetOrCreateSessionBinding( diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs index 9acb08a5..b98445ca 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs @@ -62,26 +62,7 @@ public async Task UpdatePostAsync( return; } - var attachmentPayloads = attachments - .Select(a => new AttachmentPayload - { - Fallback = a.Fallback, - Color = a.Color, - Text = a.Text, - Actions = a.Actions?.Select(act => new ActionPayload - { - Id = act.Id, - Name = act.Name, - Type = "button", - Style = act.Style, - Integration = new IntegrationPayload - { - Url = act.IntegrationUrl, - Context = act.Context - } - }).ToList() - }) - .ToList(); + var attachmentPayloads = MapAttachments(attachments); var payload = new UpdatePostPayload { @@ -102,26 +83,7 @@ private async Task PostWithAttachmentsAsync( MattermostPostMessage message, CancellationToken cancellationToken) { - var attachments = message.Attachments! - .Select(a => new AttachmentPayload - { - Fallback = a.Fallback, - Color = a.Color, - Text = a.Text, - Actions = a.Actions?.Select(act => new ActionPayload - { - Id = act.Id, - Name = act.Name, - Type = "button", - Style = act.Style, - Integration = new IntegrationPayload - { - Url = act.IntegrationUrl, - Context = act.Context - } - }).ToList() - }) - .ToList(); + var attachments = MapAttachments(message.Attachments!); var payload = new CreatePostPayload { @@ -149,7 +111,28 @@ await response.Content.ReadAsStreamAsync(cancellationToken), return new MattermostPostResult(PostId: new MattermostPostId(postId)); } - // JSON payload types for Mattermost REST API + private static List MapAttachments(IReadOnlyList source) + => source + .Select(a => new AttachmentPayload + { + Fallback = a.Fallback, + Color = a.Color, + Text = a.Text, + Actions = a.Actions?.Select(act => new ActionPayload + { + Id = act.Id, + Name = act.Name, + Type = "button", + Style = act.Style, + Integration = new IntegrationPayload + { + Url = act.IntegrationUrl, + Context = act.Context + } + }).ToList() + }) + .ToList(); + private sealed class UpdatePostPayload { public string Id { get; init; } = string.Empty; diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs index 13bc703f..b1341057 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs @@ -75,7 +75,7 @@ public MattermostThreadHistoryFetcher( promptInjectionDetector, options, serverUrl, - botUserIdFactory(), + botUserIdFactory(), // safe: ConnectAsync resolves BotUserId before this constructor runs audienceProfiles, modelCapabilities, paths, @@ -473,7 +473,7 @@ private static async Task> FetchRawMessagesAsyn } var results = new List(threadResponse.Order.Count); - var normalizedServerUrl = serverUrl.TrimEnd('/'); // serverUrl may not be pre-normalized when called from test delegates + var normalizedServerUrl = serverUrl.TrimEnd('/'); // Order list is provided by the API in chronological order foreach (var postId in threadResponse.Order) @@ -483,7 +483,6 @@ private static async Task> FetchRawMessagesAsyn if (!threadResponse.Posts.TryGetValue(postId, out var post)) continue; - // Skip deleted posts if (post.DeletedAt > 0) continue; @@ -577,8 +576,7 @@ private static async Task> ResolveFileRef } catch { - if (IOFile.Exists(stagingPath)) - IOFile.Delete(stagingPath); + IOFile.Delete(stagingPath); throw; } diff --git a/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs index 2bc65265..1ee87fab 100644 --- a/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs @@ -4,6 +4,7 @@ // // ----------------------------------------------------------------------- using System.Text.Json; +using Netclaw.Actors.Protocol; using Netclaw.Channels.Mattermost; namespace Netclaw.Daemon.Configuration; @@ -83,7 +84,6 @@ public static void MapMattermostActionEndpoint(this WebApplication app) } } - // ACL: verify the clicking user is allowed to interact var options = sp.GetRequiredService(); if (!MattermostAclPolicy.IsAllowedUser(new MattermostUserId(payload.UserId), options)) { @@ -117,10 +117,10 @@ public static void MapMattermostActionEndpoint(this WebApplication app) var decisionLabel = selectedKey switch { - "approve_once" => "Approve Once", - "approve_session" => "Approve for Session", - "approve_always" => "Always Approve", - "deny" => "Deny", + ApprovalOptionKeys.ApproveOnce => ApprovalOptionKeys.ApproveOnceLabel, + ApprovalOptionKeys.ApproveSession => ApprovalOptionKeys.ApproveSessionLabel, + ApprovalOptionKeys.ApproveAlways => ApprovalOptionKeys.ApproveAlwaysLabel, + ApprovalOptionKeys.Deny => ApprovalOptionKeys.DenyLabel, _ => selectedKey }; @@ -149,6 +149,9 @@ private sealed class ActionCallbackResponse } private static bool IsValidApprovalKey(string key) - => key is "approve_once" or "approve_session" or "approve_always" or "deny"; + => key is ApprovalOptionKeys.ApproveOnce + or ApprovalOptionKeys.ApproveSession + or ApprovalOptionKeys.ApproveAlways + or ApprovalOptionKeys.Deny; }