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/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 new file mode 100644 index 00000000..e2cfbf11 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/MattermostApprovalPromptBuilderTests.cs @@ -0,0 +1,344 @@ +// ----------------------------------------------------------------------- +// +// 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); + } + + [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); + } + + [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() + { + 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.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/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/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/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..18c1354d --- /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: TimeProvider.System.GetUtcNow()); + } +} 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.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..5f0f5871 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostFixture.cs @@ -0,0 +1,277 @@ +// ----------------------------------------------------------------------- +// +// 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; + private string? _testUserToken; + + 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 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() + { + if (_container is not null) + await _container.DisposeAsync(); + } + + 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. + /// + 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) + { + using var http = CreateHttpClient(); + http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", _testUserToken); + { + 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..4c565293 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostReplyClientIntegrationTests.cs @@ -0,0 +1,124 @@ +// ----------------------------------------------------------------------- +// +// 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); + using var apiClient = _fixture.CreateBotApiClient(); + var replyClient = new MattermostNetReplyClient(botClient, apiClient); + + 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); + using var apiClient = _fixture.CreateBotApiClient(); + var replyClient = new MattermostNetReplyClient(botClient, apiClient); + + 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); + using var apiClient = _fixture.CreateBotApiClient(); + var replyClient = new MattermostNetReplyClient(botClient, apiClient); + + 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..ba1c30aa --- /dev/null +++ b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostThreadHistoryIntegrationTests.cs @@ -0,0 +1,98 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Mattermost; +using Mattermost.Models.Responses; +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 static readonly TimeSpan PollTimeout = TimeSpan.FromSeconds(10); + private static readonly TimeSpan PollInterval = TimeSpan.FromMilliseconds(100); + + 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); + + 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}"); + } + + [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); + + var threadPosts = await PollThreadPostsAsync(botClient, rootPostId, minCount: 2, ct); + + 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); + + 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.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..a3fee494 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostApprovalPromptBuilder.cs @@ -0,0 +1,162 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text; +using Netclaw.Actors.Protocol; + +namespace Netclaw.Channels.Mattermost; + +internal static class MattermostApprovalPromptBuilder +{ + public static (string Text, IReadOnlyList Attachments) BuildButtonPrompt( + ToolInteractionRequest request, + string callbackUrl, + string rootPostId, + byte[]? signingKey = null) + { + 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 requesterSenderId = request.RequesterSenderId ?? string.Empty; + var actions = request.Options + .Select(option => + { + var context = new Dictionary + { + ["call_id"] = request.CallId, + ["selected_key"] = option.Key, + ["requester_sender_id"] = requesterSenderId, + ["root_post_id"] = rootPostId + }; + + 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( + 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(); + 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(); + } + + 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("`"); + 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 + }; + + 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 new file mode 100644 index 00000000..c3a5fea2 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostAttachmentUrlTrust.cs @@ -0,0 +1,23 @@ +// ----------------------------------------------------------------------- +// +// 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) + { + // 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/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 new file mode 100644 index 00000000..912c3ca4 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs @@ -0,0 +1,199 @@ +// ----------------------------------------------------------------------- +// +// 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 readonly byte[]? _callbackSigningKey; + + private IActorRef? _gateway; + + internal IActorRef? Gateway => _gateway; + internal IMattermostGatewayClient GatewayClient => _gatewayClient; + + 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, + MattermostCallbackSigningKey? callbackSigningKey = null) + { + _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; + _callbackSigningKey = callbackSigningKey?.Key; + } + + 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."); + + try + { + await _gatewayClient.ConnectAsync(serverUrl, _options.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: DefaultChannelId, + ReplyClient: _replyClient, + ContentScanner: _contentScanner, + AudienceProfiles: _audienceProfiles, + ModelCapabilities: _modelCapabilities, + Paths: _paths, + ServerUrl: serverUrl, + CallbackUrl: _options.CallbackUrl, + BotUserId: _gatewayClient.BotUserId, + BotUsername: _gatewayClient.BotUsername, + CallbackSigningKey: _callbackSigningKey, + 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..efc83493 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostChannelOptions.cs @@ -0,0 +1,44 @@ +// ----------------------------------------------------------------------- +// +// 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; } + + /// + /// 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; } + + 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..8b943f64 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostConversationActor.cs @@ -0,0 +1,325 @@ +// ----------------------------------------------------------------------- +// +// 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 = !string.IsNullOrEmpty(dependencies.BotUsername) ? $"@{dependencies.BotUsername}" : 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; + + 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; + } + + if (message.IsBotMessage) + { + _log.Info("mattermost_event_filtered event={0} reason=bot_message", message.EventId.Value); + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventFiltered("bot_message"); + return; + } + + 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; + } + + 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(); + + 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; + } + + 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; + } + + 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) + { + 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()) + { + _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(); + + if (text.Contains(_botMentionTag, StringComparison.OrdinalIgnoreCase)) + text = text.Replace(_botMentionTag, string.Empty, StringComparison.OrdinalIgnoreCase); + + return text.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..e02ab09a --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostGatewayActor.cs @@ -0,0 +1,164 @@ +// ----------------------------------------------------------------------- +// +// 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, + 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/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..f221ce52 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs @@ -0,0 +1,85 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Reminders; + +namespace Netclaw.Channels.Mattermost; + +/// +/// Resolves Mattermost reminder targets to canonical IDs. +/// Supported inputs: +/// - raw user ID (26-char alphanumeric Mattermost ID) +/// - @userId (same, with @ prefix stripped) +/// - 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) + { + 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/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..68ec1193 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs @@ -0,0 +1,1151 @@ +// ----------------------------------------------------------------------- +// +// 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 const int MaxMattermostPostLength = 16_000; + + 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 resolvedAttachment = MattermostApprovalPromptBuilder.BuildResolvedAttachment( + pending.Request, + selectedKey, + senderId); + + using var cts = new CancellationTokenSource(OperationTimeout); + await _dependencies.ReplyClient.UpdatePostAsync( + promptPostId, + resolvedAttachment.Text ?? string.Empty, + [resolvedAttachment], + 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 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, _dependencies.CallbackSigningKey); + 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(); + 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; + } + } + + private async Task SafeReplyAsync(string text) + { + var chunks = ChunkMessage(text); + foreach (var chunk in chunks) + { + 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; + } + } + } + + private MattermostPostMessage BuildPostMessage( + string text, + IReadOnlyList? attachments = null) + => new( + ChannelId: _channelId, + Text: text, + RootPostId: _rootPostId.IsEmpty ? null : new MattermostPostId(_rootPostId.Value), + Attachments: attachments); + + 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. + 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", + 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 + { + 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; + } + + 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) + { + _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..2ca48a09 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs @@ -0,0 +1,149 @@ +// ----------------------------------------------------------------------- +// +// 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; } + + string? BotUsername { get; } + + Task ConnectAsync(string serverUrl, string botToken, CancellationToken cancellationToken = default); + + Task DisconnectAsync(CancellationToken cancellationToken = default); + + Task HandleActionCallbackAsync(MattermostGatewayInteraction interaction); +} + +public interface IMattermostReplyClient +{ + Task PostReplyAsync(MattermostPostMessage message, CancellationToken cancellationToken = default); + + 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? 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) +{ + 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 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."); + + 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."); +} + +/// +/// 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."); + + 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/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..b0c647e8 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs @@ -0,0 +1,99 @@ +// ----------------------------------------------------------------------- +// +// 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) + => _options.AllowedUserIds.Length > 0 + && !_options.AllowedUserIds.Contains(user.Id, StringComparer.Ordinal); + + 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..4bf38e76 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs @@ -0,0 +1,179 @@ +// ----------------------------------------------------------------------- +// +// 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; + + public event Func? InteractionReceived; + + public bool IsConnected => _client.IsConnected; + public MattermostUserId? BotUserId { get; private set; } + public string? BotUsername { 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); + BotUsername = me.Username; + _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 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) + { + _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); + } + + 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; + if (handler is not null) + await handler(interaction); + } + + 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..b98445ca --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs @@ -0,0 +1,178 @@ +// ----------------------------------------------------------------------- +// +// 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, 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, + 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); + } + + 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 = MapAttachments(attachments); + + 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 = MapAttachments(message.Attachments!); + + 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)); + } + + 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; + 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.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs new file mode 100644 index 00000000..b1341057 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs @@ -0,0 +1,614 @@ +// ----------------------------------------------------------------------- +// +// 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, + Func botUserIdFactory, + ToolAudienceProfiles audienceProfiles, + ModelCapabilities modelCapabilities, + NetclawPaths paths, + ILogger logger) + : this( + (rootPostId, cancellationToken) => FetchRawMessagesAsync(client, rootPostId, botUserIdFactory(), serverUrl, cancellationToken, logger), + (fileId, stagingDir, maxBytes, ct) => DownloadFileViaSdkAsync(client, fileId, stagingDir, maxBytes, ct), + contentScanner, + promptInjectionDetector, + options, + serverUrl, + botUserIdFactory(), // safe: ConnectAsync resolves BotUserId before this constructor runs + 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; + + 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 = await ResolveFileReferencesAsync(client, post.FileIdentifiers, normalizedServerUrl, logger); + 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 async Task> ResolveFileReferencesAsync( + MattermostClient client, IList fileIds, string serverUrl, ILogger logger) + { + if (fileIds.Count == 0) + return []; + + 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( + 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 + { + 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..08a73ea9 100644 --- a/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json +++ b/src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json @@ -65,6 +65,35 @@ }, "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)." }, + "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 }, + "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/MattermostActionEndpointExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs new file mode 100644 index 00000000..1ee87fab --- /dev/null +++ b/src/Netclaw.Daemon/Configuration/MattermostActionEndpointExtensions.cs @@ -0,0 +1,157 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Netclaw.Actors.Protocol; +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); + 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(); + } + } + + 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), + 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 + { + ApprovalOptionKeys.ApproveOnce => ApprovalOptionKeys.ApproveOnceLabel, + ApprovalOptionKeys.ApproveSession => ApprovalOptionKeys.ApproveSessionLabel, + ApprovalOptionKeys.ApproveAlways => ApprovalOptionKeys.ApproveAlwaysLabel, + ApprovalOptionKeys.Deny => ApprovalOptionKeys.DenyLabel, + _ => 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 ApprovalOptionKeys.ApproveOnce + or ApprovalOptionKeys.ApproveSession + or ApprovalOptionKeys.ApproveAlways + or ApprovalOptionKeys.Deny; +} + diff --git a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs new file mode 100644 index 00000000..899ed774 --- /dev/null +++ b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs @@ -0,0 +1,127 @@ +// ----------------------------------------------------------------------- +// +// 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", 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); + }); + // 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 => + { + var client = sp.GetRequiredService(); + var httpClientFactory = sp.GetRequiredService(); + var httpClient = httpClientFactory.CreateClient("mattermost-api"); + return new MattermostNetReplyClient(client, httpClient); + }); + 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 a17dd9a2..77937f19 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -212,6 +212,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. @@ -1097,6 +1098,7 @@ static void ConfigureDaemonServices( services.AddSlackChannelIntegration(configuration); services.AddDiscordChannelIntegration(configuration); + services.AddMattermostChannelIntegration(configuration); // Config hot-reload watcher services.AddSingleton();