Skip to content
Merged
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
5 changes: 4 additions & 1 deletion docs/integrations/slack-socket-mode.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ The Slack channel runs inside `netclawd` using Slack Socket Mode.
- Inbound events: `app_mention`, `message`
- Session identity: `{channelId}/{threadTs}`
- Reply behavior: assistant replies are posted back to the same thread
- Processing indicator: native Slack thread status via `assistant.threads.setStatus`
- Thread behavior: mention starts thread, thread replies continue without mention

No public webhook endpoint is required.
Expand All @@ -23,7 +24,7 @@ Create a Slack app with:
- Bot scopes at minimum:
- `app_mentions:read`
- `channels:history`
- `chat:write`
- `chat:write` for thread replies and native loading status
- `groups:history` (if private channels are used)
- `channels:read` and `groups:read` (if resolving by channel name)

Expand Down Expand Up @@ -72,6 +73,8 @@ Supported Slack settings:

- When Slack is disabled, daemon starts normally and Slack channel stays inactive.
- If Slack is enabled but required tokens are missing, daemon startup fails fast.
- While a session is processing, Netclaw sets the Slack thread status to
`is thinking...` and clears it when the session returns idle.
- Socket Mode disconnects are handled by SlackNet reconnecting client behavior.
- Slack lifecycle is hosted-service owned rather than actor-owned. Ingress only
forwards after the Slack gateway actor exists, while clean reconnect decisions
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ protected override IChannel CreateChannel(bool enabled)
new FakeSlackApiClient(auth: new StubAuthApi()),
new FakeSlackSocketModeClient(),
new RecordingSlackReplyClient(),
TestSlackGatewayDeps.DefaultChannelRegistry,
new SessionIngressGate(),
new NullContentScanner(),
SafePromptInjectionDetector.Instance,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ protected override IActorRef CreateGateway(ChannelOptionsBuilder options)
Options: slackOptions,
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: defaultChannelId,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: new RecordingSlackReplyClient(),
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
using Netclaw.Actors.Channels;
using Netclaw.Actors.Protocol;
using Netclaw.Actors.Tests.Channels.TestHelpers;
using Netclaw.Channels;
using Netclaw.Channels.Slack;
using Netclaw.Configuration;
using Netclaw.Security;
Expand Down Expand Up @@ -143,7 +144,8 @@ private IActorRef CreateActorCore(
ISessionPipeline pipeline,
ConfigurablePromptInjectionDetector detector,
string nameSuffix = "",
IThreadHistoryFetcher? historyFetcher = null)
IThreadHistoryFetcher? historyFetcher = null,
IChannelRegistry? channelRegistry = null)
{
var paths = TestSlackGatewayDeps.NewTestPaths();
var deps = new SlackGatewayDependencies(
Expand All @@ -159,6 +161,7 @@ private IActorRef CreateActorCore(
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: channelRegistry ?? TestChannelRegistries.SlackWithProcessingRenderer(_replyClient),
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: historyFetcher ?? EmptyThreadHistoryFetcher.Instance,
Expand All @@ -176,6 +179,109 @@ private IActorRef CreateActorCore(
deps), name);
}

[Fact]
public async Task Subscribes_to_processing_state_outputs()
{
var ct = TestContext.Current.CancellationToken;
var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe());
var sid = new SessionId("session-slack-processing-filter");
var pipeline = new RecordingSessionPipeline(_ => []);

CreateActorCore(sid, pipeline, detector);

var options = await pipeline.Created.WaitAsync(ct);
Assert.Equal(OutputFilter.ProcessingState, options.Filter & OutputFilter.ProcessingState);
}

[Fact]
public async Task Processing_state_output_sets_and_clears_thread_status()
{
var ct = TestContext.Current.CancellationToken;
var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe());
var sid = new SessionId("session-slack-processing-status");
var pipeline = new RecordingSessionPipeline(_ =>
[
new ProcessingStateOutput(true) { SessionId = sid },
new ProcessingStateOutput(false) { SessionId = sid },
new TurnCompleted { SessionId = sid, TurnNumber = new TurnNumber(1) }
]);

CreateActorCore(sid, pipeline, detector);

await AwaitAssertAsync(() =>
{
Assert.Collection(
_replyClient.Statuses,
status =>
{
Assert.Equal("C-test", status.ChannelId.Value);
Assert.Equal("1000.1", status.ThreadTs.Value);
Assert.Equal("is thinking...", status.Status);
},
status =>
{
Assert.Equal("C-test", status.ChannelId.Value);
Assert.Equal("1000.1", status.ThreadTs.Value);
Assert.Equal(string.Empty, status.Status);
});
}, cancellationToken: ct);
}

[Fact]
public async Task Processing_state_output_does_not_block_text_when_renderer_stalls()
{
var ct = TestContext.Current.CancellationToken;
var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe());
var sid = new SessionId("session-slack-processing-timeout");
var renderer = new BlockingProcessingRenderer();
var registry = TestChannelRegistries.SlackWithProcessingRenderer(renderer);
var pipeline = new RecordingSessionPipeline(_ =>
[
new ProcessingStateOutput(true) { SessionId = sid },
new TextOutput("visible after status failure") { SessionId = sid },
new TurnCompleted { SessionId = sid, TurnNumber = new TurnNumber(1) }
]);

CreateActorCore(sid, pipeline, detector, channelRegistry: registry);

await AwaitAssertAsync(() =>
{
Assert.Contains(_replyClient.Posts, p => p.Text == "visible after status failure");
}, cancellationToken: ct);
}

[Fact]
public async Task Active_processing_status_is_cleared_when_actor_stops()
{
var ct = TestContext.Current.CancellationToken;
var detector = new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe());
var sid = new SessionId("session-slack-processing-stop-clear");
var pipeline = new RecordingSessionPipeline(_ =>
[
new ProcessingStateOutput(true) { SessionId = sid }
]);
var actor = CreateActorCore(sid, pipeline, detector);

await AwaitAssertAsync(() =>
{
var status = Assert.Single(_replyClient.Statuses);
Assert.Equal("is thinking...", status.Status);
}, cancellationToken: ct);

var stopProbe = CreateTestProbe("slack-processing-stop-clear");
stopProbe.Watch(actor);
Sys.Stop(actor);
await stopProbe.ExpectTerminatedAsync(actor, cancellationToken: ct);

await AwaitAssertAsync(() =>
{
Assert.Collection(
_replyClient.Statuses,
status => Assert.Equal("is thinking...", status.Status),
status => Assert.Equal(string.Empty, status.Status));
}, cancellationToken: ct);
}

// Regression for #939: when the binding has no in-memory pending approval
// (passivation between prompt and click) the Slack button payload still
// carries the prompt's message TS. The binding must use it to redraw the
Expand Down Expand Up @@ -405,4 +511,19 @@ await AwaitAssertAsync(

Assert.Empty(_replyClient.Updates);
}

private sealed class BlockingProcessingRenderer : IChannelOutputRenderer
{
private readonly TaskCompletionSource _blocked = new(TaskCreationOptions.RunContinuationsAsynchronously);

public ChannelDescriptorKey Key => ChannelDescriptorKey.FromChannelType(ChannelType.Slack);

public ValueTask RenderAsync(
ChannelOutputRenderRequest request,
CancellationToken cancellationToken = default)
{
return new ValueTask(_blocked.Task);
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -279,6 +279,7 @@ private static SlackGatewayDependencies CreateDependencies(
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: new NoopReplyClient(),
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@ private IActorRef BuildGateway(
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: scanner ?? new MagicByteContentScanner(new ContentPolicy()),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -748,6 +749,12 @@ public Task UpdateThreadMessageAsync(
IReadOnlyList<Block>? blocks = null,
CancellationToken cancellationToken = default) => Task.CompletedTask;

public Task SetThreadStatusAsync(
SlackChannelId channelId,
SlackThreadTs threadTs,
string status,
CancellationToken cancellationToken = default) => Task.CompletedTask;

public Task UploadFileToThreadAsync(
SlackChannelId channelId,
SlackThreadTs threadTs,
Expand Down
24 changes: 24 additions & 0 deletions src/Netclaw.Actors.Tests/Channels/SlackFileFlowIntegrationTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -131,6 +131,7 @@ public async Task Inbound_image_file_is_downloaded_and_persisted_to_session_medi
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -215,6 +216,7 @@ public async Task App_mention_with_file_only_is_downloaded_and_persisted()
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -288,6 +290,7 @@ public async Task File_share_subtype_with_text_flows_through_full_pipeline()
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -357,6 +360,7 @@ public async Task High_risk_prompt_injection_message_is_blocked_before_session_e
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -410,6 +414,7 @@ public async Task Failed_turn_posts_single_error_without_generic_fallback()
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -464,6 +469,7 @@ public async Task Timed_out_slack_post_does_not_block_later_turns()
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -540,6 +546,7 @@ public async Task Retryable_slack_content_rejection_is_fed_back_to_session_for_c
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -615,6 +622,7 @@ public async Task Retryable_slack_file_upload_rejection_is_fed_back_to_session()
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -674,6 +682,7 @@ public async Task Timeout_during_post_sends_transport_failure_feedback_to_sessio
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -738,6 +747,7 @@ public async Task Text_approval_reply_routes_tool_interaction_response()
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -829,6 +839,7 @@ public async Task Approval_request_posts_block_buttons_with_text_fallback()
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -901,6 +912,7 @@ public async Task Button_approval_reply_routes_tool_interaction_response()
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -973,6 +985,7 @@ public async Task Button_approval_response_forwards_to_session_when_binding_cold
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -1040,6 +1053,7 @@ public async Task Generic_exception_during_post_sends_unknown_failure_feedback_t
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -1102,6 +1116,7 @@ public async Task Content_rejection_msg_too_long_sends_message_too_large_feedbac
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -1165,6 +1180,7 @@ public async Task Content_rejection_invalid_blocks_sends_content_rejected_feedba
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -1215,6 +1231,7 @@ public async Task Inbound_image_with_real_scanner_flows_to_llm()
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new MagicByteContentScanner(new ContentPolicy()),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -1277,6 +1294,7 @@ public async Task Scanner_failure_rejects_attachment_and_does_not_inline()
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: _replyClient,
ContentScanner: new FailingContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down Expand Up @@ -1445,6 +1463,12 @@ public Task UpdateThreadMessageAsync(
return Task.CompletedTask;
}

public Task SetThreadStatusAsync(
SlackChannelId channelId,
SlackThreadTs threadTs,
string status,
CancellationToken cancellationToken = default) => Task.CompletedTask;

public Task UploadFileToThreadAsync(
SlackChannelId channelId, SlackThreadTs threadTs, string filePath,
string? filename = null, CancellationToken cancellationToken = default)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -518,6 +518,7 @@ private static SlackGatewayDependencies CreateDependencies(
},
BotUserId: new SlackUserId("UBOT"),
DefaultChannelId: null,
ChannelRegistry: TestSlackGatewayDeps.DefaultChannelRegistry,
ReplyClient: new NoopReplyClient(),
ContentScanner: new NullContentScanner(),
ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance,
Expand Down
Loading
Loading