Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions Directory.Packages.props
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
<OpenTelemetryVersion>1.15.3</OpenTelemetryVersion>
<SlackNetVersion>0.17.10</SlackNetVersion>
<DiscordNetVersion>3.19.1</DiscordNetVersion>
<MattermostNetVersion>4.0.4</MattermostNetVersion>
<MicrosoftExtensionsAIVersion>10.5.0</MicrosoftExtensionsAIVersion>
<MicrosoftAspNetCoreVersion>10.0.7</MicrosoftAspNetCoreVersion>
</PropertyGroup>
Expand Down Expand Up @@ -49,6 +50,7 @@
<PackageVersion Include="OllamaSharp" Version="5.4.25" />
<PackageVersion Include="SauceControl.Blake2Fast" Version="2.0.0" />
<PackageVersion Include="Discord.Net" Version="$(DiscordNetVersion)" />
<PackageVersion Include="Mattermost.NET" Version="$(MattermostNetVersion)" />
<PackageVersion Include="SlackNet" Version="$(SlackNetVersion)" />
<PackageVersion Include="SlackNet.Extensions.DependencyInjection" Version="$(SlackNetVersion)" />
<PackageVersion Include="Cronos" Version="0.12.0" />
Expand All @@ -67,6 +69,7 @@
<PackageVersion Include="xunit.v3" Version="3.2.2" />
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.5.1" />
<PackageVersion Include="Testcontainers" Version="4.11.0" />
<PackageVersion Include="Verify.XunitV3" Version="31.16.2" />
</ItemGroup>
<!-- Source generators -->
Expand Down
2 changes: 2 additions & 0 deletions Netclaw.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
<Project Path="src/Netclaw.Actors/Netclaw.Actors.csproj" />
<Project Path="src/Netclaw.Channels/Netclaw.Channels.csproj" />
<Project Path="src/Netclaw.Channels.Discord/Netclaw.Channels.Discord.csproj" />
<Project Path="src/Netclaw.Channels.Mattermost/Netclaw.Channels.Mattermost.csproj" />
<Project Path="src/Netclaw.Channels.Slack/Netclaw.Channels.Slack.csproj" />
<Project Path="src/Netclaw.Configuration.Tests/Netclaw.Configuration.Tests.csproj" />
<Project Path="src/Netclaw.Configuration/Netclaw.Configuration.csproj" />
Expand All @@ -26,5 +27,6 @@
<Project Path="src/Netclaw.Search.Tests/Netclaw.Search.Tests.csproj" />
<Project Path="src/Netclaw.Security/Netclaw.Security.csproj" />
<Project Path="src/Netclaw.Security.Tests/Netclaw.Security.Tests.csproj" />
<Project Path="src/Netclaw.Channels.Mattermost.IntegrationTests/Netclaw.Channels.Mattermost.IntegrationTests.csproj" />
</Folder>
</Solution>
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
// -----------------------------------------------------------------------
// <copyright file="MattermostAclContractTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
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);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
// -----------------------------------------------------------------------
// <copyright file="MattermostGatewayContractTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
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());
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
// -----------------------------------------------------------------------
// <copyright file="MattermostSessionBindingContractTests.cs" company="Petabridge, LLC">
// Copyright (C) 2026 - 2026 Petabridge, LLC <https://petabridge.com>
// </copyright>
// -----------------------------------------------------------------------
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<string> 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<ChannelInput> CreateHistoryItems(int count)
{
var items = new List<ChannelInput>();
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);
}
}
Loading
Loading