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();