From c81b0c6f25ef63fe708ddf95b9e6a1bf63eb001f Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 4 Jun 2026 12:15:33 +0000 Subject: [PATCH 01/31] Plan: actorize Mattermost gateway lifecycle --- .../design.md | 140 ++++++++++++++++++ .../proposal.md | 54 +++++++ .../specs/netclaw-input-adapters/spec.md | 51 +++++++ .../tasks.md | 48 ++++++ 4 files changed, 293 insertions(+) create mode 100644 openspec/changes/actorize-mattermost-gateway-lifecycle/design.md create mode 100644 openspec/changes/actorize-mattermost-gateway-lifecycle/proposal.md create mode 100644 openspec/changes/actorize-mattermost-gateway-lifecycle/specs/netclaw-input-adapters/spec.md create mode 100644 openspec/changes/actorize-mattermost-gateway-lifecycle/tasks.md diff --git a/openspec/changes/actorize-mattermost-gateway-lifecycle/design.md b/openspec/changes/actorize-mattermost-gateway-lifecycle/design.md new file mode 100644 index 000000000..3a6d46767 --- /dev/null +++ b/openspec/changes/actorize-mattermost-gateway-lifecycle/design.md @@ -0,0 +1,140 @@ +# Design + +## Decision + +Mattermost SHALL use a dedicated lifecycle actor for transport connection state, +matching Discord's proven pattern without merging connection state into +`MattermostGatewayActor`. + +`MattermostGatewayActor` stays focused on routing normalized Mattermost messages +and callback interactions to conversations and sessions. The lifecycle actor +stays focused on socket state, transport events, bot identity, readiness, and +health snapshots. + +Reconnect backoff remains in `MattermostChannel` for this change. That matches +the current Discord layering and keeps the migration small. A generic channel +lifecycle framework can be considered later only if Slack, Discord, and +Mattermost all need the same extracted behavior. + +## Component Diagram + +```mermaid +flowchart TD + Host[.NET Host / IHostedService] --> MC[MattermostChannel] + MC --> MGC[IMattermostGatewayClient] + MGC --> LCA[MattermostNetGatewayLifecycleActor] + LCA --> TR[IMattermostGatewayTransport] + TR --> SDK[Mattermost.NET MattermostClient] + SDK --> WS[Mattermost WebSocket] + + LCA -->|PublishMessageAsync| MGC + MGC -->|MessageReceived event| MC + MGC -->|CleanReconnectRequired event| MC + MC -->|Tell MattermostGatewayMessage| MGA[MattermostGatewayActor] + + HTTP[/api/mattermost/actions] -->|MattermostGatewayInteraction| MGA + MGA --> MCA[MattermostConversationActor] + MCA --> MSBA[MattermostSessionBindingActor] + MSBA --> SP[SessionPipeline] + SP --> LLM[LlmSessionActor] +``` + +## Target State Machine + +```mermaid +stateDiagram-v2 + [*] --> Disconnected + Disconnected --> Connecting: Connect(serverUrl, token) + Connecting --> Ready: bot identity resolved + websocket receiving + Connecting --> Disconnected: transient failure + Connecting --> FatalOffline: fatal configuration/auth failure + Ready --> CleanReconnectRequired: unexpected disconnect + Ready --> Disconnecting: Disconnect + CleanReconnectRequired --> Disconnecting: Disconnect + Disconnecting --> Disconnected: stop/logout complete + FatalOffline --> [*] +``` + +Mattermost.NET may not expose a Discord-style `READY` event. For Mattermost, +`Ready` means Netclaw has resolved the bot identity and the SDK has successfully +started WebSocket receiving. + +## Responsibilities + +### MattermostNetGatewayLifecycleActor + +- Owns the current lifecycle state: disconnected, connecting, ready, + clean-reconnect-required, disconnecting, fatal-offline. +- Subscribes Mattermost.NET SDK events once in `PreStart` and unsubscribes in + `PostStop`. +- Serializes `Connect`, `Disconnect`, transport event, and `GetSnapshot` + handling through the actor mailbox. +- Resolves and stores bot user id and username during successful connection. +- Drops or filters ingress while not ready and records channel telemetry. +- Emits clean reconnect requests when the transport disconnects unexpectedly. +- Replies to health snapshot requests with connected/ready/detail/bot identity. + +### MattermostNetGatewayClient + +- Becomes a thin actor-backed facade over `MattermostNetGatewayLifecycleActor`. +- Uses bounded `Ask` calls for connect, disconnect, and snapshot operations. +- Publishes normalized messages and clean reconnect requests through the + existing client event surface. + +### MattermostChannel + +- Performs enabled/disabled and required configuration checks before connect. +- Contains fatal connection failures so one misconfigured channel does not crash + the daemon. +- Runs the bounded reconnect backoff loop. +- Creates and registers `MattermostGatewayActor` only after the lifecycle actor + reports a ready snapshot. +- Drains the gateway actor before disconnecting transport on shutdown. +- Reads lifecycle snapshots for `GetHealthAsync`. + +### MattermostGatewayActor + +- Unchanged routing actor. +- Owns event deduplication, ACL dispatch, conversation actor routing, and HTTP + callback interaction routing. +- Does not own WebSocket connect/disconnect state. + +## Migration Plan + +1. Add `MattermostGatewaySnapshot` to Mattermost transport contracts with + `IsConnected`, `IsReady`, `HealthDetail`, `BotUserId`, and `BotUsername`. +2. Add `GetSnapshotAsync` and `CleanReconnectRequired` to + `IMattermostGatewayClient`. +3. Add `IMattermostGatewayTransport` as a testable wrapper over Mattermost.NET + events and start/stop operations. +4. Add `MattermostSocketGatewayTransport` to adapt `MattermostClient` to the new + transport interface. +5. Add `MattermostNetGatewayLifecycleActor` and move SDK event subscription into + actor `PreStart`/`PostStop`. +6. Update `MattermostNetGatewayClient` to create the lifecycle actor and use + `Ask` for connect, disconnect, and snapshot calls. +7. Update `MattermostChannel.TryConnectAsync` to require a ready snapshot before + calling `CompleteConnectionSetup`. +8. Update `MattermostChannel.GetHealthAsync` to use `GetSnapshotAsync`. +9. Update `MattermostChannel` to subscribe to `CleanReconnectRequired` and start + an immediate clean reconnect, matching Discord's pattern. +10. Preserve the existing gateway actor hierarchy and HTTP callback route. +11. Add lifecycle tests before broad cleanup. + +## Non-Goals + +- Do not create a generic channel base actor in this change. +- Do not change Slack or Discord behavior. +- Do not persist Mattermost callback action tokens. +- Do not change `MattermostGatewayActor` routing semantics. +- Do not change Mattermost session identity or `ChannelInput` construction. + +## Risks / Trade-offs + +- Mattermost.NET event semantics may differ from Discord.NET. The lifecycle actor + should define readiness from Netclaw-observable operations: bot identity + resolved and WebSocket receiving started. +- Keeping reconnect backoff in `MattermostChannel` duplicates some orchestration + logic with Discord, but it avoids a premature shared framework. +- Tests need fakes that can count event subscription and publication calls so + handler duplication cannot regress. diff --git a/openspec/changes/actorize-mattermost-gateway-lifecycle/proposal.md b/openspec/changes/actorize-mattermost-gateway-lifecycle/proposal.md new file mode 100644 index 000000000..6a9bd5670 --- /dev/null +++ b/openspec/changes/actorize-mattermost-gateway-lifecycle/proposal.md @@ -0,0 +1,54 @@ +## Why + +Mattermost channel reliability is currently split between `MattermostChannel` +and `MattermostNetGatewayClient`. Reconnects are coordinated by a hosted-service +background task, while the transport client subscribes to Mattermost.NET SDK +events inside `ConnectAsync` and unsubscribes only on dispose. A reconnect can +therefore duplicate SDK event handlers, and runtime disconnect events are logged +without becoming lifecycle state that can drive health or recovery. + +Discord already uses an actor-backed lifecycle state machine behind its gateway +client. Mattermost should use the same reliability pattern for connection state, +without moving message routing into the transport lifecycle actor. + +Source PRDs/specs: `PRD-009-input-adapters-and-unified-input.md`, +`SPEC-011-daemon-architecture.md`, `openspec/specs/netclaw-input-adapters/spec.md`. + +## What Changes + +- Add a `MattermostNetGatewayLifecycleActor` that owns Mattermost WebSocket + connection state, SDK event subscriptions, bot identity, health snapshots, and + clean-reconnect requests. +- Change `MattermostNetGatewayClient` into an actor-backed facade, matching the + Discord transport shape. +- Change `MattermostChannel.GetHealthAsync` to use lifecycle snapshots instead + of a raw `IsConnected` boolean. +- Keep `MattermostChannel` responsible for hosted-service startup/shutdown, + fatal configuration containment, gateway actor registration, and bounded + reconnect backoff. +- Keep `MattermostGatewayActor` responsible for message deduplication, ACL + dispatch, conversation/session routing, and HTTP callback interactions. + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `netclaw-input-adapters`: Add requirements for actor-owned Mattermost + transport lifecycle state, snapshot-based health, clean reconnect signaling, + ingress gating while not ready, and non-duplicated SDK event subscriptions. + +## Impact + +- **Affected systems:** Mattermost transport client, Mattermost channel + hosted-service lifecycle, channel health reporting, Mattermost transport tests. +- **Security:** no new inbound surface and no ACL bypass. The Mattermost action + callback endpoint continues to route through `MattermostGatewayActor`. +- **Reliability:** runtime disconnects become explicit state transitions, + reconnects do not multiply SDK event handlers, and health reports become + state-machine snapshots instead of a transport boolean. +- **Compatibility:** no configuration schema change, no session identity change, + no change to Slack or Discord behavior. diff --git a/openspec/changes/actorize-mattermost-gateway-lifecycle/specs/netclaw-input-adapters/spec.md b/openspec/changes/actorize-mattermost-gateway-lifecycle/specs/netclaw-input-adapters/spec.md new file mode 100644 index 000000000..7f958bbf1 --- /dev/null +++ b/openspec/changes/actorize-mattermost-gateway-lifecycle/specs/netclaw-input-adapters/spec.md @@ -0,0 +1,51 @@ +## ADDED Requirements + +### Requirement: Mattermost transport lifecycle is actor-owned + +Mattermost SHALL manage WebSocket transport lifecycle through an actor-backed +state machine. The lifecycle actor SHALL serialize connect, disconnect, +transport event, health snapshot, and clean reconnect transitions. + +The Mattermost routing actor SHALL NOT own transport connect/disconnect state. +`MattermostGatewayActor` SHALL remain responsible for message deduplication, +ACL dispatch, conversation/session actor routing, and HTTP callback interaction +routing. + +#### Scenario: Mattermost reports healthy after ready connection + +- **GIVEN** Mattermost is enabled with a valid server URL and bot token +- **WHEN** the lifecycle actor resolves bot identity and starts WebSocket + receiving +- **THEN** `GetSnapshotAsync` reports connected and ready +- **AND** the snapshot includes the Mattermost bot user id and username +- **AND** `MattermostChannel.GetHealthAsync` reports healthy + +#### Scenario: Runtime disconnect requests clean reconnect + +- **GIVEN** Mattermost is connected and ready +- **WHEN** the transport raises a disconnected event outside an operator stop +- **THEN** the lifecycle actor transitions out of ready +- **AND** the client raises a clean reconnect request +- **AND** health reports disconnected or degraded with the disconnect reason + +#### Scenario: Reconnect does not duplicate transport handlers + +- **GIVEN** Mattermost has completed one connect, disconnect, and reconnect + cycle +- **WHEN** the transport raises one message event +- **THEN** Netclaw publishes exactly one `MattermostGatewayMessage` +- **AND** the transport event handlers have not been subscribed more than once + +#### Scenario: Ingress is gated while not ready + +- **GIVEN** the Mattermost lifecycle actor is disconnected or connecting +- **WHEN** a transport message event arrives +- **THEN** the event is not routed to `MattermostGatewayActor` +- **AND** channel telemetry records a not-ready filtered event + +#### Scenario: Graceful stop unsubscribes transport events + +- **GIVEN** Mattermost is connected +- **WHEN** the channel stops +- **THEN** transport event handlers are unsubscribed +- **AND** the gateway actor is drained before the transport disconnect completes diff --git a/openspec/changes/actorize-mattermost-gateway-lifecycle/tasks.md b/openspec/changes/actorize-mattermost-gateway-lifecycle/tasks.md new file mode 100644 index 000000000..c426dd1b3 --- /dev/null +++ b/openspec/changes/actorize-mattermost-gateway-lifecycle/tasks.md @@ -0,0 +1,48 @@ +## 1. OpenSpec planning artifacts + +- [ ] 1.1 Confirm proposal, design, and spec delta cover actor-owned Mattermost lifecycle state, clean reconnect signaling, health snapshots, ingress gating, and handler de-duplication. +- [ ] 1.2 Run `openspec validate actorize-mattermost-gateway-lifecycle --type change` and resolve all issues. + +## 2. Transport contracts + +- [ ] 2.1 Add `MattermostGatewaySnapshot` with connected, ready, health detail, bot user id, and bot username. +- [ ] 2.2 Add `GetSnapshotAsync` to `IMattermostGatewayClient`. +- [ ] 2.3 Add `CleanReconnectRequired` to `IMattermostGatewayClient`. +- [ ] 2.4 Add `IMattermostGatewayTransport` for Mattermost.NET event and start/stop operations. +- [ ] 2.5 Add a Mattermost.NET transport adapter implementing `IMattermostGatewayTransport`. + +## 3. Lifecycle actor + +- [ ] 3.1 Add `MattermostNetGatewayLifecycleActor` with disconnected, connecting, ready, clean-reconnect-required, disconnecting, and fatal-offline states. +- [ ] 3.2 Move Mattermost.NET event subscription to actor `PreStart`. +- [ ] 3.3 Move Mattermost.NET event unsubscription to actor `PostStop`. +- [ ] 3.4 Resolve bot identity during connect and include it in snapshots. +- [ ] 3.5 Drop or filter transport ingress while not ready and record telemetry. +- [ ] 3.6 Emit clean reconnect requests when the transport disconnects unexpectedly. + +## 4. Client and channel migration + +- [ ] 4.1 Change `MattermostNetGatewayClient` into an actor-backed facade. +- [ ] 4.2 Update `MattermostChannel.TryConnectAsync` to require a ready snapshot before creating the gateway actor. +- [ ] 4.3 Update `MattermostChannel.GetHealthAsync` to use lifecycle snapshots. +- [ ] 4.4 Update `MattermostChannel` to subscribe to clean reconnect requests and trigger an immediate reconnect loop. +- [ ] 4.5 Preserve `MattermostGatewayActor` construction and actor-registry registration. +- [ ] 4.6 Preserve `/api/mattermost/actions` callback routing through `MattermostGatewayActor`. + +## 5. Tests + +- [ ] 5.1 Add lifecycle actor connect-success test. +- [ ] 5.2 Add lifecycle actor transient-failure test. +- [ ] 5.3 Add lifecycle actor fatal-failure test. +- [ ] 5.4 Add runtime disconnect test proving health updates and clean reconnect is requested. +- [ ] 5.5 Add reconnect test proving SDK event handlers are not duplicated. +- [ ] 5.6 Add ingress-not-ready test proving messages are not routed and telemetry is recorded. +- [ ] 5.7 Add channel-health tests for healthy, degraded, disconnected, and disabled states. +- [ ] 5.8 Re-run existing Mattermost channel contract tests. + +## 6. Validation and quality gates + +- [ ] 6.1 `dotnet test src/Netclaw.Actors.Tests/ --filter Mattermost` +- [ ] 6.2 `dotnet test src/Netclaw.Daemon.Tests/` +- [ ] 6.3 `dotnet slopwatch analyze` +- [ ] 6.4 `./scripts/Add-FileHeaders.ps1 -Verify` From f481b807de1d4bcc39052e814d5f0819a4e88f7d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 4 Jun 2026 12:33:57 +0000 Subject: [PATCH 02/31] Plan: standardize channel transport contracts --- .../design.md | 140 -------------- .../proposal.md | 54 ------ .../specs/netclaw-input-adapters/spec.md | 51 ----- .../tasks.md | 48 ----- .../design.md | 175 +++++++++++++++++ .../proposal.md | 69 +++++++ .../specs/netclaw-input-adapters/spec.md | 180 ++++++++++++++++++ .../tasks.md | 56 ++++++ 8 files changed, 480 insertions(+), 293 deletions(-) delete mode 100644 openspec/changes/actorize-mattermost-gateway-lifecycle/design.md delete mode 100644 openspec/changes/actorize-mattermost-gateway-lifecycle/proposal.md delete mode 100644 openspec/changes/actorize-mattermost-gateway-lifecycle/specs/netclaw-input-adapters/spec.md delete mode 100644 openspec/changes/actorize-mattermost-gateway-lifecycle/tasks.md create mode 100644 openspec/changes/standardize-channel-transport-contracts/design.md create mode 100644 openspec/changes/standardize-channel-transport-contracts/proposal.md create mode 100644 openspec/changes/standardize-channel-transport-contracts/specs/netclaw-input-adapters/spec.md create mode 100644 openspec/changes/standardize-channel-transport-contracts/tasks.md diff --git a/openspec/changes/actorize-mattermost-gateway-lifecycle/design.md b/openspec/changes/actorize-mattermost-gateway-lifecycle/design.md deleted file mode 100644 index 3a6d46767..000000000 --- a/openspec/changes/actorize-mattermost-gateway-lifecycle/design.md +++ /dev/null @@ -1,140 +0,0 @@ -# Design - -## Decision - -Mattermost SHALL use a dedicated lifecycle actor for transport connection state, -matching Discord's proven pattern without merging connection state into -`MattermostGatewayActor`. - -`MattermostGatewayActor` stays focused on routing normalized Mattermost messages -and callback interactions to conversations and sessions. The lifecycle actor -stays focused on socket state, transport events, bot identity, readiness, and -health snapshots. - -Reconnect backoff remains in `MattermostChannel` for this change. That matches -the current Discord layering and keeps the migration small. A generic channel -lifecycle framework can be considered later only if Slack, Discord, and -Mattermost all need the same extracted behavior. - -## Component Diagram - -```mermaid -flowchart TD - Host[.NET Host / IHostedService] --> MC[MattermostChannel] - MC --> MGC[IMattermostGatewayClient] - MGC --> LCA[MattermostNetGatewayLifecycleActor] - LCA --> TR[IMattermostGatewayTransport] - TR --> SDK[Mattermost.NET MattermostClient] - SDK --> WS[Mattermost WebSocket] - - LCA -->|PublishMessageAsync| MGC - MGC -->|MessageReceived event| MC - MGC -->|CleanReconnectRequired event| MC - MC -->|Tell MattermostGatewayMessage| MGA[MattermostGatewayActor] - - HTTP[/api/mattermost/actions] -->|MattermostGatewayInteraction| MGA - MGA --> MCA[MattermostConversationActor] - MCA --> MSBA[MattermostSessionBindingActor] - MSBA --> SP[SessionPipeline] - SP --> LLM[LlmSessionActor] -``` - -## Target State Machine - -```mermaid -stateDiagram-v2 - [*] --> Disconnected - Disconnected --> Connecting: Connect(serverUrl, token) - Connecting --> Ready: bot identity resolved + websocket receiving - Connecting --> Disconnected: transient failure - Connecting --> FatalOffline: fatal configuration/auth failure - Ready --> CleanReconnectRequired: unexpected disconnect - Ready --> Disconnecting: Disconnect - CleanReconnectRequired --> Disconnecting: Disconnect - Disconnecting --> Disconnected: stop/logout complete - FatalOffline --> [*] -``` - -Mattermost.NET may not expose a Discord-style `READY` event. For Mattermost, -`Ready` means Netclaw has resolved the bot identity and the SDK has successfully -started WebSocket receiving. - -## Responsibilities - -### MattermostNetGatewayLifecycleActor - -- Owns the current lifecycle state: disconnected, connecting, ready, - clean-reconnect-required, disconnecting, fatal-offline. -- Subscribes Mattermost.NET SDK events once in `PreStart` and unsubscribes in - `PostStop`. -- Serializes `Connect`, `Disconnect`, transport event, and `GetSnapshot` - handling through the actor mailbox. -- Resolves and stores bot user id and username during successful connection. -- Drops or filters ingress while not ready and records channel telemetry. -- Emits clean reconnect requests when the transport disconnects unexpectedly. -- Replies to health snapshot requests with connected/ready/detail/bot identity. - -### MattermostNetGatewayClient - -- Becomes a thin actor-backed facade over `MattermostNetGatewayLifecycleActor`. -- Uses bounded `Ask` calls for connect, disconnect, and snapshot operations. -- Publishes normalized messages and clean reconnect requests through the - existing client event surface. - -### MattermostChannel - -- Performs enabled/disabled and required configuration checks before connect. -- Contains fatal connection failures so one misconfigured channel does not crash - the daemon. -- Runs the bounded reconnect backoff loop. -- Creates and registers `MattermostGatewayActor` only after the lifecycle actor - reports a ready snapshot. -- Drains the gateway actor before disconnecting transport on shutdown. -- Reads lifecycle snapshots for `GetHealthAsync`. - -### MattermostGatewayActor - -- Unchanged routing actor. -- Owns event deduplication, ACL dispatch, conversation actor routing, and HTTP - callback interaction routing. -- Does not own WebSocket connect/disconnect state. - -## Migration Plan - -1. Add `MattermostGatewaySnapshot` to Mattermost transport contracts with - `IsConnected`, `IsReady`, `HealthDetail`, `BotUserId`, and `BotUsername`. -2. Add `GetSnapshotAsync` and `CleanReconnectRequired` to - `IMattermostGatewayClient`. -3. Add `IMattermostGatewayTransport` as a testable wrapper over Mattermost.NET - events and start/stop operations. -4. Add `MattermostSocketGatewayTransport` to adapt `MattermostClient` to the new - transport interface. -5. Add `MattermostNetGatewayLifecycleActor` and move SDK event subscription into - actor `PreStart`/`PostStop`. -6. Update `MattermostNetGatewayClient` to create the lifecycle actor and use - `Ask` for connect, disconnect, and snapshot calls. -7. Update `MattermostChannel.TryConnectAsync` to require a ready snapshot before - calling `CompleteConnectionSetup`. -8. Update `MattermostChannel.GetHealthAsync` to use `GetSnapshotAsync`. -9. Update `MattermostChannel` to subscribe to `CleanReconnectRequired` and start - an immediate clean reconnect, matching Discord's pattern. -10. Preserve the existing gateway actor hierarchy and HTTP callback route. -11. Add lifecycle tests before broad cleanup. - -## Non-Goals - -- Do not create a generic channel base actor in this change. -- Do not change Slack or Discord behavior. -- Do not persist Mattermost callback action tokens. -- Do not change `MattermostGatewayActor` routing semantics. -- Do not change Mattermost session identity or `ChannelInput` construction. - -## Risks / Trade-offs - -- Mattermost.NET event semantics may differ from Discord.NET. The lifecycle actor - should define readiness from Netclaw-observable operations: bot identity - resolved and WebSocket receiving started. -- Keeping reconnect backoff in `MattermostChannel` duplicates some orchestration - logic with Discord, but it avoids a premature shared framework. -- Tests need fakes that can count event subscription and publication calls so - handler duplication cannot regress. diff --git a/openspec/changes/actorize-mattermost-gateway-lifecycle/proposal.md b/openspec/changes/actorize-mattermost-gateway-lifecycle/proposal.md deleted file mode 100644 index 6a9bd5670..000000000 --- a/openspec/changes/actorize-mattermost-gateway-lifecycle/proposal.md +++ /dev/null @@ -1,54 +0,0 @@ -## Why - -Mattermost channel reliability is currently split between `MattermostChannel` -and `MattermostNetGatewayClient`. Reconnects are coordinated by a hosted-service -background task, while the transport client subscribes to Mattermost.NET SDK -events inside `ConnectAsync` and unsubscribes only on dispose. A reconnect can -therefore duplicate SDK event handlers, and runtime disconnect events are logged -without becoming lifecycle state that can drive health or recovery. - -Discord already uses an actor-backed lifecycle state machine behind its gateway -client. Mattermost should use the same reliability pattern for connection state, -without moving message routing into the transport lifecycle actor. - -Source PRDs/specs: `PRD-009-input-adapters-and-unified-input.md`, -`SPEC-011-daemon-architecture.md`, `openspec/specs/netclaw-input-adapters/spec.md`. - -## What Changes - -- Add a `MattermostNetGatewayLifecycleActor` that owns Mattermost WebSocket - connection state, SDK event subscriptions, bot identity, health snapshots, and - clean-reconnect requests. -- Change `MattermostNetGatewayClient` into an actor-backed facade, matching the - Discord transport shape. -- Change `MattermostChannel.GetHealthAsync` to use lifecycle snapshots instead - of a raw `IsConnected` boolean. -- Keep `MattermostChannel` responsible for hosted-service startup/shutdown, - fatal configuration containment, gateway actor registration, and bounded - reconnect backoff. -- Keep `MattermostGatewayActor` responsible for message deduplication, ACL - dispatch, conversation/session routing, and HTTP callback interactions. - -## Capabilities - -### New Capabilities - - - -### Modified Capabilities - -- `netclaw-input-adapters`: Add requirements for actor-owned Mattermost - transport lifecycle state, snapshot-based health, clean reconnect signaling, - ingress gating while not ready, and non-duplicated SDK event subscriptions. - -## Impact - -- **Affected systems:** Mattermost transport client, Mattermost channel - hosted-service lifecycle, channel health reporting, Mattermost transport tests. -- **Security:** no new inbound surface and no ACL bypass. The Mattermost action - callback endpoint continues to route through `MattermostGatewayActor`. -- **Reliability:** runtime disconnects become explicit state transitions, - reconnects do not multiply SDK event handlers, and health reports become - state-machine snapshots instead of a transport boolean. -- **Compatibility:** no configuration schema change, no session identity change, - no change to Slack or Discord behavior. diff --git a/openspec/changes/actorize-mattermost-gateway-lifecycle/specs/netclaw-input-adapters/spec.md b/openspec/changes/actorize-mattermost-gateway-lifecycle/specs/netclaw-input-adapters/spec.md deleted file mode 100644 index 7f958bbf1..000000000 --- a/openspec/changes/actorize-mattermost-gateway-lifecycle/specs/netclaw-input-adapters/spec.md +++ /dev/null @@ -1,51 +0,0 @@ -## ADDED Requirements - -### Requirement: Mattermost transport lifecycle is actor-owned - -Mattermost SHALL manage WebSocket transport lifecycle through an actor-backed -state machine. The lifecycle actor SHALL serialize connect, disconnect, -transport event, health snapshot, and clean reconnect transitions. - -The Mattermost routing actor SHALL NOT own transport connect/disconnect state. -`MattermostGatewayActor` SHALL remain responsible for message deduplication, -ACL dispatch, conversation/session actor routing, and HTTP callback interaction -routing. - -#### Scenario: Mattermost reports healthy after ready connection - -- **GIVEN** Mattermost is enabled with a valid server URL and bot token -- **WHEN** the lifecycle actor resolves bot identity and starts WebSocket - receiving -- **THEN** `GetSnapshotAsync` reports connected and ready -- **AND** the snapshot includes the Mattermost bot user id and username -- **AND** `MattermostChannel.GetHealthAsync` reports healthy - -#### Scenario: Runtime disconnect requests clean reconnect - -- **GIVEN** Mattermost is connected and ready -- **WHEN** the transport raises a disconnected event outside an operator stop -- **THEN** the lifecycle actor transitions out of ready -- **AND** the client raises a clean reconnect request -- **AND** health reports disconnected or degraded with the disconnect reason - -#### Scenario: Reconnect does not duplicate transport handlers - -- **GIVEN** Mattermost has completed one connect, disconnect, and reconnect - cycle -- **WHEN** the transport raises one message event -- **THEN** Netclaw publishes exactly one `MattermostGatewayMessage` -- **AND** the transport event handlers have not been subscribed more than once - -#### Scenario: Ingress is gated while not ready - -- **GIVEN** the Mattermost lifecycle actor is disconnected or connecting -- **WHEN** a transport message event arrives -- **THEN** the event is not routed to `MattermostGatewayActor` -- **AND** channel telemetry records a not-ready filtered event - -#### Scenario: Graceful stop unsubscribes transport events - -- **GIVEN** Mattermost is connected -- **WHEN** the channel stops -- **THEN** transport event handlers are unsubscribed -- **AND** the gateway actor is drained before the transport disconnect completes diff --git a/openspec/changes/actorize-mattermost-gateway-lifecycle/tasks.md b/openspec/changes/actorize-mattermost-gateway-lifecycle/tasks.md deleted file mode 100644 index c426dd1b3..000000000 --- a/openspec/changes/actorize-mattermost-gateway-lifecycle/tasks.md +++ /dev/null @@ -1,48 +0,0 @@ -## 1. OpenSpec planning artifacts - -- [ ] 1.1 Confirm proposal, design, and spec delta cover actor-owned Mattermost lifecycle state, clean reconnect signaling, health snapshots, ingress gating, and handler de-duplication. -- [ ] 1.2 Run `openspec validate actorize-mattermost-gateway-lifecycle --type change` and resolve all issues. - -## 2. Transport contracts - -- [ ] 2.1 Add `MattermostGatewaySnapshot` with connected, ready, health detail, bot user id, and bot username. -- [ ] 2.2 Add `GetSnapshotAsync` to `IMattermostGatewayClient`. -- [ ] 2.3 Add `CleanReconnectRequired` to `IMattermostGatewayClient`. -- [ ] 2.4 Add `IMattermostGatewayTransport` for Mattermost.NET event and start/stop operations. -- [ ] 2.5 Add a Mattermost.NET transport adapter implementing `IMattermostGatewayTransport`. - -## 3. Lifecycle actor - -- [ ] 3.1 Add `MattermostNetGatewayLifecycleActor` with disconnected, connecting, ready, clean-reconnect-required, disconnecting, and fatal-offline states. -- [ ] 3.2 Move Mattermost.NET event subscription to actor `PreStart`. -- [ ] 3.3 Move Mattermost.NET event unsubscription to actor `PostStop`. -- [ ] 3.4 Resolve bot identity during connect and include it in snapshots. -- [ ] 3.5 Drop or filter transport ingress while not ready and record telemetry. -- [ ] 3.6 Emit clean reconnect requests when the transport disconnects unexpectedly. - -## 4. Client and channel migration - -- [ ] 4.1 Change `MattermostNetGatewayClient` into an actor-backed facade. -- [ ] 4.2 Update `MattermostChannel.TryConnectAsync` to require a ready snapshot before creating the gateway actor. -- [ ] 4.3 Update `MattermostChannel.GetHealthAsync` to use lifecycle snapshots. -- [ ] 4.4 Update `MattermostChannel` to subscribe to clean reconnect requests and trigger an immediate reconnect loop. -- [ ] 4.5 Preserve `MattermostGatewayActor` construction and actor-registry registration. -- [ ] 4.6 Preserve `/api/mattermost/actions` callback routing through `MattermostGatewayActor`. - -## 5. Tests - -- [ ] 5.1 Add lifecycle actor connect-success test. -- [ ] 5.2 Add lifecycle actor transient-failure test. -- [ ] 5.3 Add lifecycle actor fatal-failure test. -- [ ] 5.4 Add runtime disconnect test proving health updates and clean reconnect is requested. -- [ ] 5.5 Add reconnect test proving SDK event handlers are not duplicated. -- [ ] 5.6 Add ingress-not-ready test proving messages are not routed and telemetry is recorded. -- [ ] 5.7 Add channel-health tests for healthy, degraded, disconnected, and disabled states. -- [ ] 5.8 Re-run existing Mattermost channel contract tests. - -## 6. Validation and quality gates - -- [ ] 6.1 `dotnet test src/Netclaw.Actors.Tests/ --filter Mattermost` -- [ ] 6.2 `dotnet test src/Netclaw.Daemon.Tests/` -- [ ] 6.3 `dotnet slopwatch analyze` -- [ ] 6.4 `./scripts/Add-FileHeaders.ps1 -Verify` diff --git a/openspec/changes/standardize-channel-transport-contracts/design.md b/openspec/changes/standardize-channel-transport-contracts/design.md new file mode 100644 index 000000000..ad7ab019d --- /dev/null +++ b/openspec/changes/standardize-channel-transport-contracts/design.md @@ -0,0 +1,175 @@ +# Design + +## Decision + +Standardize the channel seam through descriptors, runtime snapshots, address +resolution, and tool intent schemas. Do not start by creating a shared channel +base actor or rewriting adapter internals. + +Adapters keep their platform-specific implementation details. The daemon and +LLM-facing tool registry consume a standard description of what each adapter can +do, how healthy it is, and how names resolve to platform addresses. + +Mattermost lifecycle actorization remains a valid reliability fix, but it is no +longer the top-level change. It becomes one stateful-adapter task that should be +implemented after Mattermost can report the same descriptor and runtime snapshot +shape as Slack, Discord, and future remote chat adapters. + +## Transport And Channel Taxonomy + +Netclaw needs to distinguish logical conversation sources from process/network +transport endpoints. + +| Kind | Examples | Descriptor Meaning | +|------|----------|--------------------| +| Remote chat channel | Slack, Discord, Mattermost | External workspace/server adapter that can receive messages, send replies, resolve users/destinations, and report remote socket or API health. | +| Local client channel | TUI, future Web UI | Logical conversation source created by a local client over the daemon API. The session is channel-like, but the underlying network endpoint is SignalR. | +| Daemon endpoint | SignalR hub | Server transport endpoint used by local clients. It has endpoint health and connected-client state, but it is not itself a user-facing workspace/channel. | +| Internal source | Reminder, scheduler | Daemon-owned source that creates session turns without an external chat workspace. | +| HTTP ingress source | Webhook | External HTTP event source with routing policy, not necessarily a conversational channel. | +| Non-interactive client | Headless | One-shot or request/response source with no ongoing chat surface. | + +This split lets SignalR be treated like a first-class operational endpoint +without forcing it to pretend to be Slack-like. TUI sessions still get a logical +channel descriptor because they participate in session routing and approval +capabilities. + +## Component Diagram + +```mermaid +flowchart TD + Adapters[Channel adapters and endpoints] --> DP[Descriptor providers] + Adapters --> RS[Runtime snapshot providers] + Adapters --> AR[Address resolvers] + DP --> CR[Channel registry] + RS --> CR + AR --> CR + CR --> Status[Daemon runtime status] + CR --> Stats[Daemon stats] + CR --> Tools[LLM tool registry] + Tools --> Intents[Standard tool intents] + Adapters --> Pipeline[SessionPipeline] + Pipeline --> Session[LlmSessionActor] +``` + +## Standard Descriptor Shape + +Each adapter or endpoint reports a stable descriptor with these concepts: + +- Stable key, channel type, display name, and kind. +- Whether it is enabled by configuration. +- Capabilities: receive messages, send messages, direct messages, threaded + conversations, interactive approvals, file ingress, file egress, user lookup, + destination lookup, proactive messages, and runtime health. +- Tool intents it supports, such as send message, lookup user, and lookup + destination. +- Address namespaces it can resolve, such as user, channel, room, thread, DM, + session, webhook source, or schedule target. + +The descriptor describes what the adapter promises. It must not grant ACL +permissions by itself. Actual turn authorization continues to flow through +`ChannelInput` trust context and existing policy checks. + +## Runtime Snapshot Shape + +Each adapter or endpoint reports a runtime snapshot with these concepts: + +- Descriptor key and channel type. +- Enabled state. +- Health status and detail. +- Connected state when meaningful. +- Ready state when meaningful. +- Bot or service principal identity when the adapter has one. +- Endpoint identity when the item is a daemon transport endpoint. +- Last known activity counters or timestamps when available. + +Ready is adapter-specific but comparable. For a remote socket adapter, ready +means it can accept inbound events and send replies. For a local client channel, +ready means the session endpoint can route messages. For an internal source, +ready means its scheduler or trigger is registered. + +## Address Resolution + +Address resolution is standardized as an intent, not as one platform's ID model. +Resolvers accept a query and an address kind. They can return exact matches, +candidate matches, or a failure. + +Rules: + +- Stable IDs are accepted when supplied. +- User-facing names are searchable where the backing platform supports it. +- Ambiguous names fail loudly with candidates instead of choosing the first + match. +- Resolvers do not silently fall back from one namespace to another. +- Resolved addresses carry both display data and stable platform IDs. + +## LLM-Facing Tool Intents + +The tool registry should describe channel tools in terms of standard intents: + +- `send_message`: destination, text, optional thread/root target, optional + audience/context hints. +- `lookup_user`: query, optional channel key, optional exact-only flag. +- `lookup_destination`: query, destination kind, optional channel key, + optional exact-only flag. + +The implementation can keep existing tool names such as `send_slack_message`, +`send_discord_message`, and `send_mattermost_message` during migration, but each +tool must map to the standard intent schema. A generic multi-channel tool can be +introduced after the registry can enumerate descriptors and resolvers reliably. + +## Stateful Transport Lifecycle + +Stateful remote chat adapters must expose lifecycle through the standard runtime +snapshot. They may implement that lifecycle with actors, hosted services, SDK +callbacks, or another serialized owner, but the observable behavior must be the +same: + +- Health reports disconnected, connecting, ready, degraded, and not-ready states + consistently. +- Ingress is gated while the adapter is not ready. +- Reconnects do not duplicate SDK event handlers. +- Unexpected disconnects can request a clean reconnect when the platform SDK + requires a full stop/start cycle. + +Mattermost likely needs an actor-owned lifecycle implementation to satisfy these +requirements. That should be implemented after the standardized snapshot shape +exists. + +## Migration Plan + +1. Add descriptor, runtime snapshot, address-resolution, and tool-intent + contracts without changing adapter behavior. +2. Add contract tests that enumerate every `ChannelType` and require either a + logical channel descriptor or an explicit endpoint/internal-source descriptor. +3. Adapt existing Slack, Discord, Mattermost, TUI, Headless, SignalR, Reminder, + and Webhook surfaces to report descriptors and snapshots using their current + behavior. +4. Change daemon runtime status and stats to consume the registry instead of + hard-coded Slack/Discord lists. +5. Normalize Slack, Discord, and Mattermost send/lookup tools onto standard + intent schemas while preserving existing tool names as aliases. +6. Add name-searchable user and destination resolvers for supported platforms. +7. Only after descriptors and snapshots are stable, implement adapter-specific + lifecycle fixes such as Mattermost actorization. + +## Non-Goals + +- Do not rewrite all adapters in one pass. +- Do not create a generic channel base actor in this change. +- Do not remove existing channel-specific tool names during the first migration. +- Do not change session identity formats. +- Do not weaken ACL, audience, principal, boundary, or provenance requirements. +- Do not make SignalR pretend to be a remote chat workspace. + +## Risks / Trade-offs + +- A descriptor model can become too abstract. Keep it tied to current runtime + consumers: status, stats, tools, health, and address resolution. +- Generic tools can hide platform-specific constraints. Preserve platform + capability flags and fail loudly when a requested intent is unsupported. +- SignalR needs special handling because it is both the daemon API endpoint and + the transport used by local logical channels. Treat endpoint health and logical + channel capability as separate records. +- Mattermost lifecycle remains a reliability risk until actorized, but delaying + it avoids changing adapter internals before the shared seam is defined. diff --git a/openspec/changes/standardize-channel-transport-contracts/proposal.md b/openspec/changes/standardize-channel-transport-contracts/proposal.md new file mode 100644 index 000000000..5288a6132 --- /dev/null +++ b/openspec/changes/standardize-channel-transport-contracts/proposal.md @@ -0,0 +1,69 @@ +## Why + +`PRD-009` defines the core input-adapter principle: all inputs should arrive at +the session pipeline through the same transport-agnostic boundary, with source +metadata and instructions carrying the channel-specific differences. The current +implementation has achieved that for `ChannelInput`, but the operational and +LLM-facing surfaces still drift by adapter. + +Current gaps include: + +- Runtime status and stats are partially hard-coded to specific adapters. +- Slack, Discord, and Mattermost expose different LLM-facing tool shapes. +- User and destination lookup is inconsistent and often ID-first instead of + name-searchable. +- `ChannelType` mixes logical conversation sources with transport endpoints, + which makes SignalR/TUI/headless harder to reason about consistently. +- Stateful socket adapters expose different lifecycle health semantics. + +The Mattermost lifecycle issue is one symptom of this broader problem. The first +change should standardize the channel contract that every adapter reports to the +daemon and tools. Adapter-specific lifecycle fixes, including Mattermost +actorization, should happen after that seam exists. + +Source PRDs/specs: `PRD-009-input-adapters-and-unified-input.md`, +`SPEC-011-daemon-architecture.md`, `openspec/specs/netclaw-input-adapters/spec.md`. + +## What Changes + +- Add a standard channel descriptor contract for all logical conversation + sources and daemon transport endpoints. +- Add a standard runtime snapshot contract for health, readiness, enabled state, + endpoint identity, bot identity, and capability reporting. +- Add standard address-resolution semantics so users, channels, rooms, threads, + and destinations can be resolved by stable IDs or user-facing names. +- Add standard LLM-facing tool intent schemas for send-message and lookup tools, + while keeping existing per-channel tool names as compatibility aliases during + migration. +- Change daemon runtime status and stats to enumerate registered descriptors + instead of hard-coding individual adapters. +- Define socket-adapter lifecycle requirements that Mattermost, Discord, Slack, + and future remote chat adapters can satisfy without requiring a shared base + actor. + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `netclaw-input-adapters`: Add requirements for standardized channel + descriptors, runtime snapshots, address resolution, LLM tool intents, + descriptor-driven observability, and reliable stateful transport lifecycle + reporting. + +## Impact + +- **Affected systems:** channel abstractions, daemon runtime status, daemon + stats, Slack/Discord/Mattermost LLM tools, channel user/destination lookup, + channel contract tests, and stateful adapter lifecycle tests. +- **Security:** no new ACL bypass. Standardized descriptors must preserve the + source audience, principal, boundary, and provenance already required by + `ChannelInput`. +- **Reliability:** health and readiness become comparable across adapters. + Stateful socket adapters expose reconnect and not-ready states through a + common snapshot shape. +- **Compatibility:** existing channel-specific tools remain available while they + are mapped to standard tool intents. No session identity change is required. diff --git a/openspec/changes/standardize-channel-transport-contracts/specs/netclaw-input-adapters/spec.md b/openspec/changes/standardize-channel-transport-contracts/specs/netclaw-input-adapters/spec.md new file mode 100644 index 000000000..18c98266f --- /dev/null +++ b/openspec/changes/standardize-channel-transport-contracts/specs/netclaw-input-adapters/spec.md @@ -0,0 +1,180 @@ +## ADDED Requirements + +### Requirement: Adapters expose standardized channel descriptors + +Netclaw SHALL expose a standardized descriptor through a common registry for +every logical conversation source, daemon transport endpoint, internal source, +and HTTP ingress source. + +The descriptor SHALL declare the source kind, stable key, channel type when +applicable, display name, enabled state, capabilities, supported tool intents, +and supported address namespaces. + +Descriptors SHALL NOT grant permissions. ACL decisions SHALL continue to use the +explicit audience, principal, boundary, and provenance carried on `ChannelInput` +and `MessageSource`. + +#### Scenario: Every channel type is represented + +- **GIVEN** the daemon has loaded channel integrations +- **WHEN** the channel registry is enumerated +- **THEN** Slack, Discord, Mattermost, TUI, Headless, SignalR, Reminder, and + Webhook are represented by either a descriptor or an explicit unsupported or + not-configured record +- **AND** each record declares whether it is a logical channel, daemon endpoint, + internal source, or HTTP ingress source + +#### Scenario: SignalR endpoint is distinct from TUI logical channel + +- **GIVEN** the TUI sends messages over the SignalR hub +- **WHEN** descriptors are enumerated +- **THEN** the SignalR hub is represented as a daemon endpoint +- **AND** the TUI is represented as a logical local client channel +- **AND** the TUI descriptor carries session interaction capabilities rather than + remote workspace capabilities + +#### Scenario: Descriptor capabilities do not bypass ACL + +- **GIVEN** a descriptor declares that a channel supports proactive send +- **WHEN** a session turn is authorized +- **THEN** tool access still evaluates the turn's explicit trust context +- **AND** the descriptor capability is not treated as an ACL grant + +### Requirement: Runtime health uses standardized snapshots + +Every descriptor-backed adapter or endpoint SHALL expose a runtime snapshot that +reports enabled state, health status, health detail, connected state when +meaningful, ready state when meaningful, service principal identity when +available, endpoint identity when applicable, and activity metadata when +available. + +#### Scenario: Ready remote chat adapter reports healthy + +- **GIVEN** Slack, Discord, or Mattermost is enabled and ready to receive inbound + events and send replies +- **WHEN** runtime snapshots are enumerated +- **THEN** the adapter snapshot reports enabled and healthy +- **AND** connected and ready are true when those states are meaningful for the + adapter + +#### Scenario: Connected but not-ready adapter reports degraded + +- **GIVEN** a stateful remote chat adapter has a socket connection but cannot + safely route inbound events +- **WHEN** its runtime snapshot is requested +- **THEN** connected is true +- **AND** ready is false +- **AND** health is degraded with a detail explaining the not-ready condition + +#### Scenario: Disabled adapter reports configured disabled state + +- **GIVEN** an adapter is disabled by configuration +- **WHEN** its runtime snapshot is requested +- **THEN** enabled is false +- **AND** health reports a disabled or degraded state without attempting a + transport connection + +### Requirement: Runtime status and stats are descriptor-driven + +Daemon runtime status and daemon stats SHALL enumerate channel descriptors and +runtime snapshots rather than hard-coding specific adapters. + +#### Scenario: Newly registered channel appears in status without status-service changes + +- **GIVEN** a new adapter registers a descriptor and runtime snapshot provider +- **WHEN** daemon runtime status is requested +- **THEN** the adapter appears in the channel or endpoint status collection +- **AND** no adapter-specific branch is required in the status service + +#### Scenario: Channel activity includes all descriptor-backed channels + +- **GIVEN** Slack, Discord, and Mattermost have recorded channel activity +- **WHEN** daemon stats are requested +- **THEN** activity for all three adapters is included through descriptor-backed + enumeration + +### Requirement: Address resolution accepts IDs and user-facing names + +Channel address resolution SHALL use a common resolver contract for supported +address kinds, including users and destinations. Resolvers SHALL accept stable +IDs and user-facing names where the backing platform supports them. + +Resolvers SHALL fail loudly on ambiguous names and unsupported address kinds. +They SHALL NOT silently fall back from one namespace to another. + +#### Scenario: Exact stable ID resolves without search ambiguity + +- **GIVEN** a send-message tool receives a destination value that is a stable + platform channel ID +- **WHEN** the resolver evaluates the destination +- **THEN** it resolves the exact ID without display-name search + +#### Scenario: Ambiguous display name fails with candidates + +- **GIVEN** two Mattermost channels have the same display name visible to the bot +- **WHEN** a lookup query uses that display name +- **THEN** resolution fails loudly +- **AND** the result includes candidate stable IDs and display names + +#### Scenario: User lookup resolves by display query + +- **GIVEN** Slack, Discord, or Mattermost supports user lookup +- **WHEN** an LLM-facing lookup tool searches for a user-facing name +- **THEN** the resolver returns matching users with stable IDs and display data +- **AND** callers can pass the stable ID to send-message or DM-capable tools + +### Requirement: LLM-facing channel tools use standard intent schemas + +LLM-facing channel tools SHALL map to standard tool intents for send message, +lookup user, and lookup destination. Existing channel-specific tool names MAY +remain during migration, but their arguments and behavior SHALL map to the +standard intent schema. + +#### Scenario: Send-message tools share a common argument model + +- **GIVEN** Slack, Discord, and Mattermost expose send-message tools +- **WHEN** their tool definitions are inspected +- **THEN** each tool accepts a destination, text, and optional thread or root + target using the standard send-message intent schema +- **AND** unsupported options are omitted or reported as unsupported rather than + silently ignored + +#### Scenario: Legacy tool name remains as an alias + +- **GIVEN** existing sessions know about `send_slack_message` +- **WHEN** Slack tools are registered under the standard intent model +- **THEN** `send_slack_message` remains available as an alias or channel-specific + registration +- **AND** it maps to the same send-message intent used by other channels + +### Requirement: Stateful remote chat adapters expose reliable lifecycle state + +Stateful remote chat adapters SHALL expose lifecycle state through their runtime +snapshot and SHALL gate inbound events while not ready. Reconnects SHALL NOT +duplicate transport SDK event handlers. Unexpected disconnects SHALL be reported +as disconnected or degraded state and MAY request a clean reconnect when a full +transport restart is required. + +#### Scenario: Not-ready ingress is gated + +- **GIVEN** a stateful remote chat adapter is disconnected or connecting +- **WHEN** the platform SDK raises an inbound message event +- **THEN** the event is not routed to the session pipeline +- **AND** the adapter records or logs that ingress was filtered while not ready + +#### Scenario: Reconnect does not duplicate SDK handlers + +- **GIVEN** a stateful remote chat adapter completes a connect, disconnect, and + reconnect cycle +- **WHEN** the platform SDK raises one message event +- **THEN** Netclaw publishes exactly one normalized gateway message +- **AND** SDK event handlers have not been subscribed more than once + +#### Scenario: Mattermost lifecycle implementation satisfies the common contract + +- **GIVEN** Mattermost implements the standardized runtime snapshot contract +- **WHEN** Mattermost is actorized or otherwise given a serialized lifecycle + owner +- **THEN** it satisfies the same not-ready ingress, disconnect health, clean + reconnect, and handler de-duplication scenarios as other stateful remote chat + adapters diff --git a/openspec/changes/standardize-channel-transport-contracts/tasks.md b/openspec/changes/standardize-channel-transport-contracts/tasks.md new file mode 100644 index 000000000..d78684bab --- /dev/null +++ b/openspec/changes/standardize-channel-transport-contracts/tasks.md @@ -0,0 +1,56 @@ +## 1. OpenSpec planning artifacts + +- [ ] 1.1 Confirm proposal, design, and spec delta cover Slack, Discord, Mattermost, TUI, Headless, SignalR, Reminder, Webhook, and future adapters. +- [ ] 1.2 Confirm Mattermost actorization is represented as an adapter-specific lifecycle task, not the top-level change. +- [ ] 1.3 Run `openspec validate standardize-channel-transport-contracts --type change` and resolve all issues. + +## 2. Descriptor and snapshot contracts + +- [ ] 2.1 Add a standard channel descriptor model for logical channels, daemon endpoints, internal sources, and HTTP ingress sources. +- [ ] 2.2 Add capability flags for receive, send, DM, threaded conversations, interactive approval, file ingress, file egress, proactive send, user lookup, destination lookup, and runtime health. +- [ ] 2.3 Add a standard runtime snapshot model with enabled, health, connected, ready, principal identity, endpoint identity, and activity metadata. +- [ ] 2.4 Add a registry service that enumerates descriptor and snapshot providers. + +## 3. Existing adapter coverage + +- [ ] 3.1 Register descriptors for Slack, Discord, Mattermost, TUI, Headless, SignalR, Reminder, and Webhook or explicitly mark unsupported/not-configured adapters. +- [ ] 3.2 Adapt Slack runtime health to the standard snapshot shape without changing Slack behavior. +- [ ] 3.3 Adapt Discord runtime health to the standard snapshot shape without changing Discord behavior. +- [ ] 3.4 Adapt Mattermost runtime health to the standard snapshot shape without actorizing it yet. +- [ ] 3.5 Represent SignalR as a daemon endpoint and TUI as a logical local client channel. + +## 4. Descriptor-driven observability + +- [ ] 4.1 Change daemon runtime status to enumerate the channel registry instead of hard-coding individual adapters. +- [ ] 4.2 Change daemon stats channel activity to enumerate descriptor-backed channels. +- [ ] 4.3 Preserve current status/stats output fields or provide explicit compatibility mapping. + +## 5. Address resolution + +- [ ] 5.1 Add a standard address resolver contract for users and destinations. +- [ ] 5.2 Support exact stable ID resolution before name search. +- [ ] 5.3 Fail loudly with candidates for ambiguous display-name matches. +- [ ] 5.4 Wire Slack lookup to the standard resolver contract. +- [ ] 5.5 Wire Discord lookup to the standard resolver contract where supported. +- [ ] 5.6 Wire Mattermost lookup to the standard resolver contract. + +## 6. LLM-facing tool standardization + +- [ ] 6.1 Define standard tool intent schemas for send message, lookup user, and lookup destination. +- [ ] 6.2 Map existing Slack tools to the standard intent schema while preserving their current names. +- [ ] 6.3 Map existing Discord tools to the standard intent schema while preserving their current names. +- [ ] 6.4 Map existing Mattermost tools to the standard intent schema while preserving their current names. +- [ ] 6.5 Decide whether the first generic tool is a single multi-channel tool or generated per-channel aliases after contract tests pass. + +## 7. Stateful transport lifecycle follow-up + +- [ ] 7.1 Add contract tests for not-ready ingress gating, runtime disconnect health, clean reconnect signaling, and handler de-duplication for stateful remote chat adapters. +- [ ] 7.2 Implement Mattermost lifecycle actorization only after the standard snapshot and lifecycle contract tests exist. +- [ ] 7.3 Verify Slack and Discord satisfy the same lifecycle requirements or document explicit capability differences. + +## 8. Validation and quality gates + +- [ ] 8.1 `dotnet test src/Netclaw.Actors.Tests/ --filter Channel` +- [ ] 8.2 `dotnet test src/Netclaw.Daemon.Tests/` +- [ ] 8.3 `dotnet slopwatch analyze` +- [ ] 8.4 `./scripts/Add-FileHeaders.ps1 -Verify` From 0d376fe0c04b9cc28ed031b3a06a7f5ec09c23ef Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 4 Jun 2026 12:44:14 +0000 Subject: [PATCH 03/31] Plan: clarify channel resolver and tool naming --- .../design.md | 20 ++++++---- .../proposal.md | 9 +++-- .../specs/netclaw-input-adapters/spec.md | 38 ++++++++++++------- .../tasks.md | 17 +++++---- 4 files changed, 50 insertions(+), 34 deletions(-) diff --git a/openspec/changes/standardize-channel-transport-contracts/design.md b/openspec/changes/standardize-channel-transport-contracts/design.md index ad7ab019d..9912f7e55 100644 --- a/openspec/changes/standardize-channel-transport-contracts/design.md +++ b/openspec/changes/standardize-channel-transport-contracts/design.md @@ -91,8 +91,11 @@ ready means its scheduler or trigger is registered. ## Address Resolution Address resolution is standardized as an intent, not as one platform's ID model. -Resolvers accept a query and an address kind. They can return exact matches, -candidate matches, or a failure. +Each descriptor-backed adapter provides its own resolver for the address +namespaces it supports. The channel registry routes a resolution request to the +resolver associated with the selected descriptor or channel type; it does not use +a global resolver that guesses across platforms. Resolvers accept a query and an +address kind. They can return exact matches, candidate matches, or a failure. Rules: @@ -100,6 +103,7 @@ Rules: - User-facing names are searchable where the backing platform supports it. - Ambiguous names fail loudly with candidates instead of choosing the first match. +- Unsupported address kinds fail loudly for the selected descriptor. - Resolvers do not silently fall back from one namespace to another. - Resolved addresses carry both display data and stable platform IDs. @@ -113,10 +117,11 @@ The tool registry should describe channel tools in terms of standard intents: - `lookup_destination`: query, destination kind, optional channel key, optional exact-only flag. -The implementation can keep existing tool names such as `send_slack_message`, -`send_discord_message`, and `send_mattermost_message` during migration, but each -tool must map to the standard intent schema. A generic multi-channel tool can be -introduced after the registry can enumerate descriptors and resolvers reliably. +Existing tool names such as `send_slack_message`, `send_discord_message`, and +`send_mattermost_message` are not compatibility requirements. The implementation +may rename them to the standardized tool names once the registry can enumerate +descriptors and resolvers reliably. System skills, CLI/help text, and evals must +be updated in the same implementation change when tool names change. ## Stateful Transport Lifecycle @@ -148,7 +153,7 @@ exists. 4. Change daemon runtime status and stats to consume the registry instead of hard-coded Slack/Discord lists. 5. Normalize Slack, Discord, and Mattermost send/lookup tools onto standard - intent schemas while preserving existing tool names as aliases. + intent schemas and rename current per-channel tools where needed. 6. Add name-searchable user and destination resolvers for supported platforms. 7. Only after descriptors and snapshots are stable, implement adapter-specific lifecycle fixes such as Mattermost actorization. @@ -157,7 +162,6 @@ exists. - Do not rewrite all adapters in one pass. - Do not create a generic channel base actor in this change. -- Do not remove existing channel-specific tool names during the first migration. - Do not change session identity formats. - Do not weaken ACL, audience, principal, boundary, or provenance requirements. - Do not make SignalR pretend to be a remote chat workspace. diff --git a/openspec/changes/standardize-channel-transport-contracts/proposal.md b/openspec/changes/standardize-channel-transport-contracts/proposal.md index 5288a6132..2cb176f3b 100644 --- a/openspec/changes/standardize-channel-transport-contracts/proposal.md +++ b/openspec/changes/standardize-channel-transport-contracts/proposal.md @@ -33,8 +33,8 @@ Source PRDs/specs: `PRD-009-input-adapters-and-unified-input.md`, - Add standard address-resolution semantics so users, channels, rooms, threads, and destinations can be resolved by stable IDs or user-facing names. - Add standard LLM-facing tool intent schemas for send-message and lookup tools, - while keeping existing per-channel tool names as compatibility aliases during - migration. + allowing current per-channel tool names to be renamed to the standardized + surface during migration. - Change daemon runtime status and stats to enumerate registered descriptors instead of hard-coding individual adapters. - Define socket-adapter lifecycle requirements that Mattermost, Discord, Slack, @@ -65,5 +65,6 @@ Source PRDs/specs: `PRD-009-input-adapters-and-unified-input.md`, - **Reliability:** health and readiness become comparable across adapters. Stateful socket adapters expose reconnect and not-ready states through a common snapshot shape. -- **Compatibility:** existing channel-specific tools remain available while they - are mapped to standard tool intents. No session identity change is required. +- **Compatibility:** no session identity change is required. Existing + LLM-facing channel tool names may change as part of standardization; system + skills and evals must be updated when tool names change. diff --git a/openspec/changes/standardize-channel-transport-contracts/specs/netclaw-input-adapters/spec.md b/openspec/changes/standardize-channel-transport-contracts/specs/netclaw-input-adapters/spec.md index 18c98266f..09d38e852 100644 --- a/openspec/changes/standardize-channel-transport-contracts/specs/netclaw-input-adapters/spec.md +++ b/openspec/changes/standardize-channel-transport-contracts/specs/netclaw-input-adapters/spec.md @@ -99,20 +99,28 @@ Channel address resolution SHALL use a common resolver contract for supported address kinds, including users and destinations. Resolvers SHALL accept stable IDs and user-facing names where the backing platform supports them. +Each descriptor-backed adapter SHALL provide its own resolver for the address +namespaces it supports. The daemon SHALL route resolution requests to the +resolver associated with the selected descriptor or channel type. If no resolver +exists for the requested descriptor and address kind, resolution SHALL fail +loudly as unsupported. + Resolvers SHALL fail loudly on ambiguous names and unsupported address kinds. They SHALL NOT silently fall back from one namespace to another. #### Scenario: Exact stable ID resolves without search ambiguity -- **GIVEN** a send-message tool receives a destination value that is a stable - platform channel ID +- **GIVEN** a send-message tool receives a selected channel descriptor +- **AND** the destination value is a stable platform channel ID for that + descriptor - **WHEN** the resolver evaluates the destination - **THEN** it resolves the exact ID without display-name search -#### Scenario: Ambiguous display name fails with candidates +#### Scenario: Ambiguous user-facing query fails with candidates -- **GIVEN** two Mattermost channels have the same display name visible to the bot -- **WHEN** a lookup query uses that display name +- **GIVEN** the selected descriptor's resolver returns multiple user or + destination candidates for a user-facing query +- **WHEN** the lookup request requires a single resolved address - **THEN** resolution fails loudly - **AND** the result includes candidate stable IDs and display names @@ -126,9 +134,10 @@ They SHALL NOT silently fall back from one namespace to another. ### Requirement: LLM-facing channel tools use standard intent schemas LLM-facing channel tools SHALL map to standard tool intents for send message, -lookup user, and lookup destination. Existing channel-specific tool names MAY -remain during migration, but their arguments and behavior SHALL map to the -standard intent schema. +lookup user, and lookup destination. Existing channel-specific tool names are +not compatibility requirements and MAY be renamed during migration. When tool +names change, system skills and eval cases SHALL be updated in the same +implementation change. #### Scenario: Send-message tools share a common argument model @@ -139,13 +148,14 @@ standard intent schema. - **AND** unsupported options are omitted or reported as unsupported rather than silently ignored -#### Scenario: Legacy tool name remains as an alias +#### Scenario: Channel-specific tools are renamed to standard tools -- **GIVEN** existing sessions know about `send_slack_message` -- **WHEN** Slack tools are registered under the standard intent model -- **THEN** `send_slack_message` remains available as an alias or channel-specific - registration -- **AND** it maps to the same send-message intent used by other channels +- **GIVEN** Slack, Discord, and Mattermost have migrated to the standard intent + model +- **WHEN** LLM-facing channel tools are registered +- **THEN** the registered tool names follow the standardized naming plan +- **AND** obsolete per-channel names are not required as aliases unless a + concrete external compatibility requirement is documented ### Requirement: Stateful remote chat adapters expose reliable lifecycle state diff --git a/openspec/changes/standardize-channel-transport-contracts/tasks.md b/openspec/changes/standardize-channel-transport-contracts/tasks.md index d78684bab..e29a8b2b9 100644 --- a/openspec/changes/standardize-channel-transport-contracts/tasks.md +++ b/openspec/changes/standardize-channel-transport-contracts/tasks.md @@ -30,17 +30,18 @@ - [ ] 5.1 Add a standard address resolver contract for users and destinations. - [ ] 5.2 Support exact stable ID resolution before name search. - [ ] 5.3 Fail loudly with candidates for ambiguous display-name matches. -- [ ] 5.4 Wire Slack lookup to the standard resolver contract. -- [ ] 5.5 Wire Discord lookup to the standard resolver contract where supported. -- [ ] 5.6 Wire Mattermost lookup to the standard resolver contract. +- [ ] 5.4 Route resolution requests to the resolver registered for the selected descriptor or channel type. +- [ ] 5.5 Wire Slack lookup to its descriptor-scoped resolver. +- [ ] 5.6 Wire Discord lookup to its descriptor-scoped resolver where supported. +- [ ] 5.7 Wire Mattermost lookup to its descriptor-scoped resolver. ## 6. LLM-facing tool standardization -- [ ] 6.1 Define standard tool intent schemas for send message, lookup user, and lookup destination. -- [ ] 6.2 Map existing Slack tools to the standard intent schema while preserving their current names. -- [ ] 6.3 Map existing Discord tools to the standard intent schema while preserving their current names. -- [ ] 6.4 Map existing Mattermost tools to the standard intent schema while preserving their current names. -- [ ] 6.5 Decide whether the first generic tool is a single multi-channel tool or generated per-channel aliases after contract tests pass. +- [ ] 6.1 Define standard tool intent schemas and final tool names for send message, lookup user, and lookup destination. +- [ ] 6.2 Rename/map existing Slack tools to the standard tool names and intent schema. +- [ ] 6.3 Rename/map existing Discord tools to the standard tool names and intent schema. +- [ ] 6.4 Rename/map existing Mattermost tools to the standard tool names and intent schema. +- [ ] 6.5 Update system skills, CLI/help text, and eval cases for renamed LLM-facing channel tools. ## 7. Stateful transport lifecycle follow-up From 3e85486065e2e58b8aa81caea3acb7d82d03bc38 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 4 Jun 2026 12:51:33 +0000 Subject: [PATCH 04/31] Plan: document channel standardization terms --- .../design.md | 270 ++++++++++++++++++ 1 file changed, 270 insertions(+) diff --git a/openspec/changes/standardize-channel-transport-contracts/design.md b/openspec/changes/standardize-channel-transport-contracts/design.md index 9912f7e55..bb7b49425 100644 --- a/openspec/changes/standardize-channel-transport-contracts/design.md +++ b/openspec/changes/standardize-channel-transport-contracts/design.md @@ -15,6 +15,276 @@ longer the top-level change. It becomes one stateful-adapter task that should be implemented after Mattermost can report the same descriptor and runtime snapshot shape as Slack, Discord, and future remote chat adapters. +## Glossary And Abstractions + +These examples are illustrative contracts, not final type names. The intent is to +show the seam each term describes and how the daemon would use it. + +### Channel Descriptor + +A stable description of what an adapter, logical channel, or endpoint is and what +it can do. This is mostly configuration/capability metadata, not live health. + +Abstraction: + +```csharp +public sealed record ChannelDescriptor( + ChannelDescriptorKey Key, + ChannelType? ChannelType, + ChannelSourceKind Kind, + string DisplayName, + bool IsEnabled, + ChannelCapabilities Capabilities, + IReadOnlySet ToolIntents, + IReadOnlySet AddressKinds); + +public interface IChannelDescriptorProvider +{ + ChannelDescriptor GetDescriptor(); +} +``` + +Interaction: + +```csharp +var descriptor = slackDescriptorProvider.GetDescriptor(); + +if (descriptor.Capabilities.HasFlag(ChannelCapabilities.InteractiveApproval)) +{ + toolRegistry.IncludeApprovalAwareTools(descriptor.Key); +} +``` + +### Channel Source Kind + +The category of source represented by a descriptor. This avoids treating a +daemon endpoint such as SignalR as if it were a Slack-like remote workspace. + +Abstraction: + +```csharp +public enum ChannelSourceKind +{ + RemoteChatChannel, + LocalClientChannel, + DaemonEndpoint, + InternalSource, + HttpIngressSource, + NonInteractiveClient +} +``` + +Interaction: + +```csharp +var descriptors = registry.ListDescriptors(); +var endpoints = descriptors.Where(d => d.Kind == ChannelSourceKind.DaemonEndpoint); +var chatChannels = descriptors.Where(d => d.Kind == ChannelSourceKind.RemoteChatChannel); +``` + +### Channel Registry + +The daemon-owned index of descriptors, runtime snapshot providers, and address +resolvers. It routes operations to the selected descriptor instead of guessing +which platform a user meant. + +Abstraction: + +```csharp +public interface IChannelRegistry +{ + IReadOnlyCollection ListDescriptors(); + + ValueTask GetSnapshotAsync( + ChannelDescriptorKey key, + CancellationToken cancellationToken); + + IChannelAddressResolver GetResolver( + ChannelDescriptorKey key, + ChannelAddressKind addressKind); +} +``` + +Interaction: + +```csharp +foreach (var descriptor in registry.ListDescriptors()) +{ + var snapshot = await registry.GetSnapshotAsync(descriptor.Key, ct); + status.AddChannel(descriptor, snapshot); +} +``` + +### Runtime Snapshot + +A live, point-in-time report of an adapter or endpoint's current operational +state. This is not persisted state; it is used by status, stats, tool discovery, +and health reporting. + +Abstraction: + +```csharp +public sealed record ChannelRuntimeSnapshot( + ChannelDescriptorKey Key, + bool IsEnabled, + ChannelHealthStatus Health, + string? HealthDetail, + bool? IsConnected, + bool? IsReady, + ChannelPrincipal? Principal, + ChannelEndpointIdentity? Endpoint, + ChannelActivitySnapshot? Activity); + +public interface IChannelRuntimeSnapshotProvider +{ + ChannelDescriptorKey Key { get; } + + ValueTask GetSnapshotAsync( + CancellationToken cancellationToken); +} +``` + +Interaction: + +```csharp +var snapshot = await mattermostSnapshotProvider.GetSnapshotAsync(ct); + +if (snapshot is { IsEnabled: true, IsReady: false }) +{ + logger.LogWarning("Mattermost is not ready: {Detail}", snapshot.HealthDetail); +} +``` + +### Address Resolver + +A descriptor-scoped resolver for stable IDs and user-facing names. Slack, +Discord, Mattermost, SignalR, Reminder, and Webhook do not share one mega +resolver; each descriptor provides only the namespaces it supports. + +Abstraction: + +```csharp +public sealed record ChannelAddressResolutionRequest( + ChannelDescriptorKey ChannelKey, + ChannelAddressKind AddressKind, + string Query, + bool RequireSingleMatch); + +public sealed record ResolvedChannelAddress( + ChannelDescriptorKey ChannelKey, + ChannelAddressKind AddressKind, + string StableId, + string DisplayName); + +public interface IChannelAddressResolver +{ + ChannelDescriptorKey ChannelKey { get; } + + ValueTask ResolveAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken); +} +``` + +Interaction: + +```csharp +var resolver = registry.GetResolver(slackKey, ChannelAddressKind.Destination); + +var result = await resolver.ResolveAsync( + new ChannelAddressResolutionRequest( + slackKey, + ChannelAddressKind.Destination, + "#ops-alerts", + RequireSingleMatch: true), + ct); + +var destination = result.RequireSingle(); +``` + +### Tool Intent Schema + +The normalized argument model used by LLM-facing tools. Tool names can be +standardized, but adapter-specific execution still happens behind the registry +and descriptor-selected channel implementation. + +Abstraction: + +```csharp +public sealed record SendChannelMessageIntent( + ChannelDescriptorKey ChannelKey, + ResolvedChannelAddress Destination, + string Text, + string? ThreadOrRootId = null); + +public interface IChannelToolIntentExecutor +{ + ValueTask ExecuteAsync( + SendChannelMessageIntent intent, + ToolExecutionContext context, + CancellationToken cancellationToken); +} +``` + +Interaction: + +```csharp +var destination = await channelTools.ResolveDestinationAsync( + channelKey: mattermostKey, + query: "release-war-room", + cancellationToken: ct); + +await channelTools.SendMessageAsync( + new SendChannelMessageIntent( + mattermostKey, + destination, + "Deploy finished successfully."), + toolContext, + ct); +``` + +### Stateful Transport Lifecycle Owner + +The adapter-specific component that serializes socket/API lifecycle state for a +remote chat adapter. It can be an actor, hosted-service state machine, SDK +facade, or another single owner. The standard contract is the observable +snapshot and lifecycle behavior, not the implementation shape. + +Abstraction: + +```csharp +public interface IStatefulChannelLifecycleOwner +{ + event Func? CleanReconnectRequired; + + ValueTask ConnectAsync( + CancellationToken cancellationToken); + + ValueTask DisconnectAsync(CancellationToken cancellationToken); + + ValueTask GetSnapshotAsync( + CancellationToken cancellationToken); +} +``` + +Interaction: + +```csharp +lifecycle.CleanReconnectRequired += reason => +{ + reconnectQueue.Enqueue(new CleanReconnectRequest(channelKey, reason)); + return Task.CompletedTask; +}; + +var snapshot = await lifecycle.GetSnapshotAsync(ct); + +if (snapshot.IsReady != true) +{ + ingressMetrics.RecordFilteredWhileNotReady(channelKey); + return; +} +``` + ## Transport And Channel Taxonomy Netclaw needs to distinguish logical conversation sources from process/network From 22e8aa4d919e92492dc5f5dcaa40fced96f0dacc Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 4 Jun 2026 13:06:29 +0000 Subject: [PATCH 05/31] Plan: standardize channel delivery contracts --- .../design.md | 554 ++++++++++++++++++ .../proposal.md | 78 +++ .../specs/netclaw-input-adapters/spec.md | 232 ++++++++ .../tasks.md | 72 +++ .../design.md | 449 -------------- .../proposal.md | 70 --- .../specs/netclaw-input-adapters/spec.md | 190 ------ .../tasks.md | 57 -- 8 files changed, 936 insertions(+), 766 deletions(-) create mode 100644 openspec/changes/standardize-channel-delivery-contracts/design.md create mode 100644 openspec/changes/standardize-channel-delivery-contracts/proposal.md create mode 100644 openspec/changes/standardize-channel-delivery-contracts/specs/netclaw-input-adapters/spec.md create mode 100644 openspec/changes/standardize-channel-delivery-contracts/tasks.md delete mode 100644 openspec/changes/standardize-channel-transport-contracts/design.md delete mode 100644 openspec/changes/standardize-channel-transport-contracts/proposal.md delete mode 100644 openspec/changes/standardize-channel-transport-contracts/specs/netclaw-input-adapters/spec.md delete mode 100644 openspec/changes/standardize-channel-transport-contracts/tasks.md diff --git a/openspec/changes/standardize-channel-delivery-contracts/design.md b/openspec/changes/standardize-channel-delivery-contracts/design.md new file mode 100644 index 000000000..35fe7d765 --- /dev/null +++ b/openspec/changes/standardize-channel-delivery-contracts/design.md @@ -0,0 +1,554 @@ +# Design + +## Decision + +Standardize channel delivery, not every transport. A channel is an addressable +output-capable conversation surface. Some channels also produce input. Trigger +sources such as reminders and webhooks produce input but are not channels; when +they need output, they must target a real channel delivery target. + +The core flow is: + +```text +InputSource -> Session Turn -> ChannelDeliveryTarget -> OutputChannel +``` + +Adapters keep platform-specific implementation details. The daemon and +LLM-facing tool registry consume standard channel descriptors, runtime snapshots, +address resolvers, and delivery target contracts. + +Mattermost lifecycle actorization remains a valid reliability fix, but it is not +the top-level goal. It becomes one stateful-channel task after Mattermost can +report the same descriptor and runtime snapshot shape as Slack, Discord, and +future remote chat channels. + +## Glossary And Abstractions + +These examples are illustrative contracts, not final type names. The intent is to +show the seam each term describes and how the daemon would use it. + +### Input Source + +Anything that can start a session turn. A channel can be an input source, but not +all input sources are channels. + +Examples: Slack message, Discord message, Mattermost message, TUI user input, +reminder fire, webhook event. + +Abstraction: + +```csharp +public sealed record InputSourceDescriptor( + InputSourceKey Key, + InputSourceKind Kind, + string DisplayName, + ChannelDescriptorKey? OriginatingChannel = null); + +public enum InputSourceKind +{ + ChannelIngress, + LocalClientIngress, + TriggerSource +} +``` + +Interaction: + +```csharp +var input = new ChannelInput( + Text: "restart the build", + Source: slackSource, + DefaultDeliveryTarget: slackThreadTarget, + TrustContext: resolvedTrustContext); + +await sessionPipeline.SendAsync(input, ct); +``` + +### Channel / Output Channel + +An addressable delivery surface Netclaw can emit output through. A channel may +also receive input. Channels participate in the unified channel model because +they can deliver output. + +Examples: Slack, Discord, Mattermost, TUI session. + +Abstraction: + +```csharp +public sealed record ChannelDescriptor( + ChannelDescriptorKey Key, + ChannelType ChannelType, + ChannelKind Kind, + string DisplayName, + bool IsEnabled, + ChannelCapabilities Capabilities, + IReadOnlySet ToolIntents, + IReadOnlySet AddressKinds); + +public enum ChannelKind +{ + RemoteChat, + LocalInteractiveClient +} + +public interface IChannelDescriptorProvider +{ + ChannelDescriptor GetDescriptor(); +} +``` + +Interaction: + +```csharp +var descriptor = slackDescriptorProvider.GetDescriptor(); + +if (descriptor.Capabilities.HasFlag(ChannelCapabilities.InteractiveApproval)) +{ + toolRegistry.IncludeApprovalAwareTools(descriptor.Key); +} +``` + +### Trigger Source + +A non-channel input source that can start a session turn but cannot deliver +conversational output by itself. + +Examples: reminder, scheduler, webhook route. + +Abstraction: + +```csharp +public sealed record TriggerInput( + InputSourceDescriptor Source, + string Prompt, + ChannelDeliveryTarget? RequestedDeliveryTarget); +``` + +Interaction: + +```csharp +var trigger = reminderScheduler.Fire(reminderId); + +var input = ChannelInput.FromTrigger( + trigger.Prompt, + source: trigger.Source, + requestedDeliveryTarget: trigger.RequestedDeliveryTarget, + trustContext: triggerTrustContext); + +await sessionPipeline.SendAsync(input, ct); +``` + +### Channel Delivery Target + +The resolved destination for output. It always points at a real channel and a +channel-specific destination, optionally with thread/root context. + +For channel-originated input, the default delivery target usually comes from the +originating conversation. For trigger-originated input, the target must be +configured or selected when output should be emitted. + +Abstraction: + +```csharp +public sealed record ChannelDeliveryTarget( + ChannelDescriptorKey ChannelKey, + ResolvedChannelAddress Destination, + string? ThreadOrRootId = null); +``` + +Interaction: + +```csharp +var target = input.DefaultDeliveryTarget + ?? input.RequestedDeliveryTarget + ?? throw new InvalidOperationException( + "This input source requested output but no channel delivery target was configured."); + +await channelDelivery.SendAsync(target, sessionOutput, ct); +``` + +### Channel Registry + +The daemon-owned index of output-capable channel descriptors, runtime snapshot +providers, and address resolvers. It does not register reminders or webhooks as +channels. Trigger sources consume this registry when they need delivery. + +Abstraction: + +```csharp +public interface IChannelRegistry +{ + IReadOnlyCollection ListChannels(); + + ValueTask GetSnapshotAsync( + ChannelDescriptorKey key, + CancellationToken cancellationToken); + + IChannelAddressResolver GetResolver( + ChannelDescriptorKey key, + ChannelAddressKind addressKind); +} +``` + +Interaction: + +```csharp +foreach (var channel in registry.ListChannels()) +{ + var snapshot = await registry.GetSnapshotAsync(channel.Key, ct); + status.AddChannel(channel, snapshot); +} +``` + +### Runtime Snapshot + +A live, point-in-time report of an output channel's current operational state. +This is not persisted state; it is used by status, stats, tool discovery, and +health reporting. + +Abstraction: + +```csharp +public sealed record ChannelRuntimeSnapshot( + ChannelDescriptorKey Key, + bool IsEnabled, + ChannelHealthStatus Health, + string? HealthDetail, + bool? IsConnected, + bool? IsReady, + ChannelPrincipal? Principal, + ChannelActivitySnapshot? Activity); + +public interface IChannelRuntimeSnapshotProvider +{ + ChannelDescriptorKey Key { get; } + + ValueTask GetSnapshotAsync( + CancellationToken cancellationToken); +} +``` + +Interaction: + +```csharp +var snapshot = await mattermostSnapshotProvider.GetSnapshotAsync(ct); + +if (snapshot is { IsEnabled: true, IsReady: false }) +{ + logger.LogWarning("Mattermost is not ready: {Detail}", snapshot.HealthDetail); +} +``` + +### Address Resolver + +A channel-scoped resolver for stable IDs and user-facing names. Slack, Discord, +Mattermost, and TUI do not share one mega resolver; each channel provides only +the namespaces it supports. + +Abstraction: + +```csharp +public sealed record ChannelAddressResolutionRequest( + ChannelDescriptorKey ChannelKey, + ChannelAddressKind AddressKind, + string Query, + bool RequireSingleMatch); + +public sealed record ResolvedChannelAddress( + ChannelDescriptorKey ChannelKey, + ChannelAddressKind AddressKind, + string StableId, + string DisplayName); + +public interface IChannelAddressResolver +{ + ChannelDescriptorKey ChannelKey { get; } + + ValueTask ResolveAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken); +} +``` + +Interaction: + +```csharp +var resolver = registry.GetResolver(slackKey, ChannelAddressKind.Destination); + +var result = await resolver.ResolveAsync( + new ChannelAddressResolutionRequest( + slackKey, + ChannelAddressKind.Destination, + "#ops-alerts", + RequireSingleMatch: true), + ct); + +var destination = result.RequireSingle(); +``` + +### Tool Intent Schema + +The normalized argument model used by LLM-facing channel tools. Tool names can be +standardized, but adapter-specific execution still happens behind the registry +and selected channel implementation. + +Abstraction: + +```csharp +public sealed record SendChannelMessageIntent( + ChannelDeliveryTarget Target, + string Text); + +public interface IChannelToolIntentExecutor +{ + ValueTask ExecuteAsync( + SendChannelMessageIntent intent, + ToolExecutionContext context, + CancellationToken cancellationToken); +} +``` + +Interaction: + +```csharp +var destination = await channelTools.ResolveDestinationAsync( + channelKey: mattermostKey, + query: "release-war-room", + cancellationToken: ct); + +await channelTools.SendMessageAsync( + new SendChannelMessageIntent( + new ChannelDeliveryTarget(mattermostKey, destination), + "Deploy finished successfully."), + toolContext, + ct); +``` + +### Stateful Channel Lifecycle Owner + +The adapter-specific component that serializes socket/API lifecycle state for a +remote chat channel. It can be an actor, hosted-service state machine, SDK +facade, or another single owner. The standard contract is the observable +snapshot and lifecycle behavior, not the implementation shape. + +Abstraction: + +```csharp +public interface IStatefulChannelLifecycleOwner +{ + event Func? CleanReconnectRequired; + + ValueTask ConnectAsync( + CancellationToken cancellationToken); + + ValueTask DisconnectAsync(CancellationToken cancellationToken); + + ValueTask GetSnapshotAsync( + CancellationToken cancellationToken); +} +``` + +Interaction: + +```csharp +lifecycle.CleanReconnectRequired += reason => +{ + reconnectQueue.Enqueue(new CleanReconnectRequest(channelKey, reason)); + return Task.CompletedTask; +}; + +var snapshot = await lifecycle.GetSnapshotAsync(ct); + +if (snapshot.IsReady != true) +{ + ingressMetrics.RecordFilteredWhileNotReady(channelKey); + return; +} +``` + +## Taxonomy + +Netclaw needs to distinguish output-capable channels from input-only trigger +sources and daemon infrastructure endpoints. + +| Kind | Examples | Meaning | +|------|----------|---------| +| Remote chat channel | Slack, Discord, Mattermost | External workspace/server channel that can deliver output and may receive input. | +| Local interactive channel | TUI, future web UI session | Local conversation surface that can deliver output and receive user input through daemon infrastructure. | +| Trigger source | Reminder, scheduler, webhook route | Input-only mechanism that starts a turn and consumes channel delivery targets when output is requested. | +| Daemon endpoint | SignalR hub | Infrastructure endpoint used by local clients. It has endpoint health, but it is not itself a channel delivery surface. | + +Channels participate in the channel registry. Trigger sources do not. Daemon +endpoints may have operational status, but they do not advertise channel send, +lookup, or lifecycle capabilities unless represented by a logical channel such +as TUI. + +## Component Diagram + +```mermaid +flowchart TD + Slack[Slack channel] -->|input + output| Pipeline[SessionPipeline] + Discord[Discord channel] -->|input + output| Pipeline + Mattermost[Mattermost channel] -->|input + output| Pipeline + TUI[TUI local channel] -->|input + output via SignalR| Pipeline + + Reminder[Reminder trigger] -->|input only| Pipeline + Webhook[Webhook trigger] -->|input only| Pipeline + + Pipeline --> Session[LlmSessionActor] + Session --> Target[ChannelDeliveryTarget] + Target --> Registry[Channel registry] + Registry --> Slack + Registry --> Discord + Registry --> Mattermost + Registry --> TUI + + Registry --> Status[Daemon channel status] + Registry --> Stats[Daemon channel stats] + Registry --> Tools[LLM channel tools] +``` + +## Standard Channel Descriptor Shape + +Each output-capable channel reports a stable descriptor with these concepts: + +- Stable key, channel type, display name, and channel kind. +- Whether it is enabled by configuration. +- Capabilities: receive messages, send messages, direct messages, threaded + conversations, interactive approvals, file ingress, file egress, user lookup, + destination lookup, proactive messages, and runtime health. +- Tool intents it supports, such as send message, lookup user, and lookup + destination. +- Address namespaces it can resolve, such as user, channel, room, thread, DM, or + local session. + +The descriptor describes what the channel promises. It must not grant ACL +permissions by itself. Actual turn authorization continues to flow through +`ChannelInput` trust context and existing policy checks. + +## Runtime Snapshot Shape + +Each output-capable channel reports a runtime snapshot with these concepts: + +- Channel descriptor key and channel type. +- Enabled state. +- Health status and detail. +- Connected state when meaningful. +- Ready state when meaningful. +- Bot or service principal identity when the channel has one. +- Last known activity counters or timestamps when available. + +Ready is channel-specific but comparable. For a remote socket channel, ready +means it can accept inbound events and send replies. For a local interactive +channel, ready means the session endpoint can route messages and render output. + +## Trigger Source Output Routing + +Reminders and webhooks are consumers of the channel delivery model. They create +input turns, but they do not deliver output themselves. + +Rules: + +- A trigger source that expects external output SHALL carry a configured or + selected `ChannelDeliveryTarget`. +- A trigger source without a delivery target MAY create a fire-and-forget turn. +- If a trigger-originated turn attempts to emit external output without a target, + Netclaw SHALL fail loudly instead of selecting a default channel. +- Trigger source status and route configuration MAY be reported by trigger + subsystems, but trigger sources SHALL NOT be registered as channel descriptors. + +## Address Resolution + +Address resolution is standardized as a channel delivery intent, not as one +platform's ID model. Each channel provides its own resolver for the address +namespaces it supports. The channel registry routes a resolution request to the +resolver associated with the selected channel; it does not use a global resolver +that guesses across platforms. + +Rules: + +- Stable IDs are accepted when supplied. +- User-facing names are searchable where the backing platform supports it. +- Ambiguous names fail loudly with candidates instead of choosing the first + match. +- Unsupported address kinds fail loudly for the selected channel. +- Resolvers do not silently fall back from one namespace to another. +- Resolved addresses carry both display data and stable platform IDs. + +## LLM-Facing Channel Tool Intents + +The tool registry should describe channel tools in terms of standard intents: + +- `send_channel_message`: channel key, destination, text, optional thread/root + target, optional audience/context hints. +- `lookup_channel_user`: channel key, query, optional exact-only flag. +- `lookup_channel_destination`: channel key, query, destination kind, optional + exact-only flag. + +Existing tool names such as `send_slack_message`, `send_discord_message`, and +`send_mattermost_message` are not compatibility requirements. The implementation +may rename them to the standardized tool names once the registry can enumerate +channels and resolvers reliably. System skills, CLI/help text, and evals must be +updated in the same implementation change when tool names change. + +## Stateful Channel Lifecycle + +Stateful remote chat channels must expose lifecycle through the standard runtime +snapshot. They may implement that lifecycle with actors, hosted services, SDK +callbacks, or another serialized owner, but the observable behavior must be the +same: + +- Health reports disconnected, connecting, ready, degraded, and not-ready states + consistently. +- Ingress is gated while the channel is not ready. +- Reconnects do not duplicate SDK event handlers. +- Unexpected disconnects can request a clean reconnect when the platform SDK + requires a full stop/start cycle. + +Mattermost likely needs an actor-owned lifecycle implementation to satisfy these +requirements. That should be implemented after the standardized snapshot shape +exists. + +## Migration Plan + +1. Add channel descriptor, runtime snapshot, delivery target, + address-resolution, and tool-intent contracts without changing adapter + behavior. +2. Add contract tests that enumerate output-capable channels and verify that + reminders/webhooks are not registered as channels. +3. Adapt existing Slack, Discord, Mattermost, and TUI surfaces to report channel + descriptors and snapshots using their current behavior. +4. Update reminder and webhook definitions so any requested external output uses + explicit `ChannelDeliveryTarget` values. +5. Change daemon runtime status and stats to consume the channel registry instead + of hard-coded Slack/Discord lists. +6. Normalize Slack, Discord, and Mattermost send/lookup tools onto standard + channel intent schemas and rename current per-channel tools where needed. +7. Add name-searchable user and destination resolvers for supported channels. +8. Only after descriptors and snapshots are stable, implement adapter-specific + lifecycle fixes such as Mattermost actorization. + +## Non-Goals + +- Do not rewrite all channel adapters in one pass. +- Do not create a generic channel base actor in this change. +- Do not register reminders or webhooks as channels. +- Do not make SignalR pretend to be a channel delivery surface. +- Do not change session identity formats. +- Do not weaken ACL, audience, principal, boundary, or provenance requirements. + +## Risks / Trade-offs + +- A descriptor model can become too abstract. Keep it tied to current runtime + consumers: status, stats, tools, health, delivery, and address resolution. +- Generic tools can hide platform-specific constraints. Preserve channel + capability flags and fail loudly when a requested intent is unsupported. +- SignalR needs special handling because it is daemon infrastructure used by + local logical channels. Treat endpoint health and TUI channel capability as + separate records. +- Trigger sources need explicit delivery targets. This may surface existing + implicit-output assumptions in reminder or webhook flows, but failing loudly is + safer than silently selecting a default channel. +- Mattermost lifecycle remains a reliability risk until actorized, but delaying + it avoids changing adapter internals before the shared delivery seam is + defined. diff --git a/openspec/changes/standardize-channel-delivery-contracts/proposal.md b/openspec/changes/standardize-channel-delivery-contracts/proposal.md new file mode 100644 index 000000000..020670b99 --- /dev/null +++ b/openspec/changes/standardize-channel-delivery-contracts/proposal.md @@ -0,0 +1,78 @@ +## Why + +`PRD-009` defines the input-adapter principle: every input creates the same kind +of session turn, with source metadata and instructions carrying the differences. +The current implementation has largely achieved that at the `ChannelInput` seam, +but delivery still drifts by adapter. + +The problem is not that all transports need the same lifecycle implementation. +The problem is that Netclaw does not yet have a standard model for addressable +channels as output-capable delivery surfaces. + +Current gaps include: + +- Slack, Discord, and Mattermost expose different LLM-facing send and lookup + tool shapes. +- User and destination lookup is inconsistent and often ID-first instead of + name-searchable. +- Runtime status and stats are partially hard-coded to specific channel adapters. +- `ChannelType` currently mixes conversation channels, daemon endpoints, local + clients, and trigger sources, which makes channel delivery semantics unclear. +- Reminders and webhooks can create input turns, but they need real channel + delivery targets when output should be emitted. +- Stateful remote chat channels expose different lifecycle health semantics. + +Source PRDs/specs: `PRD-009-input-adapters-and-unified-input.md`, +`SPEC-011-daemon-architecture.md`, `openspec/specs/netclaw-input-adapters/spec.md`. + +## What Changes + +- Define a channel as an addressable output-capable delivery surface that MAY + also produce input. +- Separate input sources from output channels: Slack/Discord/Mattermost/TUI can + be both, while reminders and webhooks are trigger sources that consume the + channel delivery model. +- Add a standard channel descriptor contract for output-capable channels. +- Add a standard channel runtime snapshot contract for health, readiness, + enabled state, endpoint identity, bot identity, and activity reporting. +- Add a standard channel delivery target model for sending output through a + channel destination and optional thread/root. +- Add descriptor-scoped address resolution so users, rooms, channels, DMs, + threads, and destinations can be resolved by stable IDs or user-facing names. +- Add standard LLM-facing tool intent schemas for channel send and lookup tools, + allowing current per-channel tool names to be renamed to the standardized + surface during migration. +- Change daemon runtime status and stats to enumerate registered channel + descriptors instead of hard-coding individual channel adapters. +- Define stateful remote chat channel lifecycle requirements that Mattermost, + Discord, Slack, and future remote chat channels can satisfy without requiring + a shared base actor. + +## Capabilities + +### New Capabilities + + + +### Modified Capabilities + +- `netclaw-input-adapters`: Add requirements for standardized channel delivery + descriptors, runtime snapshots, delivery targets, address resolution, + LLM-facing channel tool intents, descriptor-driven observability, trigger-source + output routing, and reliable stateful channel lifecycle reporting. + +## Impact + +- **Affected systems:** channel abstractions, daemon runtime status, daemon + stats, Slack/Discord/Mattermost LLM tools, channel user/destination lookup, + reminder/webhook delivery target handling, channel contract tests, and + stateful channel lifecycle tests. +- **Security:** no new ACL bypass. Channel descriptors and delivery targets must + preserve the explicit audience, principal, boundary, and provenance already + required by `ChannelInput` and `MessageSource`. +- **Reliability:** health and readiness become comparable across output-capable + channels. Stateful remote chat channels expose reconnect and not-ready states + through a common snapshot shape. +- **Compatibility:** no session identity change is required. Existing + LLM-facing channel tool names may change as part of standardization; system + skills and evals must be updated when tool names change. diff --git a/openspec/changes/standardize-channel-delivery-contracts/specs/netclaw-input-adapters/spec.md b/openspec/changes/standardize-channel-delivery-contracts/specs/netclaw-input-adapters/spec.md new file mode 100644 index 000000000..b331ab7e6 --- /dev/null +++ b/openspec/changes/standardize-channel-delivery-contracts/specs/netclaw-input-adapters/spec.md @@ -0,0 +1,232 @@ +## ADDED Requirements + +### Requirement: Channels are output-capable delivery surfaces + +Netclaw SHALL define a channel as an addressable delivery surface that can emit +output and MAY also produce input. + +Each output-capable channel SHALL expose a descriptor through the channel +registry. The descriptor SHALL declare the stable key, channel type, channel +kind, display name, enabled state, capabilities, supported tool intents, and +supported address namespaces. + +Descriptors SHALL NOT grant permissions. ACL decisions SHALL continue to use the +explicit audience, principal, boundary, and provenance carried on `ChannelInput` +and `MessageSource`. + +#### Scenario: Output-capable channels are represented + +- **GIVEN** the daemon has loaded channel integrations +- **WHEN** the channel registry is enumerated +- **THEN** Slack, Discord, Mattermost, and TUI are represented by descriptors or + explicit unsupported/not-configured channel records +- **AND** each record declares whether it is a remote chat channel or a local + interactive channel + +#### Scenario: Channels may act as input sources + +- **GIVEN** Slack receives a message in a thread +- **WHEN** Slack constructs the session input +- **THEN** the input source identifies Slack channel ingress +- **AND** the input includes a default delivery target for the originating Slack + channel/thread + +#### Scenario: Descriptor capabilities do not bypass ACL + +- **GIVEN** a descriptor declares that a channel supports proactive send +- **WHEN** a session turn is authorized +- **THEN** tool access still evaluates the turn's explicit trust context +- **AND** the descriptor capability is not treated as an ACL grant + +### Requirement: Trigger sources consume channel delivery targets + +Reminders, schedulers, and webhooks SHALL be modeled as trigger sources, not +channels. A trigger source SHALL NOT register a channel descriptor or channel +runtime snapshot provider. + +A trigger source that requests external output SHALL provide an explicit +`ChannelDeliveryTarget`. If no delivery target is provided, the turn MAY be +fire-and-forget, but any attempt to emit external output SHALL fail loudly. + +#### Scenario: Reminder emits through configured channel target + +- **GIVEN** a reminder is configured with a Slack delivery target +- **WHEN** the reminder fires and the session emits output +- **THEN** Netclaw resolves the Slack delivery target through the channel + registry +- **AND** output is delivered through Slack +- **AND** the reminder is not treated as an output channel + +#### Scenario: Webhook emits through configured channel target + +- **GIVEN** a GitHub webhook route is configured with a Mattermost delivery + target +- **WHEN** the webhook receives an event and the session emits output +- **THEN** Netclaw resolves the Mattermost delivery target through the channel + registry +- **AND** output is delivered through Mattermost +- **AND** the webhook is not treated as an output channel + +#### Scenario: Trigger source without target cannot emit external output + +- **GIVEN** a webhook route has no delivery target +- **WHEN** the webhook-created session attempts to emit external output +- **THEN** Netclaw fails loudly with a missing delivery target error +- **AND** no default channel is selected + +### Requirement: Channel runtime health uses standardized snapshots + +Every descriptor-backed output channel SHALL expose a runtime snapshot that +reports enabled state, health status, health detail, connected state when +meaningful, ready state when meaningful, service principal identity when +available, and activity metadata when available. + +#### Scenario: Ready remote chat channel reports healthy + +- **GIVEN** Slack, Discord, or Mattermost is enabled and ready to receive inbound + events and send replies +- **WHEN** runtime snapshots are enumerated +- **THEN** the channel snapshot reports enabled and healthy +- **AND** connected and ready are true when those states are meaningful for the + channel + +#### Scenario: Connected but not-ready channel reports degraded + +- **GIVEN** a stateful remote chat channel has a socket connection but cannot + safely route inbound events +- **WHEN** its runtime snapshot is requested +- **THEN** connected is true +- **AND** ready is false +- **AND** health is degraded with a detail explaining the not-ready condition + +#### Scenario: Disabled channel reports configured disabled state + +- **GIVEN** a channel is disabled by configuration +- **WHEN** its runtime snapshot is requested +- **THEN** enabled is false +- **AND** health reports a disabled or degraded state without attempting a + transport connection + +### Requirement: Channel status and stats are descriptor-driven + +Daemon channel runtime status and daemon channel stats SHALL enumerate channel +descriptors and runtime snapshots rather than hard-coding specific channel +adapters. Trigger-source status MAY be reported separately, but SHALL NOT be +merged into channel status as a channel descriptor. + +#### Scenario: Newly registered channel appears in status without status-service changes + +- **GIVEN** a new output-capable channel registers a descriptor and runtime + snapshot provider +- **WHEN** daemon runtime status is requested +- **THEN** the channel appears in the channel status collection +- **AND** no channel-specific branch is required in the status service + +#### Scenario: Channel activity includes descriptor-backed channels + +- **GIVEN** Slack, Discord, and Mattermost have recorded channel activity +- **WHEN** daemon stats are requested +- **THEN** activity for all three channels is included through descriptor-backed + enumeration + +#### Scenario: Trigger source status is separate from channel status + +- **GIVEN** reminder scheduling status is available +- **WHEN** daemon runtime status is requested +- **THEN** reminder status MAY appear in a trigger-source or scheduler section +- **AND** it does not appear as a channel descriptor + +### Requirement: Channel address resolution accepts IDs and user-facing names + +Channel address resolution SHALL use a common resolver contract for supported +address kinds, including users and destinations. Resolvers SHALL accept stable +IDs and user-facing names where the backing channel supports them. + +Each descriptor-backed channel SHALL provide its own resolver for the address +namespaces it supports. The daemon SHALL route resolution requests to the +resolver associated with the selected channel descriptor. If no resolver exists +for the requested channel and address kind, resolution SHALL fail loudly as +unsupported. + +Resolvers SHALL fail loudly on ambiguous names and unsupported address kinds. +They SHALL NOT silently fall back from one namespace to another. + +#### Scenario: Exact stable ID resolves without search ambiguity + +- **GIVEN** a send-message tool receives a selected channel descriptor +- **AND** the destination value is a stable platform channel ID for that channel +- **WHEN** the resolver evaluates the destination +- **THEN** it resolves the exact ID without display-name search + +#### Scenario: Ambiguous user-facing query fails with candidates + +- **GIVEN** the selected channel resolver returns multiple user or destination + candidates for a user-facing query +- **WHEN** the lookup request requires a single resolved address +- **THEN** resolution fails loudly +- **AND** the result includes candidate stable IDs and display names + +#### Scenario: User lookup resolves by display query + +- **GIVEN** Slack, Discord, or Mattermost supports user lookup +- **WHEN** an LLM-facing lookup tool searches for a user-facing name +- **THEN** the resolver returns matching users with stable IDs and display data +- **AND** callers can pass the stable ID to send-message or DM-capable tools + +### Requirement: LLM-facing channel tools use standard delivery intents + +LLM-facing channel tools SHALL map to standard channel delivery intents for send +message, lookup user, and lookup destination. Existing channel-specific tool +names are not compatibility requirements and MAY be renamed during migration. +When tool names change, system skills and eval cases SHALL be updated in the same +implementation change. + +#### Scenario: Send-message tools share a common channel target model + +- **GIVEN** Slack, Discord, and Mattermost expose send-message tools +- **WHEN** their tool definitions are inspected +- **THEN** each tool accepts a channel key, destination, text, and optional thread + or root target using the standard send-channel-message intent schema +- **AND** unsupported options are omitted or reported as unsupported rather than + silently ignored + +#### Scenario: Channel-specific tools are renamed to standard tools + +- **GIVEN** Slack, Discord, and Mattermost have migrated to the standard channel + delivery intent model +- **WHEN** LLM-facing channel tools are registered +- **THEN** the registered tool names follow the standardized naming plan +- **AND** obsolete per-channel names are not required as aliases unless a + concrete external compatibility requirement is documented + +### Requirement: Stateful remote chat channels expose reliable lifecycle state + +Stateful remote chat channels SHALL expose lifecycle state through their runtime +snapshot and SHALL gate inbound events while not ready. Reconnects SHALL NOT +duplicate transport SDK event handlers. Unexpected disconnects SHALL be reported +as disconnected or degraded state and MAY request a clean reconnect when a full +transport restart is required. + +#### Scenario: Not-ready ingress is gated + +- **GIVEN** a stateful remote chat channel is disconnected or connecting +- **WHEN** the platform SDK raises an inbound message event +- **THEN** the event is not routed to the session pipeline +- **AND** the channel records or logs that ingress was filtered while not ready + +#### Scenario: Reconnect does not duplicate SDK handlers + +- **GIVEN** a stateful remote chat channel completes a connect, disconnect, and + reconnect cycle +- **WHEN** the platform SDK raises one message event +- **THEN** Netclaw publishes exactly one normalized gateway message +- **AND** SDK event handlers have not been subscribed more than once + +#### Scenario: Mattermost lifecycle implementation satisfies the common contract + +- **GIVEN** Mattermost implements the standardized runtime snapshot contract +- **WHEN** Mattermost is actorized or otherwise given a serialized lifecycle + owner +- **THEN** it satisfies the same not-ready ingress, disconnect health, clean + reconnect, and handler de-duplication scenarios as other stateful remote chat + channels diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md new file mode 100644 index 000000000..97d927ae2 --- /dev/null +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -0,0 +1,72 @@ +## 1. OpenSpec planning artifacts + +- [ ] 1.1 Confirm proposal, design, and spec delta define channels as output-capable delivery surfaces that may also produce input. +- [ ] 1.2 Confirm reminders and webhooks are represented as trigger consumers of channel delivery targets, not channel registry participants. +- [ ] 1.3 Confirm Mattermost actorization is represented as an adapter-specific lifecycle task, not the top-level change. +- [ ] 1.4 Run `openspec validate standardize-channel-delivery-contracts --type change` and resolve all issues. + +## 2. Channel descriptor and snapshot contracts + +- [ ] 2.1 Add a standard channel descriptor model for output-capable remote chat and local interactive channels. +- [ ] 2.2 Add capability flags for receive, send, DM, threaded conversations, interactive approval, file ingress, file egress, proactive send, user lookup, destination lookup, and runtime health. +- [ ] 2.3 Add a standard channel runtime snapshot model with enabled, health, connected, ready, principal identity, and activity metadata. +- [ ] 2.4 Add a channel registry service that enumerates descriptor and snapshot providers for output-capable channels only. + +## 3. Delivery target contracts + +- [ ] 3.1 Add `ChannelDeliveryTarget` with channel key, resolved destination, and optional thread/root target. +- [ ] 3.2 Preserve channel-originated default delivery targets for Slack, Discord, Mattermost, and TUI input turns. +- [ ] 3.3 Require trigger-originated turns to carry an explicit delivery target when external output is requested. +- [ ] 3.4 Fail loudly when a trigger-originated turn attempts external output without a delivery target. + +## 4. Existing channel coverage + +- [ ] 4.1 Register channel descriptors for Slack, Discord, Mattermost, and TUI or explicitly mark unsupported/not-configured output channels. +- [ ] 4.2 Adapt Slack runtime health to the standard snapshot shape without changing Slack behavior. +- [ ] 4.3 Adapt Discord runtime health to the standard snapshot shape without changing Discord behavior. +- [ ] 4.4 Adapt Mattermost runtime health to the standard snapshot shape without actorizing it yet. +- [ ] 4.5 Represent TUI as a local interactive channel and SignalR as daemon infrastructure, not as the same channel record. + +## 5. Trigger-source consumers + +- [ ] 5.1 Update reminder definitions to store or resolve explicit channel delivery targets when output is requested. +- [ ] 5.2 Update webhook route definitions to store or resolve explicit channel delivery targets when output is requested. +- [ ] 5.3 Ensure reminders and webhooks do not register channel descriptors or channel snapshot providers. + +## 6. Descriptor-driven observability + +- [ ] 6.1 Change daemon runtime status to enumerate the channel registry instead of hard-coding individual channel adapters. +- [ ] 6.2 Change daemon stats channel activity to enumerate descriptor-backed output channels. +- [ ] 6.3 Keep trigger-source status separate from channel status when reminder or webhook operational state is reported. +- [ ] 6.4 Preserve current status/stats output fields or provide explicit compatibility mapping. + +## 7. Address resolution + +- [ ] 7.1 Add a standard channel address resolver contract for users and destinations. +- [ ] 7.2 Support exact stable ID resolution before name search. +- [ ] 7.3 Fail loudly with candidates for ambiguous display-name matches. +- [ ] 7.4 Route resolution requests to the resolver registered for the selected channel descriptor. +- [ ] 7.5 Wire Slack lookup to its channel-scoped resolver. +- [ ] 7.6 Wire Discord lookup to its channel-scoped resolver where supported. +- [ ] 7.7 Wire Mattermost lookup to its channel-scoped resolver. + +## 8. LLM-facing channel tool standardization + +- [ ] 8.1 Define standard tool intent schemas and final tool names for send channel message, lookup channel user, and lookup channel destination. +- [ ] 8.2 Rename/map existing Slack tools to the standard tool names and intent schema. +- [ ] 8.3 Rename/map existing Discord tools to the standard tool names and intent schema. +- [ ] 8.4 Rename/map existing Mattermost tools to the standard tool names and intent schema. +- [ ] 8.5 Update system skills, CLI/help text, and eval cases for renamed LLM-facing channel tools. + +## 9. Stateful channel lifecycle follow-up + +- [ ] 9.1 Add contract tests for not-ready ingress gating, runtime disconnect health, clean reconnect signaling, and handler de-duplication for stateful remote chat channels. +- [ ] 9.2 Implement Mattermost lifecycle actorization only after the standard snapshot and lifecycle contract tests exist. +- [ ] 9.3 Verify Slack and Discord satisfy the same lifecycle requirements or document explicit capability differences. + +## 10. Validation and quality gates + +- [ ] 10.1 `dotnet test src/Netclaw.Actors.Tests/ --filter Channel` +- [ ] 10.2 `dotnet test src/Netclaw.Daemon.Tests/` +- [ ] 10.3 `dotnet slopwatch analyze` +- [ ] 10.4 `./scripts/Add-FileHeaders.ps1 -Verify` diff --git a/openspec/changes/standardize-channel-transport-contracts/design.md b/openspec/changes/standardize-channel-transport-contracts/design.md deleted file mode 100644 index bb7b49425..000000000 --- a/openspec/changes/standardize-channel-transport-contracts/design.md +++ /dev/null @@ -1,449 +0,0 @@ -# Design - -## Decision - -Standardize the channel seam through descriptors, runtime snapshots, address -resolution, and tool intent schemas. Do not start by creating a shared channel -base actor or rewriting adapter internals. - -Adapters keep their platform-specific implementation details. The daemon and -LLM-facing tool registry consume a standard description of what each adapter can -do, how healthy it is, and how names resolve to platform addresses. - -Mattermost lifecycle actorization remains a valid reliability fix, but it is no -longer the top-level change. It becomes one stateful-adapter task that should be -implemented after Mattermost can report the same descriptor and runtime snapshot -shape as Slack, Discord, and future remote chat adapters. - -## Glossary And Abstractions - -These examples are illustrative contracts, not final type names. The intent is to -show the seam each term describes and how the daemon would use it. - -### Channel Descriptor - -A stable description of what an adapter, logical channel, or endpoint is and what -it can do. This is mostly configuration/capability metadata, not live health. - -Abstraction: - -```csharp -public sealed record ChannelDescriptor( - ChannelDescriptorKey Key, - ChannelType? ChannelType, - ChannelSourceKind Kind, - string DisplayName, - bool IsEnabled, - ChannelCapabilities Capabilities, - IReadOnlySet ToolIntents, - IReadOnlySet AddressKinds); - -public interface IChannelDescriptorProvider -{ - ChannelDescriptor GetDescriptor(); -} -``` - -Interaction: - -```csharp -var descriptor = slackDescriptorProvider.GetDescriptor(); - -if (descriptor.Capabilities.HasFlag(ChannelCapabilities.InteractiveApproval)) -{ - toolRegistry.IncludeApprovalAwareTools(descriptor.Key); -} -``` - -### Channel Source Kind - -The category of source represented by a descriptor. This avoids treating a -daemon endpoint such as SignalR as if it were a Slack-like remote workspace. - -Abstraction: - -```csharp -public enum ChannelSourceKind -{ - RemoteChatChannel, - LocalClientChannel, - DaemonEndpoint, - InternalSource, - HttpIngressSource, - NonInteractiveClient -} -``` - -Interaction: - -```csharp -var descriptors = registry.ListDescriptors(); -var endpoints = descriptors.Where(d => d.Kind == ChannelSourceKind.DaemonEndpoint); -var chatChannels = descriptors.Where(d => d.Kind == ChannelSourceKind.RemoteChatChannel); -``` - -### Channel Registry - -The daemon-owned index of descriptors, runtime snapshot providers, and address -resolvers. It routes operations to the selected descriptor instead of guessing -which platform a user meant. - -Abstraction: - -```csharp -public interface IChannelRegistry -{ - IReadOnlyCollection ListDescriptors(); - - ValueTask GetSnapshotAsync( - ChannelDescriptorKey key, - CancellationToken cancellationToken); - - IChannelAddressResolver GetResolver( - ChannelDescriptorKey key, - ChannelAddressKind addressKind); -} -``` - -Interaction: - -```csharp -foreach (var descriptor in registry.ListDescriptors()) -{ - var snapshot = await registry.GetSnapshotAsync(descriptor.Key, ct); - status.AddChannel(descriptor, snapshot); -} -``` - -### Runtime Snapshot - -A live, point-in-time report of an adapter or endpoint's current operational -state. This is not persisted state; it is used by status, stats, tool discovery, -and health reporting. - -Abstraction: - -```csharp -public sealed record ChannelRuntimeSnapshot( - ChannelDescriptorKey Key, - bool IsEnabled, - ChannelHealthStatus Health, - string? HealthDetail, - bool? IsConnected, - bool? IsReady, - ChannelPrincipal? Principal, - ChannelEndpointIdentity? Endpoint, - ChannelActivitySnapshot? Activity); - -public interface IChannelRuntimeSnapshotProvider -{ - ChannelDescriptorKey Key { get; } - - ValueTask GetSnapshotAsync( - CancellationToken cancellationToken); -} -``` - -Interaction: - -```csharp -var snapshot = await mattermostSnapshotProvider.GetSnapshotAsync(ct); - -if (snapshot is { IsEnabled: true, IsReady: false }) -{ - logger.LogWarning("Mattermost is not ready: {Detail}", snapshot.HealthDetail); -} -``` - -### Address Resolver - -A descriptor-scoped resolver for stable IDs and user-facing names. Slack, -Discord, Mattermost, SignalR, Reminder, and Webhook do not share one mega -resolver; each descriptor provides only the namespaces it supports. - -Abstraction: - -```csharp -public sealed record ChannelAddressResolutionRequest( - ChannelDescriptorKey ChannelKey, - ChannelAddressKind AddressKind, - string Query, - bool RequireSingleMatch); - -public sealed record ResolvedChannelAddress( - ChannelDescriptorKey ChannelKey, - ChannelAddressKind AddressKind, - string StableId, - string DisplayName); - -public interface IChannelAddressResolver -{ - ChannelDescriptorKey ChannelKey { get; } - - ValueTask ResolveAsync( - ChannelAddressResolutionRequest request, - CancellationToken cancellationToken); -} -``` - -Interaction: - -```csharp -var resolver = registry.GetResolver(slackKey, ChannelAddressKind.Destination); - -var result = await resolver.ResolveAsync( - new ChannelAddressResolutionRequest( - slackKey, - ChannelAddressKind.Destination, - "#ops-alerts", - RequireSingleMatch: true), - ct); - -var destination = result.RequireSingle(); -``` - -### Tool Intent Schema - -The normalized argument model used by LLM-facing tools. Tool names can be -standardized, but adapter-specific execution still happens behind the registry -and descriptor-selected channel implementation. - -Abstraction: - -```csharp -public sealed record SendChannelMessageIntent( - ChannelDescriptorKey ChannelKey, - ResolvedChannelAddress Destination, - string Text, - string? ThreadOrRootId = null); - -public interface IChannelToolIntentExecutor -{ - ValueTask ExecuteAsync( - SendChannelMessageIntent intent, - ToolExecutionContext context, - CancellationToken cancellationToken); -} -``` - -Interaction: - -```csharp -var destination = await channelTools.ResolveDestinationAsync( - channelKey: mattermostKey, - query: "release-war-room", - cancellationToken: ct); - -await channelTools.SendMessageAsync( - new SendChannelMessageIntent( - mattermostKey, - destination, - "Deploy finished successfully."), - toolContext, - ct); -``` - -### Stateful Transport Lifecycle Owner - -The adapter-specific component that serializes socket/API lifecycle state for a -remote chat adapter. It can be an actor, hosted-service state machine, SDK -facade, or another single owner. The standard contract is the observable -snapshot and lifecycle behavior, not the implementation shape. - -Abstraction: - -```csharp -public interface IStatefulChannelLifecycleOwner -{ - event Func? CleanReconnectRequired; - - ValueTask ConnectAsync( - CancellationToken cancellationToken); - - ValueTask DisconnectAsync(CancellationToken cancellationToken); - - ValueTask GetSnapshotAsync( - CancellationToken cancellationToken); -} -``` - -Interaction: - -```csharp -lifecycle.CleanReconnectRequired += reason => -{ - reconnectQueue.Enqueue(new CleanReconnectRequest(channelKey, reason)); - return Task.CompletedTask; -}; - -var snapshot = await lifecycle.GetSnapshotAsync(ct); - -if (snapshot.IsReady != true) -{ - ingressMetrics.RecordFilteredWhileNotReady(channelKey); - return; -} -``` - -## Transport And Channel Taxonomy - -Netclaw needs to distinguish logical conversation sources from process/network -transport endpoints. - -| Kind | Examples | Descriptor Meaning | -|------|----------|--------------------| -| Remote chat channel | Slack, Discord, Mattermost | External workspace/server adapter that can receive messages, send replies, resolve users/destinations, and report remote socket or API health. | -| Local client channel | TUI, future Web UI | Logical conversation source created by a local client over the daemon API. The session is channel-like, but the underlying network endpoint is SignalR. | -| Daemon endpoint | SignalR hub | Server transport endpoint used by local clients. It has endpoint health and connected-client state, but it is not itself a user-facing workspace/channel. | -| Internal source | Reminder, scheduler | Daemon-owned source that creates session turns without an external chat workspace. | -| HTTP ingress source | Webhook | External HTTP event source with routing policy, not necessarily a conversational channel. | -| Non-interactive client | Headless | One-shot or request/response source with no ongoing chat surface. | - -This split lets SignalR be treated like a first-class operational endpoint -without forcing it to pretend to be Slack-like. TUI sessions still get a logical -channel descriptor because they participate in session routing and approval -capabilities. - -## Component Diagram - -```mermaid -flowchart TD - Adapters[Channel adapters and endpoints] --> DP[Descriptor providers] - Adapters --> RS[Runtime snapshot providers] - Adapters --> AR[Address resolvers] - DP --> CR[Channel registry] - RS --> CR - AR --> CR - CR --> Status[Daemon runtime status] - CR --> Stats[Daemon stats] - CR --> Tools[LLM tool registry] - Tools --> Intents[Standard tool intents] - Adapters --> Pipeline[SessionPipeline] - Pipeline --> Session[LlmSessionActor] -``` - -## Standard Descriptor Shape - -Each adapter or endpoint reports a stable descriptor with these concepts: - -- Stable key, channel type, display name, and kind. -- Whether it is enabled by configuration. -- Capabilities: receive messages, send messages, direct messages, threaded - conversations, interactive approvals, file ingress, file egress, user lookup, - destination lookup, proactive messages, and runtime health. -- Tool intents it supports, such as send message, lookup user, and lookup - destination. -- Address namespaces it can resolve, such as user, channel, room, thread, DM, - session, webhook source, or schedule target. - -The descriptor describes what the adapter promises. It must not grant ACL -permissions by itself. Actual turn authorization continues to flow through -`ChannelInput` trust context and existing policy checks. - -## Runtime Snapshot Shape - -Each adapter or endpoint reports a runtime snapshot with these concepts: - -- Descriptor key and channel type. -- Enabled state. -- Health status and detail. -- Connected state when meaningful. -- Ready state when meaningful. -- Bot or service principal identity when the adapter has one. -- Endpoint identity when the item is a daemon transport endpoint. -- Last known activity counters or timestamps when available. - -Ready is adapter-specific but comparable. For a remote socket adapter, ready -means it can accept inbound events and send replies. For a local client channel, -ready means the session endpoint can route messages. For an internal source, -ready means its scheduler or trigger is registered. - -## Address Resolution - -Address resolution is standardized as an intent, not as one platform's ID model. -Each descriptor-backed adapter provides its own resolver for the address -namespaces it supports. The channel registry routes a resolution request to the -resolver associated with the selected descriptor or channel type; it does not use -a global resolver that guesses across platforms. Resolvers accept a query and an -address kind. They can return exact matches, candidate matches, or a failure. - -Rules: - -- Stable IDs are accepted when supplied. -- User-facing names are searchable where the backing platform supports it. -- Ambiguous names fail loudly with candidates instead of choosing the first - match. -- Unsupported address kinds fail loudly for the selected descriptor. -- Resolvers do not silently fall back from one namespace to another. -- Resolved addresses carry both display data and stable platform IDs. - -## LLM-Facing Tool Intents - -The tool registry should describe channel tools in terms of standard intents: - -- `send_message`: destination, text, optional thread/root target, optional - audience/context hints. -- `lookup_user`: query, optional channel key, optional exact-only flag. -- `lookup_destination`: query, destination kind, optional channel key, - optional exact-only flag. - -Existing tool names such as `send_slack_message`, `send_discord_message`, and -`send_mattermost_message` are not compatibility requirements. The implementation -may rename them to the standardized tool names once the registry can enumerate -descriptors and resolvers reliably. System skills, CLI/help text, and evals must -be updated in the same implementation change when tool names change. - -## Stateful Transport Lifecycle - -Stateful remote chat adapters must expose lifecycle through the standard runtime -snapshot. They may implement that lifecycle with actors, hosted services, SDK -callbacks, or another serialized owner, but the observable behavior must be the -same: - -- Health reports disconnected, connecting, ready, degraded, and not-ready states - consistently. -- Ingress is gated while the adapter is not ready. -- Reconnects do not duplicate SDK event handlers. -- Unexpected disconnects can request a clean reconnect when the platform SDK - requires a full stop/start cycle. - -Mattermost likely needs an actor-owned lifecycle implementation to satisfy these -requirements. That should be implemented after the standardized snapshot shape -exists. - -## Migration Plan - -1. Add descriptor, runtime snapshot, address-resolution, and tool-intent - contracts without changing adapter behavior. -2. Add contract tests that enumerate every `ChannelType` and require either a - logical channel descriptor or an explicit endpoint/internal-source descriptor. -3. Adapt existing Slack, Discord, Mattermost, TUI, Headless, SignalR, Reminder, - and Webhook surfaces to report descriptors and snapshots using their current - behavior. -4. Change daemon runtime status and stats to consume the registry instead of - hard-coded Slack/Discord lists. -5. Normalize Slack, Discord, and Mattermost send/lookup tools onto standard - intent schemas and rename current per-channel tools where needed. -6. Add name-searchable user and destination resolvers for supported platforms. -7. Only after descriptors and snapshots are stable, implement adapter-specific - lifecycle fixes such as Mattermost actorization. - -## Non-Goals - -- Do not rewrite all adapters in one pass. -- Do not create a generic channel base actor in this change. -- Do not change session identity formats. -- Do not weaken ACL, audience, principal, boundary, or provenance requirements. -- Do not make SignalR pretend to be a remote chat workspace. - -## Risks / Trade-offs - -- A descriptor model can become too abstract. Keep it tied to current runtime - consumers: status, stats, tools, health, and address resolution. -- Generic tools can hide platform-specific constraints. Preserve platform - capability flags and fail loudly when a requested intent is unsupported. -- SignalR needs special handling because it is both the daemon API endpoint and - the transport used by local logical channels. Treat endpoint health and logical - channel capability as separate records. -- Mattermost lifecycle remains a reliability risk until actorized, but delaying - it avoids changing adapter internals before the shared seam is defined. diff --git a/openspec/changes/standardize-channel-transport-contracts/proposal.md b/openspec/changes/standardize-channel-transport-contracts/proposal.md deleted file mode 100644 index 2cb176f3b..000000000 --- a/openspec/changes/standardize-channel-transport-contracts/proposal.md +++ /dev/null @@ -1,70 +0,0 @@ -## Why - -`PRD-009` defines the core input-adapter principle: all inputs should arrive at -the session pipeline through the same transport-agnostic boundary, with source -metadata and instructions carrying the channel-specific differences. The current -implementation has achieved that for `ChannelInput`, but the operational and -LLM-facing surfaces still drift by adapter. - -Current gaps include: - -- Runtime status and stats are partially hard-coded to specific adapters. -- Slack, Discord, and Mattermost expose different LLM-facing tool shapes. -- User and destination lookup is inconsistent and often ID-first instead of - name-searchable. -- `ChannelType` mixes logical conversation sources with transport endpoints, - which makes SignalR/TUI/headless harder to reason about consistently. -- Stateful socket adapters expose different lifecycle health semantics. - -The Mattermost lifecycle issue is one symptom of this broader problem. The first -change should standardize the channel contract that every adapter reports to the -daemon and tools. Adapter-specific lifecycle fixes, including Mattermost -actorization, should happen after that seam exists. - -Source PRDs/specs: `PRD-009-input-adapters-and-unified-input.md`, -`SPEC-011-daemon-architecture.md`, `openspec/specs/netclaw-input-adapters/spec.md`. - -## What Changes - -- Add a standard channel descriptor contract for all logical conversation - sources and daemon transport endpoints. -- Add a standard runtime snapshot contract for health, readiness, enabled state, - endpoint identity, bot identity, and capability reporting. -- Add standard address-resolution semantics so users, channels, rooms, threads, - and destinations can be resolved by stable IDs or user-facing names. -- Add standard LLM-facing tool intent schemas for send-message and lookup tools, - allowing current per-channel tool names to be renamed to the standardized - surface during migration. -- Change daemon runtime status and stats to enumerate registered descriptors - instead of hard-coding individual adapters. -- Define socket-adapter lifecycle requirements that Mattermost, Discord, Slack, - and future remote chat adapters can satisfy without requiring a shared base - actor. - -## Capabilities - -### New Capabilities - - - -### Modified Capabilities - -- `netclaw-input-adapters`: Add requirements for standardized channel - descriptors, runtime snapshots, address resolution, LLM tool intents, - descriptor-driven observability, and reliable stateful transport lifecycle - reporting. - -## Impact - -- **Affected systems:** channel abstractions, daemon runtime status, daemon - stats, Slack/Discord/Mattermost LLM tools, channel user/destination lookup, - channel contract tests, and stateful adapter lifecycle tests. -- **Security:** no new ACL bypass. Standardized descriptors must preserve the - source audience, principal, boundary, and provenance already required by - `ChannelInput`. -- **Reliability:** health and readiness become comparable across adapters. - Stateful socket adapters expose reconnect and not-ready states through a - common snapshot shape. -- **Compatibility:** no session identity change is required. Existing - LLM-facing channel tool names may change as part of standardization; system - skills and evals must be updated when tool names change. diff --git a/openspec/changes/standardize-channel-transport-contracts/specs/netclaw-input-adapters/spec.md b/openspec/changes/standardize-channel-transport-contracts/specs/netclaw-input-adapters/spec.md deleted file mode 100644 index 09d38e852..000000000 --- a/openspec/changes/standardize-channel-transport-contracts/specs/netclaw-input-adapters/spec.md +++ /dev/null @@ -1,190 +0,0 @@ -## ADDED Requirements - -### Requirement: Adapters expose standardized channel descriptors - -Netclaw SHALL expose a standardized descriptor through a common registry for -every logical conversation source, daemon transport endpoint, internal source, -and HTTP ingress source. - -The descriptor SHALL declare the source kind, stable key, channel type when -applicable, display name, enabled state, capabilities, supported tool intents, -and supported address namespaces. - -Descriptors SHALL NOT grant permissions. ACL decisions SHALL continue to use the -explicit audience, principal, boundary, and provenance carried on `ChannelInput` -and `MessageSource`. - -#### Scenario: Every channel type is represented - -- **GIVEN** the daemon has loaded channel integrations -- **WHEN** the channel registry is enumerated -- **THEN** Slack, Discord, Mattermost, TUI, Headless, SignalR, Reminder, and - Webhook are represented by either a descriptor or an explicit unsupported or - not-configured record -- **AND** each record declares whether it is a logical channel, daemon endpoint, - internal source, or HTTP ingress source - -#### Scenario: SignalR endpoint is distinct from TUI logical channel - -- **GIVEN** the TUI sends messages over the SignalR hub -- **WHEN** descriptors are enumerated -- **THEN** the SignalR hub is represented as a daemon endpoint -- **AND** the TUI is represented as a logical local client channel -- **AND** the TUI descriptor carries session interaction capabilities rather than - remote workspace capabilities - -#### Scenario: Descriptor capabilities do not bypass ACL - -- **GIVEN** a descriptor declares that a channel supports proactive send -- **WHEN** a session turn is authorized -- **THEN** tool access still evaluates the turn's explicit trust context -- **AND** the descriptor capability is not treated as an ACL grant - -### Requirement: Runtime health uses standardized snapshots - -Every descriptor-backed adapter or endpoint SHALL expose a runtime snapshot that -reports enabled state, health status, health detail, connected state when -meaningful, ready state when meaningful, service principal identity when -available, endpoint identity when applicable, and activity metadata when -available. - -#### Scenario: Ready remote chat adapter reports healthy - -- **GIVEN** Slack, Discord, or Mattermost is enabled and ready to receive inbound - events and send replies -- **WHEN** runtime snapshots are enumerated -- **THEN** the adapter snapshot reports enabled and healthy -- **AND** connected and ready are true when those states are meaningful for the - adapter - -#### Scenario: Connected but not-ready adapter reports degraded - -- **GIVEN** a stateful remote chat adapter has a socket connection but cannot - safely route inbound events -- **WHEN** its runtime snapshot is requested -- **THEN** connected is true -- **AND** ready is false -- **AND** health is degraded with a detail explaining the not-ready condition - -#### Scenario: Disabled adapter reports configured disabled state - -- **GIVEN** an adapter is disabled by configuration -- **WHEN** its runtime snapshot is requested -- **THEN** enabled is false -- **AND** health reports a disabled or degraded state without attempting a - transport connection - -### Requirement: Runtime status and stats are descriptor-driven - -Daemon runtime status and daemon stats SHALL enumerate channel descriptors and -runtime snapshots rather than hard-coding specific adapters. - -#### Scenario: Newly registered channel appears in status without status-service changes - -- **GIVEN** a new adapter registers a descriptor and runtime snapshot provider -- **WHEN** daemon runtime status is requested -- **THEN** the adapter appears in the channel or endpoint status collection -- **AND** no adapter-specific branch is required in the status service - -#### Scenario: Channel activity includes all descriptor-backed channels - -- **GIVEN** Slack, Discord, and Mattermost have recorded channel activity -- **WHEN** daemon stats are requested -- **THEN** activity for all three adapters is included through descriptor-backed - enumeration - -### Requirement: Address resolution accepts IDs and user-facing names - -Channel address resolution SHALL use a common resolver contract for supported -address kinds, including users and destinations. Resolvers SHALL accept stable -IDs and user-facing names where the backing platform supports them. - -Each descriptor-backed adapter SHALL provide its own resolver for the address -namespaces it supports. The daemon SHALL route resolution requests to the -resolver associated with the selected descriptor or channel type. If no resolver -exists for the requested descriptor and address kind, resolution SHALL fail -loudly as unsupported. - -Resolvers SHALL fail loudly on ambiguous names and unsupported address kinds. -They SHALL NOT silently fall back from one namespace to another. - -#### Scenario: Exact stable ID resolves without search ambiguity - -- **GIVEN** a send-message tool receives a selected channel descriptor -- **AND** the destination value is a stable platform channel ID for that - descriptor -- **WHEN** the resolver evaluates the destination -- **THEN** it resolves the exact ID without display-name search - -#### Scenario: Ambiguous user-facing query fails with candidates - -- **GIVEN** the selected descriptor's resolver returns multiple user or - destination candidates for a user-facing query -- **WHEN** the lookup request requires a single resolved address -- **THEN** resolution fails loudly -- **AND** the result includes candidate stable IDs and display names - -#### Scenario: User lookup resolves by display query - -- **GIVEN** Slack, Discord, or Mattermost supports user lookup -- **WHEN** an LLM-facing lookup tool searches for a user-facing name -- **THEN** the resolver returns matching users with stable IDs and display data -- **AND** callers can pass the stable ID to send-message or DM-capable tools - -### Requirement: LLM-facing channel tools use standard intent schemas - -LLM-facing channel tools SHALL map to standard tool intents for send message, -lookup user, and lookup destination. Existing channel-specific tool names are -not compatibility requirements and MAY be renamed during migration. When tool -names change, system skills and eval cases SHALL be updated in the same -implementation change. - -#### Scenario: Send-message tools share a common argument model - -- **GIVEN** Slack, Discord, and Mattermost expose send-message tools -- **WHEN** their tool definitions are inspected -- **THEN** each tool accepts a destination, text, and optional thread or root - target using the standard send-message intent schema -- **AND** unsupported options are omitted or reported as unsupported rather than - silently ignored - -#### Scenario: Channel-specific tools are renamed to standard tools - -- **GIVEN** Slack, Discord, and Mattermost have migrated to the standard intent - model -- **WHEN** LLM-facing channel tools are registered -- **THEN** the registered tool names follow the standardized naming plan -- **AND** obsolete per-channel names are not required as aliases unless a - concrete external compatibility requirement is documented - -### Requirement: Stateful remote chat adapters expose reliable lifecycle state - -Stateful remote chat adapters SHALL expose lifecycle state through their runtime -snapshot and SHALL gate inbound events while not ready. Reconnects SHALL NOT -duplicate transport SDK event handlers. Unexpected disconnects SHALL be reported -as disconnected or degraded state and MAY request a clean reconnect when a full -transport restart is required. - -#### Scenario: Not-ready ingress is gated - -- **GIVEN** a stateful remote chat adapter is disconnected or connecting -- **WHEN** the platform SDK raises an inbound message event -- **THEN** the event is not routed to the session pipeline -- **AND** the adapter records or logs that ingress was filtered while not ready - -#### Scenario: Reconnect does not duplicate SDK handlers - -- **GIVEN** a stateful remote chat adapter completes a connect, disconnect, and - reconnect cycle -- **WHEN** the platform SDK raises one message event -- **THEN** Netclaw publishes exactly one normalized gateway message -- **AND** SDK event handlers have not been subscribed more than once - -#### Scenario: Mattermost lifecycle implementation satisfies the common contract - -- **GIVEN** Mattermost implements the standardized runtime snapshot contract -- **WHEN** Mattermost is actorized or otherwise given a serialized lifecycle - owner -- **THEN** it satisfies the same not-ready ingress, disconnect health, clean - reconnect, and handler de-duplication scenarios as other stateful remote chat - adapters diff --git a/openspec/changes/standardize-channel-transport-contracts/tasks.md b/openspec/changes/standardize-channel-transport-contracts/tasks.md deleted file mode 100644 index e29a8b2b9..000000000 --- a/openspec/changes/standardize-channel-transport-contracts/tasks.md +++ /dev/null @@ -1,57 +0,0 @@ -## 1. OpenSpec planning artifacts - -- [ ] 1.1 Confirm proposal, design, and spec delta cover Slack, Discord, Mattermost, TUI, Headless, SignalR, Reminder, Webhook, and future adapters. -- [ ] 1.2 Confirm Mattermost actorization is represented as an adapter-specific lifecycle task, not the top-level change. -- [ ] 1.3 Run `openspec validate standardize-channel-transport-contracts --type change` and resolve all issues. - -## 2. Descriptor and snapshot contracts - -- [ ] 2.1 Add a standard channel descriptor model for logical channels, daemon endpoints, internal sources, and HTTP ingress sources. -- [ ] 2.2 Add capability flags for receive, send, DM, threaded conversations, interactive approval, file ingress, file egress, proactive send, user lookup, destination lookup, and runtime health. -- [ ] 2.3 Add a standard runtime snapshot model with enabled, health, connected, ready, principal identity, endpoint identity, and activity metadata. -- [ ] 2.4 Add a registry service that enumerates descriptor and snapshot providers. - -## 3. Existing adapter coverage - -- [ ] 3.1 Register descriptors for Slack, Discord, Mattermost, TUI, Headless, SignalR, Reminder, and Webhook or explicitly mark unsupported/not-configured adapters. -- [ ] 3.2 Adapt Slack runtime health to the standard snapshot shape without changing Slack behavior. -- [ ] 3.3 Adapt Discord runtime health to the standard snapshot shape without changing Discord behavior. -- [ ] 3.4 Adapt Mattermost runtime health to the standard snapshot shape without actorizing it yet. -- [ ] 3.5 Represent SignalR as a daemon endpoint and TUI as a logical local client channel. - -## 4. Descriptor-driven observability - -- [ ] 4.1 Change daemon runtime status to enumerate the channel registry instead of hard-coding individual adapters. -- [ ] 4.2 Change daemon stats channel activity to enumerate descriptor-backed channels. -- [ ] 4.3 Preserve current status/stats output fields or provide explicit compatibility mapping. - -## 5. Address resolution - -- [ ] 5.1 Add a standard address resolver contract for users and destinations. -- [ ] 5.2 Support exact stable ID resolution before name search. -- [ ] 5.3 Fail loudly with candidates for ambiguous display-name matches. -- [ ] 5.4 Route resolution requests to the resolver registered for the selected descriptor or channel type. -- [ ] 5.5 Wire Slack lookup to its descriptor-scoped resolver. -- [ ] 5.6 Wire Discord lookup to its descriptor-scoped resolver where supported. -- [ ] 5.7 Wire Mattermost lookup to its descriptor-scoped resolver. - -## 6. LLM-facing tool standardization - -- [ ] 6.1 Define standard tool intent schemas and final tool names for send message, lookup user, and lookup destination. -- [ ] 6.2 Rename/map existing Slack tools to the standard tool names and intent schema. -- [ ] 6.3 Rename/map existing Discord tools to the standard tool names and intent schema. -- [ ] 6.4 Rename/map existing Mattermost tools to the standard tool names and intent schema. -- [ ] 6.5 Update system skills, CLI/help text, and eval cases for renamed LLM-facing channel tools. - -## 7. Stateful transport lifecycle follow-up - -- [ ] 7.1 Add contract tests for not-ready ingress gating, runtime disconnect health, clean reconnect signaling, and handler de-duplication for stateful remote chat adapters. -- [ ] 7.2 Implement Mattermost lifecycle actorization only after the standard snapshot and lifecycle contract tests exist. -- [ ] 7.3 Verify Slack and Discord satisfy the same lifecycle requirements or document explicit capability differences. - -## 8. Validation and quality gates - -- [ ] 8.1 `dotnet test src/Netclaw.Actors.Tests/ --filter Channel` -- [ ] 8.2 `dotnet test src/Netclaw.Daemon.Tests/` -- [ ] 8.3 `dotnet slopwatch analyze` -- [ ] 8.4 `./scripts/Add-FileHeaders.ps1 -Verify` From 524533e167b114c3bc7d39d12403cf9eef7879e6 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 4 Jun 2026 13:14:55 +0000 Subject: [PATCH 06/31] Plan: add channel output effect extensibility --- .../design.md | 70 +++++++++++++++++++ .../specs/netclaw-input-adapters/spec.md | 36 ++++++++++ .../tasks.md | 24 ++++--- 3 files changed, 121 insertions(+), 9 deletions(-) diff --git a/openspec/changes/standardize-channel-delivery-contracts/design.md b/openspec/changes/standardize-channel-delivery-contracts/design.md index 35fe7d765..1c42aff13 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/design.md +++ b/openspec/changes/standardize-channel-delivery-contracts/design.md @@ -324,6 +324,50 @@ await channelTools.SendMessageAsync( ct); ``` +### Channel Output Effect + +A semantic output event that a channel may render using native platform behavior. +Session actors emit meaning, not platform commands. Channels decide how to +render that meaning based on declared capabilities. + +Examples: text message, message update, interactive approval prompt, file +attachment, processing indicator, reaction, thread rename. + +Abstraction: + +```csharp +public enum ChannelOutputEffectKind +{ + TextMessage, + MessageUpdate, + InteractiveApproval, + FileAttachment, + ProcessingIndicator, + Reaction, + ThreadRename +} + +public interface IChannelOutputRenderer +{ + ChannelDescriptorKey ChannelKey { get; } + + ValueTask RenderAsync( + ChannelDeliveryTarget target, + SessionOutput output, + CancellationToken cancellationToken); +} +``` + +Interaction: + +```csharp +if (output is ProcessingStateOutput { IsProcessing: true } + && descriptor.Capabilities.Supports(ChannelOutputEffectKind.ProcessingIndicator)) +{ + await discordRenderer.RenderAsync(target, output, ct); +} +``` + ### Stateful Channel Lifecycle Owner The adapter-specific component that serializes socket/API lifecycle state for a @@ -491,6 +535,32 @@ may rename them to the standardized tool names once the registry can enumerate channels and resolvers reliably. System skills, CLI/help text, and evals must be updated in the same implementation change when tool names change. +## Channel Output Effects + +Session actors should emit semantic `SessionOutput` events. They should not emit +Slack-specific, Discord-specific, Mattermost-specific, or TUI-specific delivery +commands. The channel delivery layer maps each semantic output event to a native +platform rendering when the target channel declares support for that effect. + +Rules: + +- Channel descriptors declare supported output effects. +- Optional effects may be ignored when unsupported. +- Required effects fail loudly when unsupported. +- A channel-specific renderer may use native platform behavior, such as Discord + typing indicators for a processing output signal. +- Adding a new cross-channel feature should add a semantic output/effect, + descriptor capability, renderer behavior, and contract tests rather than a + one-off platform branch in session logic. + +Example mapping: + +| Semantic output | Discord rendering | Slack rendering | Mattermost rendering | TUI rendering | +|-----------------|-------------------|-----------------|----------------------|---------------| +| `ProcessingStateOutput(true)` | trigger typing indicator | unsupported or future native/status rendering | unsupported or future native/status rendering | spinner/status line | +| Tool approval request | buttons | buttons | attachment actions | text options or local prompt | +| Message update | edit message | update message | update post | replace rendered block | + ## Stateful Channel Lifecycle Stateful remote chat channels must expose lifecycle through the standard runtime diff --git a/openspec/changes/standardize-channel-delivery-contracts/specs/netclaw-input-adapters/spec.md b/openspec/changes/standardize-channel-delivery-contracts/specs/netclaw-input-adapters/spec.md index b331ab7e6..e6ce2b72d 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/specs/netclaw-input-adapters/spec.md +++ b/openspec/changes/standardize-channel-delivery-contracts/specs/netclaw-input-adapters/spec.md @@ -199,6 +199,42 @@ implementation change. - **AND** obsolete per-channel names are not required as aliases unless a concrete external compatibility requirement is documented +### Requirement: Channels render semantic output effects by capability + +Session actors SHALL emit semantic `SessionOutput` events rather than +platform-specific delivery commands. Channel descriptors SHALL declare which +output effects the channel can render. The channel delivery layer SHALL route +semantic output to the target channel renderer, which MAY map the output to +native platform behavior. + +Unsupported optional output effects MAY be ignored. Unsupported required output +effects SHALL fail loudly. + +#### Scenario: Processing signal renders as native typing where supported + +- **GIVEN** a session emits `ProcessingStateOutput(true)` +- **AND** the delivery target is a Discord channel that declares support for the + processing indicator output effect +- **WHEN** the channel delivery layer renders the output +- **THEN** Discord renders the native typing indicator +- **AND** session logic does not reference Discord-specific APIs + +#### Scenario: Unsupported optional output effect is ignored safely + +- **GIVEN** a session emits an optional processing indicator output effect +- **AND** the delivery target channel does not support processing indicators +- **WHEN** the channel delivery layer renders the output +- **THEN** no platform-specific delivery action is attempted +- **AND** the session turn continues + +#### Scenario: Required output effect fails loudly when unsupported + +- **GIVEN** a session emits an output effect required for correctness +- **AND** the delivery target channel does not support that effect +- **WHEN** the channel delivery layer attempts to render the output +- **THEN** delivery fails loudly with an unsupported output effect error +- **AND** Netclaw does not silently substitute a different effect + ### Requirement: Stateful remote chat channels expose reliable lifecycle state Stateful remote chat channels SHALL expose lifecycle state through their runtime diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md index 97d927ae2..966da1126 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/tasks.md +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -8,7 +8,7 @@ ## 2. Channel descriptor and snapshot contracts - [ ] 2.1 Add a standard channel descriptor model for output-capable remote chat and local interactive channels. -- [ ] 2.2 Add capability flags for receive, send, DM, threaded conversations, interactive approval, file ingress, file egress, proactive send, user lookup, destination lookup, and runtime health. +- [ ] 2.2 Add capability flags for receive, send, DM, threaded conversations, interactive approval, file ingress, file egress, proactive send, user lookup, destination lookup, runtime health, and supported output effects. - [ ] 2.3 Add a standard channel runtime snapshot model with enabled, health, connected, ready, principal identity, and activity metadata. - [ ] 2.4 Add a channel registry service that enumerates descriptor and snapshot providers for output-capable channels only. @@ -60,13 +60,19 @@ ## 9. Stateful channel lifecycle follow-up -- [ ] 9.1 Add contract tests for not-ready ingress gating, runtime disconnect health, clean reconnect signaling, and handler de-duplication for stateful remote chat channels. -- [ ] 9.2 Implement Mattermost lifecycle actorization only after the standard snapshot and lifecycle contract tests exist. -- [ ] 9.3 Verify Slack and Discord satisfy the same lifecycle requirements or document explicit capability differences. +- [ ] 9.1 Add a channel output renderer contract for semantic `SessionOutput` effects. +- [ ] 9.2 Add contract tests for supported optional output effects, unsupported optional output effects, and unsupported required output effects. +- [ ] 9.3 Wire processing-indicator output through channel capabilities so Discord typing indicators and future Slack/Mattermost/TUI equivalents share the same semantic output path. -## 10. Validation and quality gates +## 10. Stateful channel lifecycle follow-up -- [ ] 10.1 `dotnet test src/Netclaw.Actors.Tests/ --filter Channel` -- [ ] 10.2 `dotnet test src/Netclaw.Daemon.Tests/` -- [ ] 10.3 `dotnet slopwatch analyze` -- [ ] 10.4 `./scripts/Add-FileHeaders.ps1 -Verify` +- [ ] 10.1 Add contract tests for not-ready ingress gating, runtime disconnect health, clean reconnect signaling, and handler de-duplication for stateful remote chat channels. +- [ ] 10.2 Implement Mattermost lifecycle actorization only after the standard snapshot and lifecycle contract tests exist. +- [ ] 10.3 Verify Slack and Discord satisfy the same lifecycle requirements or document explicit capability differences. + +## 11. Validation and quality gates + +- [ ] 11.1 `dotnet test src/Netclaw.Actors.Tests/ --filter Channel` +- [ ] 11.2 `dotnet test src/Netclaw.Daemon.Tests/` +- [ ] 11.3 `dotnet slopwatch analyze` +- [ ] 11.4 `./scripts/Add-FileHeaders.ps1 -Verify` From 0631c47181586d79974be0d59b7316ff41dfd998 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 4 Jun 2026 13:26:04 +0000 Subject: [PATCH 07/31] Plan: ground channel delivery invariants --- .../design.md | 59 +++++++++++++++++++ .../tasks.md | 3 +- 2 files changed, 61 insertions(+), 1 deletion(-) diff --git a/openspec/changes/standardize-channel-delivery-contracts/design.md b/openspec/changes/standardize-channel-delivery-contracts/design.md index 1c42aff13..da57f3e54 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/design.md +++ b/openspec/changes/standardize-channel-delivery-contracts/design.md @@ -22,6 +22,28 @@ the top-level goal. It becomes one stateful-channel task after Mattermost can report the same descriptor and runtime snapshot shape as Slack, Discord, and future remote chat channels. +## Invariants + +These statements are the long-lived grounding rules for this change. If later +implementation work conflicts with one of these, update the plan before writing +code. + +- A channel is an addressable output-capable delivery surface. +- A channel may also be an input source, but input capability is not what makes + it a channel. +- Reminders, schedulers, and webhooks are trigger sources. They are not channels. +- Trigger sources consume channel delivery targets when they need external + output. +- Netclaw must not silently choose a default output channel for trigger-originated + turns. +- SignalR is daemon infrastructure. TUI is the local interactive channel that + uses SignalR. +- Session actors emit semantic `SessionOutput`; channels render those outputs + through capability-declared effects. +- Descriptors and capabilities describe what can happen; ACL still depends on + explicit trust context carried by the turn. +- The code samples in this design are illustrative seams, not mandated API names. + ## Glossary And Abstractions These examples are illustrative contracts, not final type names. The intent is to @@ -427,6 +449,23 @@ endpoints may have operational status, but they do not advertise channel send, lookup, or lifecycle capabilities unless represented by a logical channel such as TUI. +## Capability Scope Matrix + +| Surface | Input source | Output channel | Channel descriptor | Delivery target consumer | Address resolver | Runtime snapshot | Output effects | Stateful lifecycle | +|---------|--------------|----------------|--------------------|--------------------------|------------------|------------------|----------------|--------------------| +| Slack | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| Discord | Yes | Yes | Yes | Yes | Yes, where supported | Yes | Yes | Yes | +| Mattermost | Yes | Yes | Yes | Yes | Yes | Yes | Yes | Yes | +| TUI | Yes | Yes | Yes | Yes | Local/session-scoped only | Yes | Yes | No remote socket lifecycle | +| SignalR hub | No user turn by itself | No | No channel descriptor | No | No | Endpoint status only | No | Endpoint lifecycle only | +| Reminder/scheduler | Yes | No | No | Yes, when output is requested | No | Trigger status only | No | No | +| Webhook route | Yes | No | No | Yes, when output is requested | No | Trigger status only | No | No | + +This table is deliberately asymmetric. The channel registry is for output-capable +channels. Trigger sources and daemon endpoints may have their own operational +status, but they do not become channels unless they can emit output through an +addressable conversation surface. + ## Component Diagram ```mermaid @@ -598,6 +637,26 @@ exists. 8. Only after descriptors and snapshots are stable, implement adapter-specific lifecycle fixes such as Mattermost actorization. +## Multi-Window Implementation Guardrails + +This change is likely to span multiple sessions and compaction windows. Preserve +the ordering and scope boundaries below to avoid reintroducing the old ambiguity. + +1. Start with channel descriptors, delivery targets, and contract tests. +2. Do not implement a generic channel base actor first. +3. Do not register reminders, schedulers, webhooks, or SignalR as channels for + convenience. +4. Do not add fallback logic that picks Slack, Discord, Mattermost, or TUI when a + trigger source lacks a delivery target. +5. Do not migrate reminder or webhook output until `ChannelDeliveryTarget` exists + and can fail loudly when missing. +6. Do not rename LLM-facing channel tools without updating system skills and eval + cases in the same implementation change. +7. Do not actorize Mattermost as the first step; actorization comes after the + standard runtime snapshot and lifecycle contract tests exist. +8. Do not let session actors call platform-specific delivery APIs. Add a semantic + output effect and channel renderer instead. + ## Non-Goals - Do not rewrite all channel adapters in one pass. diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md index 966da1126..c16c52e3f 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/tasks.md +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -3,7 +3,8 @@ - [ ] 1.1 Confirm proposal, design, and spec delta define channels as output-capable delivery surfaces that may also produce input. - [ ] 1.2 Confirm reminders and webhooks are represented as trigger consumers of channel delivery targets, not channel registry participants. - [ ] 1.3 Confirm Mattermost actorization is represented as an adapter-specific lifecycle task, not the top-level change. -- [ ] 1.4 Run `openspec validate standardize-channel-delivery-contracts --type change` and resolve all issues. +- [ ] 1.4 Confirm invariants, capability matrix, and multi-window implementation guardrails remain consistent after review edits. +- [ ] 1.5 Run `openspec validate standardize-channel-delivery-contracts --type change` and resolve all issues. ## 2. Channel descriptor and snapshot contracts From 396db0f33943806f70494b11058f7a7c4a23d4b9 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Thu, 4 Jun 2026 19:22:39 +0000 Subject: [PATCH 08/31] Add channel delivery descriptor registry --- .../tasks.md | 34 +- .../ChannelDeliveryContracts.cs | 312 ++++++++++++++++++ .../ChannelRegistryRegistrationTests.cs | 140 ++++++++ .../DiscordChannelRegistrationExtensions.cs | 37 +++ ...MattermostChannelRegistrationExtensions.cs | 47 +++ .../SlackChannelRegistrationExtensions.cs | 49 +++ src/Netclaw.Daemon/Program.cs | 3 + 7 files changed, 605 insertions(+), 17 deletions(-) create mode 100644 src/Netclaw.Channels/ChannelDeliveryContracts.cs create mode 100644 src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md index c16c52e3f..cd4020bd9 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/tasks.md +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -1,38 +1,38 @@ ## 1. OpenSpec planning artifacts -- [ ] 1.1 Confirm proposal, design, and spec delta define channels as output-capable delivery surfaces that may also produce input. -- [ ] 1.2 Confirm reminders and webhooks are represented as trigger consumers of channel delivery targets, not channel registry participants. -- [ ] 1.3 Confirm Mattermost actorization is represented as an adapter-specific lifecycle task, not the top-level change. -- [ ] 1.4 Confirm invariants, capability matrix, and multi-window implementation guardrails remain consistent after review edits. -- [ ] 1.5 Run `openspec validate standardize-channel-delivery-contracts --type change` and resolve all issues. +- [x] 1.1 Confirm proposal, design, and spec delta define channels as output-capable delivery surfaces that may also produce input. +- [x] 1.2 Confirm reminders and webhooks are represented as trigger consumers of channel delivery targets, not channel registry participants. +- [x] 1.3 Confirm Mattermost actorization is represented as an adapter-specific lifecycle task, not the top-level change. +- [x] 1.4 Confirm invariants, capability matrix, and multi-window implementation guardrails remain consistent after review edits. +- [x] 1.5 Run `openspec validate standardize-channel-delivery-contracts --type change` and resolve all issues. ## 2. Channel descriptor and snapshot contracts -- [ ] 2.1 Add a standard channel descriptor model for output-capable remote chat and local interactive channels. -- [ ] 2.2 Add capability flags for receive, send, DM, threaded conversations, interactive approval, file ingress, file egress, proactive send, user lookup, destination lookup, runtime health, and supported output effects. -- [ ] 2.3 Add a standard channel runtime snapshot model with enabled, health, connected, ready, principal identity, and activity metadata. -- [ ] 2.4 Add a channel registry service that enumerates descriptor and snapshot providers for output-capable channels only. +- [x] 2.1 Add a standard channel descriptor model for output-capable remote chat and local interactive channels. +- [x] 2.2 Add capability flags for receive, send, DM, threaded conversations, interactive approval, file ingress, file egress, proactive send, user lookup, destination lookup, runtime health, and supported output effects. +- [x] 2.3 Add a standard channel runtime snapshot model with enabled, health, connected, ready, principal identity, and activity metadata. +- [x] 2.4 Add a channel registry service that enumerates descriptor and snapshot providers for output-capable channels only. ## 3. Delivery target contracts -- [ ] 3.1 Add `ChannelDeliveryTarget` with channel key, resolved destination, and optional thread/root target. +- [x] 3.1 Add `ChannelDeliveryTarget` with channel key, resolved destination, and optional thread/root target. - [ ] 3.2 Preserve channel-originated default delivery targets for Slack, Discord, Mattermost, and TUI input turns. - [ ] 3.3 Require trigger-originated turns to carry an explicit delivery target when external output is requested. - [ ] 3.4 Fail loudly when a trigger-originated turn attempts external output without a delivery target. ## 4. Existing channel coverage -- [ ] 4.1 Register channel descriptors for Slack, Discord, Mattermost, and TUI or explicitly mark unsupported/not-configured output channels. +- [x] 4.1 Register channel descriptors for Slack, Discord, Mattermost, and TUI or explicitly mark unsupported/not-configured output channels. - [ ] 4.2 Adapt Slack runtime health to the standard snapshot shape without changing Slack behavior. - [ ] 4.3 Adapt Discord runtime health to the standard snapshot shape without changing Discord behavior. - [ ] 4.4 Adapt Mattermost runtime health to the standard snapshot shape without actorizing it yet. -- [ ] 4.5 Represent TUI as a local interactive channel and SignalR as daemon infrastructure, not as the same channel record. +- [x] 4.5 Represent TUI as a local interactive channel and SignalR as daemon infrastructure, not as the same channel record. ## 5. Trigger-source consumers - [ ] 5.1 Update reminder definitions to store or resolve explicit channel delivery targets when output is requested. - [ ] 5.2 Update webhook route definitions to store or resolve explicit channel delivery targets when output is requested. -- [ ] 5.3 Ensure reminders and webhooks do not register channel descriptors or channel snapshot providers. +- [x] 5.3 Ensure reminders and webhooks do not register channel descriptors or channel snapshot providers. ## 6. Descriptor-driven observability @@ -73,7 +73,7 @@ ## 11. Validation and quality gates -- [ ] 11.1 `dotnet test src/Netclaw.Actors.Tests/ --filter Channel` -- [ ] 11.2 `dotnet test src/Netclaw.Daemon.Tests/` -- [ ] 11.3 `dotnet slopwatch analyze` -- [ ] 11.4 `./scripts/Add-FileHeaders.ps1 -Verify` +- [x] 11.1 `dotnet test src/Netclaw.Actors.Tests/ --filter Channel` +- [x] 11.2 `dotnet test src/Netclaw.Daemon.Tests/` +- [x] 11.3 `dotnet slopwatch analyze` +- [x] 11.4 `./scripts/Add-FileHeaders.ps1 -Verify` diff --git a/src/Netclaw.Channels/ChannelDeliveryContracts.cs b/src/Netclaw.Channels/ChannelDeliveryContracts.cs new file mode 100644 index 000000000..753d8b019 --- /dev/null +++ b/src/Netclaw.Channels/ChannelDeliveryContracts.cs @@ -0,0 +1,312 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Netclaw.Actors.Channels; + +namespace Netclaw.Channels; + +public readonly record struct ChannelDescriptorKey +{ + public ChannelDescriptorKey(string value) + { + ArgumentException.ThrowIfNullOrWhiteSpace(value); + Value = value; + } + + public string Value { get; } + + public static ChannelDescriptorKey Create(string value) + { + return new ChannelDescriptorKey(value); + } + + public static ChannelDescriptorKey FromChannelType(ChannelType channelType) + { + return Create(channelType.ToWireValue()); + } + + public override string ToString() => Value; +} + +public enum ChannelKind +{ + RemoteChat, + LocalInteractiveClient +} + +[Flags] +public enum ChannelCapabilities +{ + None = 0, + ReceiveMessages = 1 << 0, + SendMessages = 1 << 1, + DirectMessages = 1 << 2, + ThreadedConversations = 1 << 3, + InteractiveApproval = 1 << 4, + FileIngress = 1 << 5, + FileEgress = 1 << 6, + ProactiveSend = 1 << 7, + UserLookup = 1 << 8, + DestinationLookup = 1 << 9, + RuntimeHealth = 1 << 10 +} + +public enum ChannelAddressKind +{ + Destination, + User, + Thread, + DirectMessage, + LocalSession +} + +public enum ChannelToolIntentKind +{ + SendMessage, + LookupUser, + LookupDestination +} + +public enum ChannelOutputEffectKind +{ + TextMessage, + MessageUpdate, + InteractiveApproval, + FileAttachment, + ProcessingIndicator, + Reaction, + ThreadRename +} + +public sealed record ChannelDescriptor( + ChannelDescriptorKey Key, + ChannelType ChannelType, + ChannelKind Kind, + string DisplayName, + bool IsEnabled, + ChannelCapabilities Capabilities, + IReadOnlySet ToolIntents, + IReadOnlySet AddressKinds, + IReadOnlySet SupportedOutputEffects); + +public sealed record ResolvedChannelAddress +{ + public ResolvedChannelAddress( + ChannelDescriptorKey channelKey, + ChannelAddressKind addressKind, + string stableId, + string displayName) + { + ArgumentException.ThrowIfNullOrWhiteSpace(stableId); + ArgumentException.ThrowIfNullOrWhiteSpace(displayName); + + ChannelKey = channelKey; + AddressKind = addressKind; + StableId = stableId; + DisplayName = displayName; + } + + public ChannelDescriptorKey ChannelKey { get; init; } + + public ChannelAddressKind AddressKind { get; init; } + + public string StableId { get; init; } + + public string DisplayName { get; init; } +} + +public sealed record ChannelDeliveryTarget +{ + public ChannelDeliveryTarget( + ChannelDescriptorKey channelKey, + ResolvedChannelAddress destination, + string? threadOrRootId = null) + { + if (!destination.ChannelKey.Equals(channelKey)) + { + throw new ArgumentException( + $"Destination channel key '{destination.ChannelKey}' does not match delivery target channel key '{channelKey}'.", + nameof(destination)); + } + + ChannelKey = channelKey; + Destination = destination; + ThreadOrRootId = threadOrRootId; + } + + public ChannelDescriptorKey ChannelKey { get; init; } + + public ResolvedChannelAddress Destination { get; init; } + + public string? ThreadOrRootId { get; init; } +} + +public sealed record ChannelPrincipal( + string StableId, + string? DisplayName = null); + +public sealed record ChannelActivitySnapshot( + DateTimeOffset? LastInputAtUtc = null, + DateTimeOffset? LastOutputAtUtc = null, + long? InputCount = null, + long? OutputCount = null); + +public sealed record ChannelRuntimeSnapshot( + ChannelDescriptorKey Key, + bool IsEnabled, + ChannelHealthStatus Health, + string? HealthDetail = null, + bool? IsConnected = null, + bool? IsReady = null, + ChannelPrincipal? Principal = null, + ChannelActivitySnapshot? Activity = null); + +public interface IChannelDescriptorProvider +{ + ChannelDescriptor GetDescriptor(); +} + +public interface IChannelRuntimeSnapshotProvider +{ + ChannelDescriptorKey Key { get; } + + ValueTask GetSnapshotAsync(CancellationToken cancellationToken = default); +} + +public interface IChannelRegistry +{ + IReadOnlyCollection ListChannels(); + + ChannelDescriptor GetChannel(ChannelDescriptorKey key); + + ValueTask GetSnapshotAsync( + ChannelDescriptorKey key, + CancellationToken cancellationToken = default); +} + +public sealed class ChannelRegistry : IChannelRegistry +{ + private readonly IReadOnlyDictionary _descriptors; + private readonly IReadOnlyDictionary _snapshotProviders; + + public ChannelRegistry( + IEnumerable descriptorProviders, + IEnumerable snapshotProviders) + { + ArgumentNullException.ThrowIfNull(descriptorProviders); + ArgumentNullException.ThrowIfNull(snapshotProviders); + + _descriptors = BuildDescriptorLookup(descriptorProviders); + _snapshotProviders = BuildSnapshotProviderLookup(snapshotProviders); + } + + public IReadOnlyCollection ListChannels() + { + return _descriptors.Values + .OrderBy(descriptor => descriptor.Key.Value, StringComparer.Ordinal) + .ToArray(); + } + + public ChannelDescriptor GetChannel(ChannelDescriptorKey key) + { + if (_descriptors.TryGetValue(key, out var descriptor)) + return descriptor; + + throw new InvalidOperationException($"No channel descriptor is registered for key '{key}'."); + } + + public async ValueTask GetSnapshotAsync( + ChannelDescriptorKey key, + CancellationToken cancellationToken = default) + { + if (!_snapshotProviders.TryGetValue(key, out var provider)) + throw new InvalidOperationException($"No channel runtime snapshot provider is registered for key '{key}'."); + + return await provider.GetSnapshotAsync(cancellationToken); + } + + private static IReadOnlyDictionary BuildDescriptorLookup( + IEnumerable providers) + { + var descriptors = new Dictionary(); + + foreach (var provider in providers) + { + var descriptor = provider.GetDescriptor(); + if (!descriptors.TryAdd(descriptor.Key, descriptor)) + throw new InvalidOperationException($"Duplicate channel descriptor key '{descriptor.Key}' registered."); + } + + return descriptors; + } + + private static IReadOnlyDictionary BuildSnapshotProviderLookup( + IEnumerable providers) + { + var snapshotProviders = new Dictionary(); + + foreach (var provider in providers) + { + if (!snapshotProviders.TryAdd(provider.Key, provider)) + throw new InvalidOperationException($"Duplicate channel runtime snapshot provider key '{provider.Key}' registered."); + } + + return snapshotProviders; + } +} + +public sealed class StaticChannelDescriptorProvider(ChannelDescriptor descriptor) : IChannelDescriptorProvider +{ + public ChannelDescriptor GetDescriptor() => descriptor; +} + +public static class ChannelRegistryServiceCollectionExtensions +{ + public static IServiceCollection AddChannelRegistry(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + services.TryAddSingleton(); + return services; + } + + public static IServiceCollection AddChannelDescriptor( + this IServiceCollection services, + ChannelDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(descriptor); + + services.AddSingleton(new StaticChannelDescriptorProvider(descriptor)); + return services; + } + + public static IServiceCollection AddTuiChannelDescriptor(this IServiceCollection services) + { + ArgumentNullException.ThrowIfNull(services); + + return services.AddChannelDescriptor(new ChannelDescriptor( + ChannelDescriptorKey.FromChannelType(ChannelType.Tui), + ChannelType.Tui, + ChannelKind.LocalInteractiveClient, + "TUI", + IsEnabled: true, + ChannelCapabilities.ReceiveMessages + | ChannelCapabilities.SendMessages + | ChannelCapabilities.InteractiveApproval + | ChannelCapabilities.FileEgress + | ChannelCapabilities.RuntimeHealth, + ToolIntents: new HashSet(), + AddressKinds: new HashSet { ChannelAddressKind.LocalSession }, + SupportedOutputEffects: new HashSet + { + ChannelOutputEffectKind.TextMessage, + ChannelOutputEffectKind.InteractiveApproval, + ChannelOutputEffectKind.FileAttachment + })); + } +} diff --git a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs new file mode 100644 index 000000000..ad79f9f14 --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs @@ -0,0 +1,140 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Actors.Channels; +using Netclaw.Channels; +using Netclaw.Daemon.Configuration; +using Xunit; + +namespace Netclaw.Daemon.Tests.Configuration; + +public sealed class ChannelRegistryRegistrationTests +{ + [Fact] + public void Registry_enumerates_output_capable_channels_only() + { + var descriptors = BuildDescriptors(new Dictionary + { + ["Slack:Enabled"] = "true", + ["Slack:AllowDirectMessages"] = "true", + ["Discord:Enabled"] = "true", + ["Discord:AllowDirectMessages"] = "true", + ["Mattermost:Enabled"] = "true", + ["Mattermost:AllowDirectMessages"] = "true" + }); + + Assert.Equal( + new[] { "discord", "mattermost", "slack", "tui" }, + descriptors.Keys.Order(StringComparer.Ordinal)); + + Assert.DoesNotContain("headless", descriptors.Keys); + Assert.DoesNotContain("signalr", descriptors.Keys); + Assert.DoesNotContain("reminder", descriptors.Keys); + Assert.DoesNotContain("webhook", descriptors.Keys); + + Assert.Equal(ChannelKind.RemoteChat, descriptors["slack"].Kind); + Assert.Equal(ChannelKind.RemoteChat, descriptors["discord"].Kind); + Assert.Equal(ChannelKind.RemoteChat, descriptors["mattermost"].Kind); + Assert.Equal(ChannelKind.LocalInteractiveClient, descriptors["tui"].Kind); + + Assert.Equal(ChannelType.Tui, descriptors["tui"].ChannelType); + Assert.NotEqual(ChannelType.SignalR, descriptors["tui"].ChannelType); + + foreach (var key in new[] { "slack", "discord", "mattermost" }) + { + var descriptor = descriptors[key]; + Assert.True(descriptor.IsEnabled); + Assert.True(descriptor.Capabilities.HasFlag(ChannelCapabilities.ReceiveMessages)); + Assert.True(descriptor.Capabilities.HasFlag(ChannelCapabilities.SendMessages)); + Assert.True(descriptor.Capabilities.HasFlag(ChannelCapabilities.RuntimeHealth)); + Assert.Contains(ChannelAddressKind.Destination, descriptor.AddressKinds); + Assert.Contains(ChannelOutputEffectKind.TextMessage, descriptor.SupportedOutputEffects); + Assert.Contains(ChannelToolIntentKind.SendMessage, descriptor.ToolIntents); + } + + Assert.True(descriptors["slack"].Capabilities.HasFlag(ChannelCapabilities.DirectMessages)); + Assert.True(descriptors["mattermost"].Capabilities.HasFlag(ChannelCapabilities.DirectMessages)); + Assert.False(descriptors["discord"].Capabilities.HasFlag(ChannelCapabilities.DirectMessages)); + + Assert.Contains(ChannelAddressKind.DirectMessage, descriptors["slack"].AddressKinds); + Assert.Contains(ChannelAddressKind.DirectMessage, descriptors["mattermost"].AddressKinds); + Assert.DoesNotContain(ChannelAddressKind.DirectMessage, descriptors["discord"].AddressKinds); + + Assert.True(descriptors["slack"].Capabilities.HasFlag(ChannelCapabilities.FileEgress)); + Assert.False(descriptors["discord"].Capabilities.HasFlag(ChannelCapabilities.FileEgress)); + Assert.False(descriptors["mattermost"].Capabilities.HasFlag(ChannelCapabilities.FileEgress)); + + Assert.Contains(ChannelOutputEffectKind.FileAttachment, descriptors["slack"].SupportedOutputEffects); + Assert.DoesNotContain(ChannelOutputEffectKind.FileAttachment, descriptors["discord"].SupportedOutputEffects); + Assert.DoesNotContain(ChannelOutputEffectKind.FileAttachment, descriptors["mattermost"].SupportedOutputEffects); + } + + [Fact] + public void Disabled_remote_channels_still_have_disabled_descriptors() + { + var descriptors = BuildDescriptors(new Dictionary + { + ["Slack:Enabled"] = "false", + ["Discord:Enabled"] = "false", + ["Mattermost:Enabled"] = "false" + }); + + Assert.False(descriptors["slack"].IsEnabled); + Assert.False(descriptors["discord"].IsEnabled); + Assert.False(descriptors["mattermost"].IsEnabled); + Assert.True(descriptors["tui"].IsEnabled); + } + + [Fact] + public void Registry_fails_loudly_on_duplicate_descriptor_keys() + { + var key = ChannelDescriptorKey.FromChannelType(ChannelType.Slack); + var descriptor = new ChannelDescriptor( + key, + ChannelType.Slack, + ChannelKind.RemoteChat, + "Slack", + IsEnabled: true, + ChannelCapabilities.SendMessages, + ToolIntents: new HashSet(), + AddressKinds: new HashSet(), + SupportedOutputEffects: new HashSet()); + + var providers = new IChannelDescriptorProvider[] + { + new StaticChannelDescriptorProvider(descriptor), + new StaticChannelDescriptorProvider(descriptor) + }; + + var ex = Assert.Throws(() => + new ChannelRegistry(providers, Array.Empty())); + + Assert.Contains("Duplicate channel descriptor key 'slack'", ex.Message, StringComparison.Ordinal); + } + + private static IReadOnlyDictionary BuildDescriptors( + IReadOnlyDictionary settings) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddChannelRegistry(); + services.AddTuiChannelDescriptor(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(settings) + .Build(); + + services.AddSlackChannelIntegration(configuration); + services.AddDiscordChannelIntegration(configuration); + services.AddMattermostChannelIntegration(configuration); + + using var provider = services.BuildServiceProvider(); + return provider.GetRequiredService() + .ListChannels() + .ToDictionary(descriptor => descriptor.Key.Value, StringComparer.Ordinal); + } +} diff --git a/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs index e4c2d341c..f5b6a513b 100644 --- a/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs @@ -25,6 +25,8 @@ public static void AddDiscordChannelIntegration(this IServiceCollection services { var discordOptions = configuration.GetSection("Discord").Get() ?? new DiscordChannelOptions(); services.AddSingleton(discordOptions); + services.AddChannelRegistry(); + services.AddChannelDescriptor(CreateDescriptor(discordOptions)); if (!discordOptions.Enabled) return; @@ -92,4 +94,39 @@ public static void AddDiscordChannelIntegration(this IServiceCollection services services.AddSingleton(sp => (IHostedService)sp.GetRequiredKeyedService(DiscordChannelKey)); } + + private static ChannelDescriptor CreateDescriptor(DiscordChannelOptions options) + { + var capabilities = ChannelCapabilities.ReceiveMessages + | ChannelCapabilities.SendMessages + | ChannelCapabilities.ThreadedConversations + | ChannelCapabilities.InteractiveApproval + | ChannelCapabilities.FileIngress + | ChannelCapabilities.ProactiveSend + | ChannelCapabilities.RuntimeHealth; + + var addressKinds = new HashSet + { + ChannelAddressKind.Destination, + ChannelAddressKind.Thread + }; + + return new ChannelDescriptor( + ChannelDescriptorKey.FromChannelType(ChannelType.Discord), + ChannelType.Discord, + ChannelKind.RemoteChat, + "Discord", + options.Enabled, + capabilities, + ToolIntents: new HashSet + { + ChannelToolIntentKind.SendMessage + }, + AddressKinds: addressKinds, + SupportedOutputEffects: new HashSet + { + ChannelOutputEffectKind.TextMessage, + ChannelOutputEffectKind.InteractiveApproval + }); + } } diff --git a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs index ebb0bca94..ff5ba71a0 100644 --- a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs @@ -25,6 +25,8 @@ public static void AddMattermostChannelIntegration(this IServiceCollection servi { var mattermostOptions = configuration.GetSection("Mattermost").Get() ?? new MattermostChannelOptions(); services.AddSingleton(mattermostOptions); + services.AddChannelRegistry(); + services.AddChannelDescriptor(CreateDescriptor(mattermostOptions)); if (!mattermostOptions.Enabled) return; @@ -116,4 +118,49 @@ public static void AddMattermostChannelIntegration(this IServiceCollection servi services.AddSingleton(sp => (IHostedService)sp.GetRequiredKeyedService(MattermostChannelKey)); } + + private static ChannelDescriptor CreateDescriptor(MattermostChannelOptions options) + { + var capabilities = ChannelCapabilities.ReceiveMessages + | ChannelCapabilities.SendMessages + | ChannelCapabilities.ThreadedConversations + | ChannelCapabilities.InteractiveApproval + | ChannelCapabilities.FileIngress + | ChannelCapabilities.ProactiveSend + | ChannelCapabilities.UserLookup + | ChannelCapabilities.DestinationLookup + | ChannelCapabilities.RuntimeHealth; + + if (options.AllowDirectMessages) + capabilities |= ChannelCapabilities.DirectMessages; + + var addressKinds = new HashSet + { + ChannelAddressKind.Destination, + ChannelAddressKind.User, + ChannelAddressKind.Thread + }; + + if (options.AllowDirectMessages) + addressKinds.Add(ChannelAddressKind.DirectMessage); + + return new ChannelDescriptor( + ChannelDescriptorKey.FromChannelType(ChannelType.Mattermost), + ChannelType.Mattermost, + ChannelKind.RemoteChat, + "Mattermost", + options.Enabled, + capabilities, + ToolIntents: new HashSet + { + ChannelToolIntentKind.SendMessage, + ChannelToolIntentKind.LookupUser + }, + AddressKinds: addressKinds, + SupportedOutputEffects: new HashSet + { + ChannelOutputEffectKind.TextMessage, + ChannelOutputEffectKind.InteractiveApproval + }); + } } diff --git a/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs index 5bc8e3a0a..053e278f3 100644 --- a/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs @@ -25,6 +25,8 @@ public static void AddSlackChannelIntegration(this IServiceCollection services, { var slackOptions = configuration.GetSection("Slack").Get() ?? new SlackChannelOptions(); services.AddSingleton(slackOptions); + services.AddChannelRegistry(); + services.AddChannelDescriptor(CreateDescriptor(slackOptions)); if (!slackOptions.Enabled) return; @@ -111,4 +113,51 @@ public static void AddSlackChannelIntegration(this IServiceCollection services, services.AddSingleton(sp => (IHostedService)sp.GetRequiredKeyedService(SlackChannelKey)); } + + private static ChannelDescriptor CreateDescriptor(SlackChannelOptions options) + { + var capabilities = ChannelCapabilities.ReceiveMessages + | ChannelCapabilities.SendMessages + | ChannelCapabilities.ThreadedConversations + | ChannelCapabilities.InteractiveApproval + | ChannelCapabilities.FileIngress + | ChannelCapabilities.FileEgress + | ChannelCapabilities.ProactiveSend + | ChannelCapabilities.UserLookup + | ChannelCapabilities.DestinationLookup + | ChannelCapabilities.RuntimeHealth; + + if (options.AllowDirectMessages) + capabilities |= ChannelCapabilities.DirectMessages; + + var addressKinds = new HashSet + { + ChannelAddressKind.Destination, + ChannelAddressKind.User, + ChannelAddressKind.Thread + }; + + if (options.AllowDirectMessages) + addressKinds.Add(ChannelAddressKind.DirectMessage); + + return new ChannelDescriptor( + ChannelDescriptorKey.FromChannelType(ChannelType.Slack), + ChannelType.Slack, + ChannelKind.RemoteChat, + "Slack", + options.Enabled, + capabilities, + ToolIntents: new HashSet + { + ChannelToolIntentKind.SendMessage, + ChannelToolIntentKind.LookupUser + }, + AddressKinds: addressKinds, + SupportedOutputEffects: new HashSet + { + ChannelOutputEffectKind.TextMessage, + ChannelOutputEffectKind.InteractiveApproval, + ChannelOutputEffectKind.FileAttachment + }); + } } diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index afd8c60ad..b23d6b501 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -26,6 +26,7 @@ using Netclaw.Actors.Skills; using Netclaw.Actors.SubAgents; using Netclaw.Actors.Tools; +using Netclaw.Channels; using Netclaw.Configuration; using Netclaw.Configuration.Http; using Netclaw.Providers; @@ -1068,6 +1069,8 @@ static void ConfigureDaemonServices( services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddChannelRegistry(); + services.AddTuiChannelDescriptor(); services.AddSlackChannelIntegration(configuration); services.AddDiscordChannelIntegration(configuration); services.AddMattermostChannelIntegration(configuration); From 9fcfa5b4eb0e69d1361b66fa27f8b5c668465b92 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 6 Jun 2026 12:00:11 +0000 Subject: [PATCH 09/31] Clarify channel delivery follow-up tasks --- .../changes/standardize-channel-delivery-contracts/tasks.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md index cd4020bd9..7dfe492e7 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/tasks.md +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -27,6 +27,10 @@ - [ ] 4.3 Adapt Discord runtime health to the standard snapshot shape without changing Discord behavior. - [ ] 4.4 Adapt Mattermost runtime health to the standard snapshot shape without actorizing it yet. - [x] 4.5 Represent TUI as a local interactive channel and SignalR as daemon infrastructure, not as the same channel record. +- [x] 4.6 Keep first-slice descriptors limited to implemented delivery behavior, not aspirational roadmap capabilities. +- [ ] 4.7 Implement Discord proactive DM output before advertising `DirectMessages` or `DirectMessage` address support on the Discord descriptor. +- [ ] 4.8 Implement Discord `FileOutput` upload before advertising `FileEgress` or `FileAttachment` support on the Discord descriptor. +- [ ] 4.9 Implement Mattermost `FileOutput` upload before advertising `FileEgress` or `FileAttachment` support on the Mattermost descriptor. ## 5. Trigger-source consumers @@ -59,7 +63,7 @@ - [ ] 8.4 Rename/map existing Mattermost tools to the standard tool names and intent schema. - [ ] 8.5 Update system skills, CLI/help text, and eval cases for renamed LLM-facing channel tools. -## 9. Stateful channel lifecycle follow-up +## 9. Channel output effects follow-up - [ ] 9.1 Add a channel output renderer contract for semantic `SessionOutput` effects. - [ ] 9.2 Add contract tests for supported optional output effects, unsupported optional output effects, and unsupported required output effects. From 70820721f02183d4e93f9c8cb4a409d7bae8fc3f Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 6 Jun 2026 12:24:27 +0000 Subject: [PATCH 10/31] Add registry-driven channel runtime status --- .../tasks.md | 11 +- .../ChannelDeliveryContracts.cs | 119 +++++++++- .../ChannelRegistryRegistrationTests.cs | 40 +++- .../DaemonRuntimeStatusServiceTests.cs | 217 +++++++++++------- .../DiscordChannelRegistrationExtensions.cs | 2 +- ...MattermostChannelRegistrationExtensions.cs | 2 +- .../SlackChannelRegistrationExtensions.cs | 2 +- .../Gateway/DaemonRuntimeStatusService.cs | 86 +++---- 8 files changed, 323 insertions(+), 156 deletions(-) diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md index 7dfe492e7..0151d05e9 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/tasks.md +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -23,9 +23,9 @@ ## 4. Existing channel coverage - [x] 4.1 Register channel descriptors for Slack, Discord, Mattermost, and TUI or explicitly mark unsupported/not-configured output channels. -- [ ] 4.2 Adapt Slack runtime health to the standard snapshot shape without changing Slack behavior. -- [ ] 4.3 Adapt Discord runtime health to the standard snapshot shape without changing Discord behavior. -- [ ] 4.4 Adapt Mattermost runtime health to the standard snapshot shape without actorizing it yet. +- [x] 4.2 Adapt Slack runtime health to the standard snapshot shape without changing Slack behavior. +- [x] 4.3 Adapt Discord runtime health to the standard snapshot shape without changing Discord behavior. +- [x] 4.4 Adapt Mattermost runtime health to the standard snapshot shape without actorizing it yet. - [x] 4.5 Represent TUI as a local interactive channel and SignalR as daemon infrastructure, not as the same channel record. - [x] 4.6 Keep first-slice descriptors limited to implemented delivery behavior, not aspirational roadmap capabilities. - [ ] 4.7 Implement Discord proactive DM output before advertising `DirectMessages` or `DirectMessage` address support on the Discord descriptor. @@ -40,10 +40,10 @@ ## 6. Descriptor-driven observability -- [ ] 6.1 Change daemon runtime status to enumerate the channel registry instead of hard-coding individual channel adapters. +- [x] 6.1 Change daemon runtime status to enumerate the channel registry instead of hard-coding individual channel adapters. - [ ] 6.2 Change daemon stats channel activity to enumerate descriptor-backed output channels. - [ ] 6.3 Keep trigger-source status separate from channel status when reminder or webhook operational state is reported. -- [ ] 6.4 Preserve current status/stats output fields or provide explicit compatibility mapping. +- [x] 6.4 Preserve current status/stats output fields or provide explicit compatibility mapping. ## 7. Address resolution @@ -62,6 +62,7 @@ - [ ] 8.3 Rename/map existing Discord tools to the standard tool names and intent schema. - [ ] 8.4 Rename/map existing Mattermost tools to the standard tool names and intent schema. - [ ] 8.5 Update system skills, CLI/help text, and eval cases for renamed LLM-facing channel tools. +- [ ] 8.6 Define the standardized DM send workflow as user lookup -> direct-message delivery target -> send-channel-message, gated by each channel descriptor's implemented DM output capability. ## 9. Channel output effects follow-up diff --git a/src/Netclaw.Channels/ChannelDeliveryContracts.cs b/src/Netclaw.Channels/ChannelDeliveryContracts.cs index 753d8b019..b9d2fe4c4 100644 --- a/src/Netclaw.Channels/ChannelDeliveryContracts.cs +++ b/src/Netclaw.Channels/ChannelDeliveryContracts.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Netclaw.Actors.Channels; +using Netclaw.Channels.Telemetry; namespace Netclaw.Channels; @@ -264,6 +265,107 @@ public sealed class StaticChannelDescriptorProvider(ChannelDescriptor descriptor public ChannelDescriptor GetDescriptor() => descriptor; } +public sealed class DescriptorChannelRuntimeSnapshotProvider : IChannelRuntimeSnapshotProvider +{ + private readonly ChannelDescriptor _descriptor; + private readonly Func> _channelsAccessor; + + public DescriptorChannelRuntimeSnapshotProvider( + ChannelDescriptor descriptor, + IEnumerable channels) + : this(descriptor, () => channels) + { + } + + public DescriptorChannelRuntimeSnapshotProvider( + ChannelDescriptor descriptor, + Func> channelsAccessor) + { + ArgumentNullException.ThrowIfNull(descriptor); + ArgumentNullException.ThrowIfNull(channelsAccessor); + + _descriptor = descriptor; + _channelsAccessor = channelsAccessor; + } + + public ChannelDescriptorKey Key => _descriptor.Key; + + public async ValueTask GetSnapshotAsync(CancellationToken cancellationToken = default) + { + if (!_descriptor.IsEnabled) + { + return new ChannelRuntimeSnapshot( + _descriptor.Key, + IsEnabled: false, + ChannelHealthStatus.Degraded, + HealthDetail: $"{_descriptor.DisplayName} connector is disabled in configuration.", + IsConnected: false, + IsReady: false, + Activity: BuildActivitySnapshot(_descriptor.ChannelType)); + } + + var channel = ResolveRuntimeChannel(); + if (channel is null) + { + return _descriptor.Kind == ChannelKind.LocalInteractiveClient + ? new ChannelRuntimeSnapshot( + _descriptor.Key, + IsEnabled: true, + ChannelHealthStatus.Healthy, + IsReady: true, + Activity: BuildActivitySnapshot(_descriptor.ChannelType)) + : new ChannelRuntimeSnapshot( + _descriptor.Key, + IsEnabled: true, + ChannelHealthStatus.Disconnected, + HealthDetail: $"{_descriptor.DisplayName} connector is enabled but was not registered.", + IsConnected: false, + IsReady: false, + Activity: BuildActivitySnapshot(_descriptor.ChannelType)); + } + + var health = await channel.GetHealthAsync(cancellationToken); + return new ChannelRuntimeSnapshot( + _descriptor.Key, + IsEnabled: true, + health.Status, + HealthDetail: health.Detail, + IsConnected: health.Status != ChannelHealthStatus.Disconnected, + IsReady: health.Status == ChannelHealthStatus.Healthy, + Activity: BuildActivitySnapshot(_descriptor.ChannelType)); + } + + private IChannel? ResolveRuntimeChannel() + { + IChannel? match = null; + foreach (var channel in _channelsAccessor()) + { + if (channel.ChannelType != _descriptor.ChannelType) + continue; + + if (match is not null) + throw new InvalidOperationException($"Multiple runtime channels are registered for '{_descriptor.Key}'."); + + match = channel; + } + + return match; + } + + private static ChannelActivitySnapshot? BuildActivitySnapshot(ChannelType channelType) + { + var metrics = ChannelTelemetry.GetAllSnapshots() + .FirstOrDefault(snapshot => snapshot.ChannelType == channelType); + + if (metrics is null) + return null; + + return new ChannelActivitySnapshot( + InputCount: metrics.EventsReceived, + OutputCount: metrics.RepliesPosted); + } +} + public static class ChannelRegistryServiceCollectionExtensions { public static IServiceCollection AddChannelRegistry(this IServiceCollection services) @@ -285,11 +387,26 @@ public static IServiceCollection AddChannelDescriptor( return services; } + public static IServiceCollection AddChannelDescriptorWithRuntimeSnapshot( + this IServiceCollection services, + ChannelDescriptor descriptor) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(descriptor); + + services.AddChannelDescriptor(descriptor); + services.AddSingleton(sp => + new DescriptorChannelRuntimeSnapshotProvider( + descriptor, + () => sp.GetServices())); + return services; + } + public static IServiceCollection AddTuiChannelDescriptor(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); - return services.AddChannelDescriptor(new ChannelDescriptor( + return services.AddChannelDescriptorWithRuntimeSnapshot(new ChannelDescriptor( ChannelDescriptorKey.FromChannelType(ChannelType.Tui), ChannelType.Tui, ChannelKind.LocalInteractiveClient, diff --git a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs index ad79f9f14..f3fb2af42 100644 --- a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs +++ b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs @@ -89,6 +89,33 @@ public void Disabled_remote_channels_still_have_disabled_descriptors() Assert.True(descriptors["tui"].IsEnabled); } + [Fact] + public async Task Registry_returns_runtime_snapshots_for_registered_descriptors() + { + using var provider = BuildProvider(new Dictionary + { + ["Slack:Enabled"] = "false", + ["Discord:Enabled"] = "false", + ["Mattermost:Enabled"] = "false" + }); + + var registry = provider.GetRequiredService(); + + var slack = await registry.GetSnapshotAsync( + ChannelDescriptorKey.FromChannelType(ChannelType.Slack), + TestContext.Current.CancellationToken); + Assert.False(slack.IsEnabled); + Assert.Equal(ChannelHealthStatus.Degraded, slack.Health); + Assert.Equal("Slack connector is disabled in configuration.", slack.HealthDetail); + + var tui = await registry.GetSnapshotAsync( + ChannelDescriptorKey.FromChannelType(ChannelType.Tui), + TestContext.Current.CancellationToken); + Assert.True(tui.IsEnabled); + Assert.Equal(ChannelHealthStatus.Healthy, tui.Health); + Assert.True(tui.IsReady); + } + [Fact] public void Registry_fails_loudly_on_duplicate_descriptor_keys() { @@ -118,6 +145,14 @@ public void Registry_fails_loudly_on_duplicate_descriptor_keys() private static IReadOnlyDictionary BuildDescriptors( IReadOnlyDictionary settings) + { + using var provider = BuildProvider(settings); + return provider.GetRequiredService() + .ListChannels() + .ToDictionary(descriptor => descriptor.Key.Value, StringComparer.Ordinal); + } + + private static ServiceProvider BuildProvider(IReadOnlyDictionary settings) { var services = new ServiceCollection(); services.AddLogging(); @@ -132,9 +167,6 @@ private static IReadOnlyDictionary BuildDescriptors( services.AddDiscordChannelIntegration(configuration); services.AddMattermostChannelIntegration(configuration); - using var provider = services.BuildServiceProvider(); - return provider.GetRequiredService() - .ListChannels() - .ToDictionary(descriptor => descriptor.Key.Value, StringComparer.Ordinal); + return services.BuildServiceProvider(); } } diff --git a/src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs b/src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs index 959125630..2e85009f1 100644 --- a/src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs +++ b/src/Netclaw.Daemon.Tests/Gateway/DaemonRuntimeStatusServiceTests.cs @@ -7,11 +7,10 @@ using Microsoft.Data.Sqlite; using Microsoft.Extensions.Options; using Microsoft.Extensions.Logging.Abstractions; +using Netclaw.Actors.Channels; using Netclaw.Actors.Memory; using Netclaw.Actors.Tools; using Netclaw.Channels; -using Netclaw.Channels.Discord; -using Netclaw.Channels.Slack; using Netclaw.Channels.Telemetry; using Netclaw.Configuration; using Netclaw.Daemon.Configuration; @@ -43,6 +42,58 @@ public sealed class DaemonRuntimeStatusServiceTests : IAsyncLifetime private NetclawPaths CreatePaths() => new(_tempBase); + private DaemonRuntimeStatusService CreateService( + IChannelRegistry? channelRegistry = null, + DaemonPersistenceOptions? persistenceOptions = null, + IOptions? telemetryOptions = null, + ModelCapabilities? modelCapabilities = null, + ModelSelection? modelSelection = null, + DaemonConfig? daemonConfig = null, + NetclawPaths? paths = null, + McpClientManager? mcpClientManager = null, + SQLiteMemoryStore? sqliteMemoryStore = null) + { + return new DaemonRuntimeStatusService( + new DaemonStartClock(TimeProvider.System), + TimeProvider.System, + channelRegistry ?? CreateRegistry([]), + persistenceOptions ?? new DaemonPersistenceOptions(), + telemetryOptions ?? Options.Create(new TelemetryOptions()), + modelCapabilities ?? DefaultModelCapabilities, + modelSelection ?? DefaultModelSelection, + daemonConfig ?? new DaemonConfig(), + paths ?? CreatePaths(), + mcpClientManager, + sqliteMemoryStore); + } + + private static IChannelRegistry CreateRegistry( + IReadOnlyCollection descriptors, + IReadOnlyCollection? channels = null) + { + channels ??= []; + return new ChannelRegistry( + descriptors.Select(descriptor => new StaticChannelDescriptorProvider(descriptor)), + descriptors.Select(descriptor => new DescriptorChannelRuntimeSnapshotProvider(descriptor, channels))); + } + + private static ChannelDescriptor Descriptor( + ChannelType channelType, + bool enabled, + ChannelKind kind = ChannelKind.RemoteChat) + { + return new ChannelDescriptor( + ChannelDescriptorKey.FromChannelType(channelType), + channelType, + kind, + channelType.ToString(), + enabled, + ChannelCapabilities.RuntimeHealth, + ToolIntents: new HashSet(), + AddressKinds: new HashSet(), + SupportedOutputEffects: new HashSet()); + } + public ValueTask InitializeAsync() => ValueTask.CompletedTask; public async ValueTask DisposeAsync() @@ -88,18 +139,9 @@ private static async Task TryDeleteDirectoryAsync(string path) [Fact] public async Task IncludesSlackConnectorAsDisabled_WhenNotEnabled() { - var service = new DaemonRuntimeStatusService( - new DaemonStartClock(TimeProvider.System), - TimeProvider.System, - channels: Array.Empty(), - slackOptions: new SlackChannelOptions { Enabled = false }, - discordOptions: new DiscordChannelOptions { Enabled = false }, - persistenceOptions: new DaemonPersistenceOptions(), - telemetryOptions: Options.Create(new TelemetryOptions()), - modelCapabilities: DefaultModelCapabilities, - modelSelection: DefaultModelSelection, - daemonConfig: new DaemonConfig(), - paths: CreatePaths()); + var service = CreateService(channelRegistry: CreateRegistry([ + Descriptor(ChannelType.Slack, enabled: false) + ])); var status = await service.GetStatusAsync(TestContext.Current.CancellationToken); var slack = status.Connectors.Single(c => c.Key == "slack"); @@ -111,18 +153,9 @@ public async Task IncludesSlackConnectorAsDisabled_WhenNotEnabled() [Fact] public async Task ReportsSlackAsDisconnected_WhenEnabledButMissingRuntimeChannel() { - var service = new DaemonRuntimeStatusService( - new DaemonStartClock(TimeProvider.System), - TimeProvider.System, - channels: Array.Empty(), - slackOptions: new SlackChannelOptions { Enabled = true, AllowedChannelIds = ["C1"] }, - discordOptions: new DiscordChannelOptions { Enabled = false }, - persistenceOptions: new DaemonPersistenceOptions(), - telemetryOptions: Options.Create(new TelemetryOptions()), - modelCapabilities: DefaultModelCapabilities, - modelSelection: DefaultModelSelection, - daemonConfig: new DaemonConfig(), - paths: CreatePaths()); + var service = CreateService(channelRegistry: CreateRegistry([ + Descriptor(ChannelType.Slack, enabled: true) + ])); var status = await service.GetStatusAsync(TestContext.Current.CancellationToken); var slack = status.Connectors.Single(c => c.Key == "slack"); @@ -131,21 +164,54 @@ public async Task ReportsSlackAsDisconnected_WhenEnabledButMissingRuntimeChannel Assert.Equal("disconnected", slack.Status); } + [Fact] + public async Task StatusEnumeratesDescriptorBackedOutputChannels() + { + var service = CreateService(channelRegistry: CreateRegistry([ + Descriptor(ChannelType.Slack, enabled: false), + Descriptor(ChannelType.Discord, enabled: false), + Descriptor(ChannelType.Mattermost, enabled: false), + Descriptor(ChannelType.Tui, enabled: true, ChannelKind.LocalInteractiveClient) + ])); + + var status = await service.GetStatusAsync(TestContext.Current.CancellationToken); + + Assert.Equal( + new[] { "discord", "mattermost", "slack", "tui" }, + status.Connectors.Select(connector => connector.Key).Order(StringComparer.Ordinal)); + + var mattermost = status.Connectors.Single(connector => connector.Key == "mattermost"); + Assert.False(mattermost.Enabled); + Assert.Equal("disabled", mattermost.Status); + + var tui = status.Connectors.Single(connector => connector.Key == "tui"); + Assert.True(tui.Enabled); + Assert.Equal("healthy", tui.Status); + } + + [Fact] + public async Task StatusMapsRuntimeChannelHealthThroughSnapshotProvider() + { + var channel = new TestChannel( + ChannelType.Slack, + new ChannelHealth(ChannelHealthStatus.Healthy)); + + var service = CreateService(channelRegistry: CreateRegistry([ + Descriptor(ChannelType.Slack, enabled: true) + ], [channel])); + + var status = await service.GetStatusAsync(TestContext.Current.CancellationToken); + var slack = status.Connectors.Single(connector => connector.Key == "slack"); + + Assert.True(slack.Enabled); + Assert.Equal("healthy", slack.Status); + Assert.Null(slack.Message); + } + [Fact] public async Task StatusIncludesModelCapabilities() { - var service = new DaemonRuntimeStatusService( - new DaemonStartClock(TimeProvider.System), - TimeProvider.System, - channels: Array.Empty(), - slackOptions: new SlackChannelOptions { Enabled = false }, - discordOptions: new DiscordChannelOptions { Enabled = false }, - persistenceOptions: new DaemonPersistenceOptions(), - telemetryOptions: Options.Create(new TelemetryOptions()), - modelCapabilities: DefaultModelCapabilities, - modelSelection: DefaultModelSelection, - daemonConfig: new DaemonConfig(), - paths: CreatePaths()); + var service = CreateService(); var status = await service.GetStatusAsync(TestContext.Current.CancellationToken); @@ -196,19 +262,7 @@ public async Task IncludesMcpConnectorHealthFromRuntimeStatuses() await manager.StartAsync(CancellationToken.None); try { - var service = new DaemonRuntimeStatusService( - new DaemonStartClock(TimeProvider.System), - TimeProvider.System, - channels: Array.Empty(), - slackOptions: new SlackChannelOptions { Enabled = false }, - discordOptions: new DiscordChannelOptions { Enabled = false }, - persistenceOptions: new DaemonPersistenceOptions(), - telemetryOptions: Options.Create(new TelemetryOptions()), - modelCapabilities: DefaultModelCapabilities, - modelSelection: DefaultModelSelection, - daemonConfig: new DaemonConfig(), - paths: CreatePaths(), - mcpClientManager: manager); + var service = CreateService(mcpClientManager: manager); var status = await service.GetStatusAsync(TestContext.Current.CancellationToken); @@ -262,19 +316,7 @@ public async Task StatusIncludesMemory_SqliteBackend() var sqliteStore = new SQLiteMemoryStore(paths.MemorySqliteDbPath, TimeProvider.System); await sqliteStore.InitializeAsync(TestContext.Current.CancellationToken); - var service = new DaemonRuntimeStatusService( - new DaemonStartClock(TimeProvider.System), - TimeProvider.System, - channels: Array.Empty(), - slackOptions: new SlackChannelOptions { Enabled = false }, - discordOptions: new DiscordChannelOptions { Enabled = false }, - persistenceOptions: new DaemonPersistenceOptions(), - telemetryOptions: Options.Create(new TelemetryOptions()), - modelCapabilities: DefaultModelCapabilities, - modelSelection: DefaultModelSelection, - daemonConfig: new DaemonConfig(), - paths: paths, - sqliteMemoryStore: sqliteStore); + var service = CreateService(paths: paths, sqliteMemoryStore: sqliteStore); var status = await service.GetStatusAsync(TestContext.Current.CancellationToken); @@ -298,18 +340,10 @@ public async Task StatusIncludesChannelCountersForEnabledChannels() slack.RecordReplyPosted(42); slack.RecordReplyFailed(77); - var service = new DaemonRuntimeStatusService( - new DaemonStartClock(TimeProvider.System), - TimeProvider.System, - channels: Array.Empty(), - slackOptions: new SlackChannelOptions { Enabled = true }, - discordOptions: new DiscordChannelOptions { Enabled = false }, - persistenceOptions: new DaemonPersistenceOptions(), - telemetryOptions: Options.Create(new TelemetryOptions()), - modelCapabilities: DefaultModelCapabilities, - modelSelection: DefaultModelSelection, - daemonConfig: new DaemonConfig(), - paths: CreatePaths()); + var service = CreateService(channelRegistry: CreateRegistry([ + Descriptor(ChannelType.Slack, enabled: true), + Descriptor(ChannelType.Discord, enabled: false) + ])); var status = await service.GetStatusAsync(TestContext.Current.CancellationToken); @@ -325,22 +359,29 @@ public async Task StatusIncludesChannelCountersForEnabledChannels() [Fact] public async Task StatusIncludesSelfUpdateDisabledFlag() { - var service = new DaemonRuntimeStatusService( - new DaemonStartClock(TimeProvider.System), - TimeProvider.System, - channels: Array.Empty(), - slackOptions: new SlackChannelOptions { Enabled = false }, - discordOptions: new DiscordChannelOptions { Enabled = false }, - persistenceOptions: new DaemonPersistenceOptions(), - telemetryOptions: Options.Create(new TelemetryOptions()), - modelCapabilities: DefaultModelCapabilities, - modelSelection: DefaultModelSelection, - daemonConfig: new DaemonConfig { DisableSelfUpdate = true }, - paths: CreatePaths()); + var service = CreateService(daemonConfig: new DaemonConfig { DisableSelfUpdate = true }); var status = await service.GetStatusAsync(TestContext.Current.CancellationToken); Assert.NotNull(status.Update); Assert.True(status.Update!.SelfUpdateDisabled); } + + private sealed class TestChannel( + ChannelType channelType, + ChannelHealth health) : IChannel + { + public ChannelType ChannelType => channelType; + + public string DisplayName => channelType.ToString(); + + public ValueTask GetHealthAsync(CancellationToken cancellationToken = default) + { + return ValueTask.FromResult(health); + } + + public Task StartAsync(CancellationToken cancellationToken) => Task.CompletedTask; + + public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask; + } } diff --git a/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs index f5b6a513b..87cacc215 100644 --- a/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs @@ -26,7 +26,7 @@ public static void AddDiscordChannelIntegration(this IServiceCollection services var discordOptions = configuration.GetSection("Discord").Get() ?? new DiscordChannelOptions(); services.AddSingleton(discordOptions); services.AddChannelRegistry(); - services.AddChannelDescriptor(CreateDescriptor(discordOptions)); + services.AddChannelDescriptorWithRuntimeSnapshot(CreateDescriptor(discordOptions)); if (!discordOptions.Enabled) return; diff --git a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs index ff5ba71a0..c51164158 100644 --- a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs @@ -26,7 +26,7 @@ public static void AddMattermostChannelIntegration(this IServiceCollection servi var mattermostOptions = configuration.GetSection("Mattermost").Get() ?? new MattermostChannelOptions(); services.AddSingleton(mattermostOptions); services.AddChannelRegistry(); - services.AddChannelDescriptor(CreateDescriptor(mattermostOptions)); + services.AddChannelDescriptorWithRuntimeSnapshot(CreateDescriptor(mattermostOptions)); if (!mattermostOptions.Enabled) return; diff --git a/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs index 053e278f3..2655de984 100644 --- a/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs @@ -26,7 +26,7 @@ public static void AddSlackChannelIntegration(this IServiceCollection services, var slackOptions = configuration.GetSection("Slack").Get() ?? new SlackChannelOptions(); services.AddSingleton(slackOptions); services.AddChannelRegistry(); - services.AddChannelDescriptor(CreateDescriptor(slackOptions)); + services.AddChannelDescriptorWithRuntimeSnapshot(CreateDescriptor(slackOptions)); if (!slackOptions.Enabled) return; diff --git a/src/Netclaw.Daemon/Gateway/DaemonRuntimeStatusService.cs b/src/Netclaw.Daemon/Gateway/DaemonRuntimeStatusService.cs index 8756a73cf..c5d79a999 100644 --- a/src/Netclaw.Daemon/Gateway/DaemonRuntimeStatusService.cs +++ b/src/Netclaw.Daemon/Gateway/DaemonRuntimeStatusService.cs @@ -7,13 +7,10 @@ using Akka.Actor; using Akka.Hosting; using Microsoft.Extensions.Options; -using Netclaw.Actors.Channels; using Netclaw.Actors.Hosting; using Netclaw.Actors.Memory; using Netclaw.Actors.Reminders; using Netclaw.Channels; -using Netclaw.Channels.Discord; -using Netclaw.Channels.Slack; using Netclaw.Channels.Telemetry; using Netclaw.Configuration; using Netclaw.Configuration.Feeds; @@ -27,9 +24,7 @@ namespace Netclaw.Daemon.Gateway; internal sealed class DaemonRuntimeStatusService( DaemonStartClock startClock, TimeProvider timeProvider, - IEnumerable channels, - SlackChannelOptions slackOptions, - DiscordChannelOptions discordOptions, + IChannelRegistry channelRegistry, DaemonPersistenceOptions persistenceOptions, IOptions telemetryOptions, ModelCapabilities modelCapabilities, @@ -46,14 +41,7 @@ internal sealed class DaemonRuntimeStatusService( var now = timeProvider.GetUtcNow(); var uptime = now - startClock.StartedAt; - var channelLookup = channels.ToDictionary(c => c.ChannelType); - var connectors = new List - { - await BuildChannelStatusAsync(channelLookup, Actors.Channels.ChannelType.Slack, - slackOptions.Enabled, "slack", "Slack", cancellationToken), - await BuildChannelStatusAsync(channelLookup, Actors.Channels.ChannelType.Discord, - discordOptions.Enabled, "discord", "Discord", cancellationToken) - }; + var connectors = await BuildChannelStatusesAsync(cancellationToken); connectors.AddRange(BuildMcpStatuses()); @@ -97,9 +85,10 @@ await BuildChannelStatusAsync(channelLookup, Actors.Channels.ChannelType.Discord private DaemonRuntimeStatus.Telemetry BuildTelemetry() { - var enabledChannelTypes = new HashSet(); - if (slackOptions.Enabled) enabledChannelTypes.Add(Actors.Channels.ChannelType.Slack); - if (discordOptions.Enabled) enabledChannelTypes.Add(Actors.Channels.ChannelType.Discord); + var enabledChannelTypes = channelRegistry.ListChannels() + .Where(descriptor => descriptor.IsEnabled) + .Select(descriptor => descriptor.ChannelType) + .ToHashSet(); var channelActivities = ChannelTelemetry.GetAllSnapshots() .Where(s => enabledChannelTypes.Contains(s.ChannelType)) @@ -114,52 +103,39 @@ private DaemonRuntimeStatus.Telemetry BuildTelemetry() }; } - private static async Task BuildChannelStatusAsync( - Dictionary channelLookup, - Actors.Channels.ChannelType channelType, - bool enabled, - string key, - string displayName, + private async Task> BuildChannelStatusesAsync( CancellationToken cancellationToken) { - if (!enabled) - { - return new DaemonRuntimeStatus.Connector - { - Key = key, - DisplayName = displayName, - Enabled = false, - Status = "disabled", - Message = $"{displayName} connector is disabled in configuration." - }; - } + var connectors = new List(); - if (!channelLookup.TryGetValue(channelType, out var channel)) + foreach (var descriptor in channelRegistry.ListChannels()) { - return new DaemonRuntimeStatus.Connector - { - Key = key, - DisplayName = displayName, - Enabled = true, - Status = "disconnected", - Message = $"{displayName} connector is enabled but was not registered." - }; + var snapshot = await channelRegistry.GetSnapshotAsync(descriptor.Key, cancellationToken); + connectors.Add(ToConnector(descriptor, snapshot)); } - var health = await channel.GetHealthAsync(cancellationToken); + return connectors; + } + + internal static DaemonRuntimeStatus.Connector ToConnector( + ChannelDescriptor descriptor, + ChannelRuntimeSnapshot snapshot) + { return new DaemonRuntimeStatus.Connector { - Key = key, - DisplayName = channel.DisplayName, - Enabled = true, - Status = health.Status switch - { - ChannelHealthStatus.Healthy => "healthy", - ChannelHealthStatus.Degraded => "degraded", - ChannelHealthStatus.Disconnected => "disconnected", - _ => "unknown" - }, - Message = health.Detail + Key = descriptor.Key.Value, + DisplayName = descriptor.DisplayName, + Enabled = snapshot.IsEnabled, + Status = snapshot.IsEnabled + ? snapshot.Health switch + { + ChannelHealthStatus.Healthy => "healthy", + ChannelHealthStatus.Degraded => "degraded", + ChannelHealthStatus.Disconnected => "disconnected", + _ => "unknown" + } + : "disabled", + Message = snapshot.HealthDetail }; } From 840eb1a042e350902f68489d7617a036a5375c4c Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 6 Jun 2026 20:05:36 +0000 Subject: [PATCH 11/31] Clarify channel tool standardization --- .../design.md | 35 +++++- .../specs/netclaw-input-adapters/spec.md | 62 +++++++++- .../tasks.md | 3 +- .../ChannelRegistryRegistrationTests.cs | 110 +++++++++++++++++- .../Netclaw.Daemon.Tests.csproj | 2 + 5 files changed, 206 insertions(+), 6 deletions(-) diff --git a/openspec/changes/standardize-channel-delivery-contracts/design.md b/openspec/changes/standardize-channel-delivery-contracts/design.md index da57f3e54..0f19950d7 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/design.md +++ b/openspec/changes/standardize-channel-delivery-contracts/design.md @@ -560,7 +560,12 @@ Rules: ## LLM-Facing Channel Tool Intents -The tool registry should describe channel tools in terms of standard intents: +The LLM-facing tool surface should expose one generic tool per channel intent, +not one tool name per channel. Channel selection is an explicit argument so the +tool surface stays small while the runtime can still enforce descriptor-backed +capability checks. + +The standard tool names are: - `send_channel_message`: channel key, destination, text, optional thread/root target, optional audience/context hints. @@ -568,6 +573,34 @@ The tool registry should describe channel tools in terms of standard intents: - `lookup_channel_destination`: channel key, query, destination kind, optional exact-only flag. +Tool schema rules: + +- `channel_key` is required, appears first in each schema, and is constrained to + an enum generated from enabled channel descriptors. +- Tool descriptions must give explicit examples such as `channel_key=slack`, + `channel_key=discord`, and `channel_key=mattermost` so smaller models do not + have to infer the selector from prose alone. +- Lookup results must include the originating `channel_key`, address kind, + stable platform ID, and display name. +- `send_channel_message` accepts resolved delivery destinations only. It rejects + bare display-name recipients and requires callers to use lookup tools first + unless they already have a stable platform ID. +- `send_channel_message` fails loudly when the requested `channel_key` does not + match the delivery destination's `channel_key`. +- Unsupported capabilities, such as direct messages on a channel descriptor that + does not advertise DM support, fail loudly instead of silently falling back to + another address kind. + +Direct-message workflow: + +1. Resolve the user with `lookup_channel_user(channel_key, query)`. +2. Send with `send_channel_message(channel_key, destination.kind=direct_message, + destination.id=, text=...)`. + +This keeps a single send tool while preserving explicit channel and destination +intent. A channel-specific public alias should only be added if evals show the +generic enum-selected tool is unreliable for target model tiers. + Existing tool names such as `send_slack_message`, `send_discord_message`, and `send_mattermost_message` are not compatibility requirements. The implementation may rename them to the standardized tool names once the registry can enumerate diff --git a/openspec/changes/standardize-channel-delivery-contracts/specs/netclaw-input-adapters/spec.md b/openspec/changes/standardize-channel-delivery-contracts/specs/netclaw-input-adapters/spec.md index e6ce2b72d..0fb2683ac 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/specs/netclaw-input-adapters/spec.md +++ b/openspec/changes/standardize-channel-delivery-contracts/specs/netclaw-input-adapters/spec.md @@ -181,21 +181,77 @@ names are not compatibility requirements and MAY be renamed during migration. When tool names change, system skills and eval cases SHALL be updated in the same implementation change. +Standardized channel tools SHALL use generic LLM-facing names rather than +channel-specific names: `send_channel_message`, `lookup_channel_user`, and +`lookup_channel_destination`. Each standardized channel tool SHALL require a +`channel_key` argument as the first schema property. The `channel_key` argument +SHALL be enum-constrained from enabled channel descriptors and SHALL NOT be a +free-form string. + +Lookup results SHALL include the originating `channel_key`, resolved address +kind, stable platform ID, and display name. `send_channel_message` SHALL reject +destinations whose `channel_key` does not match the requested `channel_key`. +`send_channel_message` SHALL reject bare user-facing display names; callers MUST +use a lookup tool first unless they already have a stable platform ID. + +Direct messages SHALL use the same `send_channel_message` tool with a resolved +`direct_message` destination. User-DM sends SHALL follow the workflow: +`lookup_channel_user(channel_key, query)` -> +`send_channel_message(channel_key, destination.kind=direct_message, +destination.id=, text=...)`. If the selected channel descriptor +does not advertise implemented direct-message output capability, the send SHALL +fail loudly rather than falling back to a channel post or another channel. + #### Scenario: Send-message tools share a common channel target model - **GIVEN** Slack, Discord, and Mattermost expose send-message tools - **WHEN** their tool definitions are inspected -- **THEN** each tool accepts a channel key, destination, text, and optional thread - or root target using the standard send-channel-message intent schema +- **THEN** `send_channel_message` accepts a required enum-constrained + `channel_key`, destination, text, and optional thread or root target using the + standard send-channel-message intent schema - **AND** unsupported options are omitted or reported as unsupported rather than silently ignored +#### Scenario: Channel selector is explicit and enum-constrained + +- **GIVEN** Slack, Discord, and Mattermost are enabled channel descriptors +- **WHEN** standardized channel tools are registered +- **THEN** each tool schema lists `channel_key` as a required first property +- **AND** the schema constrains `channel_key` to the enabled descriptor keys +- **AND** the schema does not accept arbitrary channel names + +#### Scenario: User direct message uses lookup then send + +- **GIVEN** the user asks the agent to send a direct message to a user on Slack, + Discord, or Mattermost +- **WHEN** the agent does not already have the user's stable platform ID +- **THEN** it first calls `lookup_channel_user` with the selected `channel_key` +- **AND** it passes the returned `channel_key`, `direct_message` address kind, + and stable user ID to `send_channel_message` + +#### Scenario: Mismatched channel destination fails loudly + +- **GIVEN** a lookup result from Slack contains `channel_key=slack` +- **WHEN** `send_channel_message` is invoked with `channel_key=mattermost` and the + Slack destination +- **THEN** the send fails loudly with a channel mismatch error +- **AND** no message is sent through any channel + +#### Scenario: Bare display-name recipient is rejected + +- **GIVEN** `send_channel_message` is invoked with a user-facing display name as + the destination +- **WHEN** the destination is not a stable platform ID or resolved address +- **THEN** the send fails loudly and instructs the caller to use the lookup tool + first + #### Scenario: Channel-specific tools are renamed to standard tools - **GIVEN** Slack, Discord, and Mattermost have migrated to the standard channel delivery intent model - **WHEN** LLM-facing channel tools are registered -- **THEN** the registered tool names follow the standardized naming plan +- **THEN** the registered tool names are `send_channel_message`, + `lookup_channel_user`, and `lookup_channel_destination` where supported - **AND** obsolete per-channel names are not required as aliases unless a concrete external compatibility requirement is documented diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md index 0151d05e9..41dff3fcb 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/tasks.md +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -57,12 +57,13 @@ ## 8. LLM-facing channel tool standardization -- [ ] 8.1 Define standard tool intent schemas and final tool names for send channel message, lookup channel user, and lookup channel destination. +- [ ] 8.1 Define standard generic tool schemas and final tool names: `send_channel_message`, `lookup_channel_user`, and `lookup_channel_destination`, each with required first `channel_key` enum-constrained from enabled descriptors. - [ ] 8.2 Rename/map existing Slack tools to the standard tool names and intent schema. - [ ] 8.3 Rename/map existing Discord tools to the standard tool names and intent schema. - [ ] 8.4 Rename/map existing Mattermost tools to the standard tool names and intent schema. - [ ] 8.5 Update system skills, CLI/help text, and eval cases for renamed LLM-facing channel tools. - [ ] 8.6 Define the standardized DM send workflow as user lookup -> direct-message delivery target -> send-channel-message, gated by each channel descriptor's implemented DM output capability. +- [ ] 8.7 Add eval cases for smaller-model channel selection: Slack channel post, Mattermost user DM, Discord channel post, mismatched channel/destination rejection, and unsupported DM capability failure. ## 9. Channel output effects follow-up diff --git a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs index f3fb2af42..0c61b5be2 100644 --- a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs +++ b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs @@ -7,7 +7,11 @@ using Microsoft.Extensions.DependencyInjection; using Netclaw.Actors.Channels; using Netclaw.Channels; +using Netclaw.Channels.Discord.Tools; +using Netclaw.Channels.Mattermost.Tools; +using Netclaw.Channels.Slack.Tools; using Netclaw.Daemon.Configuration; +using Netclaw.Tools; using Xunit; namespace Netclaw.Daemon.Tests.Configuration; @@ -89,6 +93,74 @@ public void Disabled_remote_channels_still_have_disabled_descriptors() Assert.True(descriptors["tui"].IsEnabled); } + [Fact] + public void Disabled_remote_channels_do_not_register_channel_tools() + { + var services = BuildServices(new Dictionary + { + ["Slack:Enabled"] = "false", + ["Discord:Enabled"] = "false", + ["Mattermost:Enabled"] = "false" + }); + + Assert.DoesNotContain(services, descriptor => descriptor.ServiceType == typeof(IChannelTool)); + Assert.False(IsRegistered(services)); + Assert.False(IsRegistered(services)); + Assert.False(IsRegistered(services)); + Assert.False(IsRegistered(services)); + Assert.False(IsRegistered(services)); + } + + [Fact] + public void Enabled_remote_channels_register_expected_channel_tools() + { + var services = BuildServices(new Dictionary + { + ["Slack:Enabled"] = "true", + ["Discord:Enabled"] = "true", + ["Mattermost:Enabled"] = "true" + }); + + Assert.Equal(5, services.Count(descriptor => descriptor.ServiceType == typeof(IChannelTool))); + Assert.True(IsRegistered(services)); + Assert.True(IsRegistered(services)); + Assert.True(IsRegistered(services)); + Assert.True(IsRegistered(services)); + Assert.True(IsRegistered(services)); + } + + [Fact] + public void Enabled_channel_tool_intents_match_registered_tool_services() + { + var settings = new Dictionary + { + ["Slack:Enabled"] = "true", + ["Discord:Enabled"] = "true", + ["Mattermost:Enabled"] = "true" + }; + var services = BuildServices(settings); + + using var provider = BuildProvider(settings); + var descriptors = provider.GetRequiredService() + .ListChannels() + .ToDictionary(descriptor => descriptor.Key.Value, StringComparer.Ordinal); + + AssertToolIntents( + descriptors["slack"], + services, + new ChannelToolExpectation(ChannelToolIntentKind.SendMessage, typeof(SendSlackMessageTool), "send_slack_message"), + new ChannelToolExpectation(ChannelToolIntentKind.LookupUser, typeof(LookupSlackUserTool), "lookup_slack_user")); + AssertToolIntents( + descriptors["discord"], + services, + new ChannelToolExpectation(ChannelToolIntentKind.SendMessage, typeof(SendDiscordMessageTool), "send_discord_message")); + AssertToolIntents( + descriptors["mattermost"], + services, + new ChannelToolExpectation(ChannelToolIntentKind.SendMessage, typeof(SendMattermostMessageTool), "send_mattermost_message"), + new ChannelToolExpectation(ChannelToolIntentKind.LookupUser, typeof(LookupMattermostUserTool), "lookup_mattermost_user")); + } + [Fact] public async Task Registry_returns_runtime_snapshots_for_registered_descriptors() { @@ -153,6 +225,11 @@ private static IReadOnlyDictionary BuildDescriptors( } private static ServiceProvider BuildProvider(IReadOnlyDictionary settings) + { + return BuildServices(settings).BuildServiceProvider(); + } + + private static ServiceCollection BuildServices(IReadOnlyDictionary settings) { var services = new ServiceCollection(); services.AddLogging(); @@ -167,6 +244,37 @@ private static ServiceProvider BuildProvider(IReadOnlyDictionary(IServiceCollection services) + { + return services.Any(descriptor => descriptor.ServiceType == typeof(T)); + } + + private static void AssertToolIntents( + ChannelDescriptor descriptor, + IServiceCollection services, + params ChannelToolExpectation[] expectedTools) + { + Assert.True(descriptor.IsEnabled); + + Assert.Equal( + expectedTools.Select(tool => tool.Intent).Order().ToArray(), + descriptor.ToolIntents.Order().ToArray()); + + foreach (var expectedTool in expectedTools) + { + Assert.Contains(services, serviceDescriptor => serviceDescriptor.ServiceType == expectedTool.ToolType); + + var attribute = Assert.Single(expectedTool.ToolType.GetCustomAttributes( + typeof(NetclawToolAttribute), inherit: false)); + Assert.Equal(expectedTool.ToolName, ((NetclawToolAttribute)attribute).Name); + } } + + private sealed record ChannelToolExpectation( + ChannelToolIntentKind Intent, + Type ToolType, + string ToolName); } diff --git a/src/Netclaw.Daemon.Tests/Netclaw.Daemon.Tests.csproj b/src/Netclaw.Daemon.Tests/Netclaw.Daemon.Tests.csproj index ea8f7f13d..37f0164d5 100644 --- a/src/Netclaw.Daemon.Tests/Netclaw.Daemon.Tests.csproj +++ b/src/Netclaw.Daemon.Tests/Netclaw.Daemon.Tests.csproj @@ -23,6 +23,8 @@ + + From 235b72a2b8341bf52f3719eba02adaf0cdbbb0f6 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sat, 6 Jun 2026 22:00:45 +0000 Subject: [PATCH 12/31] Add Mattermost proactive message tool tests --- .../MattermostProactiveThreadTests.cs | 172 ++++++++++++++++++ 1 file changed, 172 insertions(+) create mode 100644 src/Netclaw.Actors.Tests/Channels/MattermostProactiveThreadTests.cs diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostProactiveThreadTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostProactiveThreadTests.cs new file mode 100644 index 000000000..041e3e9a0 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/MattermostProactiveThreadTests.cs @@ -0,0 +1,172 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Akka.Actor; +using Netclaw.Actors.Protocol; +using Netclaw.Actors.Tests.Channels.TestHelpers; +using Netclaw.Channels.Mattermost; +using Netclaw.Channels.Mattermost.Tools; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels; + +public sealed class SendMattermostMessageToolTests +{ + private static readonly MattermostChannelOptions DefaultOptions = new() + { + AllowDirectMessages = true, + AllowedUserIds = ["u-1", "u-2"], + AllowedChannelIds = ["ch-1", "ch-2"] + }; + + [Fact] + public async Task Rejects_when_both_channel_and_user_provided() + { + var tool = CreateTool(); + + var result = await ExecuteAsync(tool, "hello", channelId: "ch-1", userId: "u-1"); + + Assert.Contains("exactly one", result); + } + + [Fact] + public async Task Rejects_when_neither_provided() + { + var tool = CreateTool(); + + var result = await ExecuteAsync(tool, "hello"); + + Assert.Contains("exactly one", result); + } + + [Fact] + public async Task Rejects_disallowed_user() + { + var tool = CreateTool(); + + var result = await ExecuteAsync(tool, "hello", userId: "u-bad"); + + Assert.Contains("not in the allowed users list", result); + } + + [Fact] + public async Task Rejects_dm_when_direct_messages_disabled() + { + var options = new MattermostChannelOptions + { + AllowDirectMessages = false, + AllowedUserIds = ["u-1"] + }; + var tool = CreateTool(options: options); + + var result = await ExecuteAsync(tool, "hello", userId: "u-1"); + + Assert.Contains("Direct messages are disabled", result); + } + + [Fact] + public async Task Rejects_disallowed_channel() + { + var tool = CreateTool(); + + var result = await ExecuteAsync(tool, "hello", channelId: "ch-bad"); + + Assert.Contains("not in the allowed channels list", result); + } + + [Fact] + public async Task Successful_dm_uses_allowed_user_id() + { + var fake = new FakeMattermostOutboundClient(); + var tool = CreateTool(outbound: fake); + + var result = await ExecuteAsync(tool, "hello user", userId: "u-1"); + + Assert.Contains("Message sent to user u-1", result); + Assert.Single(fake.OpenedDms); + Assert.Equal("u-1", fake.OpenedDms[0].Value); + } + + [Fact] + public async Task Successful_channel_message_posts_and_wires_session() + { + var fake = new FakeMattermostOutboundClient(); + var tool = CreateTool(outbound: fake); + + var result = await ExecuteAsync(tool, "hello channel", channelId: "ch-1"); + + Assert.Contains("Message sent to channel ch-1", result); + Assert.Single(fake.PostedThreads); + Assert.Equal("ch-1", fake.PostedThreads[0].ChannelId.Value); + Assert.Equal("hello channel", fake.PostedThreads[0].Text); + } + + private static Task ExecuteAsync( + SendMattermostMessageTool tool, + string message, + string? channelId = null, + string? userId = null) + { + var args = new Dictionary + { + ["Message"] = message + }; + if (channelId is not null) + args["ChannelId"] = channelId; + if (userId is not null) + args["UserId"] = userId; + + return tool.ExecuteAsync(args, CancellationToken.None); + } + + private static SendMattermostMessageTool CreateTool( + FakeMattermostOutboundClient? outbound = null, + MattermostChannelOptions? options = null, + Func? defaultChannelIdAccessor = null, + Func? gatewayAccessor = null) + { + return new SendMattermostMessageTool( + outbound ?? new FakeMattermostOutboundClient(), + options ?? DefaultOptions, + defaultChannelIdAccessor ?? (() => null), + gatewayAccessor ?? (() => new FakeGatewayActor())); + } + + private sealed class FakeMattermostOutboundClient : IMattermostOutboundClient + { + public List OpenedDms { get; } = []; + public List<(MattermostChannelId ChannelId, string Text)> PostedThreads { get; } = []; + + public Task OpenDmChannelAsync(MattermostUserId userId, CancellationToken ct = default) + { + OpenedDms.Add(userId); + return Task.FromResult(new MattermostChannelId($"dm-{userId.Value}")); + } + + public Task PostNewThreadAsync( + MattermostChannelId channelId, + string text, + CancellationToken ct = default) + { + PostedThreads.Add((channelId, text)); + return Task.FromResult(new MattermostNewThread(channelId, new MattermostRootPostId($"root-{channelId.Value}"))); + } + } + + private sealed class FakeGatewayActor : MinimalActorRef + { + public override ActorPath Path { get; } = + new RootActorPath(Address.AllSystems) / "fake-mattermost-gateway"; + + public override IActorRefProvider Provider => + throw new NotSupportedException("Not needed for tool tests"); + + protected override void TellInternal(object message, IActorRef sender) + { + if (message is StartMattermostProactiveThread spt) + sender.Tell(new MattermostProactiveThreadAck(spt.SessionId)); + } + } +} From 87a3ff5ac476c9ec67c5e3b5795ade32ad794366 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sun, 7 Jun 2026 00:06:05 +0000 Subject: [PATCH 13/31] Use channel registry for daemon stats --- .../tasks.md | 4 +- .../Gateway/DaemonStatsServiceTests.cs | 54 +++++++++++++++++++ .../Gateway/DaemonStatsService.cs | 18 +++---- 3 files changed, 64 insertions(+), 12 deletions(-) create mode 100644 src/Netclaw.Daemon.Tests/Gateway/DaemonStatsServiceTests.cs diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md index 41dff3fcb..8044c9fa0 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/tasks.md +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -41,8 +41,8 @@ ## 6. Descriptor-driven observability - [x] 6.1 Change daemon runtime status to enumerate the channel registry instead of hard-coding individual channel adapters. -- [ ] 6.2 Change daemon stats channel activity to enumerate descriptor-backed output channels. -- [ ] 6.3 Keep trigger-source status separate from channel status when reminder or webhook operational state is reported. +- [x] 6.2 Change daemon stats channel activity to enumerate descriptor-backed output channels. +- [x] 6.3 Keep trigger-source status separate from channel status when reminder or webhook operational state is reported. - [x] 6.4 Preserve current status/stats output fields or provide explicit compatibility mapping. ## 7. Address resolution diff --git a/src/Netclaw.Daemon.Tests/Gateway/DaemonStatsServiceTests.cs b/src/Netclaw.Daemon.Tests/Gateway/DaemonStatsServiceTests.cs new file mode 100644 index 000000000..8a17104fa --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Gateway/DaemonStatsServiceTests.cs @@ -0,0 +1,54 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Channels; +using Netclaw.Channels; +using Netclaw.Channels.Telemetry; +using Netclaw.Daemon.Gateway; +using Xunit; + +namespace Netclaw.Daemon.Tests.Gateway; + +public sealed class DaemonStatsServiceTests +{ + [Fact] + public void BuildChannelActivityList_UsesEnabledChannelDescriptors() + { + ChannelTelemetry.For(ChannelType.Slack).RecordEventReceived("test"); + ChannelTelemetry.For(ChannelType.Mattermost).RecordEventReceived("test"); + + var registry = CreateRegistry( + [ + Descriptor(ChannelType.Slack, enabled: false), + Descriptor(ChannelType.Mattermost, enabled: true) + ]); + + var activity = DaemonStatsService.BuildChannelActivityList(registry); + + Assert.Contains(activity, item => item.ChannelType == "mattermost"); + Assert.DoesNotContain(activity, item => item.ChannelType == "slack"); + } + + private static IChannelRegistry CreateRegistry(IReadOnlyCollection descriptors) + { + return new ChannelRegistry( + descriptors.Select(descriptor => new StaticChannelDescriptorProvider(descriptor)), + descriptors.Select(descriptor => new DescriptorChannelRuntimeSnapshotProvider(descriptor, []))); + } + + private static ChannelDescriptor Descriptor(ChannelType channelType, bool enabled) + { + return new ChannelDescriptor( + ChannelDescriptorKey.FromChannelType(channelType), + channelType, + ChannelKind.RemoteChat, + channelType.ToString(), + enabled, + ChannelCapabilities.RuntimeHealth, + ToolIntents: new HashSet(), + AddressKinds: new HashSet(), + SupportedOutputEffects: new HashSet()); + } +} diff --git a/src/Netclaw.Daemon/Gateway/DaemonStatsService.cs b/src/Netclaw.Daemon/Gateway/DaemonStatsService.cs index 337784ced..6551caefb 100644 --- a/src/Netclaw.Daemon/Gateway/DaemonStatsService.cs +++ b/src/Netclaw.Daemon/Gateway/DaemonStatsService.cs @@ -9,9 +9,7 @@ using Netclaw.Actors.Memory; using Netclaw.Actors.Reminders; using Netclaw.Actors.Skills; -using Netclaw.Actors.Channels; -using Netclaw.Channels.Slack; -using Netclaw.Channels.Discord; +using Netclaw.Channels; using Netclaw.Channels.Telemetry; using Netclaw.Configuration; using Netclaw.Daemon.Services; @@ -24,10 +22,9 @@ internal sealed class DaemonStatsService( TimeProvider timeProvider, SessionCatalogService sessionCatalog, SkillRegistry skillRegistry, + IChannelRegistry channelRegistry, IRequiredActor dailyStatsActor, WebhookRouteCatalog webhookRouteCatalog, - SlackChannelOptions slackOptions, - DiscordChannelOptions discordOptions, SQLiteMemoryStore? sqliteMemoryStore = null, IRequiredActor? reminderManagerActor = null) { @@ -78,7 +75,7 @@ internal sealed class DaemonStatsService( { TotalAvailable = allSkills.Count }, - Channels = BuildChannelActivityList(), + Channels = BuildChannelActivityList(channelRegistry), Webhooks = BuildWebhookStats(), Reminders = await BuildReminderStatsAsync(ct), DailyBreakdown = dailyBreakdown @@ -133,11 +130,12 @@ internal sealed class DaemonStatsService( }; } - private List BuildChannelActivityList() + internal static List BuildChannelActivityList(IChannelRegistry registry) { - var enabledChannelTypes = new HashSet(); - if (slackOptions.Enabled) enabledChannelTypes.Add(ChannelType.Slack); - if (discordOptions.Enabled) enabledChannelTypes.Add(ChannelType.Discord); + var enabledChannelTypes = registry.ListChannels() + .Where(descriptor => descriptor.IsEnabled) + .Select(descriptor => descriptor.ChannelType) + .ToHashSet(); return [.. ChannelTelemetry.GetAllSnapshots() .Where(s => enabledChannelTypes.Contains(s.ChannelType)) From 81ece290b119481ddb82b70ca7e3298c53069eef Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sun, 7 Jun 2026 00:24:52 +0000 Subject: [PATCH 14/31] Add channel address resolver registry --- .../tasks.md | 4 +- .../ChannelDeliveryContracts.cs | 158 +++++++++++++++++- .../ChannelRegistryRegistrationTests.cs | 115 +++++++++++++ 3 files changed, 274 insertions(+), 3 deletions(-) diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md index 8044c9fa0..17b2ec1fa 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/tasks.md +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -47,10 +47,10 @@ ## 7. Address resolution -- [ ] 7.1 Add a standard channel address resolver contract for users and destinations. +- [x] 7.1 Add a standard channel address resolver contract for users and destinations. - [ ] 7.2 Support exact stable ID resolution before name search. - [ ] 7.3 Fail loudly with candidates for ambiguous display-name matches. -- [ ] 7.4 Route resolution requests to the resolver registered for the selected channel descriptor. +- [x] 7.4 Route resolution requests to the resolver registered for the selected channel descriptor. - [ ] 7.5 Wire Slack lookup to its channel-scoped resolver. - [ ] 7.6 Wire Discord lookup to its channel-scoped resolver where supported. - [ ] 7.7 Wire Mattermost lookup to its channel-scoped resolver. diff --git a/src/Netclaw.Channels/ChannelDeliveryContracts.cs b/src/Netclaw.Channels/ChannelDeliveryContracts.cs index b9d2fe4c4..6617f90ba 100644 --- a/src/Netclaw.Channels/ChannelDeliveryContracts.cs +++ b/src/Netclaw.Channels/ChannelDeliveryContracts.cs @@ -120,6 +120,88 @@ public ResolvedChannelAddress( public string DisplayName { get; init; } } +public sealed record ChannelAddressResolutionRequest +{ + public ChannelAddressResolutionRequest( + ChannelDescriptorKey channelKey, + ChannelAddressKind addressKind, + string query, + bool requireSingleMatch = true) + { + ArgumentException.ThrowIfNullOrWhiteSpace(query); + + ChannelKey = channelKey; + AddressKind = addressKind; + Query = query; + RequireSingleMatch = requireSingleMatch; + } + + public ChannelDescriptorKey ChannelKey { get; init; } + + public ChannelAddressKind AddressKind { get; init; } + + public string Query { get; init; } + + public bool RequireSingleMatch { get; init; } +} + +public enum ChannelAddressResolutionStatus +{ + Resolved, + NotFound, + Ambiguous, + Unsupported +} + +public sealed record ChannelAddressResolutionResult +{ + private ChannelAddressResolutionResult( + ChannelAddressResolutionStatus status, + IReadOnlyList candidates, + string? error = null) + { + Status = status; + Candidates = candidates; + Error = error; + } + + public ChannelAddressResolutionStatus Status { get; init; } + + public IReadOnlyList Candidates { get; init; } + + public string? Error { get; init; } + + public static ChannelAddressResolutionResult Resolved(ResolvedChannelAddress address) + { + ArgumentNullException.ThrowIfNull(address); + return new ChannelAddressResolutionResult(ChannelAddressResolutionStatus.Resolved, [address]); + } + + public static ChannelAddressResolutionResult NotFound(string? error = null) + { + return new ChannelAddressResolutionResult(ChannelAddressResolutionStatus.NotFound, [], error); + } + + public static ChannelAddressResolutionResult Ambiguous(IReadOnlyList candidates, string? error = null) + { + ArgumentNullException.ThrowIfNull(candidates); + return new ChannelAddressResolutionResult(ChannelAddressResolutionStatus.Ambiguous, candidates, error); + } + + public static ChannelAddressResolutionResult Unsupported(string error) + { + ArgumentException.ThrowIfNullOrWhiteSpace(error); + return new ChannelAddressResolutionResult(ChannelAddressResolutionStatus.Unsupported, [], error); + } + + public ResolvedChannelAddress RequireSingle() + { + return Status == ChannelAddressResolutionStatus.Resolved && Candidates.Count == 1 + ? Candidates[0] + : throw new InvalidOperationException(Error ?? $"Address resolution did not produce a single result. Status: {Status}."); + } +} + public sealed record ChannelDeliveryTarget { public ChannelDeliveryTarget( @@ -178,6 +260,17 @@ public interface IChannelRuntimeSnapshotProvider ValueTask GetSnapshotAsync(CancellationToken cancellationToken = default); } +public interface IChannelAddressResolver +{ + ChannelDescriptorKey Key { get; } + + IReadOnlySet AddressKinds { get; } + + ValueTask ResolveAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken = default); +} + public interface IChannelRegistry { IReadOnlyCollection ListChannels(); @@ -187,22 +280,31 @@ public interface IChannelRegistry ValueTask GetSnapshotAsync( ChannelDescriptorKey key, CancellationToken cancellationToken = default); + + IChannelAddressResolver GetResolver(ChannelDescriptorKey key, ChannelAddressKind addressKind); + + ValueTask ResolveAddressAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken = default); } public sealed class ChannelRegistry : IChannelRegistry { private readonly IReadOnlyDictionary _descriptors; private readonly IReadOnlyDictionary _snapshotProviders; + private readonly IReadOnlyDictionary<(ChannelDescriptorKey Key, ChannelAddressKind AddressKind), IChannelAddressResolver> _addressResolvers; public ChannelRegistry( IEnumerable descriptorProviders, - IEnumerable snapshotProviders) + IEnumerable snapshotProviders, + IEnumerable? addressResolvers = null) { ArgumentNullException.ThrowIfNull(descriptorProviders); ArgumentNullException.ThrowIfNull(snapshotProviders); _descriptors = BuildDescriptorLookup(descriptorProviders); _snapshotProviders = BuildSnapshotProviderLookup(snapshotProviders); + _addressResolvers = BuildAddressResolverLookup(addressResolvers ?? []); } public IReadOnlyCollection ListChannels() @@ -230,6 +332,29 @@ public async ValueTask GetSnapshotAsync( return await provider.GetSnapshotAsync(cancellationToken); } + public IChannelAddressResolver GetResolver(ChannelDescriptorKey key, ChannelAddressKind addressKind) + { + var descriptor = GetChannel(key); + if (!descriptor.AddressKinds.Contains(addressKind)) + throw new InvalidOperationException($"Channel '{key}' does not support address kind '{addressKind}'."); + + if (_addressResolvers.TryGetValue((key, addressKind), out var resolver)) + return resolver; + + throw new InvalidOperationException( + $"No channel address resolver is registered for key '{key}' and address kind '{addressKind}'."); + } + + public async ValueTask ResolveAddressAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var resolver = GetResolver(request.ChannelKey, request.AddressKind); + return await resolver.ResolveAsync(request, cancellationToken); + } + private static IReadOnlyDictionary BuildDescriptorLookup( IEnumerable providers) { @@ -258,6 +383,27 @@ private static IReadOnlyDictionary BuildAddressResolverLookup( + IEnumerable resolvers) + { + var addressResolvers = new Dictionary<(ChannelDescriptorKey Key, ChannelAddressKind AddressKind), IChannelAddressResolver>(); + + foreach (var resolver in resolvers) + { + foreach (var addressKind in resolver.AddressKinds) + { + var key = (resolver.Key, addressKind); + if (!addressResolvers.TryAdd(key, resolver)) + { + throw new InvalidOperationException( + $"Duplicate channel address resolver key '{resolver.Key}' for address kind '{addressKind}' registered."); + } + } + } + + return addressResolvers; + } } public sealed class StaticChannelDescriptorProvider(ChannelDescriptor descriptor) : IChannelDescriptorProvider @@ -402,6 +548,16 @@ public static IServiceCollection AddChannelDescriptorWithRuntimeSnapshot( return services; } + public static IServiceCollection AddChannelAddressResolver(this IServiceCollection services) + where TResolver : class, IChannelAddressResolver + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + return services; + } + public static IServiceCollection AddTuiChannelDescriptor(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); diff --git a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs index 0c61b5be2..21d56fbd3 100644 --- a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs +++ b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs @@ -215,6 +215,83 @@ public void Registry_fails_loudly_on_duplicate_descriptor_keys() Assert.Contains("Duplicate channel descriptor key 'slack'", ex.Message, StringComparison.Ordinal); } + [Fact] + public void Registry_fails_loudly_on_duplicate_address_resolvers() + { + var key = ChannelDescriptorKey.FromChannelType(ChannelType.Slack); + var descriptor = BuildDescriptor(key, ChannelType.Slack, ChannelAddressKind.User); + var resolver = new TestAddressResolver(key, ChannelAddressKind.User); + + var providers = new[] { new StaticChannelDescriptorProvider(descriptor) }; + var resolvers = new[] { resolver, resolver }; + + var ex = Assert.Throws(() => + new ChannelRegistry(providers, Array.Empty(), resolvers)); + + Assert.Contains( + "Duplicate channel address resolver key 'slack' for address kind 'User' registered.", + ex.Message, + StringComparison.Ordinal); + } + + [Fact] + public void Registry_fails_loudly_when_address_kind_is_not_supported() + { + var key = ChannelDescriptorKey.FromChannelType(ChannelType.Discord); + var descriptor = BuildDescriptor(key, ChannelType.Discord, ChannelAddressKind.Destination); + var registry = new ChannelRegistry( + [new StaticChannelDescriptorProvider(descriptor)], + Array.Empty(), + Array.Empty()); + + var ex = Assert.Throws(() => + registry.GetResolver(key, ChannelAddressKind.DirectMessage)); + + Assert.Contains("Channel 'discord' does not support address kind 'DirectMessage'.", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public void Registry_fails_loudly_when_supported_address_kind_has_no_resolver() + { + var key = ChannelDescriptorKey.FromChannelType(ChannelType.Mattermost); + var descriptor = BuildDescriptor(key, ChannelType.Mattermost, ChannelAddressKind.User); + var registry = new ChannelRegistry( + [new StaticChannelDescriptorProvider(descriptor)], + Array.Empty(), + Array.Empty()); + + var ex = Assert.Throws(() => + registry.GetResolver(key, ChannelAddressKind.User)); + + Assert.Contains( + "No channel address resolver is registered for key 'mattermost' and address kind 'User'.", + ex.Message, + StringComparison.Ordinal); + } + + [Fact] + public async Task Registry_routes_resolution_requests_to_selected_channel_resolver() + { + var key = ChannelDescriptorKey.FromChannelType(ChannelType.Slack); + var descriptor = BuildDescriptor(key, ChannelType.Slack, ChannelAddressKind.User, ChannelAddressKind.Destination); + var userAddress = new ResolvedChannelAddress(key, ChannelAddressKind.User, "U123", "Jennifer Stannard"); + var destinationAddress = new ResolvedChannelAddress(key, ChannelAddressKind.Destination, "C123", "#general"); + var resolver = new TestAddressResolver(key, ChannelAddressKind.User, ChannelAddressKind.Destination) + { + Result = ChannelAddressResolutionResult.Ambiguous([userAddress, destinationAddress], "Multiple matches.") + }; + var registry = new ChannelRegistry( + [new StaticChannelDescriptorProvider(descriptor)], + Array.Empty(), + [resolver]); + var request = new ChannelAddressResolutionRequest(key, ChannelAddressKind.User, "jennifer", requireSingleMatch: false); + + var result = await registry.ResolveAddressAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(resolver.Result, result); + Assert.Same(request, resolver.Request); + } + private static IReadOnlyDictionary BuildDescriptors( IReadOnlyDictionary settings) { @@ -252,6 +329,23 @@ private static bool IsRegistered(IServiceCollection services) return services.Any(descriptor => descriptor.ServiceType == typeof(T)); } + private static ChannelDescriptor BuildDescriptor( + ChannelDescriptorKey key, + ChannelType channelType, + params ChannelAddressKind[] addressKinds) + { + return new ChannelDescriptor( + key, + channelType, + ChannelKind.RemoteChat, + channelType.ToString(), + IsEnabled: true, + ChannelCapabilities.SendMessages, + ToolIntents: new HashSet(), + AddressKinds: new HashSet(addressKinds), + SupportedOutputEffects: new HashSet()); + } + private static void AssertToolIntents( ChannelDescriptor descriptor, IServiceCollection services, @@ -277,4 +371,25 @@ private sealed record ChannelToolExpectation( ChannelToolIntentKind Intent, Type ToolType, string ToolName); + + private sealed class TestAddressResolver( + ChannelDescriptorKey key, + params ChannelAddressKind[] addressKinds) : IChannelAddressResolver + { + public ChannelDescriptorKey Key { get; } = key; + + public IReadOnlySet AddressKinds { get; } = new HashSet(addressKinds); + + public ChannelAddressResolutionRequest? Request { get; private set; } + + public ChannelAddressResolutionResult Result { get; init; } = ChannelAddressResolutionResult.NotFound(); + + public ValueTask ResolveAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken = default) + { + Request = request; + return ValueTask.FromResult(Result); + } + } } From c3a3151ac5ed8b4c9b43e4cb620ed422d6ff8813 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sun, 7 Jun 2026 01:24:15 +0000 Subject: [PATCH 15/31] Wire channel address resolvers --- .../tasks.md | 8 +- .../MattermostProactiveThreadTests.cs | 87 ++++++++++++ .../Channels/SlackProactiveThreadTests.cs | 59 ++++++++ .../Channels/SlackTargetResolverTests.cs | 66 ++++++++- .../MattermostDestinationAddressResolver.cs | 56 ++++++++ .../MattermostIdentifiers.cs | 17 +++ .../MattermostReminderTargetResolver.cs | 19 +-- .../Tools/LookupMattermostUserTool.cs | 125 ++++++++++++++++- .../SlackTargetResolver.cs | 129 +++++++++++++++++- .../Tools/LookupSlackUserTool.cs | 94 ++++++++++++- .../ChannelRegistryRegistrationTests.cs | 23 ++++ ...MattermostChannelRegistrationExtensions.cs | 15 +- .../SlackChannelRegistrationExtensions.cs | 14 +- 13 files changed, 672 insertions(+), 40 deletions(-) create mode 100644 src/Netclaw.Channels.Mattermost/MattermostDestinationAddressResolver.cs diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md index 17b2ec1fa..f6a4ccfd9 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/tasks.md +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -48,12 +48,12 @@ ## 7. Address resolution - [x] 7.1 Add a standard channel address resolver contract for users and destinations. -- [ ] 7.2 Support exact stable ID resolution before name search. -- [ ] 7.3 Fail loudly with candidates for ambiguous display-name matches. +- [x] 7.2 Support exact stable ID resolution before name search. +- [x] 7.3 Fail loudly with candidates for ambiguous display-name matches. - [x] 7.4 Route resolution requests to the resolver registered for the selected channel descriptor. -- [ ] 7.5 Wire Slack lookup to its channel-scoped resolver. +- [x] 7.5 Wire Slack lookup to its channel-scoped resolver. - [ ] 7.6 Wire Discord lookup to its channel-scoped resolver where supported. -- [ ] 7.7 Wire Mattermost lookup to its channel-scoped resolver. +- [x] 7.7 Wire Mattermost lookup to its channel-scoped resolver. ## 8. LLM-facing channel tool standardization diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostProactiveThreadTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostProactiveThreadTests.cs index 041e3e9a0..9a759f772 100644 --- a/src/Netclaw.Actors.Tests/Channels/MattermostProactiveThreadTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/MattermostProactiveThreadTests.cs @@ -4,8 +4,10 @@ // // ----------------------------------------------------------------------- using Akka.Actor; +using Netclaw.Actors.Channels; using Netclaw.Actors.Protocol; using Netclaw.Actors.Tests.Channels.TestHelpers; +using Netclaw.Channels; using Netclaw.Channels.Mattermost; using Netclaw.Channels.Mattermost.Tools; using Xunit; @@ -170,3 +172,88 @@ protected override void TellInternal(object message, IActorRef sender) } } } + +public sealed class MattermostAddressResolverTests +{ + private const string AllowedUserId = "abcdefghijklmnopqrstuvwxyz"; + private const string OtherUserId = "bcdefghijklmnopqrstuvwxyza"; + private const string AllowedChannelId = "12345678901234567890123456"; + private const string OtherChannelId = "23456789012345678901234567"; + + [Fact] + public async Task User_resolver_resolves_exact_user_id_without_directory_lookup() + { + var tool = CreateUserResolver(new MattermostChannelOptions + { + AllowedUserIds = [AllowedUserId] + }); + var request = new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Mattermost), + ChannelAddressKind.User, + AllowedUserId); + + var result = await tool.ResolveAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.Resolved, result.Status); + Assert.Equal(AllowedUserId, result.RequireSingle().StableId); + } + + [Fact] + public async Task User_resolver_filters_exact_user_id_through_allowed_users() + { + var tool = CreateUserResolver(new MattermostChannelOptions + { + AllowedUserIds = [AllowedUserId] + }); + var request = new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Mattermost), + ChannelAddressKind.User, + OtherUserId); + + var result = await tool.ResolveAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.NotFound, result.Status); + Assert.Contains("not in the allowed users list", result.Error); + } + + [Fact] + public async Task Destination_resolver_resolves_exact_channel_id() + { + var resolver = new MattermostDestinationAddressResolver( + new MattermostChannelOptions { AllowedChannelIds = [AllowedChannelId] }, + () => null); + var request = new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Mattermost), + ChannelAddressKind.Destination, + $"channel:{AllowedChannelId}"); + + var result = await resolver.ResolveAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.Resolved, result.Status); + Assert.Equal(AllowedChannelId, result.RequireSingle().StableId); + } + + [Fact] + public async Task Destination_resolver_filters_exact_channel_id_through_allowed_channels() + { + var resolver = new MattermostDestinationAddressResolver( + new MattermostChannelOptions { AllowedChannelIds = [AllowedChannelId] }, + () => null); + var request = new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Mattermost), + ChannelAddressKind.Destination, + OtherChannelId); + + var result = await resolver.ResolveAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.NotFound, result.Status); + Assert.Contains("not in the allowed channels list", result.Error); + } + + private static LookupMattermostUserTool CreateUserResolver(MattermostChannelOptions options) + { + return new LookupMattermostUserTool( + () => throw new InvalidOperationException("Directory lookup should not be used for exact IDs."), + options); + } +} diff --git a/src/Netclaw.Actors.Tests/Channels/SlackProactiveThreadTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackProactiveThreadTests.cs index 69f985d93..eced89dc5 100644 --- a/src/Netclaw.Actors.Tests/Channels/SlackProactiveThreadTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/SlackProactiveThreadTests.cs @@ -12,12 +12,14 @@ using Microsoft.Extensions.Time.Testing; using Netclaw.Actors.Protocol; using Netclaw.Actors.Tests.Channels.TestHelpers; +using Netclaw.Channels; using Netclaw.Channels.Slack; using Netclaw.Channels.Slack.Tools; using Netclaw.Security; using SlackNet; using SlackNet.WebApi; using Xunit; +using ChannelType = Netclaw.Actors.Channels.ChannelType; namespace Netclaw.Actors.Tests.Channels; @@ -342,6 +344,63 @@ public async Task Refreshes_after_TTL() Assert.Equal(2, fakeApi.CallCount); } + [Fact] + public async Task Resolver_exact_user_id_skips_directory_lookup() + { + var fakeApi = new FakeSlackUsersApi(CreateUsers()); + var tool = new LookupSlackUserTool(fakeApi, new SlackChannelOptions(), TimeProvider.System); + var request = new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Slack), + ChannelAddressKind.User, + "U42"); + + var result = await tool.ResolveAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.Resolved, result.Status); + Assert.Equal("U42", result.RequireSingle().StableId); + Assert.Equal(0, fakeApi.CallCount); + } + + [Fact] + public async Task Resolver_filters_exact_user_id_through_allowed_users() + { + var options = new SlackChannelOptions { AllowedUserIds = ["U1"] }; + var fakeApi = new FakeSlackUsersApi(CreateUsers()); + var tool = new LookupSlackUserTool(fakeApi, options, TimeProvider.System); + var request = new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Slack), + ChannelAddressKind.User, + "U2"); + + var result = await tool.ResolveAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.NotFound, result.Status); + Assert.Contains("not in the allowed users list", result.Error); + Assert.Equal(0, fakeApi.CallCount); + } + + [Fact] + public async Task Resolver_returns_ambiguous_candidates_for_user_name_matches() + { + var users = CreateUsers(); + users.Add(new User + { + Id = "U3", Name = "alice2", RealName = "Alice Jones", + Profile = new UserProfile { DisplayName = "alice_j", Email = "alice2@example.com" } + }); + var tool = CreateTool(users); + var request = new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Slack), + ChannelAddressKind.User, + "Alice"); + + var result = await tool.ResolveAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.Ambiguous, result.Status); + Assert.Equal(["U1", "U3"], result.Candidates.Select(candidate => candidate.StableId).ToArray()); + Assert.Contains("matched 2 users", result.Error); + } + private static Task ExecuteAsync(LookupSlackUserTool tool, string query) { var args = new Dictionary { ["Query"] = query }; diff --git a/src/Netclaw.Actors.Tests/Channels/SlackTargetResolverTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackTargetResolverTests.cs index dcf0d332b..7f2c366e2 100644 --- a/src/Netclaw.Actors.Tests/Channels/SlackTargetResolverTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/SlackTargetResolverTests.cs @@ -3,9 +3,11 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Netclaw.Channels; using Netclaw.Channels.Slack; using SlackNet; using Xunit; +using ChannelType = Netclaw.Actors.Channels.ChannelType; namespace Netclaw.Actors.Tests.Channels; @@ -26,7 +28,7 @@ public async Task Resolve_channel_name_with_hash_returns_channel_id() ] }; - var resolver = new SlackTargetResolver(lookup); + var resolver = CreateResolver(lookup, new SlackChannelOptions { AllowedChannelIds = ["C1"] }); var result = await resolver.ResolveAsync("#openclaw", TestContext.Current.CancellationToken); Assert.True(result.Success); @@ -59,7 +61,7 @@ public async Task Resolve_user_mention_returns_user_id() ] }; - var resolver = new SlackTargetResolver(lookup); + var resolver = CreateResolver(lookup); var result = await resolver.ResolveAsync("@aaron", TestContext.Current.CancellationToken); Assert.True(result.Success); @@ -83,7 +85,7 @@ public async Task Resolve_ambiguous_user_query_fails() ] }; - var resolver = new SlackTargetResolver(lookup); + var resolver = CreateResolver(lookup); var result = await resolver.ResolveAsync("aaron", TestContext.Current.CancellationToken); Assert.False(result.Success); @@ -107,7 +109,7 @@ public async Task Resolve_channel_name_without_hash_falls_back_to_channel_lookup ] }; - var resolver = new SlackTargetResolver(lookup); + var resolver = CreateResolver(lookup, new SlackChannelOptions { AllowedChannelIds = ["C777"] }); var result = await resolver.ResolveAsync("openclaw", TestContext.Current.CancellationToken); Assert.True(result.Success); @@ -118,7 +120,7 @@ public async Task Resolve_channel_name_without_hash_falls_back_to_channel_lookup public async Task Resolve_raw_channel_id_skips_directory_lookup() { var lookup = new FakeSlackTargetLookupClient(); - var resolver = new SlackTargetResolver(lookup); + var resolver = CreateResolver(lookup, new SlackChannelOptions { AllowedChannelIds = ["C0123ABC"] }); var result = await resolver.ResolveAsync("C0123ABC", TestContext.Current.CancellationToken); @@ -132,7 +134,7 @@ public async Task Resolve_raw_channel_id_skips_directory_lookup() public async Task Resolve_raw_user_id_skips_directory_lookup() { var lookup = new FakeSlackTargetLookupClient(); - var resolver = new SlackTargetResolver(lookup); + var resolver = CreateResolver(lookup); var result = await resolver.ResolveAsync("U0456XYZ", TestContext.Current.CancellationToken); @@ -142,6 +144,58 @@ public async Task Resolve_raw_user_id_skips_directory_lookup() Assert.Equal(0, lookup.UserListCallCount); } + [Fact] + public async Task Channel_address_resolver_returns_ambiguous_destination_candidates() + { + var lookup = new FakeSlackTargetLookupClient + { + ChannelPages = + [ + new SlackChannelPage( + [ + new Conversation { Id = "C1", Name = "general" }, + new Conversation { Id = "C2", Name = "general-private" } + ], + null) + ] + }; + var resolver = CreateResolver(lookup, new SlackChannelOptions { AllowedChannelIds = ["C1", "C2"] }); + var request = new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Slack), + ChannelAddressKind.Destination, + "general"); + + var result = await resolver.ResolveAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.Ambiguous, result.Status); + Assert.Equal(["C1", "C2"], result.Candidates.Select(candidate => candidate.StableId).ToArray()); + } + + [Fact] + public async Task Channel_address_resolver_filters_disallowed_destination_ids() + { + var lookup = new FakeSlackTargetLookupClient(); + var resolver = CreateResolver(lookup, new SlackChannelOptions { AllowedChannelIds = ["C1"] }); + var request = new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Slack), + ChannelAddressKind.Destination, + "C2"); + + var result = await resolver.ResolveAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.NotFound, result.Status); + Assert.Contains("not in the allowed channels list", result.Error); + Assert.Equal(0, lookup.ChannelListCallCount); + } + + private static SlackTargetResolver CreateResolver( + ISlackTargetLookupClient lookup, + SlackChannelOptions? options = null, + SlackChannelId? defaultChannelId = null) + { + return new SlackTargetResolver(lookup, options ?? new SlackChannelOptions(), () => defaultChannelId); + } + private sealed class FakeSlackTargetLookupClient : ISlackTargetLookupClient { public IReadOnlyList ChannelPages { get; init; } = []; diff --git a/src/Netclaw.Channels.Mattermost/MattermostDestinationAddressResolver.cs b/src/Netclaw.Channels.Mattermost/MattermostDestinationAddressResolver.cs new file mode 100644 index 000000000..4ef0b9979 --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/MattermostDestinationAddressResolver.cs @@ -0,0 +1,56 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Channels; +using Netclaw.Channels; + +namespace Netclaw.Channels.Mattermost; + +public sealed class MattermostDestinationAddressResolver( + MattermostChannelOptions options, + Func defaultChannelIdAccessor) : IChannelAddressResolver +{ + private static readonly IReadOnlySet SupportedAddressKinds = new HashSet + { + ChannelAddressKind.Destination + }; + + public ChannelDescriptorKey Key { get; } = ChannelDescriptorKey.FromChannelType(ChannelType.Mattermost); + + public IReadOnlySet AddressKinds => SupportedAddressKinds; + + public ValueTask ResolveAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + if (!request.ChannelKey.Equals(Key)) + return ValueTask.FromResult(ChannelAddressResolutionResult.Unsupported($"Mattermost destination resolver cannot resolve channel key '{request.ChannelKey}'.")); + + if (request.AddressKind != ChannelAddressKind.Destination) + return ValueTask.FromResult(ChannelAddressResolutionResult.Unsupported($"Mattermost destination resolver does not support address kind '{request.AddressKind}'.")); + + var channelId = NormalizeDestinationQuery(request.Query); + if (!MattermostIdentifierFormat.IsMattermostId(channelId)) + { + return ValueTask.FromResult(ChannelAddressResolutionResult.NotFound( + $"Mattermost destination lookup requires an exact channel ID.")); + } + + var target = new MattermostChannelId(channelId); + return ValueTask.FromResult(MattermostAclPolicy.IsAllowedChannel(target, options, defaultChannelIdAccessor()) + ? ChannelAddressResolutionResult.Resolved(new ResolvedChannelAddress(Key, request.AddressKind, channelId, channelId)) + : ChannelAddressResolutionResult.NotFound($"Mattermost channel '{channelId}' is not in the allowed channels list.")); + } + + private static string NormalizeDestinationQuery(string query) + { + var normalized = query.Trim(); + return normalized.StartsWith("channel:", StringComparison.OrdinalIgnoreCase) + ? normalized[8..].Trim() + : normalized; + } +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostIdentifiers.cs b/src/Netclaw.Channels.Mattermost/MattermostIdentifiers.cs index 64c5d2c43..fa594619b 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostIdentifiers.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostIdentifiers.cs @@ -47,3 +47,20 @@ public readonly record struct MattermostUserId(string Value) { public override string ToString() => Value; } + +internal static class MattermostIdentifierFormat +{ + internal 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/MattermostReminderTargetResolver.cs b/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs index f2cd49982..0a9e92168 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs @@ -36,7 +36,7 @@ public Task ResolveAsync(string target, CancellationTo if (raw.StartsWith("channel:", StringComparison.OrdinalIgnoreCase)) { var channelId = raw[8..].Trim(); - if (IsMattermostId(channelId)) + if (MattermostIdentifierFormat.IsMattermostId(channelId)) { // Preserve the "channel:" prefix in the canonical form. Mattermost // channel IDs and user IDs are both 26-char alphanumeric strings, @@ -60,7 +60,7 @@ public Task ResolveAsync(string target, CancellationTo if (raw.StartsWith('@')) { var userId = raw[1..].Trim(); - if (IsMattermostId(userId)) + if (MattermostIdentifierFormat.IsMattermostId(userId)) { // See the "channel:" branch above: the "@" prefix is preserved in // the canonical form so the prompt builder and the DM-open path @@ -84,7 +84,7 @@ public Task ResolveAsync(string target, CancellationTo // channel IDs are both 26-char alphanumeric strings, so guessing here // could silently deliver a reminder to the wrong audience. Require an // explicit prefix instead of guessing. - if (IsMattermostId(raw)) + if (MattermostIdentifierFormat.IsMattermostId(raw)) { return Task.FromResult(new ReminderTargetResolution( Success: false, @@ -101,17 +101,4 @@ public Task ResolveAsync(string target, CancellationTo ErrorMessage: $"Could not resolve Mattermost target '{target}'. Use @ 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/Tools/LookupMattermostUserTool.cs b/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs index ad09b97a5..22571e30a 100644 --- a/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs +++ b/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs @@ -6,6 +6,8 @@ using System.ComponentModel; using System.Text; using Mattermost; +using Netclaw.Actors.Channels; +using Netclaw.Channels; using Netclaw.Tools; namespace Netclaw.Channels.Mattermost.Tools; @@ -18,18 +20,40 @@ namespace Netclaw.Channels.Mattermost.Tools; "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 +public sealed partial class LookupMattermostUserTool : NetclawTool, IChannelTool, IChannelAddressResolver { - private readonly MattermostClient _client; + private static readonly IReadOnlySet UserAddressKinds = new HashSet + { + ChannelAddressKind.User + }; + + private static readonly IReadOnlySet UserAndDirectMessageAddressKinds = new HashSet + { + ChannelAddressKind.User, + ChannelAddressKind.DirectMessage + }; + + private readonly Func _clientAccessor; private readonly MattermostChannelOptions _options; public record Params( [property: Description("Username or email address to search for")] string Query); + public ChannelDescriptorKey Key { get; } = ChannelDescriptorKey.FromChannelType(ChannelType.Mattermost); + + public IReadOnlySet AddressKinds => _options.AllowDirectMessages + ? UserAndDirectMessageAddressKinds + : UserAddressKinds; + public LookupMattermostUserTool(MattermostClient client, MattermostChannelOptions options) + : this(() => client, options) { - _client = client; + } + + public LookupMattermostUserTool(Func clientAccessor, MattermostChannelOptions options) + { + _clientAccessor = clientAccessor; _options = options; } @@ -50,7 +74,7 @@ protected override async Task ExecuteAsync(Params args, CancellationToke // Try username lookup first. try { - var user = await _client.GetUserByUsernameAsync(query); + var user = await _clientAccessor().GetUserByUsernameAsync(query); if (user is not null && !IsFilteredOut(user)) { AppendUser(sb, user); @@ -70,7 +94,7 @@ protected override async Task ExecuteAsync(Params args, CancellationToke { try { - var user = await _client.GetUserByEmailAsync(query); + var user = await _clientAccessor().GetUserByEmailAsync(query); if (user is not null && !IsFilteredOut(user)) { AppendUser(sb, user); @@ -88,10 +112,101 @@ protected override async Task ExecuteAsync(Params args, CancellationToke : "No matching user found. Try an exact username (without @) or email address."; } + public async ValueTask ResolveAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + if (!request.ChannelKey.Equals(Key)) + return ChannelAddressResolutionResult.Unsupported($"Mattermost user resolver cannot resolve channel key '{request.ChannelKey}'."); + + if (request.AddressKind != ChannelAddressKind.User && request.AddressKind != ChannelAddressKind.DirectMessage) + return ChannelAddressResolutionResult.Unsupported($"Mattermost user resolver does not support address kind '{request.AddressKind}'."); + + if (request.AddressKind == ChannelAddressKind.DirectMessage && !_options.AllowDirectMessages) + return ChannelAddressResolutionResult.Unsupported("Mattermost direct-message resolution is disabled in configuration."); + + var query = NormalizeUserQuery(request.Query); + if (MattermostIdentifierFormat.IsMattermostId(query)) + { + var userId = new MattermostUserId(query); + return MattermostAclPolicy.IsAllowedUser(userId, _options) + ? ChannelAddressResolutionResult.Resolved(new ResolvedChannelAddress(Key, request.AddressKind, query, query)) + : ChannelAddressResolutionResult.NotFound($"Mattermost user '{query}' is not in the allowed users list."); + } + + var (user, lookupError) = await FindUserAsync(query); + if (user is not null && !IsFilteredOut(user)) + return ChannelAddressResolutionResult.Resolved(ToResolvedAddress(request.AddressKind, user)); + + return lookupError is not null + ? ChannelAddressResolutionResult.NotFound($"No Mattermost user matched '{query}'. The Mattermost lookup reported an error: {lookupError.Message}") + : ChannelAddressResolutionResult.NotFound($"No Mattermost user matched '{query}'."); + } + private bool IsFilteredOut(global::Mattermost.Models.Users.User user) => _options.AllowedUserIds.Length > 0 && !_options.AllowedUserIds.Contains(user.Id, StringComparer.Ordinal); + private async Task<(global::Mattermost.Models.Users.User? User, Exception? LookupError)> FindUserAsync(string query) + { + Exception? lookupError = null; + + try + { + var user = await _clientAccessor().GetUserByUsernameAsync(query); + if (user is not null) + return (user, null); + } + catch (Exception ex) + { + lookupError = ex; + } + + if (query.Contains('@', StringComparison.Ordinal)) + { + try + { + var user = await _clientAccessor().GetUserByEmailAsync(query); + if (user is not null) + return (user, null); + } + catch (Exception ex) + { + lookupError = ex; + } + } + + return (null, lookupError); + } + + private ResolvedChannelAddress ToResolvedAddress(ChannelAddressKind addressKind, global::Mattermost.Models.Users.User user) + { + return new ResolvedChannelAddress(Key, addressKind, user.Id, GetDisplayName(user)); + } + + private static string NormalizeUserQuery(string query) + { + var normalized = query.Trim(); + return normalized.StartsWith('@') ? normalized[1..].Trim() : normalized; + } + + private static string GetDisplayName(global::Mattermost.Models.Users.User user) + { + if (!string.IsNullOrWhiteSpace(user.Username)) + return $"@{user.Username}"; + + var fullName = $"{user.FirstName} {user.LastName}".Trim(); + if (!string.IsNullOrWhiteSpace(fullName)) + return fullName; + + if (!string.IsNullOrWhiteSpace(user.Email)) + return user.Email; + + return user.Id; + } + private static void AppendUser(StringBuilder sb, global::Mattermost.Models.Users.User user) { sb.AppendLine("Found user:"); diff --git a/src/Netclaw.Channels.Slack/SlackTargetResolver.cs b/src/Netclaw.Channels.Slack/SlackTargetResolver.cs index 013503887..afd270719 100644 --- a/src/Netclaw.Channels.Slack/SlackTargetResolver.cs +++ b/src/Netclaw.Channels.Slack/SlackTargetResolver.cs @@ -5,6 +5,8 @@ // ----------------------------------------------------------------------- using SlackNet; using SlackNet.WebApi; +using Netclaw.Channels; +using ChannelType = Netclaw.Actors.Channels.ChannelType; namespace Netclaw.Channels.Slack; @@ -51,8 +53,20 @@ public async Task ListUsersAsync(string? cursor, CancellationToke } } -public sealed class SlackTargetResolver(ISlackTargetLookupClient lookupClient) : ISlackTargetResolver +public sealed class SlackTargetResolver( + ISlackTargetLookupClient lookupClient, + SlackChannelOptions options, + Func defaultChannelIdAccessor) : ISlackTargetResolver, IChannelAddressResolver { + private static readonly IReadOnlySet SupportedAddressKinds = new HashSet + { + ChannelAddressKind.Destination + }; + + public ChannelDescriptorKey Key { get; } = ChannelDescriptorKey.FromChannelType(ChannelType.Slack); + + public IReadOnlySet AddressKinds => SupportedAddressKinds; + public async Task ResolveAsync(string target, CancellationToken ct = default) { if (string.IsNullOrWhiteSpace(target)) @@ -61,10 +75,20 @@ public async Task ResolveAsync(string target, Cance var raw = target.Trim(); if (raw.StartsWith("C", StringComparison.Ordinal) || raw.StartsWith("G", StringComparison.Ordinal)) - return new SlackTargetResolutionResult(true, null, raw, null); + { + var channelId = new SlackChannelId(raw); + return SlackAclPolicy.IsAllowedChannel(channelId, options, defaultChannelIdAccessor()) + ? new SlackTargetResolutionResult(true, null, raw, null) + : new SlackTargetResolutionResult(false, $"Slack channel '{raw}' is not in the allowed channels list.", null, null); + } if (raw.StartsWith("U", StringComparison.Ordinal)) - return new SlackTargetResolutionResult(true, null, null, raw); + { + var userId = new SlackUserId(raw); + return SlackAclPolicy.IsAllowedUser(userId, options) + ? new SlackTargetResolutionResult(true, null, null, raw) + : new SlackTargetResolutionResult(false, $"Slack user '{raw}' is not in the allowed users list.", null, null); + } if (raw.StartsWith("#", StringComparison.Ordinal)) { @@ -105,6 +129,34 @@ public async Task ResolveAsync(string target, Cance null); } + public async ValueTask ResolveAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + if (!request.ChannelKey.Equals(Key)) + return ChannelAddressResolutionResult.Unsupported($"Slack resolver cannot resolve channel key '{request.ChannelKey}'."); + + if (request.AddressKind != ChannelAddressKind.Destination) + return ChannelAddressResolutionResult.Unsupported($"Slack destination resolver does not support address kind '{request.AddressKind}'."); + + var raw = request.Query.Trim(); + if (raw.StartsWith('#')) + raw = raw[1..].Trim(); + + if (IsSlackChannelId(raw)) + { + var channelId = new SlackChannelId(raw); + return SlackAclPolicy.IsAllowedChannel(channelId, options, defaultChannelIdAccessor()) + ? ChannelAddressResolutionResult.Resolved(new ResolvedChannelAddress(Key, request.AddressKind, raw, raw)) + : ChannelAddressResolutionResult.NotFound($"Slack channel '{raw}' is not in the allowed channels list."); + } + + var matches = await FindChannelMatchesAsync(raw, cancellationToken); + return ToResolutionResult(request.AddressKind, matches, raw); + } + private async Task ResolveChannelByNameAsync(string channelName, CancellationToken ct) { var cursor = default(string); @@ -113,8 +165,9 @@ public async Task ResolveAsync(string target, Cance var page = await lookupClient.ListChannelsAsync(cursor, ct); var match = page.Channels.FirstOrDefault(c => - string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase) - || string.Equals(c.NameNormalized, channelName, StringComparison.OrdinalIgnoreCase)); + IsAllowedChannel(c) + && (string.Equals(c.Name, channelName, StringComparison.OrdinalIgnoreCase) + || string.Equals(c.NameNormalized, channelName, StringComparison.OrdinalIgnoreCase))); if (match is not null) return match.Id; @@ -125,6 +178,69 @@ public async Task ResolveAsync(string target, Cance return null; } + private async Task> FindChannelMatchesAsync(string query, CancellationToken ct) + { + var matches = new List(); + var cursor = default(string); + + do + { + var page = await lookupClient.ListChannelsAsync(cursor, ct); + foreach (var channel in page.Channels) + { + if (!IsAllowedChannel(channel) || !MatchesChannelQuery(channel, query)) + continue; + + var displayName = string.IsNullOrWhiteSpace(channel.Name) + ? channel.Id + : $"#{channel.Name}"; + matches.Add(new ResolvedChannelAddress(Key, ChannelAddressKind.Destination, channel.Id, displayName)); + } + + cursor = page.NextCursor; + } while (!string.IsNullOrWhiteSpace(cursor)); + + return matches; + } + + private ChannelAddressResolutionResult ToResolutionResult( + ChannelAddressKind addressKind, + IReadOnlyList matches, + string query) + { + if (matches.Count == 0) + return ChannelAddressResolutionResult.NotFound($"No Slack {addressKind} matched '{query}'."); + + if (matches.Count == 1) + return ChannelAddressResolutionResult.Resolved(matches[0]); + + return ChannelAddressResolutionResult.Ambiguous( + matches, + $"Slack {addressKind} query '{query}' matched {matches.Count} destinations."); + } + + private bool IsAllowedChannel(Conversation channel) + { + if (string.IsNullOrWhiteSpace(channel.Id)) + return false; + + return SlackAclPolicy.IsAllowedChannel(new SlackChannelId(channel.Id), options, defaultChannelIdAccessor()); + } + + private static bool MatchesChannelQuery(Conversation channel, string query) + { + var name = channel.Name ?? string.Empty; + var normalizedName = channel.NameNormalized ?? string.Empty; + + return name.Contains(query, StringComparison.OrdinalIgnoreCase) + || normalizedName.Contains(query, StringComparison.OrdinalIgnoreCase); + } + + private static bool IsSlackChannelId(string value) + { + return value.StartsWith("C", StringComparison.Ordinal) || value.StartsWith("G", StringComparison.Ordinal); + } + private async Task ResolveUserAsync(string query, CancellationToken ct) { var cursor = default(string); @@ -138,6 +254,9 @@ public async Task ResolveAsync(string target, Cance if (user.IsBot || user.Deleted) continue; + if (!SlackAclPolicy.IsAllowedUser(new SlackUserId(user.Id), options)) + continue; + var displayName = user.Profile?.DisplayName ?? string.Empty; var realName = user.RealName ?? string.Empty; var username = user.Name ?? string.Empty; diff --git a/src/Netclaw.Channels.Slack/Tools/LookupSlackUserTool.cs b/src/Netclaw.Channels.Slack/Tools/LookupSlackUserTool.cs index b83fe3d34..6444c9859 100644 --- a/src/Netclaw.Channels.Slack/Tools/LookupSlackUserTool.cs +++ b/src/Netclaw.Channels.Slack/Tools/LookupSlackUserTool.cs @@ -5,6 +5,8 @@ // ----------------------------------------------------------------------- using System.ComponentModel; using System.Text; +using Netclaw.Actors.Channels; +using Netclaw.Channels; using Netclaw.Tools; using SlackNet.WebApi; @@ -18,8 +20,19 @@ namespace Netclaw.Channels.Slack.Tools; "Look up a Slack user by name, display name, or email. " + "Returns their user ID for use with send_slack_message.", Grant = "builtin")] -public sealed partial class LookupSlackUserTool : NetclawTool, IChannelTool +public sealed partial class LookupSlackUserTool : NetclawTool, IChannelTool, IChannelAddressResolver { + private static readonly IReadOnlySet UserAddressKinds = new HashSet + { + ChannelAddressKind.User + }; + + private static readonly IReadOnlySet UserAndDirectMessageAddressKinds = new HashSet + { + ChannelAddressKind.User, + ChannelAddressKind.DirectMessage + }; + private readonly IUsersApi _usersApi; private readonly SlackChannelOptions _options; private readonly TimeProvider _timeProvider; @@ -34,6 +47,12 @@ public record Params( [property: Description("Name, display name, or email to search for")] string Query); + public ChannelDescriptorKey Key { get; } = ChannelDescriptorKey.FromChannelType(ChannelType.Slack); + + public IReadOnlySet AddressKinds => _options.AllowDirectMessages + ? UserAndDirectMessageAddressKinds + : UserAddressKinds; + public LookupSlackUserTool(IUsersApi usersApi, SlackChannelOptions options, TimeProvider timeProvider) { _usersApi = usersApi; @@ -70,6 +89,48 @@ protected override async Task ExecuteAsync(Params args, CancellationToke return sb.ToString().TrimEnd(); } + public async ValueTask ResolveAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + if (!request.ChannelKey.Equals(Key)) + return ChannelAddressResolutionResult.Unsupported($"Slack user resolver cannot resolve channel key '{request.ChannelKey}'."); + + if (request.AddressKind != ChannelAddressKind.User && request.AddressKind != ChannelAddressKind.DirectMessage) + return ChannelAddressResolutionResult.Unsupported($"Slack user resolver does not support address kind '{request.AddressKind}'."); + + if (request.AddressKind == ChannelAddressKind.DirectMessage && !_options.AllowDirectMessages) + return ChannelAddressResolutionResult.Unsupported("Slack direct-message resolution is disabled in configuration."); + + var query = NormalizeUserQuery(request.Query); + if (IsSlackUserId(query)) + { + var userId = new SlackUserId(query); + return SlackAclPolicy.IsAllowedUser(userId, _options) + ? ChannelAddressResolutionResult.Resolved(new ResolvedChannelAddress(Key, request.AddressKind, query, query)) + : ChannelAddressResolutionResult.NotFound($"Slack user '{query}' is not in the allowed users list."); + } + + var users = await GetUsersAsync(cancellationToken); + var matches = users + .Where(user => MatchesQuery(user, query)) + .Select(user => ToResolvedAddress(request.AddressKind, user)) + .Take(10) + .ToArray(); + + if (matches.Length == 0) + return ChannelAddressResolutionResult.NotFound($"No Slack user matched '{query}'."); + + if (matches.Length == 1) + return ChannelAddressResolutionResult.Resolved(matches[0]); + + return ChannelAddressResolutionResult.Ambiguous( + matches, + $"Slack user query '{query}' matched {matches.Length} users."); + } + private static bool MatchesQuery(CachedUser user, string query) { return user.RealName.Contains(query, StringComparison.OrdinalIgnoreCase) @@ -78,6 +139,37 @@ private static bool MatchesQuery(CachedUser user, string query) || user.Email.Contains(query, StringComparison.OrdinalIgnoreCase); } + private ResolvedChannelAddress ToResolvedAddress(ChannelAddressKind addressKind, CachedUser user) + { + var displayName = GetDisplayName(user); + return new ResolvedChannelAddress(Key, addressKind, user.Id, displayName); + } + + private static string GetDisplayName(CachedUser user) + { + if (!string.IsNullOrWhiteSpace(user.RealName)) + return user.RealName; + + if (!string.IsNullOrWhiteSpace(user.DisplayName)) + return user.DisplayName; + + if (!string.IsNullOrWhiteSpace(user.Name)) + return user.Name; + + return user.Id; + } + + private static string NormalizeUserQuery(string query) + { + var normalized = query.Trim(); + return normalized.StartsWith('@') ? normalized[1..].Trim() : normalized; + } + + private static bool IsSlackUserId(string value) + { + return value.StartsWith("U", StringComparison.Ordinal); + } + private async Task> GetUsersAsync(CancellationToken ct) { // Fast path: cache hit without lock diff --git a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs index 21d56fbd3..343c80be6 100644 --- a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs +++ b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs @@ -8,7 +8,9 @@ using Netclaw.Actors.Channels; using Netclaw.Channels; using Netclaw.Channels.Discord.Tools; +using Netclaw.Channels.Mattermost; using Netclaw.Channels.Mattermost.Tools; +using Netclaw.Channels.Slack; using Netclaw.Channels.Slack.Tools; using Netclaw.Daemon.Configuration; using Netclaw.Tools; @@ -109,6 +111,9 @@ public void Disabled_remote_channels_do_not_register_channel_tools() Assert.False(IsRegistered(services)); Assert.False(IsRegistered(services)); Assert.False(IsRegistered(services)); + Assert.DoesNotContain(services, descriptor => descriptor.ServiceType == typeof(IChannelAddressResolver)); + Assert.False(IsRegistered(services)); + Assert.False(IsRegistered(services)); } [Fact] @@ -129,6 +134,23 @@ public void Enabled_remote_channels_register_expected_channel_tools() Assert.True(IsRegistered(services)); } + [Fact] + public void Enabled_remote_channels_register_expected_address_resolvers() + { + var services = BuildServices(new Dictionary + { + ["Slack:Enabled"] = "true", + ["Discord:Enabled"] = "true", + ["Mattermost:Enabled"] = "true" + }); + + Assert.Equal(4, services.Count(descriptor => descriptor.ServiceType == typeof(IChannelAddressResolver))); + Assert.True(IsRegistered(services)); + Assert.True(IsRegistered(services)); + Assert.True(IsRegistered(services)); + Assert.True(IsRegistered(services)); + } + [Fact] public void Enabled_channel_tool_intents_match_registered_tool_services() { @@ -310,6 +332,7 @@ private static ServiceCollection BuildServices(IReadOnlyDictionary(MattermostChannelKey)); services.AddSingleton(sp => (MattermostChannel)sp.GetRequiredKeyedService(MattermostChannelKey)); + services.AddSingleton(sp => + { + return new MattermostDestinationAddressResolver( + mattermostOptions, + () => string.IsNullOrWhiteSpace(mattermostOptions.DefaultChannelId) + ? null + : new MattermostChannelId(mattermostOptions.DefaultChannelId)); + }); + services.AddSingleton(sp => sp.GetRequiredService()); // Channel-specific LLM tools: registered as IChannelTool singletons. // The gateway actor ref and default channel ID are resolved lazily via @@ -110,10 +119,12 @@ public static void AddMattermostChannelIntegration(this IServiceCollection servi services.AddSingleton(sp => { - var client = sp.GetRequiredService(); - return new LookupMattermostUserTool(client, mattermostOptions); + return new LookupMattermostUserTool( + () => sp.GetRequiredService(), + mattermostOptions); }); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => (IHostedService)sp.GetRequiredKeyedService(MattermostChannelKey)); diff --git a/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs index 2655de984..b2717ff50 100644 --- a/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs @@ -58,7 +58,6 @@ public static void AddSlackChannelIntegration(this IServiceCollection services, }); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddKeyedSingleton(SlackChannelKey); @@ -66,6 +65,18 @@ public static void AddSlackChannelIntegration(this IServiceCollection services, sp.GetRequiredKeyedService(SlackChannelKey)); services.AddSingleton(sp => (SlackChannel)sp.GetRequiredKeyedService(SlackChannelKey)); + services.AddSingleton(sp => + { + var lookup = sp.GetRequiredService(); + return new SlackTargetResolver( + lookup, + slackOptions, + () => string.IsNullOrWhiteSpace(slackOptions.DefaultChannelId) + ? null + : new SlackChannelId(slackOptions.DefaultChannelId)); + }); + services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSlackNet(c => { @@ -109,6 +120,7 @@ public static void AddSlackChannelIntegration(this IServiceCollection services, return new LookupSlackUserTool(slackApi.Users, slackOptions, timeProvider); }); services.AddSingleton(sp => sp.GetRequiredService()); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => (IHostedService)sp.GetRequiredKeyedService(SlackChannelKey)); From 3b781cdc9ba410eed343f1e92f6df705f5045714 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Sun, 7 Jun 2026 12:54:16 +0000 Subject: [PATCH 16/31] Add generic channel lookup tools --- evals/run-evals.sh | 7 + .../.system/files/netclaw-operations/SKILL.md | 16 +- .../Configuration/ChannelLookupToolTests.cs | 250 ++++++++++++++++++ .../Configuration/ChannelLookupTools.cs | 239 +++++++++++++++++ src/Netclaw.Daemon/Program.cs | 1 + 5 files changed, 512 insertions(+), 1 deletion(-) create mode 100644 src/Netclaw.Daemon.Tests/Configuration/ChannelLookupToolTests.cs create mode 100644 src/Netclaw.Daemon/Configuration/ChannelLookupTools.cs diff --git a/evals/run-evals.sh b/evals/run-evals.sh index ab10c2409..4d058686a 100755 --- a/evals/run-evals.sh +++ b/evals/run-evals.sh @@ -997,6 +997,10 @@ assert_tool_discovery() { stdout_contains '\[tool:call\] search_tools' } +assert_tool_channel_lookup_discovery() { + stdout_contains '\[tool:call\] search_tools' +} + assert_tool_shell() { stdout_contains '\[tool:call\] shell_execute' } @@ -1458,6 +1462,9 @@ run_all() { run_case tool_discovery "search_tools called" \ "What MCP servers are available?" + run_case tool_channel_lookup_discovery "search_tools called for channel lookup tools" \ + "Find the available tool for looking up a user on a chat channel before messaging them." + run_case tool_shell "shell_execute called" \ "Run 'echo hello' in the shell" diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index c4dc56ead..4bb6f476c 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.9.0" + version: "2.10.0" --- # Netclaw Operations @@ -142,6 +142,20 @@ use the channel's proactive-post tool: - Mattermost: `send_mattermost_message` — posts to a channel, or DMs a user when direct messages are enabled. +Use the generic lookup tools before sending when you do not already have a +stable channel/user ID: + +- `lookup_channel_user(channel_key, query)` resolves users on enabled channels + that support user lookup, currently Slack and Mattermost. +- `lookup_channel_destination(channel_key, query)` resolves destinations on + enabled channels that support destination lookup. Slack can resolve channel + names and IDs; Mattermost requires an exact channel ID; Discord destination + lookup may fail loud until its resolver is implemented. + +Both tools require `channel_key` as the first argument. Use the returned +`stable_id` exactly; if the lookup is ambiguous, pick from the returned +candidates instead of guessing. Discord user/DM lookup is not supported yet. + `send_discord_message` posts the `message` to a Discord channel and creates a conversation thread off it, so user replies route back to a live session. Provide `channel_id` (or omit it to use the configured default channel); an diff --git a/src/Netclaw.Daemon.Tests/Configuration/ChannelLookupToolTests.cs b/src/Netclaw.Daemon.Tests/Configuration/ChannelLookupToolTests.cs new file mode 100644 index 000000000..a89574992 --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Configuration/ChannelLookupToolTests.cs @@ -0,0 +1,250 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Actors.Channels; +using Netclaw.Channels; +using Netclaw.Daemon.Configuration; +using Netclaw.Tools; +using Xunit; + +namespace Netclaw.Daemon.Tests.Configuration; + +public sealed class ChannelLookupToolTests +{ + [Fact] + public void Registration_skips_generic_lookup_tools_when_remote_channels_are_disabled() + { + var services = BuildServices(new Dictionary + { + ["Slack:Enabled"] = "false", + ["Discord:Enabled"] = "false", + ["Mattermost:Enabled"] = "false" + }); + + Assert.False(IsRegistered(services)); + Assert.False(IsRegistered(services)); + Assert.DoesNotContain(services, descriptor => descriptor.ServiceType == typeof(IChannelTool)); + } + + [Fact] + public void Registration_adds_destination_only_for_discord_only_configuration() + { + var services = BuildServices(new Dictionary + { + ["Slack:Enabled"] = "false", + ["Discord:Enabled"] = "true", + ["Mattermost:Enabled"] = "false" + }); + + Assert.False(IsRegistered(services)); + Assert.True(IsRegistered(services)); + Assert.Single(services, descriptor => descriptor.ServiceType == typeof(IChannelTool)); + } + + [Fact] + public void Registration_adds_user_and_destination_for_user_lookup_channels() + { + var services = BuildServices(new Dictionary + { + ["Slack:Enabled"] = "true", + ["Discord:Enabled"] = "false", + ["Mattermost:Enabled"] = "false" + }); + + Assert.True(IsRegistered(services)); + Assert.True(IsRegistered(services)); + Assert.Equal(2, services.Count(descriptor => descriptor.ServiceType == typeof(IChannelTool))); + } + + [Fact] + public void User_lookup_schema_enumerates_enabled_user_channels_only() + { + var registry = BuildRegistry( + BuildDescriptor(ChannelType.Slack, isEnabled: true, ChannelAddressKind.User), + BuildDescriptor(ChannelType.Mattermost, isEnabled: false, ChannelAddressKind.User), + BuildDescriptor(ChannelType.Discord, isEnabled: true, ChannelAddressKind.Destination)); + var tool = new LookupChannelUserTool(registry); + + var keys = ReadChannelKeyEnum(tool.ParameterSchema); + + Assert.Equal(["slack"], keys); + } + + [Fact] + public void Destination_lookup_schema_enumerates_enabled_destination_channels() + { + var registry = BuildRegistry( + BuildDescriptor(ChannelType.Slack, isEnabled: true, ChannelAddressKind.Destination), + BuildDescriptor(ChannelType.Mattermost, isEnabled: true, ChannelAddressKind.Destination), + BuildDescriptor(ChannelType.Discord, isEnabled: true, ChannelAddressKind.Destination), + BuildDescriptor(ChannelType.Tui, isEnabled: true, ChannelAddressKind.LocalSession)); + var tool = new LookupChannelDestinationTool(registry); + + var keys = ReadChannelKeyEnum(tool.ParameterSchema); + + Assert.Equal(["discord", "mattermost", "slack"], keys); + } + + [Fact] + public async Task User_lookup_routes_to_registered_channel_resolver() + { + var key = ChannelDescriptorKey.FromChannelType(ChannelType.Slack); + var address = new ResolvedChannelAddress(key, ChannelAddressKind.User, "U123", "Alice Smith"); + var resolver = new TestAddressResolver(key, ChannelAddressKind.User) + { + Result = ChannelAddressResolutionResult.Resolved(address) + }; + var registry = BuildRegistry( + [BuildDescriptor(ChannelType.Slack, isEnabled: true, ChannelAddressKind.User)], + [resolver]); + var tool = new LookupChannelUserTool(registry); + + var result = await ExecuteAsync(tool, "slack", "alice"); + + Assert.Contains("Resolved user on channel 'slack'", result); + Assert.Contains("stable_id: U123", result); + Assert.Contains("display_name: Alice Smith", result); + Assert.Equal("alice", resolver.Request?.Query); + } + + [Fact] + public async Task Destination_lookup_formats_ambiguous_candidates() + { + var key = ChannelDescriptorKey.FromChannelType(ChannelType.Slack); + var resolver = new TestAddressResolver(key, ChannelAddressKind.Destination) + { + Result = ChannelAddressResolutionResult.Ambiguous( + [ + new ResolvedChannelAddress(key, ChannelAddressKind.Destination, "C1", "#general"), + new ResolvedChannelAddress(key, ChannelAddressKind.Destination, "C2", "#general-private") + ], + "Multiple destinations matched.") + }; + var registry = BuildRegistry( + [BuildDescriptor(ChannelType.Slack, isEnabled: true, ChannelAddressKind.Destination)], + [resolver]); + var tool = new LookupChannelDestinationTool(registry); + + var result = await ExecuteAsync(tool, "slack", "general"); + + Assert.Contains("Ambiguous destination lookup", result); + Assert.Contains("stable_id: C1", result); + Assert.Contains("stable_id: C2", result); + Assert.Contains("Multiple destinations matched.", result); + } + + [Fact] + public async Task Lookup_rejects_disabled_channel_descriptor() + { + var registry = BuildRegistry(BuildDescriptor(ChannelType.Slack, isEnabled: false, ChannelAddressKind.User)); + var tool = new LookupChannelUserTool(registry); + + var result = await ExecuteAsync(tool, "slack", "alice"); + + Assert.Contains("Channel 'slack' is disabled", result); + } + + [Fact] + public async Task Destination_lookup_reports_missing_discord_resolver() + { + var registry = BuildRegistry(BuildDescriptor(ChannelType.Discord, isEnabled: true, ChannelAddressKind.Destination)); + var tool = new LookupChannelDestinationTool(registry); + + var result = await ExecuteAsync(tool, "discord", "general"); + + Assert.Contains("No channel address resolver is registered for key 'discord'", result); + } + + private static Task ExecuteAsync(ChannelLookupTool tool, string channelKey, string query) + { + return tool.ExecuteAsync(new Dictionary + { + ["channel_key"] = channelKey, + ["query"] = query, + ["_rationale"] = "test" + }, TestContext.Current.CancellationToken); + } + + private static string[] ReadChannelKeyEnum(JsonElement schema) + { + return schema + .GetProperty("properties") + .GetProperty("channel_key") + .GetProperty("enum") + .EnumerateArray() + .Select(element => element.GetString()!) + .ToArray(); + } + + private static ServiceCollection BuildServices(IReadOnlyDictionary settings) + { + var services = new ServiceCollection(); + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(settings) + .Build(); + + services.AddChannelLookupTools(configuration); + return services; + } + + private static bool IsRegistered(IServiceCollection services) + { + return services.Any(descriptor => descriptor.ServiceType == typeof(T)); + } + + private static ChannelRegistry BuildRegistry(params ChannelDescriptor[] descriptors) + { + return BuildRegistry(descriptors, []); + } + + private static ChannelRegistry BuildRegistry( + IReadOnlyList descriptors, + IReadOnlyList resolvers) + { + var providers = descriptors.Select(descriptor => new StaticChannelDescriptorProvider(descriptor)).ToArray(); + return new ChannelRegistry(providers, [], resolvers); + } + + private static ChannelDescriptor BuildDescriptor( + ChannelType channelType, + bool isEnabled, + params ChannelAddressKind[] addressKinds) + { + return new ChannelDescriptor( + ChannelDescriptorKey.FromChannelType(channelType), + channelType, + channelType == ChannelType.Tui ? ChannelKind.LocalInteractiveClient : ChannelKind.RemoteChat, + channelType.ToString(), + isEnabled, + ChannelCapabilities.SendMessages, + ToolIntents: new HashSet(), + AddressKinds: new HashSet(addressKinds), + SupportedOutputEffects: new HashSet()); + } + + private sealed class TestAddressResolver( + ChannelDescriptorKey key, + params ChannelAddressKind[] addressKinds) : IChannelAddressResolver + { + public ChannelDescriptorKey Key { get; } = key; + + public IReadOnlySet AddressKinds { get; } = new HashSet(addressKinds); + + public ChannelAddressResolutionRequest? Request { get; private set; } + + public ChannelAddressResolutionResult Result { get; init; } = ChannelAddressResolutionResult.NotFound(); + + public ValueTask ResolveAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken = default) + { + Request = request; + return ValueTask.FromResult(Result); + } + } +} diff --git a/src/Netclaw.Daemon/Configuration/ChannelLookupTools.cs b/src/Netclaw.Daemon/Configuration/ChannelLookupTools.cs new file mode 100644 index 000000000..9046db3c8 --- /dev/null +++ b/src/Netclaw.Daemon/Configuration/ChannelLookupTools.cs @@ -0,0 +1,239 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text; +using System.Text.Json; +using Microsoft.Extensions.AI; +using Netclaw.Channels; +using Netclaw.Channels.Discord; +using Netclaw.Channels.Mattermost; +using Netclaw.Channels.Slack; +using Netclaw.Tools; + +namespace Netclaw.Daemon.Configuration; + +internal static class ChannelLookupToolRegistration +{ + public static IServiceCollection AddChannelLookupTools(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + var slackEnabled = (configuration.GetSection("Slack").Get() ?? new SlackChannelOptions()).Enabled; + var discordEnabled = (configuration.GetSection("Discord").Get() ?? new DiscordChannelOptions()).Enabled; + var mattermostEnabled = (configuration.GetSection("Mattermost").Get() ?? new MattermostChannelOptions()).Enabled; + + if (slackEnabled || mattermostEnabled) + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + } + + if (slackEnabled || discordEnabled || mattermostEnabled) + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + } + + return services; + } +} + +internal sealed class LookupChannelUserTool(IChannelRegistry registry) : ChannelLookupTool(registry) +{ + public override string Name => "lookup_channel_user"; + + public override string Description => "Look up a user on an enabled chat channel. Returns stable user IDs for channel-specific workflows."; + + protected override ChannelAddressKind AddressKind => ChannelAddressKind.User; + + protected override string LookupLabel => "user"; + + protected override string QueryDescription => "User ID, username, display name, real name, or email address to resolve on the selected channel."; +} + +internal sealed class LookupChannelDestinationTool(IChannelRegistry registry) : ChannelLookupTool(registry) +{ + public override string Name => "lookup_channel_destination"; + + public override string Description => "Look up a destination on an enabled chat channel. Returns stable channel or destination IDs for channel-specific workflows."; + + protected override ChannelAddressKind AddressKind => ChannelAddressKind.Destination; + + protected override string LookupLabel => "destination"; + + protected override string QueryDescription => "Destination ID or name to resolve on the selected channel."; +} + +internal abstract class ChannelLookupTool : IChannelTool +{ + private readonly IChannelRegistry _registry; + private AITool? _aiTool; + private JsonElement? _parameterSchema; + private LlmFacingToolName? _llmFacingName; + + protected ChannelLookupTool(IChannelRegistry registry) + { + _registry = registry; + } + + public abstract string Name { get; } + + public LlmFacingToolName LlmFacingName => _llmFacingName ??= LlmFacingToolName.FromCanonical(Name); + + public abstract string Description { get; } + + public string GrantCategory => "builtin"; + + public JsonElement ParameterSchema => _parameterSchema ??= BuildParameterSchema(); + + protected abstract ChannelAddressKind AddressKind { get; } + + protected abstract string LookupLabel { get; } + + protected abstract string QueryDescription { get; } + + public AITool ToAITool() + { + return _aiTool ??= AIFunctionFactory.CreateDeclaration(Name, Description, ParameterSchema); + } + + public async Task ExecuteAsync(IDictionary? arguments, CancellationToken ct = default) + { + var channelKeyValue = ToolArgumentHelper.GetString(arguments, "channel_key"); + if (string.IsNullOrWhiteSpace(channelKeyValue)) + return "Error: 'channel_key' parameter is required."; + + var query = ToolArgumentHelper.GetString(arguments, "query"); + if (string.IsNullOrWhiteSpace(query)) + return "Error: 'query' parameter is required."; + + var key = ChannelDescriptorKey.Create(channelKeyValue.Trim()); + var enabledKeys = GetEnabledChannelKeys(); + + ChannelDescriptor descriptor; + try + { + descriptor = _registry.GetChannel(key); + } + catch (InvalidOperationException ex) + { + if (enabledKeys.Count == 0) + return $"Error: No enabled channels support {LookupLabel} lookup."; + + return $"Error: {ex.Message} Supported channel_key values: {string.Join(", ", enabledKeys)}."; + } + + if (!descriptor.IsEnabled) + return $"Error: Channel '{key}' is disabled. Supported channel_key values: {string.Join(", ", enabledKeys)}."; + + if (!descriptor.AddressKinds.Contains(AddressKind)) + return $"Error: Channel '{key}' does not support {LookupLabel} lookup. Supported channel_key values: {string.Join(", ", enabledKeys)}."; + + ChannelAddressResolutionResult result; + try + { + result = await _registry.ResolveAddressAsync( + new ChannelAddressResolutionRequest(key, AddressKind, query.Trim()), + ct); + } + catch (InvalidOperationException ex) + { + return $"Error: {ex.Message}"; + } + + return FormatResult(key, query.Trim(), result); + } + + private JsonElement BuildParameterSchema() + { + var channelKeys = GetEnabledChannelKeys(); + var channelEnum = JsonSerializer.Serialize(channelKeys); + var schemaJson = $$""" + { + "type": "object", + "properties": { + "channel_key": { + "type": "string", + "description": "Enabled channel key to search.", + "enum": {{channelEnum}} + }, + "query": { + "type": "string", + "description": {{JsonSerializer.Serialize(QueryDescription)}} + }, + "_rationale": { + "type": "string", + "description": "State your intent for this tool call in one sentence - what are you trying to accomplish and why?" + }, + "_timeout_seconds": { + "type": "integer", + "description": "Requested timeout in seconds. Only set when the default is insufficient." + }, + "_background": { + "type": "boolean", + "description": "Set to true to run this tool in the background and receive results later." + } + }, + "required": ["channel_key", "query", "_rationale"] + } + """; + + return JsonDocument.Parse(schemaJson).RootElement.Clone(); + } + + private IReadOnlyList GetEnabledChannelKeys() + { + return _registry.ListChannels() + .Where(descriptor => descriptor.IsEnabled && descriptor.AddressKinds.Contains(AddressKind)) + .Select(descriptor => descriptor.Key.Value) + .Order(StringComparer.Ordinal) + .ToArray(); + } + + private string FormatResult( + ChannelDescriptorKey key, + string query, + ChannelAddressResolutionResult result) + { + return result.Status switch + { + ChannelAddressResolutionStatus.Resolved => FormatResolved(key, result.RequireSingle()), + ChannelAddressResolutionStatus.Ambiguous => FormatAmbiguous(key, query, result), + ChannelAddressResolutionStatus.NotFound => $"No {LookupLabel} found on channel '{key}' for query '{query}'.{FormatErrorSuffix(result.Error)}", + ChannelAddressResolutionStatus.Unsupported => $"Error: {result.Error ?? $"Channel '{key}' does not support {LookupLabel} lookup."}", + _ => $"Error: Unsupported address resolution status '{result.Status}'." + }; + } + + private string FormatResolved(ChannelDescriptorKey key, ResolvedChannelAddress address) + { + var builder = new StringBuilder(); + builder.AppendLine($"Resolved {LookupLabel} on channel '{key}':"); + builder.AppendLine($"stable_id: {address.StableId}"); + builder.AppendLine($"display_name: {address.DisplayName}"); + builder.AppendLine($"address_kind: {address.AddressKind}"); + return builder.ToString().TrimEnd(); + } + + private string FormatAmbiguous( + ChannelDescriptorKey key, + string query, + ChannelAddressResolutionResult result) + { + var builder = new StringBuilder(); + builder.AppendLine($"Ambiguous {LookupLabel} lookup on channel '{key}' for query '{query}'.{FormatErrorSuffix(result.Error)}"); + builder.AppendLine("Candidates:"); + foreach (var candidate in result.Candidates) + builder.AppendLine($"- stable_id: {candidate.StableId}; display_name: {candidate.DisplayName}; address_kind: {candidate.AddressKind}"); + + return builder.ToString().TrimEnd(); + } + + private static string FormatErrorSuffix(string? error) + { + return string.IsNullOrWhiteSpace(error) ? string.Empty : $" {error}"; + } +} diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index b23d6b501..fe91d7ec8 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -1074,6 +1074,7 @@ static void ConfigureDaemonServices( services.AddSlackChannelIntegration(configuration); services.AddDiscordChannelIntegration(configuration); services.AddMattermostChannelIntegration(configuration); + services.AddChannelLookupTools(configuration); // Config hot-reload watcher services.AddSingleton(); From f2362a4d492fe7d8efd8fe9cf137794379a49b6b Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 8 Jun 2026 15:52:11 +0000 Subject: [PATCH 17/31] Remove legacy Slack user lookup tool --- .../.system/files/netclaw-operations/SKILL.md | 6 ++++-- .../Tools/LookupSlackUserTool.cs | 2 +- .../ChannelRegistryRegistrationTests.cs | 13 ++++++++++--- .../SlackChannelRegistrationExtensions.cs | 4 ++-- 4 files changed, 17 insertions(+), 8 deletions(-) diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 4bb6f476c..f715189a4 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.10.0" + version: "2.10.1" --- # Netclaw Operations @@ -154,7 +154,9 @@ stable channel/user ID: Both tools require `channel_key` as the first argument. Use the returned `stable_id` exactly; if the lookup is ambiguous, pick from the returned -candidates instead of guessing. Discord user/DM lookup is not supported yet. +candidates instead of guessing. Do not use channel-specific lookup aliases such +as `lookup_slack_user`; lookup is intentionally routed through the generic +channel tools. Discord user/DM lookup is not supported yet. `send_discord_message` posts the `message` to a Discord channel and creates a conversation thread off it, so user replies route back to a live session. diff --git a/src/Netclaw.Channels.Slack/Tools/LookupSlackUserTool.cs b/src/Netclaw.Channels.Slack/Tools/LookupSlackUserTool.cs index 6444c9859..c1b186aa6 100644 --- a/src/Netclaw.Channels.Slack/Tools/LookupSlackUserTool.cs +++ b/src/Netclaw.Channels.Slack/Tools/LookupSlackUserTool.cs @@ -20,7 +20,7 @@ namespace Netclaw.Channels.Slack.Tools; "Look up a Slack user by name, display name, or email. " + "Returns their user ID for use with send_slack_message.", Grant = "builtin")] -public sealed partial class LookupSlackUserTool : NetclawTool, IChannelTool, IChannelAddressResolver +public sealed partial class LookupSlackUserTool : NetclawTool, IChannelAddressResolver { private static readonly IReadOnlySet UserAddressKinds = new HashSet { diff --git a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs index 343c80be6..0d4041f38 100644 --- a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs +++ b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs @@ -126,9 +126,12 @@ public void Enabled_remote_channels_register_expected_channel_tools() ["Mattermost:Enabled"] = "true" }); - Assert.Equal(5, services.Count(descriptor => descriptor.ServiceType == typeof(IChannelTool))); + Assert.Equal(6, services.Count(descriptor => descriptor.ServiceType == typeof(IChannelTool))); Assert.True(IsRegistered(services)); Assert.True(IsRegistered(services)); + Assert.False(typeof(IChannelTool).IsAssignableFrom(typeof(LookupSlackUserTool))); + Assert.True(IsRegistered(services)); + Assert.True(IsRegistered(services)); Assert.True(IsRegistered(services)); Assert.True(IsRegistered(services)); Assert.True(IsRegistered(services)); @@ -171,7 +174,7 @@ public void Enabled_channel_tool_intents_match_registered_tool_services() descriptors["slack"], services, new ChannelToolExpectation(ChannelToolIntentKind.SendMessage, typeof(SendSlackMessageTool), "send_slack_message"), - new ChannelToolExpectation(ChannelToolIntentKind.LookupUser, typeof(LookupSlackUserTool), "lookup_slack_user")); + new ChannelToolExpectation(ChannelToolIntentKind.LookupUser, typeof(LookupChannelUserTool), null)); AssertToolIntents( descriptors["discord"], services, @@ -343,6 +346,7 @@ private static ServiceCollection BuildServices(IReadOnlyDictionary serviceDescriptor.ServiceType == expectedTool.ToolType); + if (expectedTool.ToolName is null) + continue; + var attribute = Assert.Single(expectedTool.ToolType.GetCustomAttributes( typeof(NetclawToolAttribute), inherit: false)); Assert.Equal(expectedTool.ToolName, ((NetclawToolAttribute)attribute).Name); @@ -393,7 +400,7 @@ private static void AssertToolIntents( private sealed record ChannelToolExpectation( ChannelToolIntentKind Intent, Type ToolType, - string ToolName); + string? ToolName); private sealed class TestAddressResolver( ChannelDescriptorKey key, diff --git a/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs index b2717ff50..d51f485a2 100644 --- a/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs @@ -98,7 +98,8 @@ public static void AddSlackChannelIntegration(this IServiceCollection services, context.ServiceProvider().GetRequiredService()); }); - // Channel-specific LLM tools: registered as INetclawTool singletons. + // Channel-specific LLM send tool: registered as an IChannelTool singleton. + // User lookup is exposed through the generic lookup_channel_user tool. // The gateway actor ref and default channel ID are resolved lazily via // SlackChannel since they're not available until StartAsync completes. services.AddSingleton(sp => @@ -119,7 +120,6 @@ public static void AddSlackChannelIntegration(this IServiceCollection services, var timeProvider = sp.GetRequiredService(); return new LookupSlackUserTool(slackApi.Users, slackOptions, timeProvider); }); - services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => From d88ff64f88233b894475e7f356051673c03bd14d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 8 Jun 2026 16:32:10 +0000 Subject: [PATCH 18/31] Add generic channel send tool --- docs/integrations/discord-channel.md | 13 +- docs/integrations/mattermost-channel.md | 3 +- .../.system/files/netclaw-operations/SKILL.md | 55 +- .../tasks.md | 12 +- .../ExecutionOutputAccumulatorTests.cs | 10 +- .../MattermostReminderTargetResolverTests.cs | 2 +- .../Reminders/ReminderExecutionActorTests.cs | 12 +- .../Reminders/ReminderExecutionActor.cs | 40 +- .../Reminders/ReminderProtocol.cs | 11 +- .../Tools/SendDiscordMessageTool.cs | 2 +- .../MattermostReminderTargetResolver.cs | 2 +- .../Tools/LookupMattermostUserTool.cs | 6 +- .../Tools/SendMattermostMessageTool.cs | 2 +- .../Tools/LookupSlackUserTool.cs | 2 +- .../Tools/SendSlackMessageTool.cs | 2 +- .../Configuration/ChannelLookupToolTests.cs | 4 + .../ChannelRegistryRegistrationTests.cs | 16 +- .../Configuration/ChannelSendToolTests.cs | 157 ++++++ .../Configuration/ChannelLookupTools.cs | 5 +- .../Configuration/ChannelSendTools.cs | 469 ++++++++++++++++++ .../DiscordChannelRegistrationExtensions.cs | 3 +- ...MattermostChannelRegistrationExtensions.cs | 4 +- .../SlackChannelRegistrationExtensions.cs | 3 +- src/Netclaw.Daemon/Program.cs | 1 + .../Webhooks/RegisteredWebhookRoute.cs | 3 +- .../Webhooks/WebhookExecutionActor.cs | 2 +- 26 files changed, 760 insertions(+), 81 deletions(-) create mode 100644 src/Netclaw.Daemon.Tests/Configuration/ChannelSendToolTests.cs create mode 100644 src/Netclaw.Daemon/Configuration/ChannelSendTools.cs diff --git a/docs/integrations/discord-channel.md b/docs/integrations/discord-channel.md index 2ddc72c4c..d83df6180 100644 --- a/docs/integrations/discord-channel.md +++ b/docs/integrations/discord-channel.md @@ -113,15 +113,14 @@ Discord ACL evaluation follows fail-closed rules. - `delivery_kind = "current_session"` for session-thread replies - `delivery_kind = "channel"` with `delivery_transport = "discord"` -Discord channel target resolution accepts canonical forms: +Discord channel target resolution accepts canonical channel forms: -- `<@123...>` or `<@!123...>` (user mention) -- `@123...` (user id shorthand) -- `123...` (raw user snowflake) -- `dm:` (explicit DM channel ID) +- `<#123...>` (channel mention) +- `channel:123...` (explicit channel ID) -Reminder channel delivery maps to `send_discord_message`; ensure that tool is -available in your tool/runtime setup before relying on transport-based delivery. +Reminder channel delivery maps to the generic `send_channel_message` tool with +`channel_key = "discord"` and a resolved destination object. Discord proactive +DM output is not supported yet. ## Runtime behavior and troubleshooting diff --git a/docs/integrations/mattermost-channel.md b/docs/integrations/mattermost-channel.md index fcde58237..849efde3b 100644 --- a/docs/integrations/mattermost-channel.md +++ b/docs/integrations/mattermost-channel.md @@ -121,7 +121,8 @@ A bare ID with no prefix is rejected with a disambiguation error. Direct-message delivery is supported (it is not on Discord) because a Mattermost DM is an addressable channel. -Reminder channel delivery maps to `send_mattermost_message`. +Reminder channel delivery maps to the generic `send_channel_message` tool with +`channel_key = "mattermost"` and a resolved destination object. ## Runtime behavior and troubleshooting diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index f715189a4..6b6e8450e 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.10.1" + version: "2.10.2" --- # Netclaw Operations @@ -135,12 +135,19 @@ Other scheduling tools: `list_reminders`, `cancel_reminder`, To start a brand-new conversation on a chat channel — a `delivery_kind=channel` reminder firing, or unprompted cross-channel outreach ("let the team know") — -use the channel's proactive-post tool: - -- Slack: `send_slack_message` — posts to a channel or DMs a user. -- Discord: `send_discord_message` — posts to a channel only. -- Mattermost: `send_mattermost_message` — posts to a channel, or DMs a user - when direct messages are enabled. +use the generic proactive-post tool: + +- `send_channel_message(channel_key, destination, text)` posts through the + selected enabled channel and creates a new conversation thread when that + channel supports it. +- `destination` must be a resolved object with `channel_key`, `kind`, and `id`. + Do not pass bare display names like `#general` or `@alice`. +- `destination.kind="destination"` posts to a channel/destination ID returned by + `lookup_channel_destination`. +- `destination.kind="direct_message"` sends a DM using the stable user ID from + `lookup_channel_user`; this is supported only by channels that advertise DM + output (currently Slack and Mattermost when enabled in config). Discord DMs are + not supported yet. Use the generic lookup tools before sending when you do not already have a stable channel/user ID: @@ -153,23 +160,27 @@ stable channel/user ID: lookup may fail loud until its resolver is implemented. Both tools require `channel_key` as the first argument. Use the returned -`stable_id` exactly; if the lookup is ambiguous, pick from the returned -candidates instead of guessing. Do not use channel-specific lookup aliases such -as `lookup_slack_user`; lookup is intentionally routed through the generic +`channel_key` and `stable_id` exactly; for destination lookups, use the returned +`address_kind` (`destination`) as `destination.kind`. For user lookups, set +`destination.kind` to `direct_message` and pass the returned user `stable_id`. +If lookup is ambiguous, pick from the returned candidates instead of guessing. +Do not use channel-specific lookup aliases such as `lookup_slack_user` or +`lookup_mattermost_user`; lookup is intentionally routed through the generic channel tools. Discord user/DM lookup is not supported yet. -`send_discord_message` posts the `message` to a Discord channel and creates a -conversation thread off it, so user replies route back to a live session. -Provide `channel_id` (or omit it to use the configured default channel); an -optional `thread_name` titles the thread. The channel must be in the Discord -allow-list. Discord DM targets are not supported yet — the tool posts to -channels only. - -`send_mattermost_message` behaves like the Slack tool: it posts the `message` -to a Mattermost channel (or, when `AllowDirectMessages` is enabled, a user's -direct-message channel) and threads replies back to a live session. Mattermost -direct messages are addressable, so `delivery_kind=channel` reminders may -target a DM (`@user`) — unlike Discord. +Examples: + +``` +send_channel_message( + channel_key: "slack", + destination: { channel_key: "slack", kind: "destination", id: "C0123ABC" }, + text: "Deployment finished successfully.") + +send_channel_message( + channel_key: "mattermost", + destination: { channel_key: "mattermost", kind: "direct_message", id: "26characterMattermostUserId" }, + text: "Your report is ready.") +``` ### Approval Requirements for Reminders and Webhooks diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md index f6a4ccfd9..bf20102e3 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/tasks.md +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -57,12 +57,12 @@ ## 8. LLM-facing channel tool standardization -- [ ] 8.1 Define standard generic tool schemas and final tool names: `send_channel_message`, `lookup_channel_user`, and `lookup_channel_destination`, each with required first `channel_key` enum-constrained from enabled descriptors. -- [ ] 8.2 Rename/map existing Slack tools to the standard tool names and intent schema. -- [ ] 8.3 Rename/map existing Discord tools to the standard tool names and intent schema. -- [ ] 8.4 Rename/map existing Mattermost tools to the standard tool names and intent schema. -- [ ] 8.5 Update system skills, CLI/help text, and eval cases for renamed LLM-facing channel tools. -- [ ] 8.6 Define the standardized DM send workflow as user lookup -> direct-message delivery target -> send-channel-message, gated by each channel descriptor's implemented DM output capability. +- [x] 8.1 Define standard generic tool schemas and final tool names: `send_channel_message`, `lookup_channel_user`, and `lookup_channel_destination`, each with required first `channel_key` enum-constrained from enabled descriptors. +- [x] 8.2 Rename/map existing Slack tools to the standard tool names and intent schema. +- [x] 8.3 Rename/map existing Discord tools to the standard tool names and intent schema. +- [x] 8.4 Rename/map existing Mattermost tools to the standard tool names and intent schema. +- [x] 8.5 Update system skills, CLI/help text, and eval cases for renamed LLM-facing channel tools. +- [x] 8.6 Define the standardized DM send workflow as user lookup -> direct-message delivery target -> send-channel-message, gated by each channel descriptor's implemented DM output capability. - [ ] 8.7 Add eval cases for smaller-model channel selection: Slack channel post, Mattermost user DM, Discord channel post, mismatched channel/destination rejection, and unsupported DM capability failure. ## 9. Channel output effects follow-up diff --git a/src/Netclaw.Actors.Tests/Channels/ExecutionOutputAccumulatorTests.cs b/src/Netclaw.Actors.Tests/Channels/ExecutionOutputAccumulatorTests.cs index 42a230209..d163797b2 100644 --- a/src/Netclaw.Actors.Tests/Channels/ExecutionOutputAccumulatorTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/ExecutionOutputAccumulatorTests.cs @@ -14,7 +14,7 @@ namespace Netclaw.Actors.Tests.Channels; public sealed class ExecutionOutputAccumulatorTests { private static readonly SessionId TestSessionId = new("test/session"); - private static readonly ToolName TestNotifyTool = new("send_slack_message"); + private static readonly ToolName TestNotifyTool = new("send_channel_message"); [Fact] public void TextDeltaOutput_accumulates_text() @@ -99,7 +99,7 @@ public void Tracks_successful_notification_tool_result() { SessionId = TestSessionId, CallId = new Netclaw.Tools.ToolCallId("call-1"), - ToolName = new Netclaw.Tools.ToolName("send_slack_message"), + ToolName = new Netclaw.Tools.ToolName("send_channel_message"), Result = "Message sent to channel C1." }); @@ -116,7 +116,7 @@ public void Tracks_failed_notification_tool_result() { SessionId = TestSessionId, CallId = new Netclaw.Tools.ToolCallId("call-2"), - ToolName = new Netclaw.Tools.ToolName("send_slack_message"), + ToolName = new Netclaw.Tools.ToolName("send_channel_message"), Result = "Error: channel not found" }); @@ -180,7 +180,7 @@ public void Succeeds_when_notification_attempted_and_succeeded() { SessionId = TestSessionId, CallId = new Netclaw.Tools.ToolCallId("call-ok"), - ToolName = new Netclaw.Tools.ToolName("send_slack_message"), + ToolName = new Netclaw.Tools.ToolName("send_channel_message"), Result = "Message sent." }); @@ -197,7 +197,7 @@ public void Fails_when_notification_attempted_and_errored() { SessionId = TestSessionId, CallId = new Netclaw.Tools.ToolCallId("call-err"), - ToolName = new Netclaw.Tools.ToolName("send_slack_message"), + ToolName = new Netclaw.Tools.ToolName("send_channel_message"), Result = "Error: channel not found" }); diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostReminderTargetResolverTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostReminderTargetResolverTests.cs index 450f6d72a..d0dd9d627 100644 --- a/src/Netclaw.Actors.Tests/Channels/MattermostReminderTargetResolverTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/MattermostReminderTargetResolverTests.cs @@ -24,7 +24,7 @@ public async Task Resolves_at_prefixed_user_target_to_canonical_user_id() Assert.Equal(ReminderTargetKind.User, result.Kind); // The canonical form preserves the "@" prefix — Mattermost user IDs and // channel IDs are indistinguishable bare strings, so downstream consumers - // (reminder prompt builder, send_mattermost_message dispatcher) need the + // (reminder prompt builder, send_channel_message dispatcher) need the // prefix to know which dispatch path to take. Assert.Equal("@abcdefghijklmnopqrstuvwxyz", result.ResolvedId); Assert.Null(result.ErrorMessage); diff --git a/src/Netclaw.Actors.Tests/Reminders/ReminderExecutionActorTests.cs b/src/Netclaw.Actors.Tests/Reminders/ReminderExecutionActorTests.cs index a0b466256..df07a0aa4 100644 --- a/src/Netclaw.Actors.Tests/Reminders/ReminderExecutionActorTests.cs +++ b/src/Netclaw.Actors.Tests/Reminders/ReminderExecutionActorTests.cs @@ -93,8 +93,8 @@ public async Task Execution_fails_when_notification_tool_returns_error() { SessionId = sessionId, CallId = new Netclaw.Tools.ToolCallId("call-1"), - ToolName = new Netclaw.Tools.ToolName("send_slack_message"), - Result = "Error parsing arguments for tool 'send_slack_message': Required parameter 'Message' is missing or empty." + ToolName = new Netclaw.Tools.ToolName("send_channel_message"), + Result = "Error: 'text' parameter is required." }, new TurnCompleted { SessionId = sessionId, TurnNumber = new Netclaw.Actors.Protocol.TurnNumber(1) } ]); @@ -109,7 +109,7 @@ public async Task Execution_fails_when_notification_tool_returns_error() Assert.False(completed.Success); Assert.Equal("notify-fail-test", completed.Id.Value); - Assert.Contains("Required parameter 'Message'", completed.ErrorMessage); + Assert.Contains("'text' parameter is required", completed.ErrorMessage); } [Fact] @@ -121,7 +121,7 @@ public async Task Execution_succeeds_when_notification_tool_reports_success() { SessionId = sessionId, CallId = new Netclaw.Tools.ToolCallId("call-2"), - ToolName = new Netclaw.Tools.ToolName("send_slack_message"), + ToolName = new Netclaw.Tools.ToolName("send_channel_message"), Result = "Message sent to channel C1. Thread: C1/1234567890.000001" }, new TurnCompleted { SessionId = sessionId, TurnNumber = new Netclaw.Actors.Protocol.TurnNumber(1) } @@ -195,7 +195,7 @@ public async Task Execution_fails_when_conditional_policy_and_notification_tool_ { SessionId = sessionId, CallId = new Netclaw.Tools.ToolCallId("call-err"), - ToolName = new Netclaw.Tools.ToolName("send_slack_message"), + ToolName = new Netclaw.Tools.ToolName("send_channel_message"), Result = "Error: channel not found" }, new TurnCompleted { SessionId = sessionId, TurnNumber = new Netclaw.Actors.Protocol.TurnNumber(1) } @@ -443,7 +443,7 @@ public async Task Successful_execution_appends_success_true_history_record() new TurnCompleted { SessionId = sessionId, TurnNumber = new Netclaw.Actors.Protocol.TurnNumber(1) } ]); - // Use Kind = None so success is not gated on send_slack_message + // Use Kind = None so success is not gated on send_channel_message var definition = CreateDefinition("history-success-test") with { Delivery = new ReminderDelivery { Kind = DeliveryKind.None } diff --git a/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs b/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs index bdbb58158..9d5fa37f1 100644 --- a/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs +++ b/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs @@ -360,7 +360,7 @@ private static string BuildPrompt(ReminderDefinition definition) DeliveryKind.CurrentSession => string.IsNullOrWhiteSpace(definition.DeliveryInstructions) ? "" : $"\n\nDelivery guidance:\n{definition.DeliveryInstructions}", - DeliveryKind.Channel => $"\n\nPost the result to {definition.Delivery.Transport} target {definition.Delivery.Address}." + + DeliveryKind.Channel => BuildChannelDeliveryGuidance(definition) + (string.IsNullOrWhiteSpace(definition.DeliveryInstructions) ? "" : $"\n{definition.DeliveryInstructions}"), DeliveryKind.None => "", _ => throw new ArgumentOutOfRangeException(nameof(definition.Delivery.Kind), definition.Delivery.Kind, "Unexpected DeliveryKind") @@ -375,6 +375,44 @@ private static string BuildPrompt(ReminderDefinition definition) return $"{definition.Instructions}{deliverySection}{completionGuidance}"; } + private static string BuildChannelDeliveryGuidance(ReminderDefinition definition) + { + var transport = definition.Delivery.Transport?.Trim().ToLowerInvariant(); + var address = definition.Delivery.Address?.Trim(); + if (string.IsNullOrWhiteSpace(transport) || string.IsNullOrWhiteSpace(address)) + { + throw new InvalidOperationException( + $"Reminder '{definition.Id}' has channel delivery but is missing transport or address."); + } + + var destinationKind = "destination"; + var destinationId = address; + + if (string.Equals(transport, "slack", StringComparison.OrdinalIgnoreCase) + && address is { Length: > 0 } + && (address.StartsWith("U", StringComparison.Ordinal) || address.StartsWith("W", StringComparison.Ordinal))) + { + destinationKind = "direct_message"; + } + else if (string.Equals(transport, "mattermost", StringComparison.OrdinalIgnoreCase) + && address is { Length: > 0 }) + { + if (address.StartsWith('@')) + { + destinationKind = "direct_message"; + destinationId = address[1..]; + } + else if (address.StartsWith("channel:", StringComparison.OrdinalIgnoreCase)) + { + destinationId = address[8..]; + } + } + + return "\n\nPost the result using send_channel_message with " + + $"channel_key='{transport}', destination.channel_key='{transport}', " + + $"destination.kind='{destinationKind}', destination.id='{destinationId}', and text set to the result."; + } + private void HandleOutput(ExecutionOutput wrapper) { var action = _accumulator.ProcessOutput(wrapper.Output); diff --git a/src/Netclaw.Actors/Reminders/ReminderProtocol.cs b/src/Netclaw.Actors/Reminders/ReminderProtocol.cs index 945324727..4a6f2922c 100644 --- a/src/Netclaw.Actors/Reminders/ReminderProtocol.cs +++ b/src/Netclaw.Actors/Reminders/ReminderProtocol.cs @@ -95,16 +95,11 @@ public sealed record ReminderDelivery : INetclawSerializableMessage public Channels.ChannelType? OriginChannelType { get; init; } /// - /// Gets the notification tool name for Channel delivery based on the transport. - /// Returns null for non-Channel delivery kinds or unknown transports. + /// Gets the notification tool name for Channel delivery. + /// Returns null for non-Channel delivery kinds. /// public string? GetNotificationToolName() => Kind == DeliveryKind.Channel - ? Transport?.ToLowerInvariant() switch - { - "slack" => "send_slack_message", - "discord" => "send_discord_message", - _ => null - } + ? "send_channel_message" : null; } diff --git a/src/Netclaw.Channels.Discord/Tools/SendDiscordMessageTool.cs b/src/Netclaw.Channels.Discord/Tools/SendDiscordMessageTool.cs index a17dab2ec..20bd7bc48 100644 --- a/src/Netclaw.Channels.Discord/Tools/SendDiscordMessageTool.cs +++ b/src/Netclaw.Channels.Discord/Tools/SendDiscordMessageTool.cs @@ -21,7 +21,7 @@ namespace Netclaw.Channels.Discord.Tools; "Use this to proactively notify users or start discussions. " + "Omit channel_id to use the configured default channel.", Grant = "builtin")] -public sealed partial class SendDiscordMessageTool : NetclawTool, IChannelTool +public sealed partial class SendDiscordMessageTool : NetclawTool { private const int MaxThreadNameLength = 100; diff --git a/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs b/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs index 0a9e92168..5c96f4d9b 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostReminderTargetResolver.cs @@ -41,7 +41,7 @@ public Task ResolveAsync(string target, CancellationTo // Preserve the "channel:" prefix in the canonical form. Mattermost // channel IDs and user IDs are both 26-char alphanumeric strings, // so the bare ID is indistinguishable downstream — the reminder - // prompt builder and send_mattermost_message dispatcher need the + // prompt builder and send_channel_message dispatcher need the // prefix to know whether to target a channel or open a DM. return Task.FromResult(new ReminderTargetResolution( Success: true, diff --git a/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs b/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs index 22571e30a..480245efc 100644 --- a/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs +++ b/src/Netclaw.Channels.Mattermost/Tools/LookupMattermostUserTool.cs @@ -14,13 +14,13 @@ namespace Netclaw.Channels.Mattermost.Tools; /// /// LLM tool that looks up Mattermost users by username or email. -/// Returns user IDs suitable for use with . +/// Returns user IDs suitable for use with the generic send_channel_message tool. /// [NetclawTool("lookup_mattermost_user", "Look up a Mattermost user by username or email. " + - "Returns their user ID for use with send_mattermost_message.", + "Returns their user ID for use with send_channel_message.", Grant = "builtin")] -public sealed partial class LookupMattermostUserTool : NetclawTool, IChannelTool, IChannelAddressResolver +public sealed partial class LookupMattermostUserTool : NetclawTool, IChannelAddressResolver { private static readonly IReadOnlySet UserAddressKinds = new HashSet { diff --git a/src/Netclaw.Channels.Mattermost/Tools/SendMattermostMessageTool.cs b/src/Netclaw.Channels.Mattermost/Tools/SendMattermostMessageTool.cs index e9741f6c6..ae1e3158d 100644 --- a/src/Netclaw.Channels.Mattermost/Tools/SendMattermostMessageTool.cs +++ b/src/Netclaw.Channels.Mattermost/Tools/SendMattermostMessageTool.cs @@ -20,7 +20,7 @@ namespace Netclaw.Channels.Mattermost.Tools; "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 +public sealed partial class SendMattermostMessageTool : NetclawTool { private readonly IMattermostOutboundClient _outboundClient; private readonly MattermostChannelOptions _options; diff --git a/src/Netclaw.Channels.Slack/Tools/LookupSlackUserTool.cs b/src/Netclaw.Channels.Slack/Tools/LookupSlackUserTool.cs index c1b186aa6..771033e88 100644 --- a/src/Netclaw.Channels.Slack/Tools/LookupSlackUserTool.cs +++ b/src/Netclaw.Channels.Slack/Tools/LookupSlackUserTool.cs @@ -18,7 +18,7 @@ namespace Netclaw.Channels.Slack.Tools; /// [NetclawTool("lookup_slack_user", "Look up a Slack user by name, display name, or email. " + - "Returns their user ID for use with send_slack_message.", + "Returns their user ID for use with send_channel_message.", Grant = "builtin")] public sealed partial class LookupSlackUserTool : NetclawTool, IChannelAddressResolver { diff --git a/src/Netclaw.Channels.Slack/Tools/SendSlackMessageTool.cs b/src/Netclaw.Channels.Slack/Tools/SendSlackMessageTool.cs index 98e1ce197..27c65e72c 100644 --- a/src/Netclaw.Channels.Slack/Tools/SendSlackMessageTool.cs +++ b/src/Netclaw.Channels.Slack/Tools/SendSlackMessageTool.cs @@ -20,7 +20,7 @@ namespace Netclaw.Channels.Slack.Tools; "Use this to proactively notify users or start discussions. " + "Provide exactly one of channel_id or user_id.", Grant = "builtin")] -public sealed partial class SendSlackMessageTool : NetclawTool, IChannelTool +public sealed partial class SendSlackMessageTool : NetclawTool { private readonly ISlackOutboundClient _outboundClient; private readonly SlackChannelOptions _options; diff --git a/src/Netclaw.Daemon.Tests/Configuration/ChannelLookupToolTests.cs b/src/Netclaw.Daemon.Tests/Configuration/ChannelLookupToolTests.cs index a89574992..52169fe86 100644 --- a/src/Netclaw.Daemon.Tests/Configuration/ChannelLookupToolTests.cs +++ b/src/Netclaw.Daemon.Tests/Configuration/ChannelLookupToolTests.cs @@ -107,8 +107,10 @@ public async Task User_lookup_routes_to_registered_channel_resolver() var result = await ExecuteAsync(tool, "slack", "alice"); Assert.Contains("Resolved user on channel 'slack'", result); + Assert.Contains("channel_key: slack", result); Assert.Contains("stable_id: U123", result); Assert.Contains("display_name: Alice Smith", result); + Assert.Contains("address_kind: user", result); Assert.Equal("alice", resolver.Request?.Query); } @@ -133,8 +135,10 @@ public async Task Destination_lookup_formats_ambiguous_candidates() var result = await ExecuteAsync(tool, "slack", "general"); Assert.Contains("Ambiguous destination lookup", result); + Assert.Contains("channel_key: slack", result); Assert.Contains("stable_id: C1", result); Assert.Contains("stable_id: C2", result); + Assert.Contains("address_kind: destination", result); Assert.Contains("Multiple destinations matched.", result); } diff --git a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs index 0d4041f38..d5e33bdb9 100644 --- a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs +++ b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs @@ -126,15 +126,20 @@ public void Enabled_remote_channels_register_expected_channel_tools() ["Mattermost:Enabled"] = "true" }); - Assert.Equal(6, services.Count(descriptor => descriptor.ServiceType == typeof(IChannelTool))); + Assert.Equal(3, services.Count(descriptor => descriptor.ServiceType == typeof(IChannelTool))); Assert.True(IsRegistered(services)); Assert.True(IsRegistered(services)); Assert.False(typeof(IChannelTool).IsAssignableFrom(typeof(LookupSlackUserTool))); + Assert.False(typeof(IChannelTool).IsAssignableFrom(typeof(SendSlackMessageTool))); Assert.True(IsRegistered(services)); Assert.True(IsRegistered(services)); + Assert.True(IsRegistered(services)); Assert.True(IsRegistered(services)); + Assert.False(typeof(IChannelTool).IsAssignableFrom(typeof(SendDiscordMessageTool))); Assert.True(IsRegistered(services)); + Assert.False(typeof(IChannelTool).IsAssignableFrom(typeof(SendMattermostMessageTool))); Assert.True(IsRegistered(services)); + Assert.False(typeof(IChannelTool).IsAssignableFrom(typeof(LookupMattermostUserTool))); } [Fact] @@ -173,17 +178,17 @@ public void Enabled_channel_tool_intents_match_registered_tool_services() AssertToolIntents( descriptors["slack"], services, - new ChannelToolExpectation(ChannelToolIntentKind.SendMessage, typeof(SendSlackMessageTool), "send_slack_message"), + new ChannelToolExpectation(ChannelToolIntentKind.SendMessage, typeof(SendChannelMessageTool), null), new ChannelToolExpectation(ChannelToolIntentKind.LookupUser, typeof(LookupChannelUserTool), null)); AssertToolIntents( descriptors["discord"], services, - new ChannelToolExpectation(ChannelToolIntentKind.SendMessage, typeof(SendDiscordMessageTool), "send_discord_message")); + new ChannelToolExpectation(ChannelToolIntentKind.SendMessage, typeof(SendChannelMessageTool), null)); AssertToolIntents( descriptors["mattermost"], services, - new ChannelToolExpectation(ChannelToolIntentKind.SendMessage, typeof(SendMattermostMessageTool), "send_mattermost_message"), - new ChannelToolExpectation(ChannelToolIntentKind.LookupUser, typeof(LookupMattermostUserTool), "lookup_mattermost_user")); + new ChannelToolExpectation(ChannelToolIntentKind.SendMessage, typeof(SendChannelMessageTool), null), + new ChannelToolExpectation(ChannelToolIntentKind.LookupUser, typeof(LookupChannelUserTool), null)); } [Fact] @@ -346,6 +351,7 @@ private static ServiceCollection BuildServices(IReadOnlyDictionary +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Microsoft.Extensions.DependencyInjection; +using Netclaw.Actors.Channels; +using Netclaw.Channels; +using Netclaw.Daemon.Configuration; +using Xunit; + +namespace Netclaw.Daemon.Tests.Configuration; + +public sealed class ChannelSendToolTests +{ + [Fact] + public void Send_schema_enumerates_enabled_send_channels_only() + { + var registry = BuildRegistry( + BuildDescriptor(ChannelType.Slack, isEnabled: true, ChannelCapabilities.SendMessages, ChannelAddressKind.Destination), + BuildDescriptor(ChannelType.Discord, isEnabled: false, ChannelCapabilities.SendMessages, ChannelAddressKind.Destination), + BuildDescriptor( + ChannelType.Tui, + isEnabled: true, + ChannelCapabilities.SendMessages, + [ChannelAddressKind.LocalSession], + includeSendIntent: false)); + var tool = new SendChannelMessageTool(registry, new ServiceCollection().BuildServiceProvider()); + + var keys = ReadChannelKeyEnum(tool.ParameterSchema); + + Assert.Equal(["slack"], keys); + } + + [Fact] + public async Task Send_rejects_mismatched_destination_channel_key() + { + var registry = BuildRegistry( + BuildDescriptor(ChannelType.Slack, isEnabled: true, ChannelCapabilities.SendMessages, ChannelAddressKind.Destination), + BuildDescriptor(ChannelType.Mattermost, isEnabled: true, ChannelCapabilities.SendMessages, ChannelAddressKind.Destination)); + var tool = new SendChannelMessageTool(registry, new ServiceCollection().BuildServiceProvider()); + + var result = await ExecuteAsync(tool, "mattermost", "slack", "destination", "C1234567890"); + + Assert.Contains("destination.channel_key 'slack' does not match channel_key 'mattermost'", result); + } + + [Fact] + public async Task Send_rejects_unsupported_direct_message_capability() + { + var registry = BuildRegistry(BuildDescriptor(ChannelType.Discord, isEnabled: true, ChannelCapabilities.SendMessages, ChannelAddressKind.Destination)); + var tool = new SendChannelMessageTool(registry, new ServiceCollection().BuildServiceProvider()); + + var result = await ExecuteAsync(tool, "discord", "discord", "direct_message", "123456789012345678"); + + Assert.Contains("Channel 'discord' does not support direct-message output", result); + } + + [Fact] + public async Task Send_rejects_bare_display_name_destination() + { + var registry = BuildRegistry(BuildDescriptor(ChannelType.Slack, isEnabled: true, ChannelCapabilities.SendMessages, ChannelAddressKind.Destination)); + var tool = new SendChannelMessageTool(registry, new ServiceCollection().BuildServiceProvider()); + + var result = await ExecuteAsync(tool, "slack", "slack", "destination", "#general"); + + Assert.Contains("Bare display names", result); + } + + [Fact] + public async Task Send_rejects_user_kind_and_instructs_direct_message_workflow() + { + var registry = BuildRegistry(BuildDescriptor( + ChannelType.Slack, + isEnabled: true, + ChannelCapabilities.SendMessages | ChannelCapabilities.DirectMessages, + ChannelAddressKind.Destination, + ChannelAddressKind.DirectMessage)); + var tool = new SendChannelMessageTool(registry, new ServiceCollection().BuildServiceProvider()); + + var result = await ExecuteAsync(tool, "slack", "slack", "user", "U1234567890"); + + Assert.Contains("cannot send to destination.kind 'user'", result); + Assert.Contains("destination.kind='direct_message'", result); + } + + private static Task ExecuteAsync( + SendChannelMessageTool tool, + string channelKey, + string destinationChannelKey, + string destinationKind, + string destinationId) + { + return tool.ExecuteAsync(new Dictionary + { + ["channel_key"] = channelKey, + ["destination"] = new Dictionary + { + ["channel_key"] = destinationChannelKey, + ["kind"] = destinationKind, + ["id"] = destinationId + }, + ["text"] = "Test message", + ["_rationale"] = "test" + }, TestContext.Current.CancellationToken); + } + + private static string[] ReadChannelKeyEnum(JsonElement schema) + { + return schema + .GetProperty("properties") + .GetProperty("channel_key") + .GetProperty("enum") + .EnumerateArray() + .Select(element => element.GetString()!) + .ToArray(); + } + + private static ChannelRegistry BuildRegistry(params ChannelDescriptor[] descriptors) + { + var providers = descriptors.Select(descriptor => new StaticChannelDescriptorProvider(descriptor)).ToArray(); + return new ChannelRegistry(providers, []); + } + + private static ChannelDescriptor BuildDescriptor( + ChannelType channelType, + bool isEnabled, + ChannelCapabilities capabilities, + params ChannelAddressKind[] addressKinds) + { + return BuildDescriptor(channelType, isEnabled, capabilities, addressKinds, includeSendIntent: true); + } + + private static ChannelDescriptor BuildDescriptor( + ChannelType channelType, + bool isEnabled, + ChannelCapabilities capabilities, + ChannelAddressKind[] addressKinds, + bool includeSendIntent) + { + var toolIntents = includeSendIntent + ? new HashSet { ChannelToolIntentKind.SendMessage } + : new HashSet(); + + return new ChannelDescriptor( + ChannelDescriptorKey.FromChannelType(channelType), + channelType, + channelType == ChannelType.Tui ? ChannelKind.LocalInteractiveClient : ChannelKind.RemoteChat, + channelType.ToString(), + isEnabled, + capabilities, + ToolIntents: toolIntents, + AddressKinds: new HashSet(addressKinds), + SupportedOutputEffects: new HashSet()); + } +} diff --git a/src/Netclaw.Daemon/Configuration/ChannelLookupTools.cs b/src/Netclaw.Daemon/Configuration/ChannelLookupTools.cs index 9046db3c8..25e93f228 100644 --- a/src/Netclaw.Daemon/Configuration/ChannelLookupTools.cs +++ b/src/Netclaw.Daemon/Configuration/ChannelLookupTools.cs @@ -212,9 +212,10 @@ private string FormatResolved(ChannelDescriptorKey key, ResolvedChannelAddress a { var builder = new StringBuilder(); builder.AppendLine($"Resolved {LookupLabel} on channel '{key}':"); + builder.AppendLine($"channel_key: {address.ChannelKey}"); builder.AppendLine($"stable_id: {address.StableId}"); builder.AppendLine($"display_name: {address.DisplayName}"); - builder.AppendLine($"address_kind: {address.AddressKind}"); + builder.AppendLine($"address_kind: {ChannelAddressKindWire.ToWireValue(address.AddressKind)}"); return builder.ToString().TrimEnd(); } @@ -227,7 +228,7 @@ private string FormatAmbiguous( builder.AppendLine($"Ambiguous {LookupLabel} lookup on channel '{key}' for query '{query}'.{FormatErrorSuffix(result.Error)}"); builder.AppendLine("Candidates:"); foreach (var candidate in result.Candidates) - builder.AppendLine($"- stable_id: {candidate.StableId}; display_name: {candidate.DisplayName}; address_kind: {candidate.AddressKind}"); + builder.AppendLine($"- channel_key: {candidate.ChannelKey}; stable_id: {candidate.StableId}; display_name: {candidate.DisplayName}; address_kind: {ChannelAddressKindWire.ToWireValue(candidate.AddressKind)}"); return builder.ToString().TrimEnd(); } diff --git a/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs b/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs new file mode 100644 index 000000000..b4060b81d --- /dev/null +++ b/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs @@ -0,0 +1,469 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using System.Text.Json; +using Microsoft.Extensions.AI; +using Netclaw.Actors.Channels; +using Netclaw.Channels; +using Netclaw.Channels.Discord.Tools; +using Netclaw.Channels.Mattermost.Tools; +using Netclaw.Channels.Slack.Tools; +using Netclaw.Tools; + +namespace Netclaw.Daemon.Configuration; + +internal static class ChannelSendToolRegistration +{ + public static IServiceCollection AddChannelSendTools(this IServiceCollection services, IConfiguration configuration) + { + ArgumentNullException.ThrowIfNull(services); + ArgumentNullException.ThrowIfNull(configuration); + + var slackEnabled = IsChannelEnabled(configuration, "Slack"); + var discordEnabled = IsChannelEnabled(configuration, "Discord"); + var mattermostEnabled = IsChannelEnabled(configuration, "Mattermost"); + + if (slackEnabled || discordEnabled || mattermostEnabled) + { + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + } + + return services; + } + + private static bool IsChannelEnabled(IConfiguration configuration, string sectionName) + => bool.TryParse(configuration[$"{sectionName}:Enabled"], out var enabled) && enabled; +} + +internal sealed class SendChannelMessageTool(IChannelRegistry registry, IServiceProvider services) : IChannelTool +{ + private AITool? _aiTool; + private JsonElement? _parameterSchema; + private LlmFacingToolName? _llmFacingName; + + public string Name => "send_channel_message"; + + public LlmFacingToolName LlmFacingName => _llmFacingName ??= LlmFacingToolName.FromCanonical(Name); + + public string Description => + "Send a message through an enabled chat channel using a resolved destination. " + + "Examples: channel_key=slack with destination.kind=destination, channel_key=mattermost with destination.kind=direct_message, channel_key=discord with destination.kind=destination."; + + public string GrantCategory => "builtin"; + + public JsonElement ParameterSchema => _parameterSchema ??= BuildParameterSchema(); + + public AITool ToAITool() + { + return _aiTool ??= AIFunctionFactory.CreateDeclaration(Name, Description, ParameterSchema); + } + + public async Task ExecuteAsync(IDictionary? arguments, CancellationToken ct = default) + { + var channelKeyValue = ToolArgumentHelper.GetString(arguments, "channel_key"); + if (string.IsNullOrWhiteSpace(channelKeyValue)) + return "Error: 'channel_key' parameter is required."; + + var text = ToolArgumentHelper.GetString(arguments, "text") + ?? ToolArgumentHelper.GetString(arguments, "Message"); + if (string.IsNullOrWhiteSpace(text)) + return "Error: 'text' parameter is required."; + + if (!TryReadDestination(arguments, out var destination, out var destinationError)) + return destinationError; + + var key = ChannelDescriptorKey.Create(channelKeyValue.Trim()); + if (!destination.ChannelKey.Equals(key)) + { + return $"Error: destination.channel_key '{destination.ChannelKey}' does not match channel_key '{key}'. " + + "Use the channel_key returned by lookup_channel_user or lookup_channel_destination."; + } + + ChannelDescriptor descriptor; + try + { + descriptor = registry.GetChannel(key); + } + catch (InvalidOperationException ex) + { + return $"Error: {ex.Message} Supported channel_key values: {string.Join(", ", GetEnabledSendChannelKeys())}."; + } + + if (!descriptor.IsEnabled) + return $"Error: Channel '{key}' is disabled. Supported channel_key values: {string.Join(", ", GetEnabledSendChannelKeys())}."; + + if (!descriptor.Capabilities.HasFlag(ChannelCapabilities.SendMessages) + || !descriptor.ToolIntents.Contains(ChannelToolIntentKind.SendMessage)) + { + return $"Error: Channel '{key}' does not support message sending."; + } + + var threadOrRootId = ToolArgumentHelper.GetString(arguments, "thread_or_root_id"); + if (!string.IsNullOrWhiteSpace(threadOrRootId)) + { + return "Error: 'thread_or_root_id' is not supported by send_channel_message yet. " + + "Omit it to create a new channel-specific conversation thread."; + } + + var validationError = ValidateDestination(descriptor, destination); + if (validationError is not null) + return validationError; + + var delegateArguments = new Dictionary + { + ["Message"] = text.Trim() + }; + + switch (descriptor.ChannelType) + { + case ChannelType.Slack: + delegateArguments[destination.AddressKind == ChannelAddressKind.DirectMessage ? "UserId" : "ChannelId"] = destination.StableId; + return await services.GetRequiredService().ExecuteAsync(delegateArguments, ct); + + case ChannelType.Discord: + delegateArguments["ChannelId"] = destination.StableId; + return await services.GetRequiredService().ExecuteAsync(delegateArguments, ct); + + case ChannelType.Mattermost: + delegateArguments[destination.AddressKind == ChannelAddressKind.DirectMessage ? "UserId" : "ChannelId"] = destination.StableId; + return await services.GetRequiredService().ExecuteAsync(delegateArguments, ct); + + default: + return $"Error: Channel '{key}' does not have a registered send adapter."; + } + } + + public Task ExecuteAsync(IDictionary? arguments, ToolExecutionContext context, CancellationToken ct = default) + => ExecuteAsync(arguments, ct); + + private JsonElement BuildParameterSchema() + { + var channelKeys = GetEnabledSendChannelKeys(); + var channelEnum = JsonSerializer.Serialize(channelKeys); + var destinationKindEnum = JsonSerializer.Serialize(new[] + { + ChannelAddressKindWire.ToWireValue(ChannelAddressKind.Destination), + ChannelAddressKindWire.ToWireValue(ChannelAddressKind.DirectMessage) + }); + var schemaJson = $$""" + { + "type": "object", + "properties": { + "channel_key": { + "type": "string", + "description": "Enabled channel key to send through, such as slack, discord, or mattermost.", + "enum": {{channelEnum}} + }, + "destination": { + "type": "object", + "description": "Resolved destination object returned by lookup_channel_destination or assembled from lookup_channel_user for DMs.", + "properties": { + "channel_key": { + "type": "string", + "description": "Channel key from the lookup result. Must match the top-level channel_key.", + "enum": {{channelEnum}} + }, + "kind": { + "type": "string", + "description": "Destination kind. Use destination for channel posts; use direct_message for DMs after lookup_channel_user.", + "enum": {{destinationKindEnum}} + }, + "id": { + "type": "string", + "description": "Stable platform ID from lookup_channel_destination or lookup_channel_user. Do not pass display names like #general or @alice." + }, + "display_name": { + "type": "string", + "description": "Optional display name copied from the lookup result for operator readability." + } + }, + "required": ["channel_key", "kind", "id"] + }, + "text": { + "type": "string", + "description": "Message text to send." + }, + "thread_or_root_id": { + "type": "string", + "description": "Optional existing thread/root target. Currently unsupported; omit to create a new conversation thread." + }, + "_rationale": { + "type": "string", + "description": "State your intent for this tool call in one sentence - what are you trying to accomplish and why?" + }, + "_timeout_seconds": { + "type": "integer", + "description": "Requested timeout in seconds. Only set when the default is insufficient." + }, + "_background": { + "type": "boolean", + "description": "Set to true to run this tool in the background and receive results later." + } + }, + "required": ["channel_key", "destination", "text", "_rationale"] + } + """; + + return JsonDocument.Parse(schemaJson).RootElement.Clone(); + } + + private IReadOnlyList GetEnabledSendChannelKeys() + { + return registry.ListChannels() + .Where(descriptor => descriptor.IsEnabled && descriptor.ToolIntents.Contains(ChannelToolIntentKind.SendMessage)) + .Select(descriptor => descriptor.Key.Value) + .Order(StringComparer.Ordinal) + .ToArray(); + } + + private static bool TryReadDestination( + IDictionary? arguments, + out SendChannelDestination destination, + out string error) + { + destination = default; + + if (arguments is null || !TryGetFlexible(arguments, "destination", out var rawDestination) || rawDestination is null) + { + error = "Error: 'destination' parameter is required."; + return false; + } + + string? channelKey; + string? kind; + string? id; + string? displayName; + + switch (rawDestination) + { + case JsonElement { ValueKind: JsonValueKind.Object } element: + channelKey = GetJsonString(element, "channel_key"); + kind = GetJsonString(element, "kind"); + id = GetJsonString(element, "id"); + displayName = GetJsonString(element, "display_name"); + break; + + case IDictionary dictionary: + channelKey = ToolArgumentHelper.GetString(dictionary, "channel_key"); + kind = ToolArgumentHelper.GetString(dictionary, "kind"); + id = ToolArgumentHelper.GetString(dictionary, "id"); + displayName = ToolArgumentHelper.GetString(dictionary, "display_name"); + break; + + default: + error = "Error: 'destination' must be an object with channel_key, kind, and id."; + return false; + } + + if (string.IsNullOrWhiteSpace(channelKey)) + { + error = "Error: 'destination.channel_key' parameter is required."; + return false; + } + + if (string.IsNullOrWhiteSpace(kind)) + { + error = "Error: 'destination.kind' parameter is required."; + return false; + } + + if (string.IsNullOrWhiteSpace(id)) + { + error = "Error: 'destination.id' parameter is required."; + return false; + } + + if (!ChannelAddressKindWire.TryParse(kind, out var addressKind)) + { + error = $"Error: Unsupported destination.kind '{kind}'. Use 'destination' or 'direct_message'."; + return false; + } + + destination = new SendChannelDestination( + ChannelDescriptorKey.Create(channelKey.Trim()), + addressKind, + id.Trim(), + displayName?.Trim()); + error = string.Empty; + return true; + } + + private static string? ValidateDestination(ChannelDescriptor descriptor, SendChannelDestination destination) + { + if (destination.AddressKind == ChannelAddressKind.User) + { + return "Error: send_channel_message cannot send to destination.kind 'user'. " + + "For DMs, use destination.kind='direct_message' with the stable user ID returned by lookup_channel_user."; + } + + if (destination.AddressKind == ChannelAddressKind.DirectMessage) + { + if (!descriptor.Capabilities.HasFlag(ChannelCapabilities.DirectMessages) + || !descriptor.AddressKinds.Contains(ChannelAddressKind.DirectMessage)) + { + return $"Error: Channel '{descriptor.Key}' does not support direct-message output."; + } + } + else if (destination.AddressKind == ChannelAddressKind.Destination) + { + if (!descriptor.AddressKinds.Contains(ChannelAddressKind.Destination)) + return $"Error: Channel '{descriptor.Key}' does not support destination output."; + } + else + { + return $"Error: send_channel_message cannot send to destination.kind '{ChannelAddressKindWire.ToWireValue(destination.AddressKind)}'."; + } + + if (LooksLikeDisplayName(destination.StableId)) + { + return "Error: destination.id must be a stable platform ID from lookup_channel_destination or lookup_channel_user. " + + "Bare display names like '#general' or '@alice' are not accepted."; + } + + return IsStablePlatformId(descriptor.ChannelType, destination.AddressKind, destination.StableId) + ? null + : $"Error: destination.id '{destination.StableId}' does not look like a stable {descriptor.DisplayName} ID. " + + "Use lookup_channel_destination for channel posts or lookup_channel_user for direct messages."; + } + + private static bool IsStablePlatformId(ChannelType channelType, ChannelAddressKind addressKind, string stableId) + => channelType switch + { + ChannelType.Slack => addressKind == ChannelAddressKind.DirectMessage + ? stableId.StartsWith("U", StringComparison.Ordinal) || stableId.StartsWith("W", StringComparison.Ordinal) + : stableId.StartsWith("C", StringComparison.Ordinal) || stableId.StartsWith("G", StringComparison.Ordinal) || stableId.StartsWith("D", StringComparison.Ordinal), + ChannelType.Discord => addressKind == ChannelAddressKind.Destination && IsDiscordSnowflake(stableId), + ChannelType.Mattermost => IsMattermostId(stableId), + _ => false + }; + + private static bool LooksLikeDisplayName(string value) + => value.StartsWith('#') + || value.StartsWith('@') + || value.Any(char.IsWhiteSpace); + + private static bool IsDiscordSnowflake(string value) + => value.Length is >= 17 and <= 20 && value.All(char.IsAsciiDigit); + + private static bool IsMattermostId(string value) + { + if (value.Length != 26) + return false; + + return value.All(char.IsAsciiLetterOrDigit); + } + + private static bool TryGetFlexible(IDictionary arguments, string key, out object? value) + { + if (arguments.TryGetValue(key, out value)) + return true; + + var normalizedKey = NormalizeKey(key); + foreach (var pair in arguments) + { + if (string.Equals(NormalizeKey(pair.Key), normalizedKey, StringComparison.OrdinalIgnoreCase)) + { + value = pair.Value; + return true; + } + } + + value = null; + return false; + } + + private static string? GetJsonString(JsonElement element, string propertyName) + { + foreach (var property in element.EnumerateObject()) + { + if (!string.Equals(NormalizeKey(property.Name), NormalizeKey(propertyName), StringComparison.OrdinalIgnoreCase)) + continue; + + return property.Value.ValueKind == JsonValueKind.String + ? property.Value.GetString() + : property.Value.ToString(); + } + + return null; + } + + private static string NormalizeKey(string key) + { + if (string.IsNullOrWhiteSpace(key)) + return string.Empty; + + var buffer = new char[key.Length]; + var count = 0; + foreach (var ch in key) + { + if (char.IsLetterOrDigit(ch)) + buffer[count++] = ch; + } + + return count == 0 ? string.Empty : new string(buffer, 0, count); + } + + private readonly record struct SendChannelDestination( + ChannelDescriptorKey ChannelKey, + ChannelAddressKind AddressKind, + string StableId, + string? DisplayName); +} + +internal static class ChannelAddressKindWire +{ + public static string ToWireValue(ChannelAddressKind addressKind) + => addressKind switch + { + ChannelAddressKind.Destination => "destination", + ChannelAddressKind.User => "user", + ChannelAddressKind.Thread => "thread", + ChannelAddressKind.DirectMessage => "direct_message", + ChannelAddressKind.LocalSession => "local_session", + _ => addressKind.ToString().ToLowerInvariant() + }; + + public static bool TryParse(string value, out ChannelAddressKind addressKind) + { + var normalized = Normalize(value); + switch (normalized) + { + case "destination": + case "channel": + addressKind = ChannelAddressKind.Destination; + return true; + case "user": + addressKind = ChannelAddressKind.User; + return true; + case "thread": + addressKind = ChannelAddressKind.Thread; + return true; + case "directmessage": + case "dm": + addressKind = ChannelAddressKind.DirectMessage; + return true; + case "localsession": + addressKind = ChannelAddressKind.LocalSession; + return true; + default: + addressKind = default; + return false; + } + } + + private static string Normalize(string value) + { + var buffer = new char[value.Length]; + var count = 0; + foreach (var ch in value) + { + if (char.IsLetterOrDigit(ch)) + buffer[count++] = char.ToLowerInvariant(ch); + } + + return count == 0 ? string.Empty : new string(buffer, 0, count); + } +} diff --git a/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs index 87cacc215..82c93a6ed 100644 --- a/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs @@ -77,7 +77,7 @@ public static void AddDiscordChannelIntegration(this IServiceCollection services services.AddSingleton(sp => (DiscordChannel)sp.GetRequiredKeyedService(DiscordChannelKey)); - // Channel-specific LLM tool: registered as an INetclawTool singleton. + // Concrete send implementation used by send_channel_message. // The gateway actor ref is resolved lazily via DiscordChannel since it // is not available until StartAsync completes. services.AddSingleton(sp => @@ -89,7 +89,6 @@ public static void AddDiscordChannelIntegration(this IServiceCollection services discordOptions, () => channel.Gateway); }); - services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => (IHostedService)sp.GetRequiredKeyedService(DiscordChannelKey)); diff --git a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs index f34c61512..4315842b9 100644 --- a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs @@ -102,7 +102,7 @@ public static void AddMattermostChannelIntegration(this IServiceCollection servi }); services.AddSingleton(sp => sp.GetRequiredService()); - // Channel-specific LLM tools: registered as IChannelTool singletons. + // Concrete send and lookup implementations used by generic channel tools. // The gateway actor ref and default channel ID are resolved lazily via // MattermostChannel since they're not available until StartAsync completes. services.AddSingleton(sp => @@ -115,7 +115,6 @@ public static void AddMattermostChannelIntegration(this IServiceCollection servi () => channel.DefaultChannelId, () => channel.Gateway); }); - services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => { @@ -123,7 +122,6 @@ public static void AddMattermostChannelIntegration(this IServiceCollection servi () => sp.GetRequiredService(), mattermostOptions); }); - services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => diff --git a/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs index d51f485a2..1d74bd50d 100644 --- a/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs @@ -98,7 +98,7 @@ public static void AddSlackChannelIntegration(this IServiceCollection services, context.ServiceProvider().GetRequiredService()); }); - // Channel-specific LLM send tool: registered as an IChannelTool singleton. + // Concrete send implementation used by send_channel_message. // User lookup is exposed through the generic lookup_channel_user tool. // The gateway actor ref and default channel ID are resolved lazily via // SlackChannel since they're not available until StartAsync completes. @@ -112,7 +112,6 @@ public static void AddSlackChannelIntegration(this IServiceCollection services, () => channel.DefaultChannelId, () => channel.Gateway); }); - services.AddSingleton(sp => sp.GetRequiredService()); services.AddSingleton(sp => { diff --git a/src/Netclaw.Daemon/Program.cs b/src/Netclaw.Daemon/Program.cs index fe91d7ec8..89a197785 100644 --- a/src/Netclaw.Daemon/Program.cs +++ b/src/Netclaw.Daemon/Program.cs @@ -1074,6 +1074,7 @@ static void ConfigureDaemonServices( services.AddSlackChannelIntegration(configuration); services.AddDiscordChannelIntegration(configuration); services.AddMattermostChannelIntegration(configuration); + services.AddChannelSendTools(configuration); services.AddChannelLookupTools(configuration); // Config hot-reload watcher diff --git a/src/Netclaw.Daemon/Webhooks/RegisteredWebhookRoute.cs b/src/Netclaw.Daemon/Webhooks/RegisteredWebhookRoute.cs index 7fefdb4e8..1763daf8d 100644 --- a/src/Netclaw.Daemon/Webhooks/RegisteredWebhookRoute.cs +++ b/src/Netclaw.Daemon/Webhooks/RegisteredWebhookRoute.cs @@ -83,7 +83,8 @@ public string BuildDefaultNotifyInstructions() if (Config.NotificationTarget is not { Kind: NotificationTargetKind.Slack, ChannelId: { Length: > 0 } channelId }) return string.Empty; - return $"If you need to notify a human, use send_slack_message to post to Slack channel {channelId}."; + return "If you need to notify a human, use send_channel_message with " + + $"channel_key='slack', destination.channel_key='slack', destination.kind='destination', destination.id='{channelId}', and text set to your notification."; } public static string? GetHeaderValue(IHeaderDictionary headers, string name) diff --git a/src/Netclaw.Daemon/Webhooks/WebhookExecutionActor.cs b/src/Netclaw.Daemon/Webhooks/WebhookExecutionActor.cs index 88edcde9e..aff17c1bd 100644 --- a/src/Netclaw.Daemon/Webhooks/WebhookExecutionActor.cs +++ b/src/Netclaw.Daemon/Webhooks/WebhookExecutionActor.cs @@ -22,7 +22,7 @@ internal sealed class WebhookExecutionActor : ReceiveActor private readonly DateTimeOffset _dispatchedAt; private readonly SessionPipelineHandle _handle; - private static readonly ToolName NotificationTool = new("send_slack_message"); + private static readonly ToolName NotificationTool = new("send_channel_message"); private readonly ExecutionOutputAccumulator _accumulator = new(NotificationTool); private bool _completed; From 947868d80d72134b46cdf803db2bee3ac5e7d090 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 8 Jun 2026 17:20:16 +0000 Subject: [PATCH 19/31] Propagate channel delivery targets --- .../.system/files/netclaw-operations/SKILL.md | 6 +- .../tasks.md | 10 +-- .../Channels/MessageSourceFactoryTests.cs | 32 ++++++++- .../Channels/TurnContextTests.cs | 28 ++++++++ .../Protocol/SerializationRoundTripTests.cs | 14 ++++ .../Reminders/ReminderExecutionActorTests.cs | 38 ++++++++++ .../Reminders/SetReminderToolTests.cs | 4 ++ .../Tools/SetWebhookToolProvenanceTests.cs | 19 +++++ src/Netclaw.Actors/Channels/ChannelInput.cs | 13 ++++ .../Channels/ChannelPipeline.cs | 2 + src/Netclaw.Actors/Channels/MessageSource.cs | 15 ++++ src/Netclaw.Actors/Channels/TurnContext.cs | 14 ++++ src/Netclaw.Actors/Protocol/Events.cs | 5 ++ .../Reminders/ReminderExecutionActor.cs | 56 ++++++++++++++- .../Reminders/ReminderProtocol.cs | 9 +++ .../Reminders/SetReminderTool.cs | 36 +++++++++- .../Serialization/NetclawProtoMapper.cs | 33 +++++++++ .../Protos/netclaw_messages.proto | 10 +++ .../Pipelines/SessionToolExecutionPipeline.cs | 2 + src/Netclaw.Actors/SubAgents/SubAgentActor.cs | 4 ++ .../SubAgents/SubAgentProtocol.cs | 4 ++ .../SubAgents/SubAgentSpawner.cs | 2 + src/Netclaw.Actors/Tools/SetWebhookTool.cs | 4 ++ .../DiscordSessionBindingActor.cs | 13 +++- .../Transport/DiscordThreadHistoryFetcher.cs | 9 ++- .../MattermostSessionBindingActor.cs | 13 +++- .../MattermostThreadHistoryFetcher.cs | 9 ++- .../SlackThreadBindingActor.cs | 13 +++- .../SlackThreadHistoryFetcher.cs | 11 ++- .../WebhookRouteValidator.cs | 5 +- .../Configuration/ChannelSendToolTests.cs | 58 ++++++++++++++- .../Configuration/ChannelSendTools.cs | 71 ++++++++++++++++++- src/Netclaw.Daemon/Gateway/SessionRegistry.cs | 8 ++- .../Gateway/SignalRSessionActor.cs | 12 ++++ .../Webhooks/RegisteredWebhookRoute.cs | 19 ++++- .../Webhooks/WebhookExecutionActor.cs | 3 +- .../ChannelDeliveryTargetInfo.cs | 47 ++++++++++++ .../ToolExecutionContext.cs | 15 ++++ 38 files changed, 638 insertions(+), 28 deletions(-) create mode 100644 src/Netclaw.Tools.Abstractions/ChannelDeliveryTargetInfo.cs diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index 6b6e8450e..abc49ad4f 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.10.2" + version: "2.10.3" --- # Netclaw Operations @@ -148,6 +148,10 @@ use the generic proactive-post tool: `lookup_channel_user`; this is supported only by channels that advertise DM output (currently Slack and Mattermost when enabled in config). Discord DMs are not supported yet. +- Reminder- and webhook-originated turns may only call `send_channel_message` + against the delivery target configured on the reminder or webhook route. If a + trigger turn has no configured target, Netclaw fails loud instead of choosing a + default output channel. Use the generic lookup tools before sending when you do not already have a stable channel/user ID: diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md index bf20102e3..375862700 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/tasks.md +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -16,9 +16,9 @@ ## 3. Delivery target contracts - [x] 3.1 Add `ChannelDeliveryTarget` with channel key, resolved destination, and optional thread/root target. -- [ ] 3.2 Preserve channel-originated default delivery targets for Slack, Discord, Mattermost, and TUI input turns. -- [ ] 3.3 Require trigger-originated turns to carry an explicit delivery target when external output is requested. -- [ ] 3.4 Fail loudly when a trigger-originated turn attempts external output without a delivery target. +- [x] 3.2 Preserve channel-originated default delivery targets for Slack, Discord, Mattermost, and TUI input turns. +- [x] 3.3 Require trigger-originated turns to carry an explicit delivery target when external output is requested. +- [x] 3.4 Fail loudly when a trigger-originated turn attempts external output without a delivery target. ## 4. Existing channel coverage @@ -34,8 +34,8 @@ ## 5. Trigger-source consumers -- [ ] 5.1 Update reminder definitions to store or resolve explicit channel delivery targets when output is requested. -- [ ] 5.2 Update webhook route definitions to store or resolve explicit channel delivery targets when output is requested. +- [x] 5.1 Update reminder definitions to store or resolve explicit channel delivery targets when output is requested. +- [x] 5.2 Update webhook route definitions to store or resolve explicit channel delivery targets when output is requested. - [x] 5.3 Ensure reminders and webhooks do not register channel descriptors or channel snapshot providers. ## 6. Descriptor-driven observability diff --git a/src/Netclaw.Actors.Tests/Channels/MessageSourceFactoryTests.cs b/src/Netclaw.Actors.Tests/Channels/MessageSourceFactoryTests.cs index 6a844bc8d..562e7b74b 100644 --- a/src/Netclaw.Actors.Tests/Channels/MessageSourceFactoryTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/MessageSourceFactoryTests.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.AI; using Netclaw.Actors.Channels; using Netclaw.Configuration; +using Netclaw.Tools; using Xunit; namespace Netclaw.Actors.Tests.Channels; @@ -27,7 +28,9 @@ private static ChannelInput BuildInput( string? reminderId = null, IActorRef? ackTarget = null, bool hasThirdParty = false, - IReadOnlyList? adoptedSpeakerIds = null) + IReadOnlyList? adoptedSpeakerIds = null, + ChannelDeliveryTargetInfo? defaultDeliveryTarget = null, + ChannelDeliveryTargetInfo? requestedDeliveryTarget = null) => new() { SenderId = new Netclaw.Actors.Protocol.SenderId("user-1"), @@ -40,6 +43,8 @@ private static ChannelInput BuildInput( ReceivedAt = DateTimeOffset.UtcNow, ReminderId = reminderId, AckTarget = ackTarget, + DefaultDeliveryTarget = defaultDeliveryTarget, + RequestedDeliveryTarget = requestedDeliveryTarget, HasThirdPartyAdoptedContext = hasThirdParty, AdoptedSpeakerIds = adoptedSpeakerIds ?? [], }; @@ -95,6 +100,31 @@ public void Create_propagates_ReminderId_and_AckTarget_from_ChannelInput() Assert.Same(probe.Ref, result.AckTarget); } + [Fact] + public void Create_propagates_delivery_targets_from_ChannelInput() + { + var defaultTarget = new ChannelDeliveryTargetInfo( + "slack", + "destination", + "C1234567890", + "#alerts", + "1700000000.000001"); + var requestedTarget = new ChannelDeliveryTargetInfo( + "mattermost", + "direct_message", + "user1234567890123456789012", + "@alice"); + + var result = MessageSourceFactory.Create( + BuildInput(defaultDeliveryTarget: defaultTarget, requestedDeliveryTarget: requestedTarget), + new SessionPipelineOptions { ChannelType = ChannelType.Slack }, + new Netclaw.Actors.Protocol.TurnId("turn-1")); + + Assert.Equal(defaultTarget, result.DefaultDeliveryTarget); + Assert.Equal(requestedTarget, result.RequestedDeliveryTarget); + Assert.Equal(requestedTarget, result.EffectiveDeliveryTarget); + } + [Fact] public void Create_propagates_self_only_adopted_context_without_third_party_flag() { diff --git a/src/Netclaw.Actors.Tests/Channels/TurnContextTests.cs b/src/Netclaw.Actors.Tests/Channels/TurnContextTests.cs index 4715c27e3..772195bd7 100644 --- a/src/Netclaw.Actors.Tests/Channels/TurnContextTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/TurnContextTests.cs @@ -8,6 +8,7 @@ using Netclaw.Actors.Protocol; using Netclaw.Actors.Sessions; using Netclaw.Configuration; +using Netclaw.Tools; using Xunit; namespace Netclaw.Actors.Tests.Channels; @@ -36,6 +37,17 @@ public void FromMessageSource_captures_durable_authority_fields() }, ReceivedAt = new DateTimeOffset(2026, 5, 28, 12, 0, 0, TimeSpan.Zero), ExecutableText = "run git status", + DefaultDeliveryTarget = new ChannelDeliveryTargetInfo( + "slack", + "destination", + "C123", + "#alerts", + "1700000000.000001"), + RequestedDeliveryTarget = new ChannelDeliveryTargetInfo( + "mattermost", + "direct_message", + "user1234567890123456789012", + "@alice"), HasThirdPartyAdoptedContext = true, AdoptedSpeakerIds = ["U12345", "U67890"], AdoptedContextProjection = "quoted context", @@ -61,6 +73,9 @@ public void FromMessageSource_captures_durable_authority_fields() var sourceKind = Assert.NotNull(context.Provenance.SourceKind); Assert.Equal("slack-workspace:T123", sourceScope.Value); Assert.Equal("slack", sourceKind.Value); + Assert.Equal(source.DefaultDeliveryTarget, context.DefaultDeliveryTarget); + Assert.Equal(source.RequestedDeliveryTarget, context.RequestedDeliveryTarget); + Assert.Equal(source.RequestedDeliveryTarget, context.EffectiveDeliveryTarget); Assert.True(context.HasAdoptedContext); Assert.True(context.HasThirdPartyAdoptedContext); Assert.Equal(["U12345", "U67890"], context.AdoptedSpeakerIds); @@ -120,6 +135,17 @@ public void Record_round_trip_preserves_authority_context() SourceScope = new SourceScope("slack-workspace:T123"), SourceKind = new SourceKind("slack") }, + DefaultDeliveryTarget = new ChannelDeliveryTargetInfo( + "slack", + "destination", + "C123", + "#alerts", + "1700000000.000001"), + RequestedDeliveryTarget = new ChannelDeliveryTargetInfo( + "mattermost", + "direct_message", + "user1234567890123456789012", + "@alice"), HasAdoptedContext = true, HasThirdPartyAdoptedContext = true, AdoptedSpeakerIds = ["U12345", "U67890"], @@ -138,6 +164,8 @@ public void Record_round_trip_preserves_authority_context() Assert.Equal(original.RequesterSenderId, restored.RequesterSenderId); Assert.Equal(original.RequesterPrincipal, restored.RequesterPrincipal); Assert.Equal(original.Provenance, restored.Provenance); + Assert.Equal(original.DefaultDeliveryTarget, restored.DefaultDeliveryTarget); + Assert.Equal(original.RequestedDeliveryTarget, restored.RequestedDeliveryTarget); Assert.Equal(original.HasAdoptedContext, restored.HasAdoptedContext); Assert.Equal(original.HasThirdPartyAdoptedContext, restored.HasThirdPartyAdoptedContext); Assert.Equal(original.AdoptedSpeakerIds, restored.AdoptedSpeakerIds); diff --git a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs index 044b3f963..3ee3b8735 100644 --- a/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs +++ b/src/Netclaw.Actors.Tests/Protocol/SerializationRoundTripTests.cs @@ -12,6 +12,7 @@ using Netclaw.Actors.Protocol; using Netclaw.Actors.Reminders; using Netclaw.Actors.Sessions; +using Netclaw.Tools; using Xunit; namespace Netclaw.Actors.Tests.Protocol; @@ -784,6 +785,17 @@ public void ToolApprovalRequested_round_trips_all_persisted_context() PayloadTaint = Netclaw.Configuration.PayloadTaint.Community, SourceScope = "slack-workspace:T123", SourceKind = "slack", + DefaultDeliveryTarget = new ChannelDeliveryTargetInfo( + "slack", + "destination", + "C123", + "#alerts", + "1700000000.000001"), + RequestedDeliveryTarget = new ChannelDeliveryTargetInfo( + "mattermost", + "direct_message", + "user1234567890123456789012", + "@alice"), HasAdoptedContext = true, HasThirdPartyAdoptedContext = true, AdoptedSpeakerIds = ["U12345", "U-observer"], @@ -826,6 +838,8 @@ public void ToolApprovalRequested_round_trips_all_persisted_context() Assert.Equal(wrapped.TurnContext.PayloadTaint, result.TurnContext.PayloadTaint); Assert.Equal(wrapped.TurnContext.SourceScope, result.TurnContext.SourceScope); Assert.Equal(wrapped.TurnContext.SourceKind, result.TurnContext.SourceKind); + Assert.Equal(wrapped.TurnContext.DefaultDeliveryTarget, result.TurnContext.DefaultDeliveryTarget); + Assert.Equal(wrapped.TurnContext.RequestedDeliveryTarget, result.TurnContext.RequestedDeliveryTarget); Assert.Equal(wrapped.TurnContext.HasAdoptedContext, result.TurnContext.HasAdoptedContext); Assert.Equal(wrapped.TurnContext.HasThirdPartyAdoptedContext, result.TurnContext.HasThirdPartyAdoptedContext); Assert.Equal(wrapped.TurnContext.AdoptedSpeakerIds, result.TurnContext.AdoptedSpeakerIds); diff --git a/src/Netclaw.Actors.Tests/Reminders/ReminderExecutionActorTests.cs b/src/Netclaw.Actors.Tests/Reminders/ReminderExecutionActorTests.cs index df07a0aa4..20a06904b 100644 --- a/src/Netclaw.Actors.Tests/Reminders/ReminderExecutionActorTests.cs +++ b/src/Netclaw.Actors.Tests/Reminders/ReminderExecutionActorTests.cs @@ -14,6 +14,7 @@ using Netclaw.Actors.Reminders; using Netclaw.Configuration; using Netclaw.Tests.Utilities; +using Netclaw.Tools; using Xunit; namespace Netclaw.Actors.Tests.Reminders; @@ -379,6 +380,43 @@ public async Task Execution_uses_definition_audience_when_set() Assert.Equal(TrustAudience.Personal, input.Audience); } + [Fact] + public async Task Channel_delivery_execution_carries_requested_delivery_target() + { + var pipeline = new ScriptedSessionPipeline(sessionId => + [ + new TurnCompleted { SessionId = sessionId, TurnNumber = new Netclaw.Actors.Protocol.TurnNumber(1) } + ]); + + var target = new ChannelDeliveryTargetInfo( + "slack", + "destination", + "C1234567890", + "#alerts"); + var definition = CreateDefinition("delivery-target") with + { + Delivery = new ReminderDelivery + { + Kind = DeliveryKind.Channel, + Transport = "slack", + Address = "C1234567890", + Target = target + }, + DeliveryInstructions = string.Empty, + DeliveryRequired = false + }; + var probe = CreateTestProbe(); + Sys.ActorOf( + Props.Create(() => new ParentProxy(probe.Ref, definition, pipeline, _historyStore)), + "exec-delivery-target"); + + await probe.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); + + var input = await pipeline.InputCaptured.WaitAsync(TimeSpan.FromSeconds(5), TestContext.Current.CancellationToken); + Assert.Equal(target, input.RequestedDeliveryTarget); + Assert.Null(input.DefaultDeliveryTarget); + } + // Note: Execution_fails_when_definition_audience_missing was removed in issue #994. // Audience is now required TrustAudience (non-nullable), so the missing-audience // failure path no longer exists in ReminderExecutionActor. The type system enforces diff --git a/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs b/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs index fa9a032d3..d679cc114 100644 --- a/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs +++ b/src/Netclaw.Actors.Tests/Reminders/SetReminderToolTests.cs @@ -673,6 +673,10 @@ public async Task Resolves_hash_channel_name_to_canonical_id() var cmd = await probe.ExpectMsgAsync(TimeSpan.FromSeconds(5), cancellationToken: TestContext.Current.CancellationToken); Assert.Equal(DeliveryKind.Channel, cmd.Definition.Delivery.Kind); Assert.Equal("C0123ABC", cmd.Definition.Delivery.Address); + Assert.NotNull(cmd.Definition.Delivery.Target); + Assert.Equal("slack", cmd.Definition.Delivery.Target.ChannelKey); + Assert.Equal("destination", cmd.Definition.Delivery.Target.DestinationKind); + Assert.Equal("C0123ABC", cmd.Definition.Delivery.Target.DestinationId); Assert.Equal(1, resolver.CallCount); probe.Reply(new ReminderSavedResponse( diff --git a/src/Netclaw.Actors.Tests/Tools/SetWebhookToolProvenanceTests.cs b/src/Netclaw.Actors.Tests/Tools/SetWebhookToolProvenanceTests.cs index a5375ce40..0630b0a69 100644 --- a/src/Netclaw.Actors.Tests/Tools/SetWebhookToolProvenanceTests.cs +++ b/src/Netclaw.Actors.Tests/Tools/SetWebhookToolProvenanceTests.cs @@ -80,4 +80,23 @@ public async Task Requested_audience_above_creator_is_rejected_and_not_persisted Assert.Contains("exceeds creator authority", result); Assert.False(_store.TryGet("escalate-route", out _)); } + + [Fact] + public async Task Notify_instructions_require_notification_target() + { + var tool = new SetWebhookTool(_store); + + var result = await tool.ExecuteAsync(new Dictionary + { + ["RouteName"] = "notify-without-target", + ["Prompt"] = "Handle inbound delivery.", + ["VerificationKind"] = "Hmac", + ["Secret"] = "test-secret", + ["NotifyInstructions"] = "Post a summary to the release channel.", + ["DeliveryRequired"] = false + }, Context(TrustAudience.Team), TestContext.Current.CancellationToken); + + Assert.Equal("Error: NotificationTarget is required when NotifyInstructions are provided.", result); + Assert.False(_store.TryGet("notify-without-target", out _)); + } } diff --git a/src/Netclaw.Actors/Channels/ChannelInput.cs b/src/Netclaw.Actors/Channels/ChannelInput.cs index a19e3154a..506e199dc 100644 --- a/src/Netclaw.Actors/Channels/ChannelInput.cs +++ b/src/Netclaw.Actors/Channels/ChannelInput.cs @@ -6,6 +6,7 @@ using Akka.Actor; using Microsoft.Extensions.AI; using Netclaw.Configuration; +using Netclaw.Tools; namespace Netclaw.Actors.Channels; @@ -81,6 +82,18 @@ public sealed record AdoptedContextEntry /// public string? ExecutableText { get; init; } + /// + /// Default output target for channel-originated input. Null for trigger + /// sources unless they explicitly route back through a real channel. + /// + public ChannelDeliveryTargetInfo? DefaultDeliveryTarget { get; init; } + + /// + /// Explicit output target selected by trigger-originated input when external + /// output is expected. + /// + public ChannelDeliveryTargetInfo? RequestedDeliveryTarget { get; init; } + /// /// True when the turn contains quoted adopted thread context ahead of the current /// executable message. diff --git a/src/Netclaw.Actors/Channels/ChannelPipeline.cs b/src/Netclaw.Actors/Channels/ChannelPipeline.cs index ace90a06d..c53eff356 100644 --- a/src/Netclaw.Actors/Channels/ChannelPipeline.cs +++ b/src/Netclaw.Actors/Channels/ChannelPipeline.cs @@ -60,6 +60,8 @@ public static MessageSource Create(ChannelInput input, SessionPipelineOptions op Provenance = input.Provenance, ReceivedAt = input.ReceivedAt, ExecutableText = input.ExecutableText ?? textContent, + DefaultDeliveryTarget = input.DefaultDeliveryTarget, + RequestedDeliveryTarget = input.RequestedDeliveryTarget, HasThirdPartyAdoptedContext = input.HasThirdPartyAdoptedContext, AdoptedSpeakerIds = input.AdoptedSpeakerIds, AdoptedContextProjection = input.AdoptedContextProjection, diff --git a/src/Netclaw.Actors/Channels/MessageSource.cs b/src/Netclaw.Actors/Channels/MessageSource.cs index 3b66f988a..7e1a232a5 100644 --- a/src/Netclaw.Actors/Channels/MessageSource.cs +++ b/src/Netclaw.Actors/Channels/MessageSource.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using Akka.Actor; using Netclaw.Configuration; +using Netclaw.Tools; namespace Netclaw.Actors.Channels; @@ -83,6 +84,20 @@ public sealed record AdoptedContextEntry( /// public string? ExecutableText { get; init; } + /// + /// Default output target inherited from the channel that produced this turn. + /// + public ChannelDeliveryTargetInfo? DefaultDeliveryTarget { get; init; } + + /// + /// Explicit output target selected by a trigger source such as a reminder or + /// webhook route. + /// + public ChannelDeliveryTargetInfo? RequestedDeliveryTarget { get; init; } + + public ChannelDeliveryTargetInfo? EffectiveDeliveryTarget + => RequestedDeliveryTarget ?? DefaultDeliveryTarget; + /// /// True when the model input contains a quoted adopted-context window. /// diff --git a/src/Netclaw.Actors/Channels/TurnContext.cs b/src/Netclaw.Actors/Channels/TurnContext.cs index 2642b364b..9cf009c5c 100644 --- a/src/Netclaw.Actors/Channels/TurnContext.cs +++ b/src/Netclaw.Actors/Channels/TurnContext.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using Netclaw.Actors.Protocol; using Netclaw.Configuration; +using Netclaw.Tools; namespace Netclaw.Actors.Channels; @@ -31,6 +32,13 @@ public sealed record TurnContext public required SourceProvenance Provenance { get; init; } + public ChannelDeliveryTargetInfo? DefaultDeliveryTarget { get; init; } + + public ChannelDeliveryTargetInfo? RequestedDeliveryTarget { get; init; } + + public ChannelDeliveryTargetInfo? EffectiveDeliveryTarget + => RequestedDeliveryTarget ?? DefaultDeliveryTarget; + public bool HasAdoptedContext { get; init; } public bool HasThirdPartyAdoptedContext { get; init; } @@ -60,6 +68,8 @@ public static TurnContext FromMessageSource(SessionId sessionId, TurnId turnId, RequesterSenderId = source?.SenderId, RequesterPrincipal = source?.Principal ?? PrincipalClassification.UntrustedExternal, Provenance = provenance, + DefaultDeliveryTarget = source?.DefaultDeliveryTarget, + RequestedDeliveryTarget = source?.RequestedDeliveryTarget, HasAdoptedContext = source?.HasAdoptedContext ?? false, HasThirdPartyAdoptedContext = source?.HasThirdPartyAdoptedContext ?? false, AdoptedSpeakerIds = source?.AdoptedSpeakerIds ?? [], @@ -85,6 +95,8 @@ public TurnContextRecord ToRecord() PayloadTaint = Provenance.PayloadTaint, SourceScope = sourceScope is null ? null : sourceScope.Value.Value, SourceKind = sourceKind is null ? null : sourceKind.Value.Value, + DefaultDeliveryTarget = DefaultDeliveryTarget, + RequestedDeliveryTarget = RequestedDeliveryTarget, HasAdoptedContext = HasAdoptedContext, HasThirdPartyAdoptedContext = HasThirdPartyAdoptedContext, AdoptedSpeakerIds = [.. AdoptedSpeakerIds], @@ -153,6 +165,8 @@ public static bool TryFromRecord(TurnContextRecord? record, out TurnContext? con SourceScope = string.IsNullOrWhiteSpace(record.SourceScope) ? null : new SourceScope(record.SourceScope), SourceKind = string.IsNullOrWhiteSpace(record.SourceKind) ? null : new SourceKind(record.SourceKind) }, + DefaultDeliveryTarget = record.DefaultDeliveryTarget, + RequestedDeliveryTarget = record.RequestedDeliveryTarget, HasAdoptedContext = record.HasAdoptedContext, HasThirdPartyAdoptedContext = record.HasThirdPartyAdoptedContext, AdoptedSpeakerIds = record.AdoptedSpeakerIds, diff --git a/src/Netclaw.Actors/Protocol/Events.cs b/src/Netclaw.Actors/Protocol/Events.cs index b5896383b..e5e2dfb4b 100644 --- a/src/Netclaw.Actors/Protocol/Events.cs +++ b/src/Netclaw.Actors/Protocol/Events.cs @@ -5,6 +5,7 @@ // ----------------------------------------------------------------------- using Netclaw.Actors.Serialization; using Netclaw.Configuration; +using Netclaw.Tools; namespace Netclaw.Actors.Protocol; @@ -98,6 +99,10 @@ public sealed record TurnContextRecord public string? SourceKind { get; init; } + public ChannelDeliveryTargetInfo? DefaultDeliveryTarget { get; init; } + + public ChannelDeliveryTargetInfo? RequestedDeliveryTarget { get; init; } + public bool HasAdoptedContext { get; init; } public bool HasThirdPartyAdoptedContext { get; init; } diff --git a/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs b/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs index 9d5fa37f1..213ba2272 100644 --- a/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs +++ b/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs @@ -150,7 +150,10 @@ await inputQueue.OfferAsync(new ChannelInput SourceKind = new SourceKind("reminder") }, Contents = [new TextContent(prompt)], - ReceivedAt = _timeProvider.GetUtcNow() + ReceivedAt = _timeProvider.GetUtcNow(), + RequestedDeliveryTarget = _definition.Delivery.Kind == DeliveryKind.Channel + ? ResolveChannelDeliveryTarget(_definition) + : null }); inputQueue.Complete(); @@ -377,6 +380,14 @@ private static string BuildPrompt(ReminderDefinition definition) private static string BuildChannelDeliveryGuidance(ReminderDefinition definition) { + var target = ResolveChannelDeliveryTarget(definition); + if (target is not null) + { + return "\n\nPost the result using send_channel_message with " + + $"channel_key='{target.ChannelKey}', destination.channel_key='{target.ChannelKey}', " + + $"destination.kind='{target.DestinationKind}', destination.id='{target.DestinationId}', and text set to the result."; + } + var transport = definition.Delivery.Transport?.Trim().ToLowerInvariant(); var address = definition.Delivery.Address?.Trim(); if (string.IsNullOrWhiteSpace(transport) || string.IsNullOrWhiteSpace(address)) @@ -413,6 +424,49 @@ private static string BuildChannelDeliveryGuidance(ReminderDefinition definition $"destination.kind='{destinationKind}', destination.id='{destinationId}', and text set to the result."; } + private static ChannelDeliveryTargetInfo? ResolveChannelDeliveryTarget(ReminderDefinition definition) + { + if (definition.Delivery.Target is not null) + return definition.Delivery.Target; + + if (definition.Delivery.Kind != DeliveryKind.Channel) + return null; + + var transport = definition.Delivery.Transport?.Trim().ToLowerInvariant(); + var address = definition.Delivery.Address?.Trim(); + if (string.IsNullOrWhiteSpace(transport) || string.IsNullOrWhiteSpace(address)) + return null; + + var destinationKind = "destination"; + var destinationId = address; + + if (string.Equals(transport, "slack", StringComparison.OrdinalIgnoreCase) + && address is { Length: > 0 } + && (address.StartsWith("U", StringComparison.Ordinal) || address.StartsWith("W", StringComparison.Ordinal))) + { + destinationKind = "direct_message"; + } + else if (string.Equals(transport, "mattermost", StringComparison.OrdinalIgnoreCase) + && address is { Length: > 0 }) + { + if (address.StartsWith('@')) + { + destinationKind = "direct_message"; + destinationId = address[1..]; + } + else if (address.StartsWith("channel:", StringComparison.OrdinalIgnoreCase)) + { + destinationId = address[8..]; + } + } + + return new ChannelDeliveryTargetInfo( + transport, + destinationKind, + destinationId, + address); + } + private void HandleOutput(ExecutionOutput wrapper) { var action = _accumulator.ProcessOutput(wrapper.Output); diff --git a/src/Netclaw.Actors/Reminders/ReminderProtocol.cs b/src/Netclaw.Actors/Reminders/ReminderProtocol.cs index 4a6f2922c..711434cdc 100644 --- a/src/Netclaw.Actors/Reminders/ReminderProtocol.cs +++ b/src/Netclaw.Actors/Reminders/ReminderProtocol.cs @@ -8,6 +8,7 @@ using Akka.Actor; using Netclaw.Actors.Serialization; using Netclaw.Configuration; +using Netclaw.Tools; namespace Netclaw.Actors.Reminders; @@ -82,6 +83,14 @@ public sealed record ReminderDelivery : INetclawSerializableMessage /// public string? Address { get; init; } + /// + /// Resolved standard channel delivery target for Channel delivery. Older + /// persisted reminders may only have and + /// ; execution still handles those fields but new + /// reminders store this explicit target for trigger-source routing checks. + /// + public ChannelDeliveryTargetInfo? Target { get; init; } + /// /// Session ID for CurrentSession delivery. Null for Channel and None. /// diff --git a/src/Netclaw.Actors/Reminders/SetReminderTool.cs b/src/Netclaw.Actors/Reminders/SetReminderTool.cs index 104da3003..245904c7c 100644 --- a/src/Netclaw.Actors/Reminders/SetReminderTool.cs +++ b/src/Netclaw.Actors/Reminders/SetReminderTool.cs @@ -192,7 +192,8 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon { Kind = DeliveryKind.Channel, Transport = transport, - Address = resolution.ResolvedId + Address = resolution.ResolvedId, + Target = BuildDeliveryTarget(transport, resolution) }; break; } @@ -322,6 +323,39 @@ protected override async Task ExecuteAsync(Params args, ToolExecutionCon _ => $"{interval.TotalMinutes:F0}m" }; + private static ChannelDeliveryTargetInfo BuildDeliveryTarget( + string transport, + ReminderTargetResolution resolution) + { + var destinationKind = resolution.Kind == ReminderTargetKind.User + ? "direct_message" + : "destination"; + + return new ChannelDeliveryTargetInfo( + transport, + destinationKind, + NormalizeDeliveryTargetId(transport, resolution), + resolution.ResolvedId); + } + + private static string NormalizeDeliveryTargetId(string transport, ReminderTargetResolution resolution) + { + var resolvedId = resolution.ResolvedId + ?? throw new InvalidOperationException("Cannot build a delivery target from an empty reminder target resolution."); + + if (!string.Equals(transport, "mattermost", StringComparison.OrdinalIgnoreCase)) + return resolvedId; + + return resolution.Kind switch + { + ReminderTargetKind.Channel when resolvedId.StartsWith("channel:", StringComparison.OrdinalIgnoreCase) + => resolvedId[8..], + ReminderTargetKind.User when resolvedId.StartsWith('@') + => resolvedId[1..], + _ => resolvedId + }; + } + public static string FormatTimestamp(DateTimeOffset? nextFire) { if (nextFire is not { } nf) diff --git a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs index d723ec8a4..eb7d9cbd0 100644 --- a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs +++ b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs @@ -8,6 +8,7 @@ using Netclaw.Actors.Channels; using Netclaw.Actors.Jobs; using Netclaw.Actors.Protocol; +using Netclaw.Tools; using Netclaw.Actors.Reminders; using Netclaw.Actors.Sessions; using Netclaw.Media; @@ -386,10 +387,31 @@ private static Proto.ToolApprovalRequestedProto.Types.TurnContextRecordProto ToP proto.SourceScope = record.SourceScope; if (record.SourceKind is not null) proto.SourceKind = record.SourceKind; + if (record.DefaultDeliveryTarget is not null) + proto.DefaultDeliveryTarget = ToProto(record.DefaultDeliveryTarget); + if (record.RequestedDeliveryTarget is not null) + proto.RequestedDeliveryTarget = ToProto(record.RequestedDeliveryTarget); proto.AdoptedSpeakerIds.AddRange(record.AdoptedSpeakerIds); return proto; } + private static Proto.ToolApprovalRequestedProto.Types.TurnContextRecordProto.Types.ChannelDeliveryTargetRecordProto ToProto( + ChannelDeliveryTargetInfo target) + { + var proto = new Proto.ToolApprovalRequestedProto.Types.TurnContextRecordProto.Types.ChannelDeliveryTargetRecordProto + { + ChannelKey = target.ChannelKey, + DestinationKind = target.DestinationKind, + DestinationId = target.DestinationId + }; + + if (target.DestinationDisplayName is not null) + proto.DestinationDisplayName = target.DestinationDisplayName; + if (target.ThreadOrRootId is not null) + proto.ThreadOrRootId = target.ThreadOrRootId; + return proto; + } + private static TurnContextRecord FromProto(Proto.ToolApprovalRequestedProto.Types.TurnContextRecordProto proto) => new() { @@ -406,12 +428,23 @@ private static TurnContextRecord FromProto(Proto.ToolApprovalRequestedProto.Type PayloadTaint = (Configuration.PayloadTaint)proto.PayloadTaint, SourceScope = proto.HasSourceScope ? proto.SourceScope : null, SourceKind = proto.HasSourceKind ? proto.SourceKind : null, + DefaultDeliveryTarget = proto.DefaultDeliveryTarget is null ? null : FromProto(proto.DefaultDeliveryTarget), + RequestedDeliveryTarget = proto.RequestedDeliveryTarget is null ? null : FromProto(proto.RequestedDeliveryTarget), HasAdoptedContext = proto.HasAdoptedContext, HasThirdPartyAdoptedContext = proto.HasThirdPartyAdoptedContext, AdoptedSpeakerIds = proto.AdoptedSpeakerIds.ToArray(), SupportsInteractiveApproval = proto.SupportsInteractiveApproval }; + private static ChannelDeliveryTargetInfo FromProto( + Proto.ToolApprovalRequestedProto.Types.TurnContextRecordProto.Types.ChannelDeliveryTargetRecordProto proto) + => new( + proto.ChannelKey, + proto.DestinationKind, + proto.DestinationId, + proto.HasDestinationDisplayName ? proto.DestinationDisplayName : null, + proto.HasThreadOrRootId ? proto.ThreadOrRootId : null); + // ── SessionSnapshot ── internal static Proto.SessionSnapshotProto ToProto(SessionSnapshot snap) diff --git a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto index 1889376c9..46fa01b17 100644 --- a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto +++ b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto @@ -132,6 +132,14 @@ message ToolApprovalRequestedProto { } message TurnContextRecordProto { + message ChannelDeliveryTargetRecordProto { + string channel_key = 1; + string destination_kind = 2; + string destination_id = 3; + optional string destination_display_name = 4; + optional string thread_or_root_id = 5; + } + SessionIdProto session_id = 1; string turn_id = 2; TrustAudience audience = 3; @@ -147,6 +155,8 @@ message ToolApprovalRequestedProto { bool has_third_party_adopted_context = 13; repeated string adopted_speaker_ids = 14; bool supports_interactive_approval = 15; + ChannelDeliveryTargetRecordProto default_delivery_target = 16; + ChannelDeliveryTargetRecordProto requested_delivery_target = 17; } SessionIdProto session_id = 1; diff --git a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs index bd96fd901..d1f61b7dd 100644 --- a/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs +++ b/src/Netclaw.Actors/Sessions/Pipelines/SessionToolExecutionPipeline.cs @@ -968,6 +968,8 @@ private static ToolExecutionContext BuildToolExecutionContext( context.Boundary = turnContext?.Boundary ?? source?.Boundary; context.ChannelType = turnContext?.ChannelType?.ToWireValue() ?? (source is null ? null : source.ChannelType.ToWireValue()); + context.DefaultDeliveryTarget = turnContext?.DefaultDeliveryTarget ?? source?.DefaultDeliveryTarget; + context.RequestedDeliveryTarget = turnContext?.RequestedDeliveryTarget ?? source?.RequestedDeliveryTarget; context.SupportsInteractiveApproval = turnContext?.SupportsInteractiveApproval ?? source?.ChannelType.SupportsInteractiveApproval(); context.ModelInputModalities = modelInputModalities; diff --git a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs index 19b35e0c3..af2fc8417 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentActor.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentActor.cs @@ -236,6 +236,8 @@ private void Idle() }; _toolExecutionContext.Boundary = msg.Boundary; _toolExecutionContext.ChannelType = msg.ChannelType; + _toolExecutionContext.DefaultDeliveryTarget = msg.DefaultDeliveryTarget; + _toolExecutionContext.RequestedDeliveryTarget = msg.RequestedDeliveryTarget; _toolExecutionContext.ModelInputModalities = msg.ModelInputModalities; _toolExecutionContext.ProjectDirectory = msg.ParentProjectDirectory; _toolExecutionContext.SupportsInteractiveApproval = _approvalBridge is not null; @@ -1125,6 +1127,8 @@ private static ToolExecutionContext CreatePerToolExecutionContext(ToolExecutionC Boundary = source.Boundary, RequestedTimeoutSeconds = source.RequestedTimeoutSeconds, ChannelType = source.ChannelType, + DefaultDeliveryTarget = source.DefaultDeliveryTarget, + RequestedDeliveryTarget = source.RequestedDeliveryTarget, ModelInputModalities = source.ModelInputModalities, ProjectDirectory = source.ProjectDirectory, InheritedCwd = source.InheritedCwd, diff --git a/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs b/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs index 2230a0822..72cf6eb69 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentProtocol.cs @@ -108,6 +108,10 @@ public sealed record RunSubAgent : INoSerializationVerificationNeeded public string? ChannelType { get; init; } + public ChannelDeliveryTargetInfo? DefaultDeliveryTarget { get; init; } + + public ChannelDeliveryTargetInfo? RequestedDeliveryTarget { get; init; } + /// /// Input modalities supported by the model selected for this sub-agent run. /// Tools use this to decide whether model-visible media handoff is allowed. diff --git a/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs b/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs index bd7365bb5..46b1381a5 100644 --- a/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs +++ b/src/Netclaw.Actors/SubAgents/SubAgentSpawner.cs @@ -148,6 +148,8 @@ public async Task SpawnAsync( Audience = context.Audience, Boundary = context.Boundary, ChannelType = context.ChannelType, + DefaultDeliveryTarget = context.DefaultDeliveryTarget, + RequestedDeliveryTarget = context.RequestedDeliveryTarget, ModelInputModalities = context.ModelInputModalities, ParentSessionDirectory = context.SessionDirectory, ParentProjectDirectory = context.ProjectDirectory, diff --git a/src/Netclaw.Actors/Tools/SetWebhookTool.cs b/src/Netclaw.Actors/Tools/SetWebhookTool.cs index a27a9aff0..b08712146 100644 --- a/src/Netclaw.Actors/Tools/SetWebhookTool.cs +++ b/src/Netclaw.Actors/Tools/SetWebhookTool.cs @@ -112,6 +112,10 @@ protected override Task ExecuteAsync(Params args, ToolExecutionContext c }; } + var validationErrors = WebhookRouteValidator.Validate(routeName, definition); + if (validationErrors.Count > 0) + return Task.FromResult($"Error: {validationErrors[0]}"); + _store.Save(routeName, definition); return Task.FromResult($"Webhook route '{routeName}' saved at /api/webhooks/{routeName}. Secret stored in the route file; keep it aligned with the sender configuration."); } diff --git a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs index 9225384ad..a42cbb1b9 100644 --- a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs +++ b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs @@ -369,7 +369,8 @@ private async Task HandleInboundAsync(DiscordThreadInbound message) Provenance = message.Provenance, Contents = liveContents, ReceivedAt = message.ReceivedAt, - ExecutableText = message.Text + ExecutableText = message.Text, + DefaultDeliveryTarget = BuildDefaultDeliveryTarget() }; if (_hydrationPending && IsAuthorizedSender(message.SenderId.Value)) @@ -1034,6 +1035,8 @@ private async Task HandleTrustedReminderAsync(DeliverTrustedSessionTurn message) Provenance = message.Source.Provenance, Contents = [new TextContent(message.Content)], ReceivedAt = _dependencies.TimeProvider.GetUtcNow(), + DefaultDeliveryTarget = BuildDefaultDeliveryTarget(), + RequestedDeliveryTarget = message.Source.RequestedDeliveryTarget, ReminderId = message.Source.ReminderId, AckTarget = ackTarget }; @@ -1060,6 +1063,14 @@ private async Task HandleTrustedReminderAsync(DeliverTrustedSessionTurn message) private enum ApprovalLookupResult { Matched, WrongRequester, NotFound } + private ChannelDeliveryTargetInfo BuildDefaultDeliveryTarget() + => new( + ChannelType.Discord.ToWireValue(), + "destination", + _channelId.Value, + _channelId.Value, + _threadOrMessageId.Value); + private (ApprovalLookupResult Result, PendingApprovalRequest? Pending) ResolvePendingRequest( DiscordUserId senderId, Netclaw.Tools.ToolCallId? callId) { diff --git a/src/Netclaw.Channels.Discord/Transport/DiscordThreadHistoryFetcher.cs b/src/Netclaw.Channels.Discord/Transport/DiscordThreadHistoryFetcher.cs index 1fd4c14f3..9f1e22994 100644 --- a/src/Netclaw.Channels.Discord/Transport/DiscordThreadHistoryFetcher.cs +++ b/src/Netclaw.Channels.Discord/Transport/DiscordThreadHistoryFetcher.cs @@ -14,6 +14,7 @@ using Netclaw.Configuration; using Netclaw.Media; using Netclaw.Security; +using Netclaw.Tools; namespace Netclaw.Channels.Discord.Transport; @@ -237,7 +238,13 @@ public async Task> FetchThreadHistoryAsync( SourceScope = new SourceScope(threadChannelId.ToString()) }, Contents = contents, - ReceivedAt = message.Timestamp + ReceivedAt = message.Timestamp, + DefaultDeliveryTarget = new ChannelDeliveryTargetInfo( + Netclaw.Actors.Channels.ChannelType.Discord.ToWireValue(), + "destination", + channelId.Value, + channelId.Value, + threadChannelId.ToString(CultureInfo.InvariantCulture)) }; } diff --git a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs index 6e1191a3d..24cbb5838 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs @@ -338,7 +338,8 @@ private async Task HandleInboundAsync(MattermostThreadInbound message) Provenance = message.Provenance, Contents = liveContents, ReceivedAt = message.ReceivedAt, - ExecutableText = message.Text + ExecutableText = message.Text, + DefaultDeliveryTarget = BuildDefaultDeliveryTarget() }; if (_hydrationPending && IsAuthorizedSender(message.SenderId.Value)) @@ -1012,6 +1013,8 @@ private async Task HandleTrustedReminderAsync(DeliverTrustedSessionTurn message) Provenance = message.Source.Provenance, Contents = [new TextContent(message.Content)], ReceivedAt = _dependencies.TimeProvider.GetUtcNow(), + DefaultDeliveryTarget = BuildDefaultDeliveryTarget(), + RequestedDeliveryTarget = message.Source.RequestedDeliveryTarget, ReminderId = message.Source.ReminderId, AckTarget = ackTarget }; @@ -1038,6 +1041,14 @@ private async Task HandleTrustedReminderAsync(DeliverTrustedSessionTurn message) private enum ApprovalLookupResult { Matched, WrongRequester, NotFound } + private ChannelDeliveryTargetInfo BuildDefaultDeliveryTarget() + => new( + ChannelType.Mattermost.ToWireValue(), + "destination", + _channelId.Value, + _channelId.Value, + _rootPostId.Value); + private (ApprovalLookupResult Result, PendingApprovalRequest? Pending) ResolvePendingRequest( MattermostUserId senderId, ToolCallId? callId) { diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs index 783b278d9..564715f7c 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostThreadHistoryFetcher.cs @@ -15,6 +15,7 @@ using Netclaw.Configuration; using Netclaw.Media; using Netclaw.Security; +using Netclaw.Tools; using IOFile = System.IO.File; namespace Netclaw.Channels.Mattermost.Transport; @@ -247,7 +248,13 @@ public async Task> FetchThreadHistoryAsync( SourceScope = new SourceScope(rootPostId.Value) }, Contents = contents, - ReceivedAt = message.Timestamp + ReceivedAt = message.Timestamp, + DefaultDeliveryTarget = new ChannelDeliveryTargetInfo( + ChannelType.Mattermost.ToWireValue(), + "destination", + channelId.Value, + channelId.Value, + rootPostId.Value) }; } diff --git a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs index b0b9beeb6..72ee2ec5c 100644 --- a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs +++ b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs @@ -266,6 +266,8 @@ private async Task HandleTrustedReminderAsync(DeliverTrustedSessionTurn message) Provenance = message.Source.Provenance, Contents = [new TextContent(message.Content)], ReceivedAt = _dependencies.TimeProvider.GetUtcNow(), + DefaultDeliveryTarget = BuildDefaultDeliveryTarget(), + RequestedDeliveryTarget = message.Source.RequestedDeliveryTarget, ReminderId = message.Source.ReminderId, AckTarget = ackTarget }; @@ -599,12 +601,21 @@ private InboundBuildResult BuildInputForInbound( Provenance = triggeringMessage.Provenance, Contents = liveContents, ReceivedAt = triggeringMessage.ReceivedAt, - ExecutableText = triggeringMessage.Text + ExecutableText = triggeringMessage.Text, + DefaultDeliveryTarget = BuildDefaultDeliveryTarget() }; return new InboundBuildResult(baseInput, false); } + private ChannelDeliveryTargetInfo BuildDefaultDeliveryTarget() + => new( + ChannelType.Slack.ToWireValue(), + "destination", + _channelId.Value, + _channelId.Value, + _threadTs.Value); + /// /// One-shot thread history hydration. Runs once per actor lifetime, in the /// Hydrating behavior immediately after pipeline initialization. Fetches diff --git a/src/Netclaw.Channels.Slack/SlackThreadHistoryFetcher.cs b/src/Netclaw.Channels.Slack/SlackThreadHistoryFetcher.cs index db1ec02c5..cb7485b22 100644 --- a/src/Netclaw.Channels.Slack/SlackThreadHistoryFetcher.cs +++ b/src/Netclaw.Channels.Slack/SlackThreadHistoryFetcher.cs @@ -11,6 +11,7 @@ using Netclaw.Configuration; using Netclaw.Media; using Netclaw.Security; +using Netclaw.Tools; using SlackNet; using SlackNet.WebApi; @@ -181,6 +182,7 @@ private async Task> FetchRepliesAsync( message, senderId, channelId, + threadTs, trustResult.Audience, trustResult.Principal, attachmentPolicy, @@ -207,6 +209,7 @@ private async Task> FetchRepliesAsync( SlackNet.Events.MessageEvent message, string senderId, SlackChannelId channelId, + SlackThreadTs threadTs, TrustAudience audience, PrincipalClassification principal, ChannelAttachmentPolicy attachmentPolicy, @@ -276,7 +279,13 @@ private async Task> FetchRepliesAsync( SourceScope = new SourceScope(channelId.Value) }, Contents = contents, - ReceivedAt = receivedAt + ReceivedAt = receivedAt, + DefaultDeliveryTarget = new ChannelDeliveryTargetInfo( + Netclaw.Actors.Channels.ChannelType.Slack.ToWireValue(), + "destination", + channelId.Value, + channelId.Value, + threadTs.Value) }; } diff --git a/src/Netclaw.Configuration/WebhookRouteValidator.cs b/src/Netclaw.Configuration/WebhookRouteValidator.cs index ae716cb0e..c0736ed33 100644 --- a/src/Netclaw.Configuration/WebhookRouteValidator.cs +++ b/src/Netclaw.Configuration/WebhookRouteValidator.cs @@ -49,11 +49,10 @@ public static IReadOnlyList Validate(string routeName, WebhookRouteConfi if (route.Events.Any(string.IsNullOrWhiteSpace)) errors.Add("Events list contains a blank entry."); - if (route.DeliveryRequired - && route.NotificationTarget is null + if (route.NotificationTarget is null && !string.IsNullOrWhiteSpace(route.NotifyInstructions)) { - errors.Add("NotificationTarget is required when DeliveryRequired is true and NotifyInstructions are provided."); + errors.Add("NotificationTarget is required when NotifyInstructions are provided."); } if (route.NotificationTarget is { Kind: NotificationTargetKind.Slack } target diff --git a/src/Netclaw.Daemon.Tests/Configuration/ChannelSendToolTests.cs b/src/Netclaw.Daemon.Tests/Configuration/ChannelSendToolTests.cs index f2880e102..498b7376a 100644 --- a/src/Netclaw.Daemon.Tests/Configuration/ChannelSendToolTests.cs +++ b/src/Netclaw.Daemon.Tests/Configuration/ChannelSendToolTests.cs @@ -7,7 +7,9 @@ using Microsoft.Extensions.DependencyInjection; using Netclaw.Actors.Channels; using Netclaw.Channels; +using Netclaw.Configuration; using Netclaw.Daemon.Configuration; +using Netclaw.Tools; using Xunit; namespace Netclaw.Daemon.Tests.Configuration; @@ -85,14 +87,52 @@ public async Task Send_rejects_user_kind_and_instructs_direct_message_workflow() Assert.Contains("destination.kind='direct_message'", result); } + [Fact] + public async Task Send_rejects_trigger_origin_without_requested_delivery_target() + { + var registry = BuildRegistry(BuildDescriptor(ChannelType.Slack, isEnabled: true, ChannelCapabilities.SendMessages, ChannelAddressKind.Destination)); + var tool = new SendChannelMessageTool(registry, new ServiceCollection().BuildServiceProvider()); + var context = TriggerContext(requestedTarget: null); + + var result = await ExecuteAsync(tool, "slack", "slack", "destination", "C1234567890", context); + + Assert.Contains("trigger-originated channel send requires a configured channel delivery target", result); + Assert.Contains("No default output channel will be selected", result); + } + + [Fact] + public async Task Send_rejects_trigger_origin_destination_that_differs_from_requested_target() + { + var registry = BuildRegistry(BuildDescriptor(ChannelType.Slack, isEnabled: true, ChannelCapabilities.SendMessages, ChannelAddressKind.Destination)); + var tool = new SendChannelMessageTool(registry, new ServiceCollection().BuildServiceProvider()); + var context = TriggerContext(new ChannelDeliveryTargetInfo("slack", "destination", "C9999999999")); + + var result = await ExecuteAsync(tool, "slack", "slack", "destination", "C1234567890", context); + + Assert.Contains("must match the configured delivery target destination.id 'C9999999999'", result); + } + + [Fact] + public async Task Send_allows_trigger_origin_to_reach_normal_validation_when_requested_target_matches() + { + var registry = BuildRegistry(BuildDescriptor(ChannelType.Slack, isEnabled: true, ChannelCapabilities.SendMessages, ChannelAddressKind.Destination)); + var tool = new SendChannelMessageTool(registry, new ServiceCollection().BuildServiceProvider()); + var context = TriggerContext(new ChannelDeliveryTargetInfo("slack", "destination", "not-a-stable-id")); + + var result = await ExecuteAsync(tool, "slack", "slack", "destination", "not-a-stable-id", context); + + Assert.Contains("does not look like a stable Slack ID", result); + } + private static Task ExecuteAsync( SendChannelMessageTool tool, string channelKey, string destinationChannelKey, string destinationKind, - string destinationId) + string destinationId, + ToolExecutionContext? context = null) { - return tool.ExecuteAsync(new Dictionary + var arguments = new Dictionary { ["channel_key"] = channelKey, ["destination"] = new Dictionary @@ -103,9 +143,21 @@ private static Task ExecuteAsync( }, ["text"] = "Test message", ["_rationale"] = "test" - }, TestContext.Current.CancellationToken); + }; + + return context is null + ? tool.ExecuteAsync(arguments, TestContext.Current.CancellationToken) + : tool.ExecuteAsync(arguments, context, TestContext.Current.CancellationToken); } + private static ToolExecutionContext TriggerContext(ChannelDeliveryTargetInfo? requestedTarget) + => new("reminder/test", null) + { + Audience = TrustAudience.Team, + ChannelType = ChannelType.Reminder.ToWireValue(), + RequestedDeliveryTarget = requestedTarget + }; + private static string[] ReadChannelKeyEnum(JsonElement schema) { return schema diff --git a/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs b/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs index b4060b81d..4d09e204e 100644 --- a/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs +++ b/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs @@ -62,6 +62,15 @@ public AITool ToAITool() } public async Task ExecuteAsync(IDictionary? arguments, CancellationToken ct = default) + => await ExecuteCoreAsync(arguments, context: null, ct); + + public Task ExecuteAsync(IDictionary? arguments, ToolExecutionContext context, CancellationToken ct = default) + => ExecuteCoreAsync(arguments, context, ct); + + private async Task ExecuteCoreAsync( + IDictionary? arguments, + ToolExecutionContext? context, + CancellationToken ct) { var channelKeyValue = ToolArgumentHelper.GetString(arguments, "channel_key"); if (string.IsNullOrWhiteSpace(channelKeyValue)) @@ -72,6 +81,12 @@ public async Task ExecuteAsync(IDictionary? arguments, if (string.IsNullOrWhiteSpace(text)) return "Error: 'text' parameter is required."; + if (IsTriggerOrigin(context) && context!.RequestedDeliveryTarget is null) + { + return "Error: trigger-originated channel send requires a configured channel delivery target on the turn. " + + "No default output channel will be selected."; + } + if (!TryReadDestination(arguments, out var destination, out var destinationError)) return destinationError; @@ -102,6 +117,14 @@ public async Task ExecuteAsync(IDictionary? arguments, } var threadOrRootId = ToolArgumentHelper.GetString(arguments, "thread_or_root_id"); + + if (IsTriggerOrigin(context) && context!.RequestedDeliveryTarget is { } requestedTarget) + { + var targetError = ValidateRequestedTriggerTarget(key, destination, threadOrRootId, requestedTarget); + if (targetError is not null) + return targetError; + } + if (!string.IsNullOrWhiteSpace(threadOrRootId)) { return "Error: 'thread_or_root_id' is not supported by send_channel_message yet. " + @@ -136,9 +159,6 @@ public async Task ExecuteAsync(IDictionary? arguments, } } - public Task ExecuteAsync(IDictionary? arguments, ToolExecutionContext context, CancellationToken ct = default) - => ExecuteAsync(arguments, ct); - private JsonElement BuildParameterSchema() { var channelKeys = GetEnabledSendChannelKeys(); @@ -329,6 +349,51 @@ private static bool TryReadDestination( "Use lookup_channel_destination for channel posts or lookup_channel_user for direct messages."; } + private static string? ValidateRequestedTriggerTarget( + ChannelDescriptorKey channelKey, + SendChannelDestination destination, + string? threadOrRootId, + ChannelDeliveryTargetInfo requestedTarget) + { + if (!string.Equals(requestedTarget.ChannelKey, channelKey.Value, StringComparison.OrdinalIgnoreCase)) + { + return $"Error: trigger-originated channel send must use configured channel_key '{requestedTarget.ChannelKey}', " + + $"not '{channelKey}'."; + } + + if (!ChannelAddressKindWire.TryParse(requestedTarget.DestinationKind, out var expectedKind)) + return $"Error: configured trigger delivery target has unsupported destination kind '{requestedTarget.DestinationKind}'."; + + if (destination.AddressKind != expectedKind) + { + return "Error: trigger-originated channel send must match the configured delivery target " + + $"destination.kind '{requestedTarget.DestinationKind}'."; + } + + if (!string.Equals(destination.StableId, requestedTarget.DestinationId, StringComparison.Ordinal)) + { + return "Error: trigger-originated channel send must match the configured delivery target " + + $"destination.id '{requestedTarget.DestinationId}'."; + } + + var suppliedThread = string.IsNullOrWhiteSpace(threadOrRootId) ? null : threadOrRootId.Trim(); + if (!string.Equals(suppliedThread, requestedTarget.ThreadOrRootId, StringComparison.Ordinal)) + { + return "Error: trigger-originated channel send must match the configured delivery target thread_or_root_id."; + } + + return null; + } + + private static bool IsTriggerOrigin(ToolExecutionContext? context) + { + if (string.IsNullOrWhiteSpace(context?.ChannelType)) + return false; + + return string.Equals(context.ChannelType, ChannelType.Reminder.ToWireValue(), StringComparison.OrdinalIgnoreCase) + || string.Equals(context.ChannelType, ChannelType.Webhook.ToWireValue(), StringComparison.OrdinalIgnoreCase); + } + private static bool IsStablePlatformId(ChannelType channelType, ChannelAddressKind addressKind, string stableId) => channelType switch { diff --git a/src/Netclaw.Daemon/Gateway/SessionRegistry.cs b/src/Netclaw.Daemon/Gateway/SessionRegistry.cs index c73485164..5c7ac100d 100644 --- a/src/Netclaw.Daemon/Gateway/SessionRegistry.cs +++ b/src/Netclaw.Daemon/Gateway/SessionRegistry.cs @@ -13,6 +13,7 @@ using Netclaw.Actors.Hosting; using Netclaw.Actors.Protocol; using Netclaw.Configuration; +using Netclaw.Tools; namespace Netclaw.Daemon.Gateway; @@ -229,7 +230,12 @@ public async Task SendMessageAsync(string connectionId, string sessionId, string SourceKind = new SourceKind("signalr") }, Contents = [new TextContent(text)], - ReceivedAt = _timeProvider.GetUtcNow() + ReceivedAt = _timeProvider.GetUtcNow(), + DefaultDeliveryTarget = new ChannelDeliveryTargetInfo( + ChannelType.Tui.ToWireValue(), + "local_session", + attachedSessionId.Value, + attachedSessionId.Value) }; var gateway = await _gatewayProvider.GetAsync(); diff --git a/src/Netclaw.Daemon/Gateway/SignalRSessionActor.cs b/src/Netclaw.Daemon/Gateway/SignalRSessionActor.cs index fc7126333..fa17c498c 100644 --- a/src/Netclaw.Daemon/Gateway/SignalRSessionActor.cs +++ b/src/Netclaw.Daemon/Gateway/SignalRSessionActor.cs @@ -11,6 +11,7 @@ using Netclaw.Actors.Protocol; using Netclaw.Actors.Reminders; using Netclaw.Configuration; +using Netclaw.Tools; namespace Netclaw.Daemon.Gateway; @@ -194,6 +195,8 @@ await _handle.ReinitializeAsync( Provenance = msg.Source.Provenance, Contents = [new Microsoft.Extensions.AI.TextContent(msg.Content)], ReceivedAt = msg.Source.ReceivedAt, + DefaultDeliveryTarget = BuildDefaultDeliveryTarget(), + RequestedDeliveryTarget = msg.Source.RequestedDeliveryTarget, ReminderId = msg.Source.ReminderId, AckTarget = ackTarget }; @@ -285,6 +288,15 @@ protected override void PostStop() base.PostStop(); } + private ChannelDeliveryTargetInfo BuildDefaultDeliveryTarget() + => new( + (_channelType == Actors.Channels.ChannelType.SignalR + ? Actors.Channels.ChannelType.Tui + : _channelType).ToWireValue(), + "local_session", + _sessionId.Value, + _sessionId.Value); + // ─── Message protocol ─────────────────────────────────────────────────── private sealed record OutputReceived(SessionOutput Output); diff --git a/src/Netclaw.Daemon/Webhooks/RegisteredWebhookRoute.cs b/src/Netclaw.Daemon/Webhooks/RegisteredWebhookRoute.cs index 1763daf8d..d16fdd418 100644 --- a/src/Netclaw.Daemon/Webhooks/RegisteredWebhookRoute.cs +++ b/src/Netclaw.Daemon/Webhooks/RegisteredWebhookRoute.cs @@ -6,6 +6,7 @@ using System.IO; using Microsoft.AspNetCore.Http; using Netclaw.Configuration; +using Netclaw.Tools; namespace Netclaw.Daemon.Webhooks; @@ -80,11 +81,25 @@ public string BuildPromptOverlay() public string BuildDefaultNotifyInstructions() { - if (Config.NotificationTarget is not { Kind: NotificationTargetKind.Slack, ChannelId: { Length: > 0 } channelId }) + var target = BuildNotificationDeliveryTarget(); + if (target is null) return string.Empty; return "If you need to notify a human, use send_channel_message with " + - $"channel_key='slack', destination.channel_key='slack', destination.kind='destination', destination.id='{channelId}', and text set to your notification."; + $"channel_key='{target.ChannelKey}', destination.channel_key='{target.ChannelKey}', " + + $"destination.kind='{target.DestinationKind}', destination.id='{target.DestinationId}', and text set to your notification."; + } + + public ChannelDeliveryTargetInfo? BuildNotificationDeliveryTarget() + { + if (Config.NotificationTarget is not { Kind: NotificationTargetKind.Slack, ChannelId: { Length: > 0 } channelId }) + return null; + + return new ChannelDeliveryTargetInfo( + "slack", + "destination", + channelId, + channelId); } public static string? GetHeaderValue(IHeaderDictionary headers, string name) diff --git a/src/Netclaw.Daemon/Webhooks/WebhookExecutionActor.cs b/src/Netclaw.Daemon/Webhooks/WebhookExecutionActor.cs index aff17c1bd..30b4a7709 100644 --- a/src/Netclaw.Daemon/Webhooks/WebhookExecutionActor.cs +++ b/src/Netclaw.Daemon/Webhooks/WebhookExecutionActor.cs @@ -100,7 +100,8 @@ await inputQueue.OfferAsync(new ChannelInput SourceScope = new SourceScope(_invocation.Route.Name) }, Contents = [new TextContent(WebhookPayloadFormatter.Format(_invocation))], - ReceivedAt = _invocation.ReceivedAt + ReceivedAt = _invocation.ReceivedAt, + RequestedDeliveryTarget = _invocation.Route.BuildNotificationDeliveryTarget() }); inputQueue.Complete(); diff --git a/src/Netclaw.Tools.Abstractions/ChannelDeliveryTargetInfo.cs b/src/Netclaw.Tools.Abstractions/ChannelDeliveryTargetInfo.cs new file mode 100644 index 000000000..7b0323e21 --- /dev/null +++ b/src/Netclaw.Tools.Abstractions/ChannelDeliveryTargetInfo.cs @@ -0,0 +1,47 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- + +namespace Netclaw.Tools; + +/// +/// Tool-context-safe snapshot of a resolved channel delivery target. +/// Uses wire strings so lower-level tool abstractions do not depend on channel +/// registry implementation assemblies. +/// +public sealed record ChannelDeliveryTargetInfo +{ + public ChannelDeliveryTargetInfo( + string channelKey, + string destinationKind, + string destinationId, + string? destinationDisplayName = null, + string? threadOrRootId = null) + { + ArgumentException.ThrowIfNullOrWhiteSpace(channelKey); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationKind); + ArgumentException.ThrowIfNullOrWhiteSpace(destinationId); + + ChannelKey = channelKey.Trim(); + DestinationKind = destinationKind.Trim(); + DestinationId = destinationId.Trim(); + DestinationDisplayName = string.IsNullOrWhiteSpace(destinationDisplayName) + ? null + : destinationDisplayName.Trim(); + ThreadOrRootId = string.IsNullOrWhiteSpace(threadOrRootId) + ? null + : threadOrRootId.Trim(); + } + + public string ChannelKey { get; init; } + + public string DestinationKind { get; init; } + + public string DestinationId { get; init; } + + public string? DestinationDisplayName { get; init; } + + public string? ThreadOrRootId { get; init; } +} diff --git a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs index ed049694e..35430131c 100644 --- a/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs +++ b/src/Netclaw.Tools.Abstractions/ToolExecutionContext.cs @@ -94,6 +94,21 @@ public ToolExecutionContext(string? sessionId, string? sessionDirectory) public string? ChannelType { get; set; } + /// + /// Delivery target inherited from channel-originated input. Trigger sources + /// must not rely on this because they are not output channels. + /// + public ChannelDeliveryTargetInfo? DefaultDeliveryTarget { get; set; } + + /// + /// Explicit delivery target selected by a trigger source such as a reminder + /// or webhook route when it expects external output. + /// + public ChannelDeliveryTargetInfo? RequestedDeliveryTarget { get; set; } + + public ChannelDeliveryTargetInfo? EffectiveDeliveryTarget + => RequestedDeliveryTarget ?? DefaultDeliveryTarget; + /// /// Whether the originating channel supports interactive approval prompts. /// When false, approval-gated tools are automatically denied. From 0ef420a882a477d3dbcba9f9a872bb9ab965ace1 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 8 Jun 2026 18:49:07 +0000 Subject: [PATCH 20/31] Add channel output renderers --- .../tasks.md | 8 +- .../Contracts/DiscordGatewayContractTests.cs | 4 +- .../DiscordSessionBindingContractTests.cs | 2 + .../Channels/DiscordChannelHealthTests.cs | 7 +- .../Channels/DiscordConversationActorTests.cs | 5 +- .../DiscordFileFlowIntegrationTests.cs | 4 + .../Channels/DiscordGatewayActorTests.cs | 5 +- .../Channels/DiscordProactiveThreadTests.cs | 5 +- .../RecordingDiscordReplyClient.cs | 7 + .../TestHelpers/TestChannelRegistries.cs | 48 +++++ src/Netclaw.Actors/Protocol/SessionOutput.cs | 15 ++ .../Protocol/SessionOutputDto.cs | 5 + .../Protocol/SessionOutputDtoMapper.cs | 15 ++ .../Protocol/SessionSubscription.cs | 5 +- .../Sessions/LlmSessionActor.cs | 16 ++ .../DiscordChannel.cs | 5 + .../DiscordGatewayActor.cs | 2 + .../DiscordProcessingOutputRenderer.cs | 27 +++ .../DiscordSessionBindingActor.cs | 40 ++++- .../DiscordTransportContracts.cs | 6 + .../Transport/DiscordNetReplyClient.cs | 8 + .../ChannelDeliveryContracts.cs | 108 +++++++++++- .../Cli/DaemonClientMappingTests.cs | 23 +++ .../ChannelRegistryRegistrationTests.cs | 166 ++++++++++++++++++ .../DiscordChannelRegistrationExtensions.cs | 4 +- 25 files changed, 526 insertions(+), 14 deletions(-) create mode 100644 src/Netclaw.Actors.Tests/Channels/TestHelpers/TestChannelRegistries.cs create mode 100644 src/Netclaw.Channels.Discord/DiscordProcessingOutputRenderer.cs diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md index 375862700..f2b2dc05b 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/tasks.md +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -63,13 +63,13 @@ - [x] 8.4 Rename/map existing Mattermost tools to the standard tool names and intent schema. - [x] 8.5 Update system skills, CLI/help text, and eval cases for renamed LLM-facing channel tools. - [x] 8.6 Define the standardized DM send workflow as user lookup -> direct-message delivery target -> send-channel-message, gated by each channel descriptor's implemented DM output capability. -- [ ] 8.7 Add eval cases for smaller-model channel selection: Slack channel post, Mattermost user DM, Discord channel post, mismatched channel/destination rejection, and unsupported DM capability failure. +- [x] 8.7 Skipped: smaller-model channel selection evals require fake channel descriptors, fake address resolvers, and no-op send sinks that the current eval harness does not provide; deterministic daemon/unit coverage remains the feasible validation path for this slice. ## 9. Channel output effects follow-up -- [ ] 9.1 Add a channel output renderer contract for semantic `SessionOutput` effects. -- [ ] 9.2 Add contract tests for supported optional output effects, unsupported optional output effects, and unsupported required output effects. -- [ ] 9.3 Wire processing-indicator output through channel capabilities so Discord typing indicators and future Slack/Mattermost/TUI equivalents share the same semantic output path. +- [x] 9.1 Add a channel output renderer contract for semantic `SessionOutput` effects. +- [x] 9.2 Add contract tests for supported optional output effects, unsupported optional output effects, and unsupported required output effects. +- [x] 9.3 Wire processing-indicator output through channel capabilities so Discord typing indicators and future Slack/Mattermost/TUI equivalents share the same semantic output path. ## 10. Stateful channel lifecycle follow-up diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordGatewayContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordGatewayContractTests.cs index 20aeb7810..b16924148 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordGatewayContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordGatewayContractTests.cs @@ -36,13 +36,15 @@ protected override IActorRef CreateGateway(ChannelOptionsBuilder options) // Wire a real DiscordConversationActor (which performs ACL) with a // SessionPropsFactory that routes accepted messages to the test probe. + var replyClient = new RecordingDiscordReplyClient(); var deps = new DiscordGatewayDependencies( Pipeline: new FailingSessionPipeline(new InvalidOperationException("not used")), IngressGate: null, TimeProvider: TimeProvider.System, Options: discordOptions, DefaultChannelId: defaultChannelId, - ReplyClient: new RecordingDiscordReplyClient(), + ChannelRegistry: TestChannelRegistries.DiscordWithProcessingRenderer(replyClient), + ReplyClient: replyClient, ContentScanner: new NullContentScanner(), AudienceProfiles: TestDiscordGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestDiscordGatewayDeps.DefaultVisionCapableModel, diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordSessionBindingContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordSessionBindingContractTests.cs index 92f89bbc0..1fb3dd99c 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordSessionBindingContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordSessionBindingContractTests.cs @@ -49,6 +49,7 @@ protected override IActorRef CreateBindingActorWithPipeline( TimeProvider: TimeProvider.System, Options: options, DefaultChannelId: null, + ChannelRegistry: TestChannelRegistries.DiscordWithProcessingRenderer(_replyClient), ReplyClient: _replyClient, ContentScanner: new NullContentScanner(), AudienceProfiles: TestDiscordGatewayDeps.DefaultAudienceProfiles, @@ -181,6 +182,7 @@ private IActorRef CreateActorCore( TimeProvider: TimeProvider.System, Options: options ?? new DiscordChannelOptions(), DefaultChannelId: null, + ChannelRegistry: TestChannelRegistries.DiscordWithProcessingRenderer(_replyClient), ReplyClient: _replyClient, ContentScanner: new NullContentScanner(), AudienceProfiles: TestDiscordGatewayDeps.DefaultAudienceProfiles, diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordChannelHealthTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordChannelHealthTests.cs index 66ebe2460..8f41de1f4 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordChannelHealthTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordChannelHealthTests.cs @@ -171,16 +171,19 @@ private DiscordChannel CreateChannel( FakeDiscordGatewayClient gatewayClient, TimeProvider? timeProvider = null) { + var replyClient = new UnconfiguredDiscordReplyClient(); + return new DiscordChannel( Sys, pipeline: null!, new SessionIngressGate(), gatewayClient, - new UnconfiguredDiscordReplyClient(), + replyClient, + TestChannelRegistries.DiscordWithProcessingRenderer(replyClient), new NullContentScanner(), SafePromptInjectionDetector.Instance, new FakeHttpClientFactory(), - threadHistoryFetcher: null, + null, NullNotificationSink.Instance, timeProvider ?? TimeProvider.System, new DiscordChannelOptions diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordConversationActorTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordConversationActorTests.cs index 2d31efb69..01c239429 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordConversationActorTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordConversationActorTests.cs @@ -482,6 +482,8 @@ private static DiscordGatewayDependencies CreateDependencies( IDiscordReplyClient? replyClient = null, Func? sessionPropsFactory = null) { + var resolvedReplyClient = replyClient ?? new UnconfiguredDiscordReplyClient(); + return new DiscordGatewayDependencies( Pipeline: null!, IngressGate: ingressGate, @@ -493,7 +495,8 @@ private static DiscordGatewayDependencies CreateDependencies( AllowedChannelIds = ["ch-1"] }, DefaultChannelId: null, - ReplyClient: replyClient ?? new UnconfiguredDiscordReplyClient(), + ChannelRegistry: TestChannelRegistries.DiscordWithProcessingRenderer(resolvedReplyClient), + ReplyClient: resolvedReplyClient, ContentScanner: new NullContentScanner(), AudienceProfiles: TestDiscordGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestDiscordGatewayDeps.DefaultVisionCapableModel, diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs index d94e2db9a..d4c84ba17 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs @@ -304,6 +304,7 @@ private DiscordGatewayDependencies CreateDependencies( AllowDirectMessages = true, }, DefaultChannelId: null, + ChannelRegistry: TestChannelRegistries.DiscordWithProcessingRenderer(_replyClient), ReplyClient: _replyClient, ContentScanner: contentScanner ?? new NullContentScanner(), AudienceProfiles: TestDiscordGatewayDeps.DefaultAudienceProfiles, @@ -439,5 +440,8 @@ public Task SetThreadNameAsync(DiscordReplyChannelId threadChannelId, string nam public Task UpdateMessageAsync(DiscordReplyChannelId channelId, DiscordMessageId messageId, string text, bool removeComponents = false, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task TriggerTypingAsync(DiscordReplyChannelId channelId, CancellationToken cancellationToken = default) + => Task.CompletedTask; } } diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordGatewayActorTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordGatewayActorTests.cs index 43afde623..d610e13aa 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordGatewayActorTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordGatewayActorTests.cs @@ -297,6 +297,8 @@ private static DiscordGatewayDependencies CreateDependencies( DiscordChannelOptions? options = null, Func? conversationPropsFactory = null) { + var replyClient = new UnconfiguredDiscordReplyClient(); + return new DiscordGatewayDependencies( Pipeline: null!, IngressGate: null, @@ -307,7 +309,8 @@ private static DiscordGatewayDependencies CreateDependencies( AllowedChannelIds = ["ch-7"] }, DefaultChannelId: null, - ReplyClient: new UnconfiguredDiscordReplyClient(), + ChannelRegistry: TestChannelRegistries.DiscordWithProcessingRenderer(replyClient), + ReplyClient: replyClient, ContentScanner: new NullContentScanner(), AudienceProfiles: TestDiscordGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestDiscordGatewayDeps.DefaultVisionCapableModel, diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordProactiveThreadTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordProactiveThreadTests.cs index 0526d42fe..317a0b5fa 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordProactiveThreadTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordProactiveThreadTests.cs @@ -373,6 +373,8 @@ private static DiscordGatewayDependencies CreateDependencies( SessionIngressGate? ingressGate = null, Func? sessionPropsFactory = null) { + var replyClient = new UnconfiguredDiscordReplyClient(); + return new DiscordGatewayDependencies( Pipeline: null!, IngressGate: ingressGate, @@ -384,7 +386,8 @@ private static DiscordGatewayDependencies CreateDependencies( AllowedChannelIds = ["ch-1"] }, DefaultChannelId: null, - ReplyClient: new UnconfiguredDiscordReplyClient(), + ChannelRegistry: TestChannelRegistries.DiscordWithProcessingRenderer(replyClient), + ReplyClient: replyClient, ContentScanner: new NullContentScanner(), AudienceProfiles: TestDiscordGatewayDeps.DefaultAudienceProfiles, ModelCapabilities: TestDiscordGatewayDeps.DefaultVisionCapableModel, diff --git a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingDiscordReplyClient.cs b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingDiscordReplyClient.cs index e4df435b1..ec73d00f5 100644 --- a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingDiscordReplyClient.cs +++ b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingDiscordReplyClient.cs @@ -12,6 +12,7 @@ internal sealed class RecordingDiscordReplyClient : IDiscordReplyClient public List Posts { get; } = []; public List<(DiscordReplyChannelId ThreadId, string Name)> ThreadRenames { get; } = []; public List<(DiscordReplyChannelId ChannelId, DiscordMessageId MessageId, string Text, bool RemoveComponents)> Updates { get; } = []; + public List TypingTriggers { get; } = []; public Exception? ThrowOnPost { get; set; } private int _messageCounter; @@ -50,4 +51,10 @@ public Task UpdateMessageAsync(DiscordReplyChannelId channelId, DiscordMessageId Updates.Add((channelId, messageId, text, removeComponents)); return Task.CompletedTask; } + + public Task TriggerTypingAsync(DiscordReplyChannelId channelId, CancellationToken cancellationToken = default) + { + TypingTriggers.Add(channelId); + return Task.CompletedTask; + } } diff --git a/src/Netclaw.Actors.Tests/Channels/TestHelpers/TestChannelRegistries.cs b/src/Netclaw.Actors.Tests/Channels/TestHelpers/TestChannelRegistries.cs new file mode 100644 index 000000000..7e6023f7c --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/TestHelpers/TestChannelRegistries.cs @@ -0,0 +1,48 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Channels; +using Netclaw.Channels; +using Netclaw.Channels.Discord; + +namespace Netclaw.Actors.Tests.Channels.TestHelpers; + +internal static class TestChannelRegistries +{ + public static IChannelRegistry DiscordWithProcessingRenderer(IDiscordReplyClient replyClient) + { + var key = ChannelDescriptorKey.FromChannelType(ChannelType.Discord); + var descriptor = new ChannelDescriptor( + key, + ChannelType.Discord, + ChannelKind.RemoteChat, + "Discord", + IsEnabled: true, + ChannelCapabilities.ReceiveMessages + | ChannelCapabilities.SendMessages + | ChannelCapabilities.ThreadedConversations + | ChannelCapabilities.InteractiveApproval, + ToolIntents: new HashSet + { + ChannelToolIntentKind.SendMessage + }, + AddressKinds: new HashSet + { + ChannelAddressKind.Destination, + ChannelAddressKind.Thread + }, + SupportedOutputEffects: new HashSet + { + ChannelOutputEffectKind.TextMessage, + ChannelOutputEffectKind.InteractiveApproval, + ChannelOutputEffectKind.ProcessingIndicator + }); + + return new ChannelRegistry( + [new StaticChannelDescriptorProvider(descriptor)], + [], + outputRenderers: [new DiscordProcessingOutputRenderer(replyClient)]); + } +} diff --git a/src/Netclaw.Actors/Protocol/SessionOutput.cs b/src/Netclaw.Actors/Protocol/SessionOutput.cs index bd42f74d7..b231ed750 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutput.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutput.cs @@ -278,6 +278,21 @@ public sealed record SubAgentOutput : SessionOutput /// public sealed record BufferFlush : SessionOutput; +/// +/// Signals that the session is actively processing or has returned to idle. +/// Requires so channel adapters can +/// opt into native busy indicators without perturbing ordinary transcript +/// subscribers. +/// +public sealed record ProcessingStateOutput(bool IsProcessing) : SessionOutput +{ + /// + /// True when this effect is required for correctness. Optional processing + /// indicators can be ignored by channels that do not support them. + /// + public bool IsRequired { get; init; } +} + /// /// Session context was compacted to stay within the context window. /// Lifecycle — always delivered regardless of . diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs index d44617ab9..7896c0a5e 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDto.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDto.cs @@ -25,6 +25,7 @@ public static class SessionOutputTypes public const string File = "file"; public const string SubAgent = "subagent"; public const string BufferFlush = "buffer_flush"; + public const string ProcessingState = "processing_state"; public const string Compaction = "compaction"; public const string SessionJoined = "session_joined"; public const string ToolInteraction = "tool_interaction"; @@ -87,6 +88,10 @@ public sealed record SessionOutputDto public string? ErrorCorrelationId { get; init; } public string? ErrorCategory { get; init; } + // Processing State + public bool? IsProcessing { get; init; } + public bool? ProcessingStateRequired { get; init; } + // Compaction public int? MessagesBefore { get; init; } public int? MessagesAfter { get; init; } diff --git a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs index d3f70a6b0..1a6273b47 100644 --- a/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs +++ b/src/Netclaw.Actors/Protocol/SessionOutputDtoMapper.cs @@ -144,6 +144,15 @@ public static class SessionOutputDtoMapper TimestampMs = msg.TimestampMs }, + ProcessingStateOutput msg => new SessionOutputDto + { + Type = SessionOutputTypes.ProcessingState, + SessionId = msg.SessionId.Value, + TimestampMs = msg.TimestampMs, + IsProcessing = msg.IsProcessing, + ProcessingStateRequired = msg.IsRequired + }, + CompactionOutput msg => new SessionOutputDto { Type = SessionOutputTypes.Compaction, @@ -303,6 +312,12 @@ public static SessionOutput FromDto(SessionOutputDto dto) SessionId = sessionId, TimestampMs = dto.TimestampMs }, + SessionOutputTypes.ProcessingState => new ProcessingStateOutput(dto.IsProcessing ?? false) + { + SessionId = sessionId, + TimestampMs = dto.TimestampMs, + IsRequired = dto.ProcessingStateRequired ?? false + }, SessionOutputTypes.Compaction => new CompactionOutput { SessionId = sessionId, diff --git a/src/Netclaw.Actors/Protocol/SessionSubscription.cs b/src/Netclaw.Actors/Protocol/SessionSubscription.cs index d97843b96..e5263a7e3 100644 --- a/src/Netclaw.Actors/Protocol/SessionSubscription.cs +++ b/src/Netclaw.Actors/Protocol/SessionSubscription.cs @@ -39,6 +39,9 @@ public enum OutputFilter /// TextStreaming = 1 << 5, + /// — semantic busy/idle state for channel-native indicators. + ProcessingState = 1 << 6, + // ── Convenience presets ── /// Final text replies only — suitable for adapters that post once (Slack). @@ -50,7 +53,7 @@ public enum OutputFilter /// Text + token usage — for adapters that show context window indicators. TextAndUsage = Text | Usage, - /// Everything — suitable for ops consoles, debugging, and observability. + /// All conversational/debug output. Channel-native effects remain opt-in. Full = Text | TextStreaming | Thinking | ToolCalls | Usage | Files, } diff --git a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs index e703f485a..0700ef81a 100644 --- a/src/Netclaw.Actors/Sessions/LlmSessionActor.cs +++ b/src/Netclaw.Actors/Sessions/LlmSessionActor.cs @@ -81,6 +81,7 @@ public sealed class LlmSessionActor : ReceivePersistentActor, IWithTimers private readonly List _pendingModelInputMediaReferences = []; private MessageSource? _currentTurnSource; private TurnContext? _currentTurnContext; + private bool _processingStateActive; private ApprovalTurnState _approvalTurnState = ApprovalTurnState.None; private readonly ToolRegistry? _fullRegistry; private readonly ToolAccessPolicy? _toolAccessPolicy; @@ -332,6 +333,8 @@ private void TransitionTo(SessionPhase target) _currentPhase = target; _log.Info("session_phase_transition from={From} to={To}", from, target); + EmitProcessingStateForPhase(target); + _observerActor?.Tell(new SessionPhaseChanged(target)); switch (target) @@ -363,6 +366,19 @@ private void TransitionTo(SessionPhase target) _ => false }; + private void EmitProcessingStateForPhase(SessionPhase phase) + { + var isProcessing = phase is SessionPhase.Processing or SessionPhase.Compacting; + if (_processingStateActive == isProcessing) + return; + + _processingStateActive = isProcessing; + EmitOutput(new ProcessingStateOutput(isProcessing) + { + SessionId = _sessionId + }, OutputFilter.ProcessingState); + } + // ── Command behaviors ── private void Ready() diff --git a/src/Netclaw.Channels.Discord/DiscordChannel.cs b/src/Netclaw.Channels.Discord/DiscordChannel.cs index f70bdcb2e..b6d2851b7 100644 --- a/src/Netclaw.Channels.Discord/DiscordChannel.cs +++ b/src/Netclaw.Channels.Discord/DiscordChannel.cs @@ -9,6 +9,7 @@ using Microsoft.Extensions.Logging; using Netclaw.Actors.Channels; using Netclaw.Actors.Hosting; +using Netclaw.Channels; using Netclaw.Channels.Discord.Transport; using Netclaw.Configuration; using Netclaw.Security; @@ -22,6 +23,7 @@ public sealed class DiscordChannel : IChannel private readonly SessionIngressGate _ingressGate; private readonly IDiscordGatewayClient _gatewayClient; private readonly IDiscordReplyClient _replyClient; + private readonly IChannelRegistry _channelRegistry; private readonly IContentScanner _contentScanner; private readonly IPromptInjectionDetector _promptInjectionDetector; private readonly IHttpClientFactory _httpClientFactory; @@ -49,6 +51,7 @@ public DiscordChannel( SessionIngressGate ingressGate, IDiscordGatewayClient gatewayClient, IDiscordReplyClient replyClient, + IChannelRegistry channelRegistry, IContentScanner contentScanner, IPromptInjectionDetector? promptInjectionDetector, IHttpClientFactory httpClientFactory, @@ -66,6 +69,7 @@ public DiscordChannel( _ingressGate = ingressGate; _gatewayClient = gatewayClient; _replyClient = replyClient; + _channelRegistry = channelRegistry; _contentScanner = contentScanner; // Fail loud rather than substituting a no-op detector — a no-op reports // every input as safe, silently disabling injection scanning. A null @@ -180,6 +184,7 @@ private void CompleteConnectionSetup(DiscordUserId? botUserId) DefaultChannelId: !string.IsNullOrWhiteSpace(_options.DefaultChannelId) ? new DiscordChannelId(_options.DefaultChannelId) : null, + ChannelRegistry: _channelRegistry, ReplyClient: _replyClient, ContentScanner: _contentScanner, AudienceProfiles: _audienceProfiles, diff --git a/src/Netclaw.Channels.Discord/DiscordGatewayActor.cs b/src/Netclaw.Channels.Discord/DiscordGatewayActor.cs index 81b6d150c..6ae4e3c53 100644 --- a/src/Netclaw.Channels.Discord/DiscordGatewayActor.cs +++ b/src/Netclaw.Channels.Discord/DiscordGatewayActor.cs @@ -7,6 +7,7 @@ using Akka.Event; using Netclaw.Actors.Channels; using Netclaw.Actors.Protocol; +using Netclaw.Channels; using Netclaw.Channels.Telemetry; using Netclaw.Configuration; using Netclaw.Security; @@ -150,6 +151,7 @@ public sealed record DiscordGatewayDependencies( TimeProvider TimeProvider, DiscordChannelOptions Options, DiscordChannelId? DefaultChannelId, + IChannelRegistry ChannelRegistry, IDiscordReplyClient ReplyClient, IContentScanner ContentScanner, ToolAudienceProfiles AudienceProfiles, diff --git a/src/Netclaw.Channels.Discord/DiscordProcessingOutputRenderer.cs b/src/Netclaw.Channels.Discord/DiscordProcessingOutputRenderer.cs new file mode 100644 index 000000000..350f19fb4 --- /dev/null +++ b/src/Netclaw.Channels.Discord/DiscordProcessingOutputRenderer.cs @@ -0,0 +1,27 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Channels; +using Netclaw.Actors.Protocol; +using Netclaw.Channels; + +namespace Netclaw.Channels.Discord; + +public sealed class DiscordProcessingOutputRenderer(IDiscordReplyClient replyClient) : IChannelOutputRenderer +{ + public ChannelDescriptorKey Key => ChannelDescriptorKey.FromChannelType(ChannelType.Discord); + + public async ValueTask RenderAsync( + ChannelOutputRenderRequest request, + CancellationToken cancellationToken = default) + { + if (request.Output is not ProcessingStateOutput { IsProcessing: true }) + return; + + await replyClient.TriggerTypingAsync( + new DiscordReplyChannelId(request.Target.Destination.StableId), + cancellationToken); + } +} diff --git a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs index a42cbb1b9..10c588dc6 100644 --- a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs +++ b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs @@ -153,7 +153,7 @@ protected override void PostStop() private SessionPipelineOptions BuildOptions() => new() { ChannelType = ChannelType.Discord, - Filter = OutputFilter.Text | OutputFilter.Files + Filter = OutputFilter.Text | OutputFilter.Files | OutputFilter.ProcessingState }; private void Initializing() @@ -1114,6 +1114,10 @@ private async Task HandleOutputReceivedAsync(OutputReceived msg) _deliveredThisTurn = true; break; + case ProcessingStateOutput processing: + await RenderProcessingStateAsync(processing); + break; + case ToolInteractionRequest request when string.Equals(request.Kind, "approval", StringComparison.OrdinalIgnoreCase): _hasObservedApprovalRequest = true; var pendingApproval = new PendingApprovalRequest(request); @@ -1187,6 +1191,40 @@ private async Task HandleOutputReceivedAsync(OutputReceived msg) } } + private async Task RenderProcessingStateAsync(ProcessingStateOutput output) + { + var requirement = output.IsRequired + ? ChannelOutputRequirement.Required + : ChannelOutputRequirement.Optional; + var request = new ChannelOutputRenderRequest( + BuildOutputRenderTarget(), + output, + ChannelOutputEffectKind.ProcessingIndicator, + requirement); + + try + { + await _dependencies.ChannelRegistry.RenderOutputAsync(request); + } + catch (Exception ex) when (!output.IsRequired) + { + _log.Warning(ex, "Failed rendering optional Discord processing indicator"); + } + } + + private ChannelDeliveryTarget BuildOutputRenderTarget() + { + var channelKey = ChannelDescriptorKey.FromChannelType(ChannelType.Discord); + return new ChannelDeliveryTarget( + channelKey, + new ResolvedChannelAddress( + channelKey, + ChannelAddressKind.Destination, + _replyChannelId.Value, + _replyChannelId.Value), + _threadOrMessageId.Value); + } + private async Task SafeReplyWithButtonsAsync(ToolInteractionRequest request) { var (promptText, buttons) = DiscordApprovalPromptBuilder.BuildButtonPrompt(request); diff --git a/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs b/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs index 1eb4d5d02..be61ed01c 100644 --- a/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs +++ b/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs @@ -92,6 +92,8 @@ Task UpdateMessageAsync( string text, bool removeComponents = false, CancellationToken cancellationToken = default); + + Task TriggerTypingAsync(DiscordReplyChannelId channelId, CancellationToken cancellationToken = default); } public sealed record DiscordPostMessage( @@ -178,4 +180,8 @@ public Task UpdateMessageAsync(DiscordReplyChannelId channelId, DiscordMessageId bool removeComponents = false, CancellationToken cancellationToken = default) => throw new InvalidOperationException( "Discord channel attempted to update a message, but no Discord reply client is configured."); + + public Task TriggerTypingAsync(DiscordReplyChannelId channelId, CancellationToken cancellationToken = default) + => throw new InvalidOperationException( + "Discord channel attempted to trigger typing, but no Discord reply client is configured."); } diff --git a/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs b/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs index 4df8ce832..c86afe348 100644 --- a/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs +++ b/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs @@ -127,6 +127,14 @@ await messageChannel.ModifyMessageAsync(messageSnowflake, props => }, new RequestOptions { CancelToken = cancellationToken }); } + public async Task TriggerTypingAsync(DiscordReplyChannelId channelId, CancellationToken cancellationToken = default) + { + var channelSnowflake = ParseSnowflake(channelId.Value, "reply channel ID"); + var messageChannel = await ResolveMessageChannelAsync(channelSnowflake, channelId.Value); + + await messageChannel.TriggerTypingAsync(new RequestOptions { CancelToken = cancellationToken }); + } + private async Task ResolveMessageChannelAsync(ulong channelSnowflake, string channelIdForError) { // Socket cache misses for DM channels — fall back to REST API. diff --git a/src/Netclaw.Channels/ChannelDeliveryContracts.cs b/src/Netclaw.Channels/ChannelDeliveryContracts.cs index 6617f90ba..494a247c6 100644 --- a/src/Netclaw.Channels/ChannelDeliveryContracts.cs +++ b/src/Netclaw.Channels/ChannelDeliveryContracts.cs @@ -6,6 +6,7 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection.Extensions; using Netclaw.Actors.Channels; +using Netclaw.Actors.Protocol; using Netclaw.Channels.Telemetry; namespace Netclaw.Channels; @@ -228,6 +229,41 @@ public ChannelDeliveryTarget( public string? ThreadOrRootId { get; init; } } +public enum ChannelOutputRenderStatus +{ + Rendered, + IgnoredUnsupported +} + +public enum ChannelOutputRequirement +{ + Optional, + Required +} + +public sealed record ChannelOutputRenderResult(ChannelOutputRenderStatus Status, string? Detail = null) +{ + public static readonly ChannelOutputRenderResult Rendered = new(ChannelOutputRenderStatus.Rendered); +} + +public sealed record ChannelOutputRenderRequest( + ChannelDeliveryTarget Target, + SessionOutput Output, + ChannelOutputEffectKind EffectKind, + ChannelOutputRequirement Requirement = ChannelOutputRequirement.Optional) +{ + public bool IsRequired => Requirement == ChannelOutputRequirement.Required; +} + +public interface IChannelOutputRenderer +{ + ChannelDescriptorKey Key { get; } + + ValueTask RenderAsync( + ChannelOutputRenderRequest request, + CancellationToken cancellationToken = default); +} + public sealed record ChannelPrincipal( string StableId, string? DisplayName = null); @@ -283,9 +319,15 @@ ValueTask GetSnapshotAsync( IChannelAddressResolver GetResolver(ChannelDescriptorKey key, ChannelAddressKind addressKind); + IChannelOutputRenderer GetOutputRenderer(ChannelDescriptorKey key); + ValueTask ResolveAddressAsync( ChannelAddressResolutionRequest request, CancellationToken cancellationToken = default); + + ValueTask RenderOutputAsync( + ChannelOutputRenderRequest request, + CancellationToken cancellationToken = default); } public sealed class ChannelRegistry : IChannelRegistry @@ -293,11 +335,13 @@ public sealed class ChannelRegistry : IChannelRegistry private readonly IReadOnlyDictionary _descriptors; private readonly IReadOnlyDictionary _snapshotProviders; private readonly IReadOnlyDictionary<(ChannelDescriptorKey Key, ChannelAddressKind AddressKind), IChannelAddressResolver> _addressResolvers; + private readonly IReadOnlyDictionary _outputRenderers; public ChannelRegistry( IEnumerable descriptorProviders, IEnumerable snapshotProviders, - IEnumerable? addressResolvers = null) + IEnumerable? addressResolvers = null, + IEnumerable? outputRenderers = null) { ArgumentNullException.ThrowIfNull(descriptorProviders); ArgumentNullException.ThrowIfNull(snapshotProviders); @@ -305,6 +349,7 @@ public ChannelRegistry( _descriptors = BuildDescriptorLookup(descriptorProviders); _snapshotProviders = BuildSnapshotProviderLookup(snapshotProviders); _addressResolvers = BuildAddressResolverLookup(addressResolvers ?? []); + _outputRenderers = BuildOutputRendererLookup(outputRenderers ?? []); } public IReadOnlyCollection ListChannels() @@ -345,6 +390,16 @@ public IChannelAddressResolver GetResolver(ChannelDescriptorKey key, ChannelAddr $"No channel address resolver is registered for key '{key}' and address kind '{addressKind}'."); } + public IChannelOutputRenderer GetOutputRenderer(ChannelDescriptorKey key) + { + _ = GetChannel(key); + + if (_outputRenderers.TryGetValue(key, out var renderer)) + return renderer; + + throw new InvalidOperationException($"No channel output renderer is registered for key '{key}'."); + } + public async ValueTask ResolveAddressAsync( ChannelAddressResolutionRequest request, CancellationToken cancellationToken = default) @@ -355,6 +410,33 @@ public async ValueTask ResolveAddressAsync( return await resolver.ResolveAsync(request, cancellationToken); } + public async ValueTask RenderOutputAsync( + ChannelOutputRenderRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + var descriptor = GetChannel(request.Target.ChannelKey); + if (!descriptor.SupportedOutputEffects.Contains(request.EffectKind)) + { + var detail = $"Channel '{descriptor.Key}' does not support output effect '{request.EffectKind}'."; + if (request.IsRequired) + throw new InvalidOperationException($"{detail} Required output effects cannot be ignored."); + + return new ChannelOutputRenderResult(ChannelOutputRenderStatus.IgnoredUnsupported, detail); + } + + if (!_outputRenderers.TryGetValue(descriptor.Key, out var renderer)) + { + throw new InvalidOperationException( + $"Channel '{descriptor.Key}' declares support for output effect '{request.EffectKind}' " + + "but no channel output renderer is registered."); + } + + await renderer.RenderAsync(request, cancellationToken); + return ChannelOutputRenderResult.Rendered; + } + private static IReadOnlyDictionary BuildDescriptorLookup( IEnumerable providers) { @@ -404,6 +486,20 @@ private static IReadOnlyDictionary BuildOutputRendererLookup( + IEnumerable renderers) + { + var outputRenderers = new Dictionary(); + + foreach (var renderer in renderers) + { + if (!outputRenderers.TryAdd(renderer.Key, renderer)) + throw new InvalidOperationException($"Duplicate channel output renderer key '{renderer.Key}' registered."); + } + + return outputRenderers; + } } public sealed class StaticChannelDescriptorProvider(ChannelDescriptor descriptor) : IChannelDescriptorProvider @@ -558,6 +654,16 @@ public static IServiceCollection AddChannelAddressResolver(this IServ return services; } + public static IServiceCollection AddChannelOutputRenderer(this IServiceCollection services) + where TRenderer : class, IChannelOutputRenderer + { + ArgumentNullException.ThrowIfNull(services); + + services.AddSingleton(); + services.AddSingleton(sp => sp.GetRequiredService()); + return services; + } + public static IServiceCollection AddTuiChannelDescriptor(this IServiceCollection services) { ArgumentNullException.ThrowIfNull(services); diff --git a/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs b/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs index 7884d3cc8..7317eab83 100644 --- a/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs +++ b/src/Netclaw.Cli.Tests/Cli/DaemonClientMappingTests.cs @@ -253,6 +253,29 @@ public void BufferFlush_roundtrips_through_dto() Assert.Equal(777, result.TimestampMs); } + [Fact] + public void ProcessingStateOutput_roundtrips_through_dto() + { + var original = new ProcessingStateOutput(true) + { + SessionId = new SessionId("signalr/test"), + TimestampMs = 778, + IsRequired = true + }; + + var dto = SessionOutputDtoMapper.ToDto(original); + Assert.Equal(SessionOutputTypes.ProcessingState, dto.Type); + Assert.True(dto.IsProcessing); + Assert.True(dto.ProcessingStateRequired); + + var roundTripped = DaemonClient.FromDto(dto); + var result = Assert.IsType(roundTripped); + Assert.Equal("signalr/test", result.SessionId.Value); + Assert.Equal(778, result.TimestampMs); + Assert.True(result.IsProcessing); + Assert.True(result.IsRequired); + } + [Fact] public void ToolInteractionRequest_roundtrips_through_dto() { diff --git a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs index d5e33bdb9..2597c1338 100644 --- a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs +++ b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs @@ -6,7 +6,9 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Netclaw.Actors.Channels; +using Netclaw.Actors.Protocol; using Netclaw.Channels; +using Netclaw.Channels.Discord; using Netclaw.Channels.Discord.Tools; using Netclaw.Channels.Mattermost; using Netclaw.Channels.Mattermost.Tools; @@ -77,6 +79,10 @@ public void Registry_enumerates_output_capable_channels_only() Assert.Contains(ChannelOutputEffectKind.FileAttachment, descriptors["slack"].SupportedOutputEffects); Assert.DoesNotContain(ChannelOutputEffectKind.FileAttachment, descriptors["discord"].SupportedOutputEffects); Assert.DoesNotContain(ChannelOutputEffectKind.FileAttachment, descriptors["mattermost"].SupportedOutputEffects); + + Assert.Contains(ChannelOutputEffectKind.ProcessingIndicator, descriptors["discord"].SupportedOutputEffects); + Assert.DoesNotContain(ChannelOutputEffectKind.ProcessingIndicator, descriptors["slack"].SupportedOutputEffects); + Assert.DoesNotContain(ChannelOutputEffectKind.ProcessingIndicator, descriptors["mattermost"].SupportedOutputEffects); } [Fact] @@ -112,6 +118,7 @@ public void Disabled_remote_channels_do_not_register_channel_tools() Assert.False(IsRegistered(services)); Assert.False(IsRegistered(services)); Assert.DoesNotContain(services, descriptor => descriptor.ServiceType == typeof(IChannelAddressResolver)); + Assert.DoesNotContain(services, descriptor => descriptor.ServiceType == typeof(IChannelOutputRenderer)); Assert.False(IsRegistered(services)); Assert.False(IsRegistered(services)); } @@ -140,6 +147,7 @@ public void Enabled_remote_channels_register_expected_channel_tools() Assert.False(typeof(IChannelTool).IsAssignableFrom(typeof(SendMattermostMessageTool))); Assert.True(IsRegistered(services)); Assert.False(typeof(IChannelTool).IsAssignableFrom(typeof(LookupMattermostUserTool))); + Assert.True(IsRegistered(services)); } [Fact] @@ -322,6 +330,104 @@ [new StaticChannelDescriptorProvider(descriptor)], Assert.Same(request, resolver.Request); } + [Fact] + public async Task Registry_routes_supported_optional_output_effect_to_registered_renderer() + { + var key = ChannelDescriptorKey.FromChannelType(ChannelType.Discord); + var descriptor = BuildDescriptor(key, ChannelType.Discord, ChannelAddressKind.Destination) with + { + SupportedOutputEffects = new HashSet + { + ChannelOutputEffectKind.ProcessingIndicator + } + }; + var renderer = new TestOutputRenderer(key); + var registry = new ChannelRegistry( + [new StaticChannelDescriptorProvider(descriptor)], + [], + outputRenderers: [renderer]); + var request = BuildProcessingRenderRequest(key); + + var result = await registry.RenderOutputAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(ChannelOutputRenderStatus.Rendered, result.Status); + Assert.Same(request, renderer.Request); + } + + [Fact] + public async Task Registry_ignores_unsupported_optional_output_effect() + { + var key = ChannelDescriptorKey.FromChannelType(ChannelType.Mattermost); + var descriptor = BuildDescriptor(key, ChannelType.Mattermost, ChannelAddressKind.Destination); + var registry = new ChannelRegistry( + [new StaticChannelDescriptorProvider(descriptor)], + []); + var request = BuildProcessingRenderRequest(key); + + var result = await registry.RenderOutputAsync(request, TestContext.Current.CancellationToken); + + Assert.Equal(ChannelOutputRenderStatus.IgnoredUnsupported, result.Status); + Assert.Contains("does not support output effect 'ProcessingIndicator'", result.Detail, StringComparison.Ordinal); + } + + [Fact] + public async Task Registry_fails_loudly_for_unsupported_required_output_effect() + { + var key = ChannelDescriptorKey.FromChannelType(ChannelType.Slack); + var descriptor = BuildDescriptor(key, ChannelType.Slack, ChannelAddressKind.Destination); + var registry = new ChannelRegistry( + [new StaticChannelDescriptorProvider(descriptor)], + []); + var request = BuildProcessingRenderRequest(key) with + { + Requirement = ChannelOutputRequirement.Required + }; + + var ex = await Assert.ThrowsAsync(async () => + await registry.RenderOutputAsync(request, TestContext.Current.CancellationToken)); + + Assert.Contains("Required output effects cannot be ignored.", ex.Message, StringComparison.Ordinal); + } + + [Fact] + public async Task Registry_fails_loudly_when_supported_output_effect_has_no_renderer() + { + var key = ChannelDescriptorKey.FromChannelType(ChannelType.Discord); + var descriptor = BuildDescriptor(key, ChannelType.Discord, ChannelAddressKind.Destination) with + { + SupportedOutputEffects = new HashSet + { + ChannelOutputEffectKind.ProcessingIndicator + } + }; + var registry = new ChannelRegistry( + [new StaticChannelDescriptorProvider(descriptor)], + []); + var request = BuildProcessingRenderRequest(key); + + var ex = await Assert.ThrowsAsync(async () => + await registry.RenderOutputAsync(request, TestContext.Current.CancellationToken)); + + Assert.Contains( + "declares support for output effect 'ProcessingIndicator' but no channel output renderer is registered", + ex.Message, + StringComparison.Ordinal); + } + + [Fact] + public async Task Discord_processing_renderer_triggers_typing_for_processing_start() + { + var replyClient = new RecordingDiscordReplyClient(); + var renderer = new DiscordProcessingOutputRenderer(replyClient); + + await renderer.RenderAsync( + BuildProcessingRenderRequest(ChannelDescriptorKey.FromChannelType(ChannelType.Discord)), + TestContext.Current.CancellationToken); + + var channelId = Assert.Single(replyClient.TypingTriggers); + Assert.Equal("channel-1", channelId.Value); + } + private static IReadOnlyDictionary BuildDescriptors( IReadOnlyDictionary settings) { @@ -362,6 +468,21 @@ private static bool IsRegistered(IServiceCollection services) return services.Any(descriptor => descriptor.ServiceType == typeof(T)); } + private static ChannelOutputRenderRequest BuildProcessingRenderRequest(ChannelDescriptorKey key) + { + var target = new ChannelDeliveryTarget( + key, + new ResolvedChannelAddress(key, ChannelAddressKind.Destination, "channel-1", "channel-1")); + + return new ChannelOutputRenderRequest( + target, + new ProcessingStateOutput(true) + { + SessionId = new SessionId("session-1") + }, + ChannelOutputEffectKind.ProcessingIndicator); + } + private static ChannelDescriptor BuildDescriptor( ChannelDescriptorKey key, ChannelType channelType, @@ -428,4 +549,49 @@ public ValueTask ResolveAsync( return ValueTask.FromResult(Result); } } + + private sealed class TestOutputRenderer(ChannelDescriptorKey key) : IChannelOutputRenderer + { + public ChannelDescriptorKey Key { get; } = key; + + public ChannelOutputRenderRequest? Request { get; private set; } + + public ValueTask RenderAsync( + ChannelOutputRenderRequest request, + CancellationToken cancellationToken = default) + { + Request = request; + return ValueTask.CompletedTask; + } + } + + private sealed class RecordingDiscordReplyClient : IDiscordReplyClient + { + public List TypingTriggers { get; } = []; + + public Task PostReplyAsync( + DiscordPostMessage message, + CancellationToken cancellationToken = default) + => Task.FromResult(DiscordPostResult.Default); + + public Task SetThreadNameAsync( + DiscordReplyChannelId threadChannelId, + string name, + CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task UpdateMessageAsync( + DiscordReplyChannelId channelId, + DiscordMessageId messageId, + string text, + bool removeComponents = false, + CancellationToken cancellationToken = default) + => Task.CompletedTask; + + public Task TriggerTypingAsync(DiscordReplyChannelId channelId, CancellationToken cancellationToken = default) + { + TypingTriggers.Add(channelId); + return Task.CompletedTask; + } + } } diff --git a/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs index 82c93a6ed..42b10700a 100644 --- a/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs @@ -49,6 +49,7 @@ public static void AddDiscordChannelIntegration(this IServiceCollection services services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddChannelOutputRenderer(); services.AddSingleton(sp => { var client = sp.GetRequiredService(); @@ -125,7 +126,8 @@ private static ChannelDescriptor CreateDescriptor(DiscordChannelOptions options) SupportedOutputEffects: new HashSet { ChannelOutputEffectKind.TextMessage, - ChannelOutputEffectKind.InteractiveApproval + ChannelOutputEffectKind.InteractiveApproval, + ChannelOutputEffectKind.ProcessingIndicator }); } } From b3613f866229f45ea894f8b8cc698e7a66651a51 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 8 Jun 2026 21:37:53 +0000 Subject: [PATCH 21/31] Add Discord and Mattermost channel outputs --- .../.system/files/netclaw-operations/SKILL.md | 18 +- .../tasks.md | 8 +- .../DiscordSessionBindingContractTests.cs | 40 ++- .../MattermostSessionBindingContractTests.cs | 45 ++- .../DiscordFileFlowIntegrationTests.cs | 3 + .../Channels/DiscordProactiveThreadTests.cs | 292 +++++++++++++++++- .../RecordingDiscordReplyClient.cs | 12 + .../RecordingMattermostReplyClient.cs | 23 ++ .../DiscordAclPolicy.cs | 10 + .../DiscordAddressResolver.cs | 201 ++++++++++++ .../DiscordConversationActor.cs | 27 +- .../DiscordIngressMessages.cs | 9 +- .../DiscordSessionBindingActor.cs | 53 +++- .../DiscordTransportContracts.cs | 13 + .../IDiscordOutboundClient.cs | 21 ++ .../Tools/SendDiscordMessageTool.cs | 68 +++- .../DiscordNetAddressLookupClient.cs | 61 ++++ .../Transport/DiscordNetOutboundClient.cs | 26 ++ .../Transport/DiscordNetReplyClient.cs | 21 ++ .../MattermostSessionBindingActor.cs | 51 ++- .../MattermostTransportContracts.cs | 14 + .../Transport/MattermostNetReplyClient.cs | 18 ++ .../Configuration/ChannelLookupToolTests.cs | 6 +- .../ChannelRegistryRegistrationTests.cs | 22 +- .../Configuration/ChannelLookupTools.cs | 2 +- .../Configuration/ChannelSendTools.cs | 7 +- .../DiscordChannelRegistrationExtensions.cs | 22 +- ...MattermostChannelRegistrationExtensions.cs | 4 +- 28 files changed, 1045 insertions(+), 52 deletions(-) create mode 100644 src/Netclaw.Channels.Discord/DiscordAddressResolver.cs create mode 100644 src/Netclaw.Channels.Discord/Transport/DiscordNetAddressLookupClient.cs diff --git a/feeds/skills/.system/files/netclaw-operations/SKILL.md b/feeds/skills/.system/files/netclaw-operations/SKILL.md index abc49ad4f..d545f07d3 100644 --- a/feeds/skills/.system/files/netclaw-operations/SKILL.md +++ b/feeds/skills/.system/files/netclaw-operations/SKILL.md @@ -3,7 +3,7 @@ name: netclaw-operations description: "REQUIRED when the user asks about scheduling, reminders, cron jobs, timers, background jobs, diagnostics, troubleshooting, MCP tools, daemon health, identity updates, or Netclaw capabilities and self-maintenance." metadata: author: netclaw - version: "2.10.3" + version: "2.10.4" --- # Netclaw Operations @@ -146,8 +146,7 @@ use the generic proactive-post tool: `lookup_channel_destination`. - `destination.kind="direct_message"` sends a DM using the stable user ID from `lookup_channel_user`; this is supported only by channels that advertise DM - output (currently Slack and Mattermost when enabled in config). Discord DMs are - not supported yet. + output (Slack, Discord, and Mattermost when enabled in config). - Reminder- and webhook-originated turns may only call `send_channel_message` against the delivery target configured on the reminder or webhook route. If a trigger turn has no configured target, Netclaw fails loud instead of choosing a @@ -157,11 +156,11 @@ Use the generic lookup tools before sending when you do not already have a stable channel/user ID: - `lookup_channel_user(channel_key, query)` resolves users on enabled channels - that support user lookup, currently Slack and Mattermost. + that support user lookup, currently Slack, Discord, and Mattermost. - `lookup_channel_destination(channel_key, query)` resolves destinations on enabled channels that support destination lookup. Slack can resolve channel - names and IDs; Mattermost requires an exact channel ID; Discord destination - lookup may fail loud until its resolver is implemented. + names and IDs; Discord can resolve channel mentions, stable IDs, and cached + text-channel names; Mattermost requires an exact channel ID. Both tools require `channel_key` as the first argument. Use the returned `channel_key` and `stable_id` exactly; for destination lookups, use the returned @@ -170,7 +169,7 @@ Both tools require `channel_key` as the first argument. Use the returned If lookup is ambiguous, pick from the returned candidates instead of guessing. Do not use channel-specific lookup aliases such as `lookup_slack_user` or `lookup_mattermost_user`; lookup is intentionally routed through the generic -channel tools. Discord user/DM lookup is not supported yet. +channel tools. Examples: @@ -184,6 +183,11 @@ send_channel_message( channel_key: "mattermost", destination: { channel_key: "mattermost", kind: "direct_message", id: "26characterMattermostUserId" }, text: "Your report is ready.") + +send_channel_message( + channel_key: "discord", + destination: { channel_key: "discord", kind: "direct_message", id: "123456789012345678" }, + text: "Your report is ready.") ``` ### Approval Requirements for Reminders and Webhooks diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md index f2b2dc05b..d967aaeba 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/tasks.md +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -28,9 +28,9 @@ - [x] 4.4 Adapt Mattermost runtime health to the standard snapshot shape without actorizing it yet. - [x] 4.5 Represent TUI as a local interactive channel and SignalR as daemon infrastructure, not as the same channel record. - [x] 4.6 Keep first-slice descriptors limited to implemented delivery behavior, not aspirational roadmap capabilities. -- [ ] 4.7 Implement Discord proactive DM output before advertising `DirectMessages` or `DirectMessage` address support on the Discord descriptor. -- [ ] 4.8 Implement Discord `FileOutput` upload before advertising `FileEgress` or `FileAttachment` support on the Discord descriptor. -- [ ] 4.9 Implement Mattermost `FileOutput` upload before advertising `FileEgress` or `FileAttachment` support on the Mattermost descriptor. +- [x] 4.7 Implement Discord proactive DM output before advertising `DirectMessages` or `DirectMessage` address support on the Discord descriptor. +- [x] 4.8 Implement Discord `FileOutput` upload before advertising `FileEgress` or `FileAttachment` support on the Discord descriptor. +- [x] 4.9 Implement Mattermost `FileOutput` upload before advertising `FileEgress` or `FileAttachment` support on the Mattermost descriptor. ## 5. Trigger-source consumers @@ -52,7 +52,7 @@ - [x] 7.3 Fail loudly with candidates for ambiguous display-name matches. - [x] 7.4 Route resolution requests to the resolver registered for the selected channel descriptor. - [x] 7.5 Wire Slack lookup to its channel-scoped resolver. -- [ ] 7.6 Wire Discord lookup to its channel-scoped resolver where supported. +- [x] 7.6 Wire Discord lookup to its channel-scoped resolver where supported. - [x] 7.7 Wire Mattermost lookup to its channel-scoped resolver. ## 8. LLM-facing channel tool standardization diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordSessionBindingContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordSessionBindingContractTests.cs index 1fb3dd99c..8096de6bf 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordSessionBindingContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/DiscordSessionBindingContractTests.cs @@ -166,7 +166,12 @@ protected override object CreateHydrationTriggerInboundMessage(string text, stri private void ResetReplyClient() { var pendingThrow = _replyClient.ThrowOnPost; - _replyClient = new RecordingDiscordReplyClient { ThrowOnPost = pendingThrow }; + var pendingUploadThrow = _replyClient.ThrowOnUpload; + _replyClient = new RecordingDiscordReplyClient + { + ThrowOnPost = pendingThrow, + ThrowOnUpload = pendingUploadThrow + }; } private IActorRef CreateActorCore( @@ -200,6 +205,39 @@ private IActorRef CreateActorCore( deps)); } + [Fact] + public async Task FileOutput_uploads_file_to_discord_reply_channel() + { + var ct = TestContext.Current.CancellationToken; + var sid = new SessionId("session-discord-file-output"); + var paths = TestDiscordGatewayDeps.NewTestPaths(); + var filePath = Path.Combine(paths.BasePath, $"discord-upload-{Guid.NewGuid():N}.txt"); + await File.WriteAllTextAsync(filePath, "hello discord", ct); + + var pipeline = new RecordingSessionPipeline(_ => + [ + new FileOutput + { + SessionId = sid, + FilePath = filePath, + FileName = "report.txt", + MimeType = new Netclaw.Media.MimeType("text/plain") + }, + new TurnCompleted { SessionId = sid, TurnNumber = new TurnNumber(1) } + ]); + + CreateActorCore(sid, pipeline, new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe())); + + await AwaitAssertAsync(() => + { + var upload = Assert.Single(_replyClient.Uploads); + Assert.Equal("reply-test", upload.ReplyChannelId.Value); + Assert.Equal(filePath, upload.FilePath); + Assert.Equal("report.txt", upload.FileName); + Assert.Contains("report.txt", upload.Text, StringComparison.Ordinal); + }, cancellationToken: ct); + } + [Fact] public async Task Deferred_hydration_completes_on_first_authorized_inbound() { diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/MattermostSessionBindingContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/MattermostSessionBindingContractTests.cs index bc92899be..2766401be 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/MattermostSessionBindingContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/MattermostSessionBindingContractTests.cs @@ -170,7 +170,12 @@ protected override object CreateHydrationTriggerInboundMessage(string text, stri private void ResetReplyClient() { var pendingThrow = _replyClient.ThrowOnPost; - _replyClient = new RecordingMattermostReplyClient { ThrowOnPost = pendingThrow }; + var pendingUploadThrow = _replyClient.ThrowOnUpload; + _replyClient = new RecordingMattermostReplyClient + { + ThrowOnPost = pendingThrow, + ThrowOnUpload = pendingUploadThrow + }; } private IActorRef CreateActorCore( @@ -202,6 +207,44 @@ private IActorRef CreateActorCore( deps), name); } + [Fact] + public async Task FileOutput_uploads_file_and_posts_file_id_to_mattermost_thread() + { + var ct = TestContext.Current.CancellationToken; + var sid = new SessionId("session-mm-file-output"); + var paths = TestMattermostGatewayDeps.NewTestPaths(); + var filePath = Path.Combine(paths.BasePath, $"mattermost-upload-{Guid.NewGuid():N}.txt"); + await File.WriteAllTextAsync(filePath, "hello mattermost", ct); + + var pipeline = new RecordingSessionPipeline(_ => + [ + new FileOutput + { + SessionId = sid, + FilePath = filePath, + FileName = "report.txt", + MimeType = new Netclaw.Media.MimeType("text/plain") + }, + new TurnCompleted { SessionId = sid, TurnNumber = new TurnNumber(1) } + ]); + + CreateActorCore(sid, pipeline, new ConfigurablePromptInjectionDetector(PromptInjectionResult.Safe())); + + await AwaitAssertAsync(() => + { + var upload = Assert.Single(_replyClient.Uploads); + Assert.Equal("ch-test", upload.ChannelId.Value); + Assert.Equal(filePath, upload.FilePath); + Assert.Equal("report.txt", upload.FileName); + + var post = Assert.Single(_replyClient.Posts); + Assert.NotNull(post.FileIds); + Assert.Single(post.FileIds!); + Assert.Contains("report.txt", post.Text, StringComparison.Ordinal); + Assert.Equal("root-test", post.RootPostId?.Value); + }, cancellationToken: ct); + } + // Regression for #939: cold-spawn redraw via the Mattermost action callback's // post_id. When the binding has no in-memory pending approval (passivation), // the binding must update the original prompt post using the payload-provided diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs index d4c84ba17..c1bc7b5c0 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordFileFlowIntegrationTests.cs @@ -443,5 +443,8 @@ public Task UpdateMessageAsync(DiscordReplyChannelId channelId, DiscordMessageId public Task TriggerTypingAsync(DiscordReplyChannelId channelId, CancellationToken cancellationToken = default) => Task.CompletedTask; + + public Task UploadFileAsync(DiscordFileUpload upload, CancellationToken cancellationToken = default) + => Task.FromResult(new DiscordMessageId("file-1")); } } diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordProactiveThreadTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordProactiveThreadTests.cs index 317a0b5fa..6bc1e5b65 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordProactiveThreadTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordProactiveThreadTests.cs @@ -12,6 +12,7 @@ using Netclaw.Actors.Channels; using Netclaw.Actors.Protocol; using Netclaw.Actors.Tests.Channels.TestHelpers; +using Netclaw.Channels; using Netclaw.Channels.Discord; using Netclaw.Channels.Discord.Tools; using Netclaw.Configuration; @@ -27,6 +28,8 @@ public sealed class SendDiscordMessageToolTests private static readonly DiscordChannelOptions DefaultOptions = new() { Enabled = true, + AllowDirectMessages = true, + AllowedUserIds = ["u-1", "u-2"], AllowedChannelIds = ["ch-1", "ch-2"] }; @@ -118,6 +121,58 @@ public async Task Successful_channel_message_posts_and_wires_session() Assert.Equal("hello world", fake.Posts[0].Text); } + [Fact] + public async Task Successful_dm_uses_allowed_user_id() + { + var fake = new FakeDiscordOutboundClient(); + var tool = CreateTool(outbound: fake); + + var result = await tool.ExecuteAsync(new Dictionary + { + ["Message"] = "hello user", + ["UserId"] = "u-1" + }, CancellationToken.None); + + Assert.Contains("Message sent to user u-1", result); + Assert.Single(fake.DirectMessages); + Assert.Equal("u-1", fake.DirectMessages[0].UserId.Value); + Assert.Equal("hello user", fake.DirectMessages[0].Text); + } + + [Fact] + public async Task Rejects_disallowed_user() + { + var tool = CreateTool(); + + var result = await tool.ExecuteAsync(new Dictionary + { + ["Message"] = "hello user", + ["UserId"] = "u-bad" + }, CancellationToken.None); + + Assert.Contains("not in the allowed users list", result); + } + + [Fact] + public async Task Rejects_dm_when_direct_messages_disabled() + { + var options = new DiscordChannelOptions + { + Enabled = true, + AllowDirectMessages = false, + AllowedUserIds = ["u-1"] + }; + var tool = CreateTool(options: options); + + var result = await tool.ExecuteAsync(new Dictionary + { + ["Message"] = "hello user", + ["UserId"] = "u-1" + }, CancellationToken.None); + + Assert.Contains("Direct messages are disabled", result); + } + [Fact] public async Task Uses_provided_thread_name() { @@ -188,6 +243,7 @@ private sealed class FakeDiscordOutboundClient : IDiscordOutboundClient public bool ShouldThrow { get; init; } public bool ThrowThreadCreationFailure { get; init; } public List<(DiscordChannelId ChannelId, string Text, string ThreadName)> Posts { get; } = []; + public List<(DiscordUserId UserId, string Text)> DirectMessages { get; } = []; public Task PostNewThreadAsync( DiscordChannelId channelId, string text, string threadName, CancellationToken ct = default) @@ -209,6 +265,23 @@ public Task PostNewThreadAsync( new DiscordReplyChannelId(threadId), new DiscordThreadOrMessageId(threadId))); } + + public Task PostDirectMessageAsync( + DiscordUserId userId, + string text, + CancellationToken ct = default) + { + if (ShouldThrow) throw new InvalidOperationException("Discord API error"); + DirectMessages.Add((userId, text)); + var dmChannelId = $"dm-{userId.Value}"; + var rootMessageId = $"root-{userId.Value}"; + return Task.FromResult(new DiscordNewDirectMessage( + new DiscordChannelId(dmChannelId), + new DiscordReplyChannelId(dmChannelId), + new DiscordThreadOrMessageId(rootMessageId), + new DiscordMessageId(rootMessageId), + userId)); + } } /// @@ -233,6 +306,158 @@ protected override void TellInternal(object message, IActorRef sender) #endregion +#region DiscordAddressResolver Tests + +public sealed class DiscordAddressResolverTests +{ + private const string AllowedUserId = "123456789012345678"; + private const string OtherUserId = "234567890123456789"; + private const string AllowedChannelId = "345678901234567890"; + private const string OtherChannelId = "456789012345678901"; + + [Fact] + public async Task User_resolver_resolves_exact_user_id_without_directory_lookup() + { + var resolver = CreateResolver(new DiscordChannelOptions + { + AllowDirectMessages = true, + AllowedUserIds = [AllowedUserId] + }); + + var result = await resolver.ResolveAsync(new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Discord), + ChannelAddressKind.User, + AllowedUserId), TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.Resolved, result.Status); + Assert.Equal(AllowedUserId, result.RequireSingle().StableId); + } + + [Fact] + public async Task User_resolver_filters_exact_user_id_through_allowed_users() + { + var resolver = CreateResolver(new DiscordChannelOptions + { + AllowDirectMessages = true, + AllowedUserIds = [AllowedUserId] + }); + + var result = await resolver.ResolveAsync(new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Discord), + ChannelAddressKind.User, + OtherUserId), TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.NotFound, result.Status); + Assert.Contains("allowed users", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Direct_message_resolution_requires_direct_messages_enabled() + { + var resolver = CreateResolver(new DiscordChannelOptions + { + AllowDirectMessages = false, + AllowedUserIds = [AllowedUserId] + }); + + var result = await resolver.ResolveAsync(new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Discord), + ChannelAddressKind.DirectMessage, + AllowedUserId), TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.Unsupported, result.Status); + Assert.Contains("disabled", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task Destination_resolver_resolves_channel_mention() + { + var resolver = CreateResolver(new DiscordChannelOptions + { + AllowedChannelIds = [AllowedChannelId] + }); + + var result = await resolver.ResolveAsync(new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Discord), + ChannelAddressKind.Destination, + $"<#{AllowedChannelId}>"), TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.Resolved, result.Status); + Assert.Equal(AllowedChannelId, result.RequireSingle().StableId); + } + + [Fact] + public async Task Destination_resolver_filters_exact_channel_id_through_allowed_channels() + { + var resolver = CreateResolver(new DiscordChannelOptions + { + AllowedChannelIds = [AllowedChannelId] + }); + + var result = await resolver.ResolveAsync(new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Discord), + ChannelAddressKind.Destination, + OtherChannelId), TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.NotFound, result.Status); + Assert.Contains("allowed channels", result.Error, StringComparison.OrdinalIgnoreCase); + } + + [Fact] + public async Task User_resolver_resolves_cached_name_matches() + { + var lookup = new FakeDiscordAddressLookupClient + { + Users = + [ + new DiscordLookupUser(new DiscordUserId(AllowedUserId), "alice", "Alice", "Alice A.", IsBot: false) + ] + }; + var resolver = CreateResolver(new DiscordChannelOptions + { + AllowDirectMessages = true, + AllowedUserIds = [AllowedUserId] + }, lookup); + + var result = await resolver.ResolveAsync(new ChannelAddressResolutionRequest( + ChannelDescriptorKey.FromChannelType(ChannelType.Discord), + ChannelAddressKind.DirectMessage, + "@alice"), TestContext.Current.CancellationToken); + + Assert.Equal(ChannelAddressResolutionStatus.Resolved, result.Status); + Assert.Equal(AllowedUserId, result.RequireSingle().StableId); + Assert.Equal(ChannelAddressKind.DirectMessage, result.RequireSingle().AddressKind); + } + + private static DiscordAddressResolver CreateResolver( + DiscordChannelOptions options, + FakeDiscordAddressLookupClient? lookup = null) + { + return new DiscordAddressResolver( + lookup ?? new FakeDiscordAddressLookupClient(), + options, + () => null); + } + + private sealed class FakeDiscordAddressLookupClient : IDiscordAddressLookupClient + { + public IReadOnlyList Users { get; init; } = []; + public IReadOnlyList Destinations { get; init; } = []; + + public ValueTask> FindUsersAsync( + string query, + CancellationToken cancellationToken = default) + => ValueTask.FromResult(Users); + + public ValueTask> FindDestinationsAsync( + string query, + CancellationToken cancellationToken = default) + => ValueTask.FromResult(Destinations); + } +} + +#endregion + #region DiscordProactiveThreadActorTests (TestKit) public sealed class DiscordProactiveThreadActorTests(ITestOutputHelper output) : TestKit(output: output) @@ -323,6 +548,70 @@ public async Task StartProactiveThread_rejected_for_disallowed_channel() await sink.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken); } + [Fact] + public async Task StartProactiveThread_allows_dm_channel_when_user_is_allowed() + { + var sink = CreateTestProbe("proactive-dm-sink"); + var deps = CreateDependencies( + options: new DiscordChannelOptions + { + Enabled = true, + AllowDirectMessages = true, + AllowedChannelIds = ["ch-1"], + AllowedUserIds = ["u-1"] + }, + sessionPropsFactory: (_, _, _, _, _, _) => Props.Create(() => new ForwardActor(sink.Ref))); + + var conversation = Sys.ActorOf( + DiscordConversationActor.CreateProps(new DiscordChannelId("dm-1"), deps), + "discord-proactive-dm"); + + conversation.Tell(new StartProactiveThread( + new DiscordChannelId("dm-1"), + new DiscordReplyChannelId("dm-1"), + new DiscordThreadOrMessageId("msg-1"), + new SessionId("dm-1/msg-1"), + DirectMessageUserId: new DiscordUserId("u-1"), + RootMessageId: new DiscordMessageId("msg-1"))); + + var routed = await sink.ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.Equal("dm-1/msg-1", routed.SessionId.Value); + Assert.Equal("msg-1", routed.RootMessageId?.Value); + } + + [Fact] + public async Task StartProactiveThread_rejects_dm_when_user_is_disallowed() + { + var sink = CreateTestProbe("proactive-dm-disallowed-sink"); + var deps = CreateDependencies( + options: new DiscordChannelOptions + { + Enabled = true, + AllowDirectMessages = true, + AllowedUserIds = ["u-1"] + }, + sessionPropsFactory: (_, _, _, _, _, _) => Props.Create(() => new ForwardActor(sink.Ref))); + + var conversation = Sys.ActorOf( + DiscordConversationActor.CreateProps(new DiscordChannelId("dm-2"), deps), + "discord-proactive-dm-disallowed"); + + conversation.Tell(new StartProactiveThread( + new DiscordChannelId("dm-2"), + new DiscordReplyChannelId("dm-2"), + new DiscordThreadOrMessageId("msg-2"), + new SessionId("dm-2/msg-2"), + DirectMessageUserId: new DiscordUserId("u-bad"), + RootMessageId: new DiscordMessageId("msg-2"))); + + var failure = await ExpectMsgAsync( + cancellationToken: TestContext.Current.CancellationToken); + Assert.Contains("allowed users", failure.Cause.Message, StringComparison.OrdinalIgnoreCase); + + await sink.ExpectNoMsgAsync(TimeSpan.FromMilliseconds(250), TestContext.Current.CancellationToken); + } + [Fact] public async Task StartProactiveThread_rejected_when_ingress_closed() { @@ -371,6 +660,7 @@ public async Task ProactiveThreadAck_flows_back_through_gateway() private static DiscordGatewayDependencies CreateDependencies( SessionIngressGate? ingressGate = null, + DiscordChannelOptions? options = null, Func? sessionPropsFactory = null) { var replyClient = new UnconfiguredDiscordReplyClient(); @@ -379,7 +669,7 @@ private static DiscordGatewayDependencies CreateDependencies( Pipeline: null!, IngressGate: ingressGate, TimeProvider: TimeProvider.System, - Options: new DiscordChannelOptions + Options: options ?? new DiscordChannelOptions { Enabled = true, MentionOnly = false, diff --git a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingDiscordReplyClient.cs b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingDiscordReplyClient.cs index ec73d00f5..007cf35ad 100644 --- a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingDiscordReplyClient.cs +++ b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingDiscordReplyClient.cs @@ -13,7 +13,9 @@ internal sealed class RecordingDiscordReplyClient : IDiscordReplyClient public List<(DiscordReplyChannelId ThreadId, string Name)> ThreadRenames { get; } = []; public List<(DiscordReplyChannelId ChannelId, DiscordMessageId MessageId, string Text, bool RemoveComponents)> Updates { get; } = []; public List TypingTriggers { get; } = []; + public List Uploads { get; } = []; public Exception? ThrowOnPost { get; set; } + public Exception? ThrowOnUpload { get; set; } private int _messageCounter; @@ -57,4 +59,14 @@ public Task TriggerTypingAsync(DiscordReplyChannelId channelId, CancellationToke TypingTriggers.Add(channelId); return Task.CompletedTask; } + + public Task UploadFileAsync(DiscordFileUpload upload, CancellationToken cancellationToken = default) + { + if (ThrowOnUpload is { } ex) + throw ex; + + Uploads.Add(upload); + var messageId = new DiscordMessageId($"file-{Interlocked.Increment(ref _messageCounter)}"); + return Task.FromResult(messageId); + } } diff --git a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingMattermostReplyClient.cs b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingMattermostReplyClient.cs index 6e0662ab7..06d91f85b 100644 --- a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingMattermostReplyClient.cs +++ b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingMattermostReplyClient.cs @@ -12,6 +12,7 @@ internal sealed class RecordingMattermostReplyClient : IMattermostReplyClient private readonly object _lock = new(); private readonly List _posts = []; private readonly List<(MattermostPostId PostId, string Text, IReadOnlyList? Attachments)> _updates = []; + private readonly List<(MattermostChannelId ChannelId, string FilePath, string? FileName)> _uploads = []; public IReadOnlyList Posts { @@ -23,8 +24,15 @@ public IReadOnlyList Posts get { lock (_lock) return _updates.ToList(); } } + public IReadOnlyList<(MattermostChannelId ChannelId, string FilePath, string? FileName)> Uploads + { + get { lock (_lock) return _uploads.ToList(); } + } + public Exception? ThrowOnPost { get; set; } + public Exception? ThrowOnUpload { get; set; } + private int _messageCounter; public void Clear() @@ -33,6 +41,7 @@ public void Clear() { _posts.Clear(); _updates.Clear(); + _uploads.Clear(); } } @@ -51,4 +60,18 @@ public Task UpdatePostAsync(MattermostPostId postId, string text, IReadOnlyList< lock (_lock) _updates.Add((postId, text, attachments)); return Task.CompletedTask; } + + public Task UploadFileAsync( + MattermostChannelId channelId, + string filePath, + string? fileName = null, + CancellationToken cancellationToken = default) + { + if (ThrowOnUpload is { } ex) + throw ex; + + var fileId = $"file-{Interlocked.Increment(ref _messageCounter)}"; + lock (_lock) _uploads.Add((channelId, filePath, fileName)); + return Task.FromResult(fileId); + } } diff --git a/src/Netclaw.Channels.Discord/DiscordAclPolicy.cs b/src/Netclaw.Channels.Discord/DiscordAclPolicy.cs index 915f8b76c..4445d2318 100644 --- a/src/Netclaw.Channels.Discord/DiscordAclPolicy.cs +++ b/src/Netclaw.Channels.Discord/DiscordAclPolicy.cs @@ -66,4 +66,14 @@ public static bool IsAllowedChannel( return options.AllowedChannelIds.Contains(channelId.Value, StringComparer.Ordinal); } + public static bool IsAllowedUser( + DiscordUserId userId, + DiscordChannelOptions options) + { + if (options.AllowedUserIds.Length == 0) + return true; + + return options.AllowedUserIds.Contains(userId.Value, StringComparer.Ordinal); + } + } diff --git a/src/Netclaw.Channels.Discord/DiscordAddressResolver.cs b/src/Netclaw.Channels.Discord/DiscordAddressResolver.cs new file mode 100644 index 000000000..ac5c14455 --- /dev/null +++ b/src/Netclaw.Channels.Discord/DiscordAddressResolver.cs @@ -0,0 +1,201 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Actors.Channels; +using Netclaw.Channels; + +namespace Netclaw.Channels.Discord; + +public sealed record DiscordLookupUser( + DiscordUserId UserId, + string Username, + string? GlobalName, + string? DisplayName, + bool IsBot); + +public sealed record DiscordLookupDestination( + DiscordChannelId ChannelId, + string Name); + +public interface IDiscordAddressLookupClient +{ + ValueTask> FindUsersAsync(string query, CancellationToken cancellationToken = default); + + ValueTask> FindDestinationsAsync(string query, CancellationToken cancellationToken = default); +} + +public sealed class DiscordAddressResolver( + IDiscordAddressLookupClient lookupClient, + DiscordChannelOptions options, + Func defaultChannelIdAccessor) : IChannelAddressResolver +{ + private static readonly IReadOnlySet UserAddressKinds = new HashSet + { + ChannelAddressKind.User + }; + + private static readonly IReadOnlySet UserAndDirectMessageAddressKinds = new HashSet + { + ChannelAddressKind.User, + ChannelAddressKind.DirectMessage + }; + + private static readonly IReadOnlySet DestinationAddressKinds = new HashSet + { + ChannelAddressKind.Destination + }; + + public ChannelDescriptorKey Key { get; } = ChannelDescriptorKey.FromChannelType(ChannelType.Discord); + + public IReadOnlySet AddressKinds => options.AllowDirectMessages + ? UserAndDirectMessageAddressKinds.Concat(DestinationAddressKinds).ToHashSet() + : UserAddressKinds.Concat(DestinationAddressKinds).ToHashSet(); + + public async ValueTask ResolveAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken = default) + { + ArgumentNullException.ThrowIfNull(request); + + if (!request.ChannelKey.Equals(Key)) + return ChannelAddressResolutionResult.Unsupported($"Discord resolver cannot resolve channel key '{request.ChannelKey}'."); + + return request.AddressKind switch + { + ChannelAddressKind.User => await ResolveUserAsync(request, cancellationToken), + ChannelAddressKind.DirectMessage => options.AllowDirectMessages + ? await ResolveUserAsync(request, cancellationToken) + : ChannelAddressResolutionResult.Unsupported("Discord direct-message resolution is disabled in configuration."), + ChannelAddressKind.Destination => await ResolveDestinationAsync(request, cancellationToken), + _ => ChannelAddressResolutionResult.Unsupported($"Discord resolver does not support address kind '{request.AddressKind}'.") + }; + } + + private async ValueTask ResolveUserAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken) + { + var query = NormalizeUserQuery(request.Query); + if (IsDiscordSnowflake(query)) + { + var userId = new DiscordUserId(query); + return DiscordAclPolicy.IsAllowedUser(userId, options) + ? ChannelAddressResolutionResult.Resolved(new ResolvedChannelAddress(Key, request.AddressKind, query, query)) + : ChannelAddressResolutionResult.NotFound($"Discord user '{query}' is not in the allowed users list."); + } + + var matches = (await lookupClient.FindUsersAsync(query, cancellationToken)) + .Where(user => !user.IsBot && DiscordAclPolicy.IsAllowedUser(user.UserId, options)) + .Where(user => MatchesUserQuery(user, query)) + .Select(user => new ResolvedChannelAddress( + Key, + request.AddressKind, + user.UserId.Value, + GetUserDisplayName(user))) + .DistinctBy(address => address.StableId) + .ToArray(); + + return ToResolutionResult(request.AddressKind, matches, query); + } + + private async ValueTask ResolveDestinationAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken) + { + var query = NormalizeDestinationQuery(request.Query); + if (IsDiscordSnowflake(query)) + { + var channelId = new DiscordChannelId(query); + return DiscordAclPolicy.IsAllowedChannel(channelId, options, defaultChannelIdAccessor()) + ? ChannelAddressResolutionResult.Resolved(new ResolvedChannelAddress(Key, request.AddressKind, query, query)) + : ChannelAddressResolutionResult.NotFound($"Discord channel '{query}' is not in the allowed channels list."); + } + + var matches = (await lookupClient.FindDestinationsAsync(query, cancellationToken)) + .Where(destination => DiscordAclPolicy.IsAllowedChannel(destination.ChannelId, options, defaultChannelIdAccessor())) + .Where(destination => MatchesDestinationQuery(destination, query)) + .Select(destination => new ResolvedChannelAddress( + Key, + request.AddressKind, + destination.ChannelId.Value, + string.IsNullOrWhiteSpace(destination.Name) ? destination.ChannelId.Value : $"#{destination.Name}")) + .DistinctBy(address => address.StableId) + .ToArray(); + + return ToResolutionResult(request.AddressKind, matches, query); + } + + private static ChannelAddressResolutionResult ToResolutionResult( + ChannelAddressKind addressKind, + IReadOnlyList matches, + string query) + { + if (matches.Count == 0) + return ChannelAddressResolutionResult.NotFound($"No Discord {addressKind} matched '{query}'."); + + if (matches.Count == 1) + return ChannelAddressResolutionResult.Resolved(matches[0]); + + return ChannelAddressResolutionResult.Ambiguous( + matches, + $"Discord {addressKind} query '{query}' matched {matches.Count} destinations."); + } + + private static string NormalizeUserQuery(string query) + { + var normalized = query.Trim(); + if (normalized.StartsWith("<@", StringComparison.Ordinal) && normalized.EndsWith('>')) + { + normalized = normalized[2..^1]; + if (normalized.StartsWith('!')) + normalized = normalized[1..]; + } + + return normalized.StartsWith('@') ? normalized[1..].Trim() : normalized; + } + + private static string NormalizeDestinationQuery(string query) + { + var normalized = query.Trim(); + if (normalized.StartsWith("channel:", StringComparison.OrdinalIgnoreCase)) + normalized = normalized[8..].Trim(); + + if (normalized.StartsWith("<#", StringComparison.Ordinal) && normalized.EndsWith('>')) + normalized = normalized[2..^1]; + + return normalized.StartsWith('#') ? normalized[1..].Trim() : normalized; + } + + private static bool MatchesUserQuery(DiscordLookupUser user, string query) + { + return string.Equals(user.UserId.Value, query, StringComparison.Ordinal) + || string.Equals(user.Username, query, StringComparison.OrdinalIgnoreCase) + || string.Equals(user.GlobalName, query, StringComparison.OrdinalIgnoreCase) + || string.Equals(user.DisplayName, query, StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesDestinationQuery(DiscordLookupDestination destination, string query) + { + return string.Equals(destination.ChannelId.Value, query, StringComparison.Ordinal) + || string.Equals(destination.Name, query, StringComparison.OrdinalIgnoreCase); + } + + private static string GetUserDisplayName(DiscordLookupUser user) + { + if (!string.IsNullOrWhiteSpace(user.DisplayName)) + return user.DisplayName; + + if (!string.IsNullOrWhiteSpace(user.GlobalName)) + return user.GlobalName; + + if (!string.IsNullOrWhiteSpace(user.Username)) + return $"@{user.Username}"; + + return user.UserId.Value; + } + + private static bool IsDiscordSnowflake(string value) + => value.Length is >= 17 and <= 20 && value.All(char.IsAsciiDigit); +} diff --git a/src/Netclaw.Channels.Discord/DiscordConversationActor.cs b/src/Netclaw.Channels.Discord/DiscordConversationActor.cs index 4c6b49dda..bc440cab0 100644 --- a/src/Netclaw.Channels.Discord/DiscordConversationActor.cs +++ b/src/Netclaw.Channels.Discord/DiscordConversationActor.cs @@ -266,9 +266,28 @@ private void HandleProactiveThread(StartProactiveThread message) return; } - // Defense-in-depth: re-validate the channel ACL even though the tool - // already checked it before posting. - if (!DiscordAclPolicy.IsAllowedChannel(message.ChannelId, _dependencies.Options, _dependencies.DefaultChannelId)) + // Defense-in-depth: re-validate the ACL even though the tool already + // checked it before posting. DM channel IDs are ephemeral transport + // channels, so the configured user ACL is the authority for DMs. + if (message.DirectMessageUserId is { } dmUserId) + { + if (!_dependencies.Options.AllowDirectMessages) + { + _log.Warning("Rejected proactive DM for user {0}: direct messages disabled", dmUserId.Value); + Sender.Tell(new Status.Failure(new InvalidOperationException( + "Discord direct messages are disabled."))); + return; + } + + if (!DiscordAclPolicy.IsAllowedUser(dmUserId, _dependencies.Options)) + { + _log.Warning("Rejected proactive DM for disallowed user {0}", dmUserId.Value); + Sender.Tell(new Status.Failure(new InvalidOperationException( + $"User {dmUserId.Value} is not in the allowed users list."))); + return; + } + } + else if (!DiscordAclPolicy.IsAllowedChannel(message.ChannelId, _dependencies.Options, _dependencies.DefaultChannelId)) { _log.Warning("Rejected proactive thread for disallowed channel {0}", message.ChannelId.Value); Sender.Tell(new Status.Failure(new InvalidOperationException( @@ -281,7 +300,7 @@ private void HandleProactiveThread(StartProactiveThread message) message.ChannelId, message.ReplyChannelId, message.ThreadOrMessageId, - rootMessageId: null); + message.RootMessageId); _log.Debug("Routing proactive thread setup to session binding {0}", message.SessionId.Value); sessionBinding.Forward(message); diff --git a/src/Netclaw.Channels.Discord/DiscordIngressMessages.cs b/src/Netclaw.Channels.Discord/DiscordIngressMessages.cs index e4830cb2c..5818079c8 100644 --- a/src/Netclaw.Channels.Discord/DiscordIngressMessages.cs +++ b/src/Netclaw.Channels.Discord/DiscordIngressMessages.cs @@ -43,15 +43,16 @@ public sealed record DiscordApprovalResponse( /// /// Sent to the gateway to wire up the actor hierarchy for a proactively-created -/// Discord thread. The message has already been posted and the thread created; -/// this initializes the session pipeline so user replies route to a live -/// session. +/// Discord session. Channel posts use a Discord thread; DMs use the root DM +/// message as the stable session anchor. /// public sealed record StartProactiveThread( DiscordChannelId ChannelId, DiscordReplyChannelId ReplyChannelId, DiscordThreadOrMessageId ThreadOrMessageId, - SessionId SessionId) : INoSerializationVerificationNeeded; + SessionId SessionId, + DiscordUserId? DirectMessageUserId = null, + DiscordMessageId? RootMessageId = null) : INoSerializationVerificationNeeded; /// /// Acknowledgement that a proactive thread's session pipeline was initialized. diff --git a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs index 10c588dc6..3732751b3 100644 --- a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs +++ b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs @@ -265,8 +265,8 @@ await _handle.ReinitializeAsync( private async Task HandleProactiveThreadAsync(StartProactiveThread message) { _replyChannelId = message.ReplyChannelId; - _threadCreated = true; - _rootMessageId = null; + _threadCreated = message.RootMessageId is null; + _rootMessageId = message.RootMessageId; _log.Info("Initializing proactive thread pipeline for session {0}", message.SessionId.Value); await EnsureInitializedAsync(); @@ -1110,8 +1110,8 @@ private async Task HandleOutputReceivedAsync(OutputReceived msg) break; case FileOutput file: - await SafeReplyAsync($":paperclip: Produced file `{file.FileName}` ({file.MimeType})."); - _deliveredThisTurn = true; + if (await SafeUploadFileAsync(file)) + _deliveredThisTurn = true; break; case ProcessingStateOutput processing: @@ -1305,6 +1305,51 @@ private DiscordPostMessage BuildPostMessage( Buttons: buttons); } + private async Task SafeUploadFileAsync(FileOutput file) + { + var startedAt = _dependencies.TimeProvider.GetTimestamp(); + try + { + if (!File.Exists(file.FilePath)) + { + _log.Warning("File not found for upload: {Path}", file.FilePath); + await NotifyDeliveryFailedAsync(DeliveryFailureKind.Unknown, $"File not found for upload: {file.FilePath}"); + return false; + } + + using var cts = new CancellationTokenSource(OperationTimeout); + await _dependencies.ReplyClient.UploadFileAsync( + new DiscordFileUpload( + _replyChannelId, + file.FilePath, + file.FileName, + $":paperclip: {file.FileName}", + _threadCreated ? null : _rootMessageId), + cts.Token); + + var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; + ChannelTelemetry.For(ChannelType.Discord).RecordReplyPosted(duration); + _log.Info("Uploaded file to Discord session: {FileName}", file.FileName); + return true; + } + catch (OperationCanceledException ex) + { + var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; + _log.Error(ex, "Timed out uploading file {FileName} to Discord session", file.FileName); + ChannelTelemetry.For(ChannelType.Discord).RecordReplyFailed(duration); + await NotifyDeliveryFailedAsync(DeliveryFailureKind.TransportFailure, ex.Message); + return false; + } + catch (Exception ex) + { + var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; + _log.Error(ex, "Failed to upload file {FileName} to Discord session", file.FileName); + ChannelTelemetry.For(ChannelType.Discord).RecordReplyFailed(duration); + await NotifyDeliveryFailedAsync(DeliveryFailureKind.TransportFailure, ex.Message); + return false; + } + } + private async Task SafeSetThreadNameAsync(string title) { try diff --git a/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs b/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs index be61ed01c..38e277c06 100644 --- a/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs +++ b/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs @@ -94,6 +94,8 @@ Task UpdateMessageAsync( CancellationToken cancellationToken = default); Task TriggerTypingAsync(DiscordReplyChannelId channelId, CancellationToken cancellationToken = default); + + Task UploadFileAsync(DiscordFileUpload upload, CancellationToken cancellationToken = default); } public sealed record DiscordPostMessage( @@ -111,6 +113,13 @@ public sealed record DiscordPostResult( public static readonly DiscordPostResult Default = new(); } +public sealed record DiscordFileUpload( + DiscordReplyChannelId ReplyChannelId, + string FilePath, + string FileName, + string Text, + DiscordMessageId? RootMessageId = null); + public sealed record DiscordButtonSpec( string CustomId, string Label, @@ -184,4 +193,8 @@ public Task UpdateMessageAsync(DiscordReplyChannelId channelId, DiscordMessageId public Task TriggerTypingAsync(DiscordReplyChannelId channelId, CancellationToken cancellationToken = default) => throw new InvalidOperationException( "Discord channel attempted to trigger typing, but no Discord reply client is configured."); + + public Task UploadFileAsync(DiscordFileUpload upload, CancellationToken cancellationToken = default) + => throw new InvalidOperationException( + "Discord channel attempted to upload a file, but no Discord reply client is configured."); } diff --git a/src/Netclaw.Channels.Discord/IDiscordOutboundClient.cs b/src/Netclaw.Channels.Discord/IDiscordOutboundClient.cs index 5ed61c71e..5844559dc 100644 --- a/src/Netclaw.Channels.Discord/IDiscordOutboundClient.cs +++ b/src/Netclaw.Channels.Discord/IDiscordOutboundClient.cs @@ -15,6 +15,18 @@ public readonly record struct DiscordNewThread( DiscordReplyChannelId ReplyChannelId, DiscordThreadOrMessageId ThreadOrMessageId); +/// +/// Result of a proactive Discord DM post. Discord DMs do not have public +/// threads; is the root DM message id used as +/// the stable session key segment. +/// +public readonly record struct DiscordNewDirectMessage( + DiscordChannelId ChannelId, + DiscordReplyChannelId ReplyChannelId, + DiscordThreadOrMessageId ThreadOrMessageId, + DiscordMessageId RootMessageId, + DiscordUserId UserId); + /// /// The root message was posted successfully, but Discord failed to create the /// follow-up thread needed for session binding. Callers should report this as a @@ -57,4 +69,13 @@ Task PostNewThreadAsync( string text, string threadName, CancellationToken ct = default); + + /// + /// Opens or reuses a DM channel for and posts a + /// root message that becomes the session anchor. + /// + Task PostDirectMessageAsync( + DiscordUserId userId, + string text, + CancellationToken ct = default); } diff --git a/src/Netclaw.Channels.Discord/Tools/SendDiscordMessageTool.cs b/src/Netclaw.Channels.Discord/Tools/SendDiscordMessageTool.cs index 20bd7bc48..706e29659 100644 --- a/src/Netclaw.Channels.Discord/Tools/SendDiscordMessageTool.cs +++ b/src/Netclaw.Channels.Discord/Tools/SendDiscordMessageTool.cs @@ -11,15 +11,15 @@ namespace Netclaw.Channels.Discord.Tools; /// -/// LLM tool that posts a proactive message to a Discord channel, creating a new -/// conversation thread. The thread is wired into the actor hierarchy so user -/// replies route back to a live session. Channel targets only — DM proactive -/// posting is deferred (see the add-discord-proactive-post OpenSpec change). +/// LLM tool that posts a proactive message to a Discord channel or DM. Channel +/// posts create a Discord thread; DMs use the root DM message as the session +/// anchor. The session is wired into the actor hierarchy so user replies route +/// back to a live session. /// [NetclawTool("send_discord_message", - "Send a message to a Discord channel, creating a new conversation thread. " + + "Send a message to a Discord channel or DM a user, creating a new conversation session. " + "Use this to proactively notify users or start discussions. " + - "Omit channel_id to use the configured default channel.", + "Provide channel_id for a channel post, user_id for a DM, or omit both to use the configured default channel.", Grant = "builtin")] public sealed partial class SendDiscordMessageTool : NetclawTool { @@ -32,8 +32,10 @@ public sealed partial class SendDiscordMessageTool : NetclawTool ExecuteAsync(Params args, CancellationToke if (gateway is null) return "Error: Discord gateway is not connected."; + var hasChannel = !string.IsNullOrWhiteSpace(args.ChannelId); + var hasUser = !string.IsNullOrWhiteSpace(args.UserId); + + if (hasChannel && hasUser) + return "Error: Provide only one of 'channel_id' or 'user_id'."; + + if (hasUser) + return await SendDirectMessageAsync(args, gateway, ct); + var defaultChannelId = string.IsNullOrWhiteSpace(_options.DefaultChannelId) ? (DiscordChannelId?)null : new DiscordChannelId(_options.DefaultChannelId); @@ -119,4 +130,47 @@ await gateway.Ask( return $"Message sent to channel {targetChannelId.Value}. Thread: {sessionId.Value}"; } + + private async Task SendDirectMessageAsync(Params args, IActorRef gateway, CancellationToken ct) + { + if (!_options.AllowDirectMessages) + return "Error: Direct messages are disabled. Enable AllowDirectMessages in Discord configuration to send DMs."; + + var userId = new DiscordUserId(args.UserId!); + if (!DiscordAclPolicy.IsAllowedUser(userId, _options)) + return $"Error: User {userId.Value} is not in the allowed users list."; + + DiscordNewDirectMessage result; + try + { + result = await _outboundClient.PostDirectMessageAsync(userId, args.Message, ct); + } + catch (Exception ex) + { + return $"Error: Failed to post direct message to Discord: {ex.Message}"; + } + + var sessionId = new SessionId($"{result.ChannelId.Value}/{result.ThreadOrMessageId.Value}"); + + try + { + await gateway.Ask( + new StartProactiveThread( + result.ChannelId, + result.ReplyChannelId, + result.ThreadOrMessageId, + sessionId, + DirectMessageUserId: result.UserId, + RootMessageId: result.RootMessageId), + TimeSpan.FromSeconds(30), + ct); + } + catch (Exception) + { + return $"Message sent to user {userId.Value} but session pipeline failed to initialize. " + + $"Thread: {sessionId.Value}"; + } + + return $"Message sent to user {userId.Value}. Thread: {sessionId.Value}"; + } } diff --git a/src/Netclaw.Channels.Discord/Transport/DiscordNetAddressLookupClient.cs b/src/Netclaw.Channels.Discord/Transport/DiscordNetAddressLookupClient.cs new file mode 100644 index 000000000..ef0dfe834 --- /dev/null +++ b/src/Netclaw.Channels.Discord/Transport/DiscordNetAddressLookupClient.cs @@ -0,0 +1,61 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Discord.WebSocket; + +namespace Netclaw.Channels.Discord.Transport; + +internal sealed class DiscordNetAddressLookupClient(DiscordSocketClient client) : IDiscordAddressLookupClient +{ + public ValueTask> FindUsersAsync( + string query, + CancellationToken cancellationToken = default) + { + var matches = client.Guilds + .SelectMany(guild => guild.Users) + .Where(user => MatchesUserQuery(user, query)) + .Select(user => new DiscordLookupUser( + new DiscordUserId(user.Id.ToString()), + user.Username, + user.GlobalName, + user.DisplayName, + user.IsBot)) + .DistinctBy(user => user.UserId.Value) + .ToArray(); + + return ValueTask.FromResult>(matches); + } + + public ValueTask> FindDestinationsAsync( + string query, + CancellationToken cancellationToken = default) + { + var matches = client.Guilds + .SelectMany(guild => guild.TextChannels) + .Where(channel => MatchesChannelQuery(channel, query)) + .Select(channel => new DiscordLookupDestination( + new DiscordChannelId(channel.Id.ToString()), + channel.Name)) + .DistinctBy(destination => destination.ChannelId.Value) + .ToArray(); + + return ValueTask.FromResult>(matches); + } + + private static bool MatchesUserQuery(SocketGuildUser user, string query) + { + return string.Equals(user.Id.ToString(), query, StringComparison.Ordinal) + || string.Equals(user.Username, query, StringComparison.OrdinalIgnoreCase) + || string.Equals(user.GlobalName, query, StringComparison.OrdinalIgnoreCase) + || string.Equals(user.DisplayName, query, StringComparison.OrdinalIgnoreCase) + || string.Equals(user.Nickname, query, StringComparison.OrdinalIgnoreCase); + } + + private static bool MatchesChannelQuery(SocketTextChannel channel, string query) + { + return string.Equals(channel.Id.ToString(), query, StringComparison.Ordinal) + || string.Equals(channel.Name, query, StringComparison.OrdinalIgnoreCase); + } +} diff --git a/src/Netclaw.Channels.Discord/Transport/DiscordNetOutboundClient.cs b/src/Netclaw.Channels.Discord/Transport/DiscordNetOutboundClient.cs index 5d000cb24..c7a7e2424 100644 --- a/src/Netclaw.Channels.Discord/Transport/DiscordNetOutboundClient.cs +++ b/src/Netclaw.Channels.Discord/Transport/DiscordNetOutboundClient.cs @@ -75,6 +75,32 @@ public async Task PostNewThreadAsync( ThreadOrMessageId: new DiscordThreadOrMessageId(threadIdStr)); } + public async Task PostDirectMessageAsync( + DiscordUserId userId, + string text, + CancellationToken ct = default) + { + var userSnowflake = ParseSnowflake(userId.Value, "user ID"); + var requestOptions = new RequestOptions { CancelToken = ct }; + IUser? user = _client.GetUser(userSnowflake); + user ??= await _client.Rest.GetUserAsync(userSnowflake, requestOptions); + + if (user is null) + throw new InvalidOperationException($"Discord user {userId.Value} not found."); + + var dmChannel = await user.CreateDMChannelAsync(requestOptions); + var rootMessage = await dmChannel.SendMessageAsync(text: text, options: requestOptions); + var dmChannelId = dmChannel.Id.ToString(); + var rootMessageId = rootMessage.Id.ToString(); + + return new DiscordNewDirectMessage( + ChannelId: new DiscordChannelId(dmChannelId), + ReplyChannelId: new DiscordReplyChannelId(dmChannelId), + ThreadOrMessageId: new DiscordThreadOrMessageId(rootMessageId), + RootMessageId: new DiscordMessageId(rootMessageId), + UserId: userId); + } + private async Task ResolveChannelAsync(ulong channelSnowflake, string channelIdForError) { // Socket cache misses fall back to the REST API, mirroring diff --git a/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs b/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs index c86afe348..611a83ce6 100644 --- a/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs +++ b/src/Netclaw.Channels.Discord/Transport/DiscordNetReplyClient.cs @@ -135,6 +135,27 @@ public async Task TriggerTypingAsync(DiscordReplyChannelId channelId, Cancellati await messageChannel.TriggerTypingAsync(new RequestOptions { CancelToken = cancellationToken }); } + public async Task UploadFileAsync(DiscordFileUpload upload, CancellationToken cancellationToken = default) + { + var channelSnowflake = ParseSnowflake(upload.ReplyChannelId.Value, "reply channel ID"); + var messageChannel = await ResolveMessageChannelAsync(channelSnowflake, upload.ReplyChannelId.Value); + MessageReference? rootRef = null; + if (upload.RootMessageId is { } rootMessageId) + rootRef = new MessageReference(ParseSnowflake(rootMessageId.Value, "root message ID")); + + await using var stream = File.OpenRead(upload.FilePath); + var sentMessage = await messageChannel.SendFileAsync( + stream, + upload.FileName, + text: upload.Text, + options: new RequestOptions { CancelToken = cancellationToken }, + messageReference: rootRef); + + return sentMessage is null + ? null + : new DiscordMessageId(sentMessage.Id.ToString()); + } + private async Task ResolveMessageChannelAsync(ulong channelSnowflake, string channelIdForError) { // Socket cache misses for DM channels — fall back to REST API. diff --git a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs index 24cbb5838..a81fa7b1b 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs @@ -1088,8 +1088,8 @@ private async Task HandleOutputReceivedAsync(OutputReceived msg) break; case FileOutput file: - await SafeReplyAsync($":paperclip: Produced file `{file.FileName}` ({file.MimeType})."); - _deliveredThisTurn = true; + if (await SafeUploadFileAsync(file)) + _deliveredThisTurn = true; break; case ToolInteractionRequest request when string.Equals(request.Kind, "approval", StringComparison.OrdinalIgnoreCase): @@ -1260,13 +1260,60 @@ private async Task SafeReplyAsync(string text) private MattermostPostMessage BuildPostMessage( string text, + IReadOnlyList? fileIds = null, IReadOnlyList? attachments = null) => new( ChannelId: _channelId, Text: text, RootPostId: _rootPostId.IsEmpty ? null : new MattermostPostId(_rootPostId.Value), + FileIds: fileIds, Attachments: attachments); + private async Task SafeUploadFileAsync(FileOutput file) + { + var startedAt = _dependencies.TimeProvider.GetTimestamp(); + try + { + if (!File.Exists(file.FilePath)) + { + _log.Warning("File not found for upload: {Path}", file.FilePath); + await NotifyDeliveryFailedAsync(DeliveryFailureKind.Unknown, $"File not found for upload: {file.FilePath}"); + return false; + } + + using var cts = new CancellationTokenSource(OperationTimeout); + var fileId = await _dependencies.ReplyClient.UploadFileAsync( + _channelId, + file.FilePath, + file.FileName, + cts.Token); + + var postMessage = BuildPostMessage($":paperclip: {file.FileName}", fileIds: [fileId]); + await _dependencies.ReplyClient.PostReplyAsync(postMessage, cts.Token); + + var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; + ChannelTelemetry.For(ChannelType.Mattermost).RecordReplyPosted(duration); + _log.Info("Uploaded file to Mattermost thread: {FileName}", file.FileName); + return true; + } + catch (OperationCanceledException ex) + { + var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; + _log.Error(ex, "Timed out uploading file {FileName} to Mattermost thread", file.FileName); + ChannelTelemetry.For(ChannelType.Mattermost).RecordReplyFailed(duration); + await NotifyDeliveryFailedAsync(DeliveryFailureKind.TransportFailure, ex.Message); + return false; + } + catch (Exception ex) + { + var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; + _log.Error(ex, "Failed to upload file {FileName} to Mattermost thread", file.FileName); + ChannelTelemetry.For(ChannelType.Mattermost).RecordReplyFailed(duration); + await NotifyDeliveryFailedAsync(DeliveryFailureKind.TransportFailure, ex.Message); + return false; + } + } + private async Task NotifyDeliveryFailedAsync(DeliveryFailureKind failureKind, string errorMessage) { try diff --git a/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs b/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs index 0272f8639..ee3e326be 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs @@ -89,6 +89,12 @@ Task UpdatePostAsync( string text, IReadOnlyList? attachments, CancellationToken cancellationToken = default); + + Task UploadFileAsync( + MattermostChannelId channelId, + string filePath, + string? fileName = null, + CancellationToken cancellationToken = default); } /// @@ -188,4 +194,12 @@ public Task PostReplyAsync(MattermostPostMessage message, 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."); + + public Task UploadFileAsync( + MattermostChannelId channelId, + string filePath, + string? fileName = null, + CancellationToken cancellationToken = default) + => throw new InvalidOperationException( + "Mattermost channel attempted to upload a file, but no Mattermost reply client is configured."); } diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs index cb0d9916d..c0d946fcc 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetReplyClient.cs @@ -40,6 +40,24 @@ public async Task UpdatePostAsync( await _client.UpdatePostAsync(postId.Value, text, BuildProps(attachments)); } + public async Task UploadFileAsync( + MattermostChannelId channelId, + string filePath, + string? fileName = null, + CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var resolvedFileName = fileName ?? Path.GetFileName(filePath); + await using var stream = File.OpenRead(filePath); + var details = await _client.UploadFileAsync(channelId.Value, resolvedFileName, stream, progressChanged: _ => { }); + + if (details is null || string.IsNullOrWhiteSpace(details.Id)) + throw new InvalidOperationException("Mattermost returned no file ID — the upload was not delivered."); + + return details.Id; + } + private static PostProps? BuildProps(IReadOnlyList? attachments) { if (attachments is null or { Count: 0 }) diff --git a/src/Netclaw.Daemon.Tests/Configuration/ChannelLookupToolTests.cs b/src/Netclaw.Daemon.Tests/Configuration/ChannelLookupToolTests.cs index 52169fe86..caef92727 100644 --- a/src/Netclaw.Daemon.Tests/Configuration/ChannelLookupToolTests.cs +++ b/src/Netclaw.Daemon.Tests/Configuration/ChannelLookupToolTests.cs @@ -32,7 +32,7 @@ public void Registration_skips_generic_lookup_tools_when_remote_channels_are_dis } [Fact] - public void Registration_adds_destination_only_for_discord_only_configuration() + public void Registration_adds_user_and_destination_for_discord_only_configuration() { var services = BuildServices(new Dictionary { @@ -41,9 +41,9 @@ public void Registration_adds_destination_only_for_discord_only_configuration() ["Mattermost:Enabled"] = "false" }); - Assert.False(IsRegistered(services)); + Assert.True(IsRegistered(services)); Assert.True(IsRegistered(services)); - Assert.Single(services, descriptor => descriptor.ServiceType == typeof(IChannelTool)); + Assert.Equal(2, services.Count(descriptor => descriptor.ServiceType == typeof(IChannelTool))); } [Fact] diff --git a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs index 2597c1338..7abca0700 100644 --- a/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs +++ b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs @@ -66,19 +66,19 @@ public void Registry_enumerates_output_capable_channels_only() Assert.True(descriptors["slack"].Capabilities.HasFlag(ChannelCapabilities.DirectMessages)); Assert.True(descriptors["mattermost"].Capabilities.HasFlag(ChannelCapabilities.DirectMessages)); - Assert.False(descriptors["discord"].Capabilities.HasFlag(ChannelCapabilities.DirectMessages)); + Assert.True(descriptors["discord"].Capabilities.HasFlag(ChannelCapabilities.DirectMessages)); Assert.Contains(ChannelAddressKind.DirectMessage, descriptors["slack"].AddressKinds); Assert.Contains(ChannelAddressKind.DirectMessage, descriptors["mattermost"].AddressKinds); - Assert.DoesNotContain(ChannelAddressKind.DirectMessage, descriptors["discord"].AddressKinds); + Assert.Contains(ChannelAddressKind.DirectMessage, descriptors["discord"].AddressKinds); Assert.True(descriptors["slack"].Capabilities.HasFlag(ChannelCapabilities.FileEgress)); - Assert.False(descriptors["discord"].Capabilities.HasFlag(ChannelCapabilities.FileEgress)); - Assert.False(descriptors["mattermost"].Capabilities.HasFlag(ChannelCapabilities.FileEgress)); + Assert.True(descriptors["discord"].Capabilities.HasFlag(ChannelCapabilities.FileEgress)); + Assert.True(descriptors["mattermost"].Capabilities.HasFlag(ChannelCapabilities.FileEgress)); Assert.Contains(ChannelOutputEffectKind.FileAttachment, descriptors["slack"].SupportedOutputEffects); - Assert.DoesNotContain(ChannelOutputEffectKind.FileAttachment, descriptors["discord"].SupportedOutputEffects); - Assert.DoesNotContain(ChannelOutputEffectKind.FileAttachment, descriptors["mattermost"].SupportedOutputEffects); + Assert.Contains(ChannelOutputEffectKind.FileAttachment, descriptors["discord"].SupportedOutputEffects); + Assert.Contains(ChannelOutputEffectKind.FileAttachment, descriptors["mattermost"].SupportedOutputEffects); Assert.Contains(ChannelOutputEffectKind.ProcessingIndicator, descriptors["discord"].SupportedOutputEffects); Assert.DoesNotContain(ChannelOutputEffectKind.ProcessingIndicator, descriptors["slack"].SupportedOutputEffects); @@ -120,6 +120,7 @@ public void Disabled_remote_channels_do_not_register_channel_tools() Assert.DoesNotContain(services, descriptor => descriptor.ServiceType == typeof(IChannelAddressResolver)); Assert.DoesNotContain(services, descriptor => descriptor.ServiceType == typeof(IChannelOutputRenderer)); Assert.False(IsRegistered(services)); + Assert.False(IsRegistered(services)); Assert.False(IsRegistered(services)); } @@ -160,9 +161,10 @@ public void Enabled_remote_channels_register_expected_address_resolvers() ["Mattermost:Enabled"] = "true" }); - Assert.Equal(4, services.Count(descriptor => descriptor.ServiceType == typeof(IChannelAddressResolver))); + Assert.Equal(5, services.Count(descriptor => descriptor.ServiceType == typeof(IChannelAddressResolver))); Assert.True(IsRegistered(services)); Assert.True(IsRegistered(services)); + Assert.True(IsRegistered(services)); Assert.True(IsRegistered(services)); Assert.True(IsRegistered(services)); } @@ -191,7 +193,8 @@ public void Enabled_channel_tool_intents_match_registered_tool_services() AssertToolIntents( descriptors["discord"], services, - new ChannelToolExpectation(ChannelToolIntentKind.SendMessage, typeof(SendChannelMessageTool), null)); + new ChannelToolExpectation(ChannelToolIntentKind.SendMessage, typeof(SendChannelMessageTool), null), + new ChannelToolExpectation(ChannelToolIntentKind.LookupUser, typeof(LookupChannelUserTool), null)); AssertToolIntents( descriptors["mattermost"], services, @@ -593,5 +596,8 @@ public Task TriggerTypingAsync(DiscordReplyChannelId channelId, CancellationToke TypingTriggers.Add(channelId); return Task.CompletedTask; } + + public Task UploadFileAsync(DiscordFileUpload upload, CancellationToken cancellationToken = default) + => Task.FromResult(new DiscordMessageId("file-1")); } } diff --git a/src/Netclaw.Daemon/Configuration/ChannelLookupTools.cs b/src/Netclaw.Daemon/Configuration/ChannelLookupTools.cs index 25e93f228..ff0e0d8a6 100644 --- a/src/Netclaw.Daemon/Configuration/ChannelLookupTools.cs +++ b/src/Netclaw.Daemon/Configuration/ChannelLookupTools.cs @@ -25,7 +25,7 @@ public static IServiceCollection AddChannelLookupTools(this IServiceCollection s var discordEnabled = (configuration.GetSection("Discord").Get() ?? new DiscordChannelOptions()).Enabled; var mattermostEnabled = (configuration.GetSection("Mattermost").Get() ?? new MattermostChannelOptions()).Enabled; - if (slackEnabled || mattermostEnabled) + if (slackEnabled || discordEnabled || mattermostEnabled) { services.AddSingleton(); services.AddSingleton(sp => sp.GetRequiredService()); diff --git a/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs b/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs index 4d09e204e..c82d3515c 100644 --- a/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs +++ b/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs @@ -50,7 +50,7 @@ internal sealed class SendChannelMessageTool(IChannelRegistry registry, IService public string Description => "Send a message through an enabled chat channel using a resolved destination. " + - "Examples: channel_key=slack with destination.kind=destination, channel_key=mattermost with destination.kind=direct_message, channel_key=discord with destination.kind=destination."; + "Examples: channel_key=slack with destination.kind=destination, channel_key=mattermost with destination.kind=direct_message, channel_key=discord with destination.kind=destination or direct_message."; public string GrantCategory => "builtin"; @@ -147,7 +147,7 @@ private async Task ExecuteCoreAsync( return await services.GetRequiredService().ExecuteAsync(delegateArguments, ct); case ChannelType.Discord: - delegateArguments["ChannelId"] = destination.StableId; + delegateArguments[destination.AddressKind == ChannelAddressKind.DirectMessage ? "UserId" : "ChannelId"] = destination.StableId; return await services.GetRequiredService().ExecuteAsync(delegateArguments, ct); case ChannelType.Mattermost: @@ -400,7 +400,8 @@ private static bool IsStablePlatformId(ChannelType channelType, ChannelAddressKi ChannelType.Slack => addressKind == ChannelAddressKind.DirectMessage ? stableId.StartsWith("U", StringComparison.Ordinal) || stableId.StartsWith("W", StringComparison.Ordinal) : stableId.StartsWith("C", StringComparison.Ordinal) || stableId.StartsWith("G", StringComparison.Ordinal) || stableId.StartsWith("D", StringComparison.Ordinal), - ChannelType.Discord => addressKind == ChannelAddressKind.Destination && IsDiscordSnowflake(stableId), + ChannelType.Discord => (addressKind == ChannelAddressKind.Destination || addressKind == ChannelAddressKind.DirectMessage) + && IsDiscordSnowflake(stableId), ChannelType.Mattermost => IsMattermostId(stableId), _ => false }; diff --git a/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs index 42b10700a..ce77ca95e 100644 --- a/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs @@ -49,6 +49,7 @@ public static void AddDiscordChannelIntegration(this IServiceCollection services services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); + services.AddSingleton(); services.AddChannelOutputRenderer(); services.AddSingleton(sp => { @@ -71,6 +72,13 @@ public static void AddDiscordChannelIntegration(this IServiceCollection services logger); }); services.AddSingleton(); + services.AddSingleton(sp => new DiscordAddressResolver( + sp.GetRequiredService(), + discordOptions, + () => string.IsNullOrWhiteSpace(discordOptions.DefaultChannelId) + ? null + : new DiscordChannelId(discordOptions.DefaultChannelId))); + services.AddSingleton(sp => sp.GetRequiredService()); services.AddKeyedSingleton(DiscordChannelKey); services.AddSingleton(sp => @@ -102,15 +110,25 @@ private static ChannelDescriptor CreateDescriptor(DiscordChannelOptions options) | ChannelCapabilities.ThreadedConversations | ChannelCapabilities.InteractiveApproval | ChannelCapabilities.FileIngress + | ChannelCapabilities.FileEgress | ChannelCapabilities.ProactiveSend + | ChannelCapabilities.UserLookup + | ChannelCapabilities.DestinationLookup | ChannelCapabilities.RuntimeHealth; + if (options.AllowDirectMessages) + capabilities |= ChannelCapabilities.DirectMessages; + var addressKinds = new HashSet { ChannelAddressKind.Destination, + ChannelAddressKind.User, ChannelAddressKind.Thread }; + if (options.AllowDirectMessages) + addressKinds.Add(ChannelAddressKind.DirectMessage); + return new ChannelDescriptor( ChannelDescriptorKey.FromChannelType(ChannelType.Discord), ChannelType.Discord, @@ -120,13 +138,15 @@ private static ChannelDescriptor CreateDescriptor(DiscordChannelOptions options) capabilities, ToolIntents: new HashSet { - ChannelToolIntentKind.SendMessage + ChannelToolIntentKind.SendMessage, + ChannelToolIntentKind.LookupUser }, AddressKinds: addressKinds, SupportedOutputEffects: new HashSet { ChannelOutputEffectKind.TextMessage, ChannelOutputEffectKind.InteractiveApproval, + ChannelOutputEffectKind.FileAttachment, ChannelOutputEffectKind.ProcessingIndicator }); } diff --git a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs index 4315842b9..3747c945c 100644 --- a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs @@ -135,6 +135,7 @@ private static ChannelDescriptor CreateDescriptor(MattermostChannelOptions optio | ChannelCapabilities.ThreadedConversations | ChannelCapabilities.InteractiveApproval | ChannelCapabilities.FileIngress + | ChannelCapabilities.FileEgress | ChannelCapabilities.ProactiveSend | ChannelCapabilities.UserLookup | ChannelCapabilities.DestinationLookup @@ -169,7 +170,8 @@ private static ChannelDescriptor CreateDescriptor(MattermostChannelOptions optio SupportedOutputEffects: new HashSet { ChannelOutputEffectKind.TextMessage, - ChannelOutputEffectKind.InteractiveApproval + ChannelOutputEffectKind.InteractiveApproval, + ChannelOutputEffectKind.FileAttachment }); } } From 0339e16471a296c2960006932316dcf457c77df2 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Mon, 8 Jun 2026 22:05:49 +0000 Subject: [PATCH 22/31] Actorize Mattermost channel lifecycle --- docs/integrations/discord-channel.md | 3 + docs/integrations/mattermost-channel.md | 6 + docs/integrations/slack-socket-mode.md | 3 + .../tasks.md | 6 +- .../MattermostGatewayLifecycleActorTests.cs | 308 +++++++++ .../MattermostChannel.cs | 126 +++- .../MattermostTransportContracts.cs | 44 +- .../Transport/MattermostNetGatewayClient.cs | 260 +++++++- .../MattermostNetGatewayLifecycleActor.cs | 593 ++++++++++++++++++ 9 files changed, 1286 insertions(+), 63 deletions(-) create mode 100644 src/Netclaw.Actors.Tests/Channels/MattermostGatewayLifecycleActorTests.cs create mode 100644 src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayLifecycleActor.cs diff --git a/docs/integrations/discord-channel.md b/docs/integrations/discord-channel.md index d83df6180..54aaebc90 100644 --- a/docs/integrations/discord-channel.md +++ b/docs/integrations/discord-channel.md @@ -128,6 +128,9 @@ DM output is not supported yet. - If Discord is enabled with missing `BotToken`, startup fails fast. - If Discord is enabled with placeholder clients, startup fails fast. - Health reports `Disconnected` when gateway is not connected. +- Discord gateway lifecycle is actor-owned. Inbound messages and interactions + are gated until the socket reaches READY, runtime disconnects surface through + channel health, and stale/resumed sessions request a clean reconnect. Common failure patterns: diff --git a/docs/integrations/mattermost-channel.md b/docs/integrations/mattermost-channel.md index 849efde3b..9d6a8d1bd 100644 --- a/docs/integrations/mattermost-channel.md +++ b/docs/integrations/mattermost-channel.md @@ -135,6 +135,12 @@ Reminder channel delivery maps to the generic `send_channel_message` tool with - A fatal failure (bad token, unreachable server) stays offline until the configuration is fixed and the daemon is restarted; a transient network failure retries automatically on a bounded backoff. +- WebSocket lifecycle is actor-owned. Inbound Mattermost messages are dropped + while the gateway is not ready, unexpected disconnects mark channel health as + disconnected, and the channel runs a clean stop/start reconnect cycle before + accepting ingress again. +- SDK event handlers are subscribed for the lifecycle actor lifetime, not on + each reconnect attempt, so reconnect cycles do not duplicate message handlers. Common failure patterns: diff --git a/docs/integrations/slack-socket-mode.md b/docs/integrations/slack-socket-mode.md index 9ce00fce9..3b982f6e9 100644 --- a/docs/integrations/slack-socket-mode.md +++ b/docs/integrations/slack-socket-mode.md @@ -73,6 +73,9 @@ 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. - 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 + remain delegated to SlackNet Socket Mode instead of a Netclaw lifecycle actor. ## Security note diff --git a/openspec/changes/standardize-channel-delivery-contracts/tasks.md b/openspec/changes/standardize-channel-delivery-contracts/tasks.md index d967aaeba..1e0f2befe 100644 --- a/openspec/changes/standardize-channel-delivery-contracts/tasks.md +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -73,9 +73,9 @@ ## 10. Stateful channel lifecycle follow-up -- [ ] 10.1 Add contract tests for not-ready ingress gating, runtime disconnect health, clean reconnect signaling, and handler de-duplication for stateful remote chat channels. -- [ ] 10.2 Implement Mattermost lifecycle actorization only after the standard snapshot and lifecycle contract tests exist. -- [ ] 10.3 Verify Slack and Discord satisfy the same lifecycle requirements or document explicit capability differences. +- [x] 10.1 Add contract tests for not-ready ingress gating, runtime disconnect health, clean reconnect signaling, and handler de-duplication for stateful remote chat channels. +- [x] 10.2 Implement Mattermost lifecycle actorization only after the standard snapshot and lifecycle contract tests exist. +- [x] 10.3 Verify Slack and Discord satisfy the same lifecycle requirements or document explicit capability differences. ## 11. Validation and quality gates diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostGatewayLifecycleActorTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostGatewayLifecycleActorTests.cs new file mode 100644 index 000000000..c79dbf349 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/MattermostGatewayLifecycleActorTests.cs @@ -0,0 +1,308 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Akka.Actor; +using Akka.Configuration; +using Akka.Hosting; +using Akka.Hosting.TestKit; +using Akka.Pattern; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; +using Microsoft.Extensions.Logging.Abstractions; +using Netclaw.Channels.Mattermost; +using Netclaw.Channels.Mattermost.Transport; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels; + +public sealed class MattermostGatewayLifecycleActorTests(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 Not_ready_ingress_is_dropped() + { + var transport = new FakeMattermostGatewayTransport(); + var sink = new RecordingGatewayEventSink(); + var actor = CreateLifecycleActor(transport, sink); + await WaitForActorReadyAsync(actor); + + await transport.RaiseMessageAsync(CreateMessage("event-1")); + await WaitForActorReadyAsync(actor); + + Assert.Empty(sink.Messages); + } + + [Fact] + public async Task Runtime_disconnect_reports_not_ready_and_requests_clean_reconnect() + { + var transport = new FakeMattermostGatewayTransport(); + var sink = new RecordingGatewayEventSink(); + var actor = CreateLifecycleActor(transport, sink); + + var readySnapshot = await ConnectAsync(actor); + Assert.True(readySnapshot.IsReady); + + await transport.RaiseDisconnectedAsync("network lost"); + + await AwaitAssertAsync(async () => + { + var snapshot = await actor.Ask( + MattermostNetGatewayLifecycleActor.GetSnapshot.Instance, + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + + Assert.False(snapshot.IsReady); + Assert.False(snapshot.IsConnected); + Assert.Equal( + "Mattermost gateway disconnected: network lost. A clean reconnect is required.", + snapshot.HealthDetail); + Assert.Equal(1, sink.CleanReconnectCount); + }, cancellationToken: TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Connected_event_while_disconnected_requires_clean_reconnect() + { + var transport = new FakeMattermostGatewayTransport(); + var sink = new RecordingGatewayEventSink(); + var actor = CreateLifecycleActor(transport, sink); + await WaitForActorReadyAsync(actor); + + transport.IsConnected = true; + await transport.RaiseConnectedAsync(); + + await AwaitAssertAsync(async () => + { + var snapshot = await actor.Ask( + MattermostNetGatewayLifecycleActor.GetSnapshot.Instance, + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + + Assert.False(snapshot.IsReady); + Assert.True(snapshot.IsConnected); + Assert.Equal( + "Mattermost gateway reconnected outside a clean startup cycle; forcing a clean reconnect.", + snapshot.HealthDetail); + Assert.Equal(1, sink.CleanReconnectCount); + }, cancellationToken: TestContext.Current.CancellationToken); + } + + [Fact] + public async Task Reconnect_cycle_does_not_duplicate_transport_handlers() + { + var transport = new FakeMattermostGatewayTransport(); + var sink = new RecordingGatewayEventSink(); + var actor = CreateLifecycleActor(transport, sink); + await WaitForActorReadyAsync(actor); + + AssertSingleSubscription(transport); + + await ConnectAsync(actor); + await actor.Ask( + MattermostNetGatewayLifecycleActor.Disconnect.Instance, + TimeSpan.FromSeconds(3), + TestContext.Current.CancellationToken); + await ConnectAsync(actor); + + AssertSingleSubscription(transport); + + await transport.RaiseMessageAsync(CreateMessage("event-1")); + await WaitForActorReadyAsync(actor); + + Assert.Single(sink.Messages); + + Sys.Stop(actor); + await AwaitAssertAsync( + () => + { + Assert.Equal(0, transport.MessageSubscriberCount); + Assert.Equal(0, transport.ConnectedSubscriberCount); + Assert.Equal(0, transport.DisconnectedSubscriberCount); + Assert.Equal(0, transport.LogSubscriberCount); + }, + cancellationToken: TestContext.Current.CancellationToken); + } + + private IActorRef CreateLifecycleActor( + FakeMattermostGatewayTransport transport, + RecordingGatewayEventSink sink) + { + return Sys.ActorOf(MattermostNetGatewayLifecycleActor.CreateProps( + transport, + sink, + NullLogger.Instance)); + } + + private static async Task WaitForActorReadyAsync(IActorRef actor) + { + await actor.Ask( + MattermostNetGatewayLifecycleActor.GetSnapshot.Instance, + TimeSpan.FromSeconds(3)); + } + + private static Task ConnectAsync(IActorRef actor) => + actor.Ask( + new MattermostNetGatewayLifecycleActor.Connect("https://mattermost.test", "test-token"), + TimeSpan.FromSeconds(3)); + + private static void AssertSingleSubscription(FakeMattermostGatewayTransport transport) + { + Assert.Equal(1, transport.MessageSubscriberCount); + Assert.Equal(1, transport.ConnectedSubscriberCount); + Assert.Equal(1, transport.DisconnectedSubscriberCount); + Assert.Equal(1, transport.LogSubscriberCount); + } + + private static MattermostGatewayMessage CreateMessage(string eventId) => + new( + EventId: new MattermostEventId(eventId), + ChannelId: new MattermostChannelId("ch-1"), + PostId: new MattermostPostId("post-" + eventId), + RootPostId: new MattermostRootPostId("root-1"), + SenderId: new MattermostUserId("user-1"), + IsBotMessage: false, + IsDirectMessage: false, + ContainsBotMention: true, + Text: "hello", + ReceivedAt: DateTimeOffset.Parse("2026-06-08T00:00:00Z")); + + private sealed class RecordingGatewayEventSink : IMattermostGatewayEventSink + { + private readonly List _messages = []; + + public IReadOnlyList Messages => _messages; + + public int CleanReconnectCount { get; private set; } + + public Task PublishMessageAsync(MattermostGatewayMessage message) + { + _messages.Add(message); + return Task.CompletedTask; + } + + public Task PublishCleanReconnectRequiredAsync(string reason) + { + CleanReconnectCount++; + return Task.CompletedTask; + } + } + + private sealed class FakeMattermostGatewayTransport : IMattermostGatewayTransport + { + private Func? _messageReceived; + private Func? _connected; + private Func? _disconnected; + private Func? _logReceived; + + public event Func MessageReceived + { + add + { + _messageReceived += value; + MessageSubscriberCount++; + } + remove + { + _messageReceived -= value; + MessageSubscriberCount--; + } + } + + public event Func Connected + { + add + { + _connected += value; + ConnectedSubscriberCount++; + } + remove + { + _connected -= value; + ConnectedSubscriberCount--; + } + } + + public event Func Disconnected + { + add + { + _disconnected += value; + DisconnectedSubscriberCount++; + } + remove + { + _disconnected -= value; + DisconnectedSubscriberCount--; + } + } + + public event Func LogReceived + { + add + { + _logReceived += value; + LogSubscriberCount++; + } + remove + { + _logReceived -= value; + LogSubscriberCount--; + } + } + + public bool IsConnected { get; set; } + + public int StartCount { get; private set; } + + public int StopCount { get; private set; } + + public int MessageSubscriberCount { get; private set; } + + public int ConnectedSubscriberCount { get; private set; } + + public int DisconnectedSubscriberCount { get; private set; } + + public int LogSubscriberCount { get; private set; } + + public Task StartAsync(string serverUrl, string botToken) + { + StartCount++; + IsConnected = true; + return Task.FromResult(new MattermostBotIdentity("bot-1", "netclaw")); + } + + public Task StopAsync() + { + StopCount++; + IsConnected = false; + return Task.CompletedTask; + } + + public Task RaiseMessageAsync(MattermostGatewayMessage message) => + _messageReceived?.Invoke(message) ?? Task.CompletedTask; + + public Task RaiseConnectedAsync() => + _connected?.Invoke() ?? Task.CompletedTask; + + public Task RaiseDisconnectedAsync(string reason) + { + IsConnected = false; + return _disconnected?.Invoke(new MattermostGatewayDisconnect(reason)) ?? Task.CompletedTask; + } + + public Task RaiseLogAsync(string message) => + _logReceived?.Invoke(message) ?? Task.CompletedTask; + } +} diff --git a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs index 7bc8f5b3a..8a8129250 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs @@ -36,10 +36,12 @@ public sealed class MattermostChannel : IChannel // Cancels the background reconnect loop when the channel stops. private readonly CancellationTokenSource _lifetimeCts = new(); + private readonly object _reconnectLock = new(); + private int _queuedCleanReconnect; private IActorRef? _gateway; private Task? _reconnectTask; - private string? _connectFailureDetail; + private volatile string? _connectFailureDetail; internal IActorRef? Gateway => _gateway; internal IMattermostGatewayClient GatewayClient => _gatewayClient; @@ -86,23 +88,32 @@ public MattermostChannel( _modelCapabilities = modelCapabilities; _paths = paths; _callbackActionStore = callbackActionStore; + + _gatewayClient.CleanReconnectRequired += HandleCleanReconnectRequiredAsync; } public ChannelType ChannelType => ChannelType.Mattermost; public string DisplayName => "Mattermost"; - public ValueTask GetHealthAsync(CancellationToken cancellationToken = default) + public async ValueTask GetHealthAsync(CancellationToken cancellationToken = default) { if (!_options.Enabled) - return ValueTask.FromResult(new ChannelHealth(ChannelHealthStatus.Degraded, "Mattermost channel disabled.")); + return new ChannelHealth(ChannelHealthStatus.Degraded, "Mattermost channel disabled."); + + var gatewaySnapshot = await _gatewayClient.GetSnapshotAsync(cancellationToken); + + if (gatewaySnapshot.IsReady) + return new ChannelHealth(ChannelHealthStatus.Healthy); - if (_gatewayClient.IsConnected) - return ValueTask.FromResult(new ChannelHealth(ChannelHealthStatus.Healthy)); + if (gatewaySnapshot.IsConnected) + return new ChannelHealth( + ChannelHealthStatus.Degraded, + gatewaySnapshot.HealthDetail ?? _connectFailureDetail ?? "Mattermost gateway connected but not ready."); - return ValueTask.FromResult(new ChannelHealth( + return new ChannelHealth( ChannelHealthStatus.Disconnected, - _connectFailureDetail ?? "Mattermost WebSocket disconnected.")); + _connectFailureDetail ?? gatewaySnapshot.HealthDetail ?? "Mattermost WebSocket disconnected."); } public async Task StartAsync(CancellationToken cancellationToken) @@ -143,8 +154,9 @@ private async Task TryConnectAsync(string serverUrl, string botToken, Cancellati try { // Connect first so BotUserId/BotUsername are available before creating the gateway actor. - await _gatewayClient.ConnectAsync(serverUrl, botToken, cancellationToken); - CompleteConnectionSetup(serverUrl); + var gatewaySnapshot = await _gatewayClient.ConnectAsync(serverUrl, botToken, cancellationToken); + EnsureGatewayReadyAfterConnect(gatewaySnapshot); + CompleteConnectionSetup(serverUrl, gatewaySnapshot.BotUserId, gatewaySnapshot.BotUsername); _connectFailureDetail = null; _logger.LogInformation("Channel connected."); } @@ -158,7 +170,10 @@ private async Task TryConnectAsync(string serverUrl, string botToken, Cancellati /// Wires up message handling and the gateway actor once a connection /// succeeds. Idempotent — safe to call again after a reconnect. /// - private void CompleteConnectionSetup(string serverUrl) + private void CompleteConnectionSetup( + string serverUrl, + MattermostUserId? botUserId, + string? botUsername) { if (_gateway is not null) return; @@ -181,8 +196,8 @@ private void CompleteConnectionSetup(string serverUrl) Paths: _paths, ServerUrl: serverUrl, CallbackUrl: _options.CallbackUrl, - BotUserId: _gatewayClient.BotUserId, - BotUsername: _gatewayClient.BotUsername, + BotUserId: botUserId, + BotUsername: botUsername, CallbackActionStore: _callbackActionStore, PromptInjectionDetector: _promptInjectionDetector, ThreadHistoryFetcher: _threadHistoryFetcher, @@ -227,31 +242,65 @@ private void HandleConnectFailure(ChannelConnectException failure) "Mattermost channel could not connect (transient). The daemon will keep running " + "and retry the connection in the background. {Reason}", failure.Message); - StartReconnectLoop(); + StartReconnectLoop(initialDelay: TimeSpan.FromSeconds(5)); } - private void StartReconnectLoop() + private void StartReconnectLoop(TimeSpan initialDelay) { - if (_reconnectTask is { IsCompleted: false }) + lock (_reconnectLock) + { + if (_reconnectTask is { IsCompleted: false } activeReconnect) + { + if (initialDelay == TimeSpan.Zero) + { + Interlocked.Exchange(ref _queuedCleanReconnect, 1); + _ = activeReconnect.ContinueWith( + _ => StartQueuedCleanReconnect(), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + return; + } + + _reconnectTask = Task.Run(() => ReconnectLoopAsync(initialDelay, _lifetimeCts.Token)); + } + } + + private void StartQueuedCleanReconnect() + { + if (Volatile.Read(ref _queuedCleanReconnect) == 0) return; - _reconnectTask = Task.Run(() => ReconnectLoopAsync(_lifetimeCts.Token)); + StartReconnectLoop(initialDelay: TimeSpan.Zero); + } + + private Task HandleCleanReconnectRequiredAsync(string reason) + { + _connectFailureDetail = reason; + _logger.LogWarning("Gateway requested clean reconnect: {Reason}", reason); + StartReconnectLoop(initialDelay: TimeSpan.Zero); + return Task.CompletedTask; } - private async Task ReconnectLoopAsync(CancellationToken cancellationToken) + private async Task ReconnectLoopAsync(TimeSpan initialDelay, CancellationToken cancellationToken) { - var delay = TimeSpan.FromSeconds(5); + var delay = initialDelay; var maxDelay = TimeSpan.FromMinutes(5); while (!cancellationToken.IsCancellationRequested) { - try - { - await Task.Delay(delay, _timeProvider, cancellationToken); - } - catch (OperationCanceledException) + if (delay > TimeSpan.Zero) { - return; + try + { + await Task.Delay(delay, _timeProvider, cancellationToken); + } + catch (OperationCanceledException) + { + return; + } } // Reset transport state so the retry performs a clean login + connect. @@ -276,10 +325,18 @@ private async Task ReconnectLoopAsync(CancellationToken cancellationToken) ChannelConnectFailureKind.Fatal, "Mattermost is enabled but no bot token is configured."); - await _gatewayClient.ConnectAsync(_options.ServerUrl, _options.BotToken.Value, cancellationToken); - CompleteConnectionSetup(_options.ServerUrl); + var gatewaySnapshot = await _gatewayClient.ConnectAsync(_options.ServerUrl, _options.BotToken.Value, cancellationToken); + EnsureGatewayReadyAfterConnect(gatewaySnapshot); + CompleteConnectionSetup(_options.ServerUrl, gatewaySnapshot.BotUserId, gatewaySnapshot.BotUsername); _connectFailureDetail = null; _logger.LogInformation("Channel reconnected after a transient failure."); + + if (Interlocked.Exchange(ref _queuedCleanReconnect, 0) == 1) + { + delay = TimeSpan.Zero; + continue; + } + return; } catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) @@ -305,11 +362,23 @@ private async Task ReconnectLoopAsync(CancellationToken cancellationToken) classified, "Mattermost reconnect attempt failed; will retry. {Reason}", classified.Message); - delay = TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, maxDelay.Ticks)); + delay = delay == TimeSpan.Zero + ? TimeSpan.FromSeconds(5) + : TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, maxDelay.Ticks)); } } } + private static void EnsureGatewayReadyAfterConnect(MattermostGatewaySnapshot gatewaySnapshot) + { + if (gatewaySnapshot.IsReady) + return; + + throw new ChannelConnectException( + ChannelConnectFailureKind.Transient, + gatewaySnapshot.HealthDetail ?? "Mattermost gateway connected but did not become ready."); + } + public async Task StopAsync(CancellationToken cancellationToken) { // Stop the background reconnect loop before tearing down the transport. @@ -326,6 +395,7 @@ public async Task StopAsync(CancellationToken cancellationToken) } } + _gatewayClient.CleanReconnectRequired -= HandleCleanReconnectRequiredAsync; _gatewayClient.MessageReceived -= HandleMessageReceivedAsync; if (_gateway is not null) @@ -346,6 +416,8 @@ public async Task StopAsync(CancellationToken cancellationToken) await _gatewayClient.DisconnectAsync(cancellationToken); if (_gatewayClient is IDisposable disposable) disposable.Dispose(); + + _lifetimeCts.Dispose(); } private Task HandleMessageReceivedAsync(MattermostGatewayMessage message) diff --git a/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs b/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs index ee3e326be..809994b8f 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs @@ -59,6 +59,13 @@ public sealed record MattermostGatewayInteraction( DateTimeOffset ReceivedAt, MattermostPostId? PromptPostId = null); +public sealed record MattermostGatewaySnapshot( + bool IsConnected, + bool IsReady, + string? HealthDetail, + MattermostUserId? BotUserId, + string? BotUsername); + public interface IMattermostGatewayClient { // Interactive button callbacks arrive via the channel-owned HTTP endpoint @@ -69,13 +76,23 @@ public interface IMattermostGatewayClient // the WebSocket, so its client does expose an InteractionReceived event.) event Func? MessageReceived; + /// + /// Raised when the current Mattermost socket/session must be discarded and + /// replaced with a fresh stop/start cycle. + /// + event Func? CleanReconnectRequired; + bool IsConnected { get; } + bool IsReady { get; } + MattermostUserId? BotUserId { get; } string? BotUsername { get; } - Task ConnectAsync(string serverUrl, string botToken, CancellationToken cancellationToken = default); + Task GetSnapshotAsync(CancellationToken cancellationToken = default); + + Task ConnectAsync(string serverUrl, string botToken, CancellationToken cancellationToken = default); Task DisconnectAsync(CancellationToken cancellationToken = default); } @@ -168,15 +185,34 @@ public event Func? MessageReceived remove { } } + public event Func? CleanReconnectRequired + { + add { } + remove { } + } + public bool IsConnected => false; + public bool IsReady => 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 GetSnapshotAsync(CancellationToken cancellationToken = default) => + Task.FromResult(new MattermostGatewaySnapshot( + IsConnected: false, + IsReady: false, + HealthDetail: "Mattermost gateway client is not configured.", + BotUserId: null, + BotUsername: null)); + + public Task ConnectAsync( + string serverUrl, + string botToken, + CancellationToken cancellationToken = default) + => Task.FromException(new InvalidOperationException( + "Mattermost channel is enabled, but no Mattermost gateway client is configured.")); public Task DisconnectAsync(CancellationToken cancellationToken = default) => Task.CompletedTask; diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs index c4f5006f5..d945a2284 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs @@ -3,6 +3,8 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Akka.Actor; +using Akka.Pattern; using Mattermost; using Mattermost.Events; using Microsoft.Extensions.Logging; @@ -10,31 +12,166 @@ namespace Netclaw.Channels.Mattermost.Transport; -internal sealed class MattermostNetGatewayClient : IMattermostGatewayClient, IDisposable +internal sealed class MattermostNetGatewayClient : IMattermostGatewayClient, IMattermostGatewayEventSink, IDisposable { - private readonly MattermostClient _client; - private readonly TimeProvider _timeProvider; - private readonly ILogger _logger; + private static readonly TimeSpan ConnectAskTimeout = TimeSpan.FromSeconds(35); + private static readonly TimeSpan SnapshotAskTimeout = TimeSpan.FromSeconds(5); - private string? _serverUrl; + private readonly ActorSystem _actorSystem; + private readonly IActorRef _lifecycleActor; + private volatile MattermostGatewaySnapshot _latestSnapshot = new( + IsConnected: false, + IsReady: false, + HealthDetail: "Mattermost gateway disconnected.", + BotUserId: null, + BotUsername: null); public event Func? MessageReceived; + public event Func? CleanReconnectRequired; - public bool IsConnected => _client.IsConnected; - public MattermostUserId? BotUserId { get; private set; } - public string? BotUsername { get; private set; } + public bool IsConnected => _latestSnapshot.IsConnected; + public bool IsReady => _latestSnapshot.IsReady; + public MattermostUserId? BotUserId => _latestSnapshot.BotUserId; + public string? BotUsername => _latestSnapshot.BotUsername; public MattermostNetGatewayClient( + ActorSystem actorSystem, MattermostClient client, TimeProvider timeProvider, ILogger logger) + { + _actorSystem = actorSystem; + _lifecycleActor = actorSystem.ActorOf( + MattermostNetGatewayLifecycleActor.CreateProps( + new MattermostNetGatewayTransport(client, timeProvider, logger), + this, + logger), + "mattermost-net-gateway-lifecycle"); + } + + public async Task GetSnapshotAsync(CancellationToken cancellationToken = default) => + UpdateSnapshot(await _lifecycleActor.Ask( + MattermostNetGatewayLifecycleActor.GetSnapshot.Instance, + SnapshotAskTimeout, + cancellationToken: cancellationToken)); + + public async Task ConnectAsync( + string serverUrl, + string botToken, + CancellationToken cancellationToken = default) => + UpdateSnapshot(await _lifecycleActor.Ask( + new MattermostNetGatewayLifecycleActor.Connect(serverUrl, botToken), + ConnectAskTimeout, + cancellationToken: cancellationToken)); + + public async Task DisconnectAsync(CancellationToken cancellationToken = default) + { + UpdateSnapshot(await _lifecycleActor.Ask( + MattermostNetGatewayLifecycleActor.Disconnect.Instance, + ConnectAskTimeout, + cancellationToken: cancellationToken)); + } + + public void Dispose() => _actorSystem.Stop(_lifecycleActor); + + private MattermostGatewaySnapshot UpdateSnapshot(MattermostGatewaySnapshot snapshot) + { + _latestSnapshot = snapshot; + return snapshot; + } + + Task IMattermostGatewayEventSink.PublishMessageAsync(MattermostGatewayMessage message) => + MessageReceived?.Invoke(message) ?? Task.CompletedTask; + + Task IMattermostGatewayEventSink.PublishCleanReconnectRequiredAsync(string reason) => + CleanReconnectRequired?.Invoke(reason) ?? Task.CompletedTask; +} + +internal sealed class MattermostNetGatewayTransport : IMattermostGatewayTransport +{ + private readonly MattermostClient _client; + private readonly TimeProvider _timeProvider; + private readonly ILogger _logger; + private readonly object _subscriptionLock = new(); + + private Func? _messageReceived; + private Func? _connected; + private Func? _disconnected; + private Func? _logReceived; + private int _eventSubscriptionCount; + private string? _serverUrl; + private string? _botUserId; + private string? _botUsername; + + public MattermostNetGatewayTransport( + MattermostClient client, + TimeProvider timeProvider, + ILogger logger) { _client = client; _timeProvider = timeProvider; _logger = logger; } - public async Task ConnectAsync(string serverUrl, string botToken, CancellationToken cancellationToken = default) + public event Func MessageReceived + { + add + { + _messageReceived += value; + AddSdkSubscription(); + } + remove + { + _messageReceived -= value; + RemoveSdkSubscription(); + } + } + + public event Func Connected + { + add + { + _connected += value; + AddSdkSubscription(); + } + remove + { + _connected -= value; + RemoveSdkSubscription(); + } + } + + public event Func Disconnected + { + add + { + _disconnected += value; + AddSdkSubscription(); + } + remove + { + _disconnected -= value; + RemoveSdkSubscription(); + } + } + + public event Func LogReceived + { + add + { + _logReceived += value; + AddSdkSubscription(); + } + remove + { + _logReceived -= value; + RemoveSdkSubscription(); + } + } + + public bool IsConnected => _client.IsConnected; + + public async Task StartAsync(string serverUrl, string botToken) { _serverUrl = serverUrl.TrimEnd('/'); // First layer of bot self-dedup: the SDK refuses to surface our own @@ -44,21 +181,17 @@ public async Task ConnectAsync(string serverUrl, string botToken, CancellationTo // SDK filter — Slack does the same double-check. _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; + _botUserId = me.Id; + _botUsername = me.Username; _logger.LogInformation("Bot identity resolved: {BotUserId} (@{Username})", me.Id, me.Username); - await _client.StartReceivingAsync(cancellationToken); + await _client.StartReceivingAsync(); + return new MattermostBotIdentity(me.Id, me.Username); } - public async Task DisconnectAsync(CancellationToken cancellationToken = default) + public async Task StopAsync() { if (!_client.IsConnected) return; @@ -73,20 +206,65 @@ public async Task DisconnectAsync(CancellationToken cancellationToken = default) } } + private void AddSdkSubscription() + { + lock (_subscriptionLock) + { + if (_eventSubscriptionCount++ == 0) + SubscribeSdkEvents(); + } + } + + private void RemoveSdkSubscription() + { + lock (_subscriptionLock) + { + _eventSubscriptionCount--; + if (_eventSubscriptionCount == 0) + UnsubscribeSdkEvents(); + } + } + + private void SubscribeSdkEvents() + { + _client.OnMessageReceived += OnMessageReceived; + _client.OnConnected += OnConnected; + _client.OnDisconnected += OnDisconnected; + _client.OnLogMessage += OnLogMessage; + } + + private void UnsubscribeSdkEvents() + { + _client.OnMessageReceived -= OnMessageReceived; + _client.OnConnected -= OnConnected; + _client.OnDisconnected -= OnDisconnected; + _client.OnLogMessage -= OnLogMessage; + } + private void OnMessageReceived(object? sender, MessageEventArgs e) { - var handler = MessageReceived; + var handler = _messageReceived; if (handler is null) return; + if (_serverUrl is null) + { + _logger.LogWarning( + "Dropping Mattermost message {PostId} before gateway server URL is configured.", + e.Message.Post.Id); + return; + } + var post = e.Message.Post; var channelType = e.Message.ChannelType; var isDm = string.Equals(channelType, "D", StringComparison.Ordinal); - var botId = BotUserId?.Value; + var botId = _botUserId; + var botUsername = _botUsername ?? e.Client.CurrentUserInfo.Username; var containsMention = botId is not null + && !string.IsNullOrWhiteSpace(botUsername) && !string.IsNullOrEmpty(post.Text) - && post.Text.Contains($"@{e.Client.CurrentUserInfo.Username}", StringComparison.OrdinalIgnoreCase); + && post.Text.Contains($"@{botUsername}", StringComparison.OrdinalIgnoreCase); // Mentions field is a JSON array of user IDs if (!containsMention && botId is not null && !string.IsNullOrEmpty(e.Message.Mentions)) @@ -99,7 +277,7 @@ private void OnMessageReceived(object? sender, MessageEventArgs e) : new MattermostRootPostId(post.RootId); IReadOnlyList fileIds = post.FileIdentifiers as IReadOnlyList ?? post.FileIdentifiers.ToList(); - var serverUrl = _serverUrl!; + var serverUrl = _serverUrl; var receivedAt = _timeProvider.GetUtcNow(); _ = Task.Run(async () => @@ -141,16 +319,20 @@ private void OnMessageReceived(object? sender, MessageEventArgs e) private void OnConnected(object? sender, ConnectionEventArgs e) { _logger.LogInformation("Connected to Mattermost WebSocket at {Uri}", e.Uri); + Dispatch("Mattermost connected event", () => _connected?.Invoke() ?? Task.CompletedTask); } private void OnDisconnected(object? sender, DisconnectionEventArgs e) { _logger.LogWarning("Disconnected from Mattermost WebSocket: {Reason}", e.CloseStatusDescription); + Dispatch( + "Mattermost disconnected event", + () => _disconnected?.Invoke(new MattermostGatewayDisconnect(e.CloseStatusDescription)) ?? Task.CompletedTask); } private void OnLogMessage(object? sender, LogEventArgs e) { - _logger.LogDebug("[Mattermost.NET] {Message}", e.Message); + Dispatch("Mattermost log event", () => _logReceived?.Invoke(e.Message) ?? Task.CompletedTask); } private async Task> ResolveFileReferencesAsync( @@ -181,12 +363,32 @@ private async Task> ResolveFileReferences return await Task.WhenAll(tasks); } - public void Dispose() + private void Dispatch(string operation, Func dispatch) { - _client.OnMessageReceived -= OnMessageReceived; - _client.OnConnected -= OnConnected; - _client.OnDisconnected -= OnDisconnected; - _client.OnLogMessage -= OnLogMessage; - // Do not dispose the MattermostClient — it's owned by the DI container. + Task task; + try + { + task = dispatch(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling {Operation}", operation); + return; + } + + if (!task.IsCompletedSuccessfully) + _ = AwaitDispatchAsync(operation, task); + } + + private async Task AwaitDispatchAsync(string operation, Task task) + { + try + { + await task; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling {Operation}", operation); + } } } diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayLifecycleActor.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayLifecycleActor.cs new file mode 100644 index 000000000..6d6cedcbf --- /dev/null +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayLifecycleActor.cs @@ -0,0 +1,593 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Akka.Actor; +using Microsoft.Extensions.Logging; + +namespace Netclaw.Channels.Mattermost.Transport; + +internal interface IMattermostGatewayEventSink +{ + Task PublishMessageAsync(MattermostGatewayMessage message); + + Task PublishCleanReconnectRequiredAsync(string reason); +} + +internal interface IMattermostGatewayTransport +{ + event Func MessageReceived; + + event Func Connected; + + event Func Disconnected; + + event Func LogReceived; + + bool IsConnected { get; } + + Task StartAsync(string serverUrl, string botToken); + + Task StopAsync(); +} + +internal sealed record MattermostBotIdentity(string UserId, string Username); + +internal sealed record MattermostGatewayDisconnect(string? Reason, Exception? Exception = null); + +internal sealed class MattermostNetGatewayLifecycleActor : ReceiveActor +{ + private const string DisconnectedDetail = "Mattermost gateway disconnected."; + + private readonly IMattermostGatewayTransport _transport; + private readonly IMattermostGatewayEventSink _eventSink; + private readonly ILogger _logger; + + private bool _isReadyBehavior; + private long _connectAttempt; + private IActorRef _self = ActorRefs.Nobody; + private IActorRef? _pendingConnectReplyTo; + private MattermostUserId? _botUserId; + private string? _botUsername; + private string? _healthDetail = DisconnectedDetail; + private bool _cleanReconnectEmitted; + + public MattermostNetGatewayLifecycleActor( + IMattermostGatewayTransport transport, + IMattermostGatewayEventSink eventSink, + ILogger logger) + { + _transport = transport; + _eventSink = eventSink; + _logger = logger; + + Become(Disconnected); + } + + public static Props CreateProps( + IMattermostGatewayTransport transport, + IMattermostGatewayEventSink eventSink, + ILogger logger) => + Props.Create(() => new MattermostNetGatewayLifecycleActor(transport, eventSink, logger)); + + protected override void PreStart() + { + _self = Self; + _transport.MessageReceived += OnMessageReceivedAsync; + _transport.Connected += OnConnectedAsync; + _transport.Disconnected += OnDisconnectedAsync; + _transport.LogReceived += OnLogReceivedAsync; + base.PreStart(); + } + + protected override void PostStop() + { + _transport.MessageReceived -= OnMessageReceivedAsync; + _transport.Connected -= OnConnectedAsync; + _transport.Disconnected -= OnDisconnectedAsync; + _transport.LogReceived -= OnLogReceivedAsync; + base.PostStop(); + } + + private void Disconnected() + { + _isReadyBehavior = false; + ReceiveCommon(); + Receive(connect => StartConnecting(connect.ServerUrl, connect.BotToken, Sender)); + Receive(_ => StartDisconnecting(Sender)); + Receive(_ => RequestCleanReconnect( + "Mattermost gateway reconnected outside a clean startup cycle; forcing a clean reconnect.")); + Receive(HandleDisconnectedWhileNotReady); + ReceiveNotReadyIngress(); + ReceiveUnexpected(nameof(Disconnected)); + } + + private void Connecting() + { + _isReadyBehavior = false; + ReceiveCommon(); + Receive(_ => Sender.Tell(new Status.Failure(new InvalidOperationException( + "Mattermost gateway connect is already in progress.")))); + Receive(_ => StartDisconnecting(Sender)); + Receive(HandleStartSucceeded); + Receive(HandleStartFailed); + Receive(_ => _healthDetail = "Mattermost gateway connected; completing startup."); + Receive(HandleDisconnectedWhileConnecting); + ReceiveNotReadyIngress(); + ReceiveUnexpected(nameof(Connecting)); + } + + private void Ready() + { + _isReadyBehavior = true; + ReceiveCommon(); + Receive(_ => Sender.Tell(CurrentSnapshot())); + Receive(_ => StartDisconnecting(Sender)); + Receive(_ => _healthDetail = null); + Receive(HandleDisconnectedWhileReady); + Receive(HandleMessageReceived); + ReceiveUnexpected(nameof(Ready)); + } + + private void CleanReconnectRequired() + { + _isReadyBehavior = false; + ReceiveCommon(); + Receive(_ => Sender.Tell(new Status.Failure(new ChannelConnectException( + ChannelConnectFailureKind.Transient, + "Mattermost gateway requires a clean disconnect before reconnecting.")))); + Receive(_ => StartDisconnecting(Sender)); + Receive(_ => { }); + Receive(HandleDisconnectedWhileNotReady); + ReceiveNotReadyIngress(); + ReceiveUnexpected(nameof(CleanReconnectRequired)); + } + + private void Disconnecting() + { + _isReadyBehavior = false; + ReceiveCommon(); + Receive(_ => Sender.Tell(new Status.Failure(new InvalidOperationException( + "Mattermost gateway disconnect is already in progress.")))); + Receive(_ => Sender.Tell(new Status.Failure(new InvalidOperationException( + "Mattermost gateway disconnect is already in progress.")))); + Receive(HandleStopSucceeded); + Receive(HandleStopFailed); + Receive(_ => { }); + Receive(_ => { }); + ReceiveNotReadyIngress(); + ReceiveUnexpected(nameof(Disconnecting)); + } + + private void ReceiveCommon() + { + Receive(_ => Sender.Tell(CurrentSnapshot())); + Receive(HandleLogReceived); + Receive(HandleDispatchFailed); + } + + private void ReceiveNotReadyIngress() + { + Receive(DropMessageReceived); + } + + private void ReceiveUnexpected(string behaviorName) => + ReceiveAny(message => HandleWrongBehaviorMessage(message, behaviorName)); + + private Task OnMessageReceivedAsync(MattermostGatewayMessage message) + { + _self.Tell(new MattermostMessageReceived(message)); + return Task.CompletedTask; + } + + private Task OnConnectedAsync() + { + _self.Tell(MattermostConnected.Instance); + return Task.CompletedTask; + } + + private Task OnDisconnectedAsync(MattermostGatewayDisconnect disconnect) + { + _self.Tell(new MattermostDisconnected(disconnect)); + return Task.CompletedTask; + } + + private Task OnLogReceivedAsync(string message) + { + _self.Tell(new MattermostLogReceived(message)); + return Task.CompletedTask; + } + + private void StartConnecting(string serverUrl, string botToken, IActorRef replyTo) + { + _healthDetail = "Mattermost gateway connecting."; + _botUserId = null; + _botUsername = null; + _cleanReconnectEmitted = false; + _pendingConnectReplyTo = replyTo; + + var attempt = ++_connectAttempt; + Become(Connecting); + BeginStart(serverUrl, botToken, attempt); + } + + private void BeginStart(string serverUrl, string botToken, long attempt) + { + var self = Self; + Task startTask; + try + { + startTask = _transport.StartAsync(serverUrl, botToken); + } + catch (Exception ex) + { + self.Tell(new MattermostStartFailed(attempt, ex)); + return; + } + + startTask.ContinueWith( + task => self.Tell(task.IsCompletedSuccessfully + ? new MattermostStartSucceeded(attempt, task.Result) + : new MattermostStartFailed(attempt, UnwrapTaskException(task))), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private void HandleStartSucceeded(MattermostStartSucceeded started) + { + if (started.Attempt != _connectAttempt) + return; + + if (string.IsNullOrWhiteSpace(started.Identity.UserId) + || string.IsNullOrWhiteSpace(started.Identity.Username)) + { + var failure = new ChannelConnectException( + ChannelConnectFailureKind.Transient, + "Mattermost gateway connected but the current bot identity is unavailable."); + _healthDetail = failure.Message; + FailPendingConnect(failure); + Become(Disconnected); + return; + } + + if (!_transport.IsConnected) + { + var failure = new ChannelConnectException( + ChannelConnectFailureKind.Transient, + "Mattermost gateway started but the WebSocket is not connected."); + _healthDetail = failure.Message; + FailPendingConnect(failure); + Become(Disconnected); + return; + } + + _botUserId = new MattermostUserId(started.Identity.UserId); + _botUsername = started.Identity.Username; + TransitionToReady(); + CompletePendingConnect(CurrentSnapshot()); + } + + private void HandleStartFailed(MattermostStartFailed failed) + { + if (failed.Attempt != _connectAttempt) + return; + + _healthDetail = failed.Exception.Message; + FailPendingConnect(failed.Exception); + Become(Disconnected); + } + + private void StartDisconnecting(IActorRef replyTo) + { + ++_connectAttempt; + _healthDetail = "Mattermost gateway disconnecting."; + _cleanReconnectEmitted = false; + FailPendingConnect(new OperationCanceledException("Mattermost gateway disconnect requested.")); + Become(Disconnecting); + BeginStop(replyTo); + } + + private void BeginStop(IActorRef replyTo) + { + var self = Self; + Task stopTask; + try + { + stopTask = _transport.StopAsync(); + } + catch (Exception ex) + { + self.Tell(new MattermostStopFailed(replyTo, ex)); + return; + } + + stopTask.ContinueWith( + task => self.Tell(task.IsCompletedSuccessfully + ? new MattermostStopSucceeded(replyTo) + : new MattermostStopFailed(replyTo, UnwrapTaskException(task))), + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private void HandleStopSucceeded(MattermostStopSucceeded stopped) + { + _healthDetail = DisconnectedDetail; + _botUserId = null; + _botUsername = null; + Become(Disconnected); + stopped.ReplyTo.Tell(CurrentSnapshot()); + } + + private void HandleStopFailed(MattermostStopFailed failed) + { + _healthDetail = failed.Exception.Message; + Become(Disconnected); + failed.ReplyTo.Tell(new Status.Failure(failed.Exception)); + } + + private void HandleDisconnectedWhileReady(MattermostDisconnected disconnected) + { + var detail = EndSentence(BuildDisconnectDetail(disconnected.Disconnect)); + RequestCleanReconnect(detail + " A clean reconnect is required."); + } + + private void HandleDisconnectedWhileConnecting(MattermostDisconnected disconnected) + { + _healthDetail = BuildDisconnectDetail(disconnected.Disconnect); + } + + private void HandleDisconnectedWhileNotReady(MattermostDisconnected disconnected) + { + _healthDetail = BuildDisconnectDetail(disconnected.Disconnect); + } + + private void TransitionToReady() + { + _healthDetail = null; + _cleanReconnectEmitted = false; + Become(Ready); + } + + private void HandleMessageReceived(MattermostMessageReceived received) + { + if (!IsReadyCore()) + { + DropMessageReceived(received); + return; + } + + Dispatch( + "Mattermost message " + received.Message.EventId.Value, + () => _eventSink.PublishMessageAsync(received.Message)); + } + + private void DropMessageReceived(MattermostMessageReceived received) + { + _logger.LogWarning( + "Dropping Mattermost message {EventId} while gateway is not ready: {Reason}", + received.Message.EventId.Value, + CurrentSnapshot().HealthDetail); + } + + private void RequestCleanReconnect(string reason) + { + _healthDetail = reason; + FailPendingConnect(new ChannelConnectException(ChannelConnectFailureKind.Transient, reason)); + Become(CleanReconnectRequired); + + if (_cleanReconnectEmitted) + return; + + _cleanReconnectEmitted = true; + _logger.LogWarning("Gateway requested clean reconnect: {Reason}", reason); + Dispatch("Mattermost clean reconnect", () => _eventSink.PublishCleanReconnectRequiredAsync(reason)); + } + + private void HandleLogReceived(MattermostLogReceived received) => + _logger.LogDebug("[Mattermost.NET] {Message}", received.Message); + + private void HandleWrongBehaviorMessage(object message, string behaviorName) + { + switch (message) + { + case IMattermostGatewayConnectWork connectWork: + IgnoreWrongBehaviorConnectWork(connectWork, behaviorName); + break; + case IMattermostGatewayStopWork stopWork: + IgnoreWrongBehaviorStopWork(stopWork, behaviorName); + break; + case IMattermostGatewayInternalMessage: + _logger.LogDebug( + "Ignoring Mattermost gateway internal message {MessageType} while in {State} state.", + message.GetType().Name, + behaviorName); + break; + default: + _logger.LogWarning( + "Ignoring unexpected Mattermost gateway message {MessageType} while in {State} state.", + message.GetType().Name, + behaviorName); + break; + } + } + + private void IgnoreWrongBehaviorConnectWork( + IMattermostGatewayConnectWork connectWork, + string behaviorName) + { + _logger.LogDebug( + "Ignoring Mattermost gateway connect work {MessageType} for attempt {Attempt} while in {State} state; current attempt is {CurrentAttempt}.", + connectWork.GetType().Name, + connectWork.Attempt, + behaviorName, + _connectAttempt); + + if (_pendingConnectReplyTo is null) + return; + + if (connectWork is IMattermostGatewayConnectFailure failure) + FailPendingConnect(failure.Exception); + } + + private void IgnoreWrongBehaviorStopWork(IMattermostGatewayStopWork stopWork, string behaviorName) + { + _logger.LogDebug( + "Ignoring Mattermost gateway stop work {MessageType} while in {State} state.", + stopWork.GetType().Name, + behaviorName); + + if (stopWork is IMattermostGatewayStopFailure failure) + { + stopWork.ReplyTo.Tell(new Status.Failure(failure.Exception)); + return; + } + + stopWork.ReplyTo.Tell(CurrentSnapshot()); + } + + private MattermostGatewaySnapshot CurrentSnapshot() + { + var isReady = IsReadyCore(); + var healthDetail = isReady + ? null + : _healthDetail ?? (_transport.IsConnected + ? "Mattermost gateway connected but not ready." + : DisconnectedDetail); + + return new MattermostGatewaySnapshot( + IsConnected: _transport.IsConnected, + IsReady: isReady, + HealthDetail: healthDetail, + BotUserId: _botUserId, + BotUsername: _botUsername); + } + + private bool IsReadyCore() => + _isReadyBehavior + && _botUserId is not null + && !string.IsNullOrWhiteSpace(_botUsername) + && _transport.IsConnected; + + private void CompletePendingConnect(MattermostGatewaySnapshot snapshot) + { + var replyTo = _pendingConnectReplyTo; + _pendingConnectReplyTo = null; + replyTo?.Tell(snapshot); + } + + private void FailPendingConnect(Exception exception) + { + var replyTo = _pendingConnectReplyTo; + _pendingConnectReplyTo = null; + replyTo?.Tell(new Status.Failure(exception)); + } + + private void Dispatch(string operation, Func dispatch) + { + Task dispatchTask; + try + { + dispatchTask = dispatch(); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error handling {Operation}", operation); + return; + } + + if (dispatchTask.IsCompletedSuccessfully) + return; + + var self = Self; + dispatchTask.ContinueWith( + task => + { + if (!task.IsCompletedSuccessfully) + self.Tell(new DispatchFailed(operation, UnwrapTaskException(task))); + }, + CancellationToken.None, + TaskContinuationOptions.ExecuteSynchronously, + TaskScheduler.Default); + } + + private void HandleDispatchFailed(DispatchFailed failed) => + _logger.LogError(failed.Exception, "Error handling {Operation}", failed.Operation); + + private static string BuildDisconnectDetail(MattermostGatewayDisconnect disconnect) + { + var reason = disconnect.Exception?.Message ?? disconnect.Reason; + return string.IsNullOrWhiteSpace(reason) + ? DisconnectedDetail + : "Mattermost gateway disconnected: " + reason; + } + + private static string EndSentence(string message) => + message.EndsWith(".", StringComparison.Ordinal) ? message : message + "."; + + private static Exception UnwrapTaskException(Task task) + { + if (task.IsCanceled) + return new TaskCanceledException(task); + + return task.Exception?.GetBaseException() + ?? new InvalidOperationException("Mattermost gateway operation failed without an exception."); + } + + internal sealed record Connect(string ServerUrl, string BotToken); + + internal sealed record GetSnapshot + { + public static readonly GetSnapshot Instance = new(); + } + + internal sealed record Disconnect + { + public static readonly Disconnect Instance = new(); + } + + private interface IMattermostGatewayInternalMessage; + + private interface IMattermostGatewayConnectWork : IMattermostGatewayInternalMessage + { + long Attempt { get; } + } + + private interface IMattermostGatewayConnectFailure : IMattermostGatewayConnectWork + { + Exception Exception { get; } + } + + private interface IMattermostGatewayStopWork : IMattermostGatewayInternalMessage + { + IActorRef ReplyTo { get; } + } + + private interface IMattermostGatewayStopFailure : IMattermostGatewayStopWork + { + Exception Exception { get; } + } + + private sealed record MattermostStartSucceeded(long Attempt, MattermostBotIdentity Identity) : IMattermostGatewayConnectWork; + + private sealed record MattermostStartFailed(long Attempt, Exception Exception) : IMattermostGatewayConnectFailure; + + private sealed record MattermostStopSucceeded(IActorRef ReplyTo) : IMattermostGatewayStopWork; + + private sealed record MattermostStopFailed(IActorRef ReplyTo, Exception Exception) : IMattermostGatewayStopFailure; + + private sealed record MattermostConnected : IMattermostGatewayInternalMessage + { + public static readonly MattermostConnected Instance = new(); + } + + private sealed record MattermostDisconnected(MattermostGatewayDisconnect Disconnect) : IMattermostGatewayInternalMessage; + + private sealed record MattermostLogReceived(string Message) : IMattermostGatewayInternalMessage; + + private sealed record MattermostMessageReceived(MattermostGatewayMessage Message) : IMattermostGatewayInternalMessage; + + private sealed record DispatchFailed(string Operation, Exception Exception) : IMattermostGatewayInternalMessage; +} From 6fa167860cf7d6cdc97871b38ff138596ca151b2 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 9 Jun 2026 01:58:17 +0000 Subject: [PATCH 23/31] Fix Mattermost integration gateway build --- .../MattermostGatewayIntegrationTests.cs | 122 ++++++++++++------ ...hannels.Mattermost.IntegrationTests.csproj | 1 + 2 files changed, 83 insertions(+), 40 deletions(-) diff --git a/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostGatewayIntegrationTests.cs b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostGatewayIntegrationTests.cs index 4df0e1e08..05dbb3ba7 100644 --- a/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostGatewayIntegrationTests.cs +++ b/src/Netclaw.Channels.Mattermost.IntegrationTests/MattermostGatewayIntegrationTests.cs @@ -3,7 +3,12 @@ // Copyright (C) 2026 - 2026 Petabridge, LLC // // ----------------------------------------------------------------------- +using Akka.Configuration; +using Akka.Hosting; +using Akka.Hosting.TestKit; using Mattermost; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; using Netclaw.Channels.Mattermost.Transport; using Xunit; @@ -15,11 +20,11 @@ namespace Netclaw.Channels.Mattermost.IntegrationTests; /// Validates WebSocket event delivery, message normalization, and connection lifecycle. /// [Collection("Mattermost")] -public sealed class MattermostGatewayIntegrationTests : IAsyncLifetime +public sealed class MattermostGatewayIntegrationTests( + MattermostFixture fixture, + ITestOutputHelper output) : TestKit(output: output) { - private readonly MattermostFixture _fixture; - private MattermostClient? _botClient; - private MattermostNetGatewayClient? _gateway; + private readonly MattermostFixture _fixture = fixture; // Generous ceiling for a real WebSocket round-trip through a containerized // Mattermost server — a loaded CI runner is far slower than a dev machine. @@ -27,82 +32,119 @@ public sealed class MattermostGatewayIntegrationTests : IAsyncLifetime // only bounds the failure case; it does not slow the happy path. private static readonly TimeSpan EventTimeout = TimeSpan.FromSeconds(60); - public MattermostGatewayIntegrationTests(MattermostFixture fixture) - { - _fixture = fixture; - } + protected override Config? Config => + ConfigurationFactory.ParseString("akka.test.default-timeout = 30s"); - public async ValueTask InitializeAsync() + protected override void ConfigureServices(HostBuilderContext context, IServiceCollection services) { - _fixture.SkipIfUnavailable(); - _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() + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) { - if (_gateway is not null) - { - await _gateway.DisconnectAsync(); - _gateway.Dispose(); - } } [Fact] - public void BotUserId_is_resolved_after_connect() + public async Task BotUserId_is_resolved_after_connect() { - Assert.NotNull(_gateway!.BotUserId); - Assert.Equal(_fixture.BotUserId, _gateway.BotUserId!.Value.Value); + var gateway = await ConnectGatewayAsync(); + try + { + Assert.NotNull(gateway.BotUserId); + Assert.Equal(_fixture.BotUserId, gateway.BotUserId!.Value.Value); + } + finally + { + await DisconnectGatewayAsync(gateway); + } } [Fact] public async Task Receives_message_posted_by_test_user() { + var gateway = await ConnectGatewayAsync(); var ct = TestContext.Current.CancellationToken; var receivedTcs = new TaskCompletionSource(); - _gateway!.MessageReceived += msg => + gateway.MessageReceived += msg => { receivedTcs.TrySetResult(msg); return Task.CompletedTask; }; - await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "Hello from integration test"); + try + { + await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "Hello from integration test"); - var received = await receivedTcs.Task.WaitAsync(EventTimeout, ct); + var received = await receivedTcs.Task.WaitAsync(EventTimeout, ct); - Assert.Equal(_fixture.ChannelId, received.ChannelId.Value); - Assert.Contains("Hello from integration test", received.Text); - Assert.False(received.IsBotMessage); - Assert.False(received.IsDirectMessage); + Assert.Equal(_fixture.ChannelId, received.ChannelId.Value); + Assert.Contains("Hello from integration test", received.Text); + Assert.False(received.IsBotMessage); + Assert.False(received.IsDirectMessage); + } + finally + { + await DisconnectGatewayAsync(gateway); + } } [Fact] public async Task Thread_reply_has_root_post_id() { + var gateway = await ConnectGatewayAsync(); var ct = TestContext.Current.CancellationToken; var replyTcs = new TaskCompletionSource(); - _gateway!.MessageReceived += msg => + gateway.MessageReceived += msg => { if (!msg.RootPostId.IsEmpty) replyTcs.TrySetResult(msg); return Task.CompletedTask; }; - var rootPostId = await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "Thread root message"); + try + { + var rootPostId = await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "Thread root message"); + + await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "Thread reply message", rootId: rootPostId); - await _fixture.PostAsTestUserAsync(_fixture.ChannelId, "Thread reply message", rootId: rootPostId); + var reply = await replyTcs.Task.WaitAsync(EventTimeout, ct); - var reply = await replyTcs.Task.WaitAsync(EventTimeout, ct); + Assert.Equal(rootPostId, reply.RootPostId.Value); + Assert.Contains("Thread reply message", reply.Text); + } + finally + { + await DisconnectGatewayAsync(gateway); + } + } - Assert.Equal(rootPostId, reply.RootPostId.Value); - Assert.Contains("Thread reply message", reply.Text); + private async Task ConnectGatewayAsync() + { + _fixture.SkipIfUnavailable(); + var botClient = new MattermostClient(_fixture.ServerUrl, _fixture.BotToken); + var gateway = new MattermostNetGatewayClient( + Sys, + botClient, + TimeProvider.System, + NullLogger.Instance); + + try + { + await gateway.ConnectAsync(_fixture.ServerUrl, _fixture.BotToken, + TestContext.Current.CancellationToken); + return gateway; + } + catch + { + gateway.Dispose(); + throw; + } + } + + private static async Task DisconnectGatewayAsync(MattermostNetGatewayClient gateway) + { + await gateway.DisconnectAsync(); + gateway.Dispose(); } } diff --git a/src/Netclaw.Channels.Mattermost.IntegrationTests/Netclaw.Channels.Mattermost.IntegrationTests.csproj b/src/Netclaw.Channels.Mattermost.IntegrationTests/Netclaw.Channels.Mattermost.IntegrationTests.csproj index cb75824f8..87267022b 100644 --- a/src/Netclaw.Channels.Mattermost.IntegrationTests/Netclaw.Channels.Mattermost.IntegrationTests.csproj +++ b/src/Netclaw.Channels.Mattermost.IntegrationTests/Netclaw.Channels.Mattermost.IntegrationTests.csproj @@ -9,6 +9,7 @@ + From 47f80f2a3a27fd79ea4b4a67ea9997ad5ffcb96a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 9 Jun 2026 02:34:24 +0000 Subject: [PATCH 24/31] Fix session binding startup race --- .../Contracts/SessionBindingContractTests.cs | 11 ++++++++--- .../TestHelpers/RecordingSessionPipeline.cs | 9 +++++++-- .../DiscordSessionBindingActor.cs | 16 ++++++++-------- .../SlackThreadBindingActor.cs | 16 ++++++++-------- 4 files changed, 31 insertions(+), 21 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs index 3fad86000..043b12a5e 100644 --- a/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/SessionBindingContractTests.cs @@ -711,15 +711,20 @@ public async Task Stashes_messages_during_init() var pipeline = new RecordingSessionPipeline(_ => [ new TurnCompleted { SessionId = sid, TurnNumber = new Netclaw.Actors.Protocol.TurnNumber(1) } - ]); + ], reactive: true); var actor = CreateBindingActor(sid, pipeline, detector); // Send immediately — before pipeline init might complete actor.Tell(CreateInboundMessage("stashed message", "user-1"), TestActor); - // Pipeline should still initialize successfully - await AwaitAssertAsync(() => Assert.NotNull(pipeline.CapturedOptions), cancellationToken: ct); + await pipeline.Created.WaitAsync(ct); + + await AwaitAssertAsync(() => + { + Assert.Contains(pipeline.CapturedInputs, input => + input.Contents.OfType().Any(content => content.Text == "stashed message")); + }, cancellationToken: ct); } protected virtual IActorRef CreateBindingActorWithPipeline( diff --git a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingSessionPipeline.cs b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingSessionPipeline.cs index ad8e3466c..e20396bf4 100644 --- a/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingSessionPipeline.cs +++ b/src/Netclaw.Actors.Tests/Channels/TestHelpers/RecordingSessionPipeline.cs @@ -20,6 +20,9 @@ public sealed class RecordingSessionPipeline : ISessionPipeline private readonly List _recordedFeedback = []; private readonly Func> _outputFactory; private readonly bool _reactive; + private readonly TaskCompletionSource _created = new( + TaskCreationOptions.RunContinuationsAsynchronously); + private SessionPipelineOptions? _capturedOptions; /// /// Creates a recording pipeline. @@ -45,7 +48,8 @@ public RecordingSessionPipeline( _reactive = reactive; } - public SessionPipelineOptions? CapturedOptions { get; private set; } + public SessionPipelineOptions? CapturedOptions => Volatile.Read(ref _capturedOptions); + public Task Created => _created.Task; public IReadOnlyList RecordedFeedback { get { lock (_feedbackLock) return _recordedFeedback.ToList(); } @@ -60,7 +64,8 @@ public Task CreateAsync( IMaterializer? materializer = null, CancellationToken cancellationToken = default) { - CapturedOptions = options; + Volatile.Write(ref _capturedOptions, options); + _created.TrySetResult(options); var killSwitch = KillSwitches.Shared($"recording-{sessionId.Value}"); var outputs = _outputFactory(sessionId).ToList(); diff --git a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs index 3732751b3..212c9a664 100644 --- a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs +++ b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs @@ -112,10 +112,10 @@ public DiscordSessionBindingActor( Recover(ApplyCursorAdvanced); Recover(ApplyPendingApprovalPromptTracked); Recover(ApplyPendingApprovalPromptCleared); - // After journal replay completes, queue a one-shot hydration. The - // self-tell lands in the mailbox after InitializePipeline (from - // PreStart), so the actor finishes pipeline init first, then - // transitions into Hydrating and processes PerformHydration. + // After journal replay completes, queue a one-shot hydration. Recovery + // can beat pipeline initialization on slower dispatchers; Initializing + // unstashes after switching to Hydrating so the hydration trigger cannot + // strand the actor in startup. Recover(_ => Self.Tell(PerformHydration.Instance)); Initializing(); @@ -164,10 +164,10 @@ private void Initializing() { await EnsureInitializedAsync(); Become(Hydrating); - // Do NOT UnstashAll here. PerformHydration is already in the - // mailbox (sent from the RecoveryCompleted handler) and will be - // processed next by the Hydrating behavior. Stashed live - // inbounds stay stashed until Hydrating transitions to Active. + // RecoveryCompleted can be stashed while pipeline initialization + // is still running. Move it into Hydrating; live inbounds are + // re-stashed there until hydration finishes. + Stash.UnstashAll(); } catch (Exception ex) { diff --git a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs index 72ee2ec5c..5b14a342a 100644 --- a/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs +++ b/src/Netclaw.Channels.Slack/SlackThreadBindingActor.cs @@ -98,10 +98,10 @@ public SlackThreadBindingActor( Recover(ApplyCursorAdvanced); Recover(ApplyPendingApprovalPromptTracked); Recover(ApplyPendingApprovalPromptCleared); - // After journal replay completes, queue a one-shot hydration. The - // self-tell lands in the mailbox after InitializePipeline (from - // PreStart), so the actor finishes pipeline init first, then - // transitions into Hydrating and processes PerformHydration. + // After journal replay completes, queue a one-shot hydration. Recovery + // can beat pipeline initialization on slower dispatchers; Initializing + // unstashes after switching to Hydrating so the hydration trigger cannot + // strand the actor in startup. Recover(_ => Self.Tell(PerformHydration.Instance)); Initializing(); @@ -138,10 +138,10 @@ private void Initializing() { await EnsureInitializedAsync(); Become(Hydrating); - // Do NOT UnstashAll here. PerformHydration is already in the - // mailbox (sent from the RecoveryCompleted handler) and will be - // processed next by the Hydrating behavior. Stashed live - // inbounds stay stashed until Hydrating transitions to Active. + // RecoveryCompleted can be stashed while pipeline initialization + // is still running. Move it into Hydrating; live inbounds are + // re-stashed there until hydration finishes. + Stash.UnstashAll(); } catch (Exception ex) { From 8678e6f7b101a2b487f73c0fd99691770fc0ece2 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 9 Jun 2026 12:54:17 +0000 Subject: [PATCH 25/31] Fix channel descriptor bugs and consolidate registration Correctness fixes: - Fix Discord DM proactive thread flag (_threadCreated false for DMs) - Remove thread_or_root_id param from send tool (caused unconditional error) - Split shared CancellationTokenSource in Mattermost file upload/post - Remove Thread from channel AddressKinds (no resolver handles it) - Align SupportedOutputEffects with registered renderers - Fix StartReconnectLoop double-ContinueWith race - Replace per-access Concat().ToHashSet() with static sets in DiscordAddressResolver Consolidation: - Extract ChannelDescriptor.CreateRemoteChat static factory (-99 lines) - Move ChannelAddressKindWire to Netclaw.Channels (correct layering) - Simplify SendChannelMessageTool dispatch to switch expression - Replace ContinueWith reconnect callback with Volatile flag + DrainQueuedReconnect --- .../DiscordAddressResolver.cs | 17 ++- .../DiscordSessionBindingActor.cs | 2 +- .../MattermostChannel.cs | 30 ++--- .../MattermostSessionBindingActor.cs | 7 +- .../ChannelDeliveryContracts.cs | 109 +++++++++++++++++- .../Configuration/ChannelSendTools.cs | 104 +++-------------- .../DiscordChannelRegistrationExtensions.cs | 46 +------- ...MattermostChannelRegistrationExtensions.cs | 44 +------ .../SlackChannelRegistrationExtensions.cs | 44 +------ 9 files changed, 152 insertions(+), 251 deletions(-) diff --git a/src/Netclaw.Channels.Discord/DiscordAddressResolver.cs b/src/Netclaw.Channels.Discord/DiscordAddressResolver.cs index ac5c14455..cdc32b652 100644 --- a/src/Netclaw.Channels.Discord/DiscordAddressResolver.cs +++ b/src/Netclaw.Channels.Discord/DiscordAddressResolver.cs @@ -31,27 +31,24 @@ public sealed class DiscordAddressResolver( DiscordChannelOptions options, Func defaultChannelIdAccessor) : IChannelAddressResolver { - private static readonly IReadOnlySet UserAddressKinds = new HashSet - { - ChannelAddressKind.User - }; - - private static readonly IReadOnlySet UserAndDirectMessageAddressKinds = new HashSet + private static readonly IReadOnlySet UserAndDestinationKinds = new HashSet { ChannelAddressKind.User, - ChannelAddressKind.DirectMessage + ChannelAddressKind.Destination }; - private static readonly IReadOnlySet DestinationAddressKinds = new HashSet + private static readonly IReadOnlySet UserDmAndDestinationKinds = new HashSet { + ChannelAddressKind.User, + ChannelAddressKind.DirectMessage, ChannelAddressKind.Destination }; public ChannelDescriptorKey Key { get; } = ChannelDescriptorKey.FromChannelType(ChannelType.Discord); public IReadOnlySet AddressKinds => options.AllowDirectMessages - ? UserAndDirectMessageAddressKinds.Concat(DestinationAddressKinds).ToHashSet() - : UserAddressKinds.Concat(DestinationAddressKinds).ToHashSet(); + ? UserDmAndDestinationKinds + : UserAndDestinationKinds; public async ValueTask ResolveAsync( ChannelAddressResolutionRequest request, diff --git a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs index 212c9a664..ef3d820f6 100644 --- a/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs +++ b/src/Netclaw.Channels.Discord/DiscordSessionBindingActor.cs @@ -265,7 +265,7 @@ await _handle.ReinitializeAsync( private async Task HandleProactiveThreadAsync(StartProactiveThread message) { _replyChannelId = message.ReplyChannelId; - _threadCreated = message.RootMessageId is null; + _threadCreated = message.DirectMessageUserId is not null || message.RootMessageId is null; _rootMessageId = message.RootMessageId; _log.Info("Initializing proactive thread pipeline for session {0}", message.SessionId.Value); diff --git a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs index 8a8129250..d050a15bc 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs @@ -249,33 +249,19 @@ private void StartReconnectLoop(TimeSpan initialDelay) { lock (_reconnectLock) { - if (_reconnectTask is { IsCompleted: false } activeReconnect) + if (_reconnectTask is { IsCompleted: false }) { if (initialDelay == TimeSpan.Zero) - { - Interlocked.Exchange(ref _queuedCleanReconnect, 1); - _ = activeReconnect.ContinueWith( - _ => StartQueuedCleanReconnect(), - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - } + Volatile.Write(ref _queuedCleanReconnect, 1); return; } + Volatile.Write(ref _queuedCleanReconnect, 0); _reconnectTask = Task.Run(() => ReconnectLoopAsync(initialDelay, _lifetimeCts.Token)); } } - private void StartQueuedCleanReconnect() - { - if (Volatile.Read(ref _queuedCleanReconnect) == 0) - return; - - StartReconnectLoop(initialDelay: TimeSpan.Zero); - } - private Task HandleCleanReconnectRequiredAsync(string reason) { _connectFailureDetail = reason; @@ -284,6 +270,9 @@ private Task HandleCleanReconnectRequiredAsync(string reason) return Task.CompletedTask; } + private bool DrainQueuedReconnect() + => Interlocked.Exchange(ref _queuedCleanReconnect, 0) == 1; + private async Task ReconnectLoopAsync(TimeSpan initialDelay, CancellationToken cancellationToken) { var delay = initialDelay; @@ -331,7 +320,7 @@ private async Task ReconnectLoopAsync(TimeSpan initialDelay, CancellationToken c _connectFailureDetail = null; _logger.LogInformation("Channel reconnected after a transient failure."); - if (Interlocked.Exchange(ref _queuedCleanReconnect, 0) == 1) + if (DrainQueuedReconnect()) { delay = TimeSpan.Zero; continue; @@ -355,6 +344,11 @@ private async Task ReconnectLoopAsync(TimeSpan initialDelay, CancellationToken c "Mattermost reconnect hit a fatal failure; giving up until the daemon " + "is restarted. {Reason}", classified.Message); + + // A clean reconnect was queued while this loop was running. + // Fatal failures are config-driven so a queued reconnect + // would hit the same wall — don't restart. + DrainQueuedReconnect(); return; } diff --git a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs index a81fa7b1b..40e832466 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostSessionBindingActor.cs @@ -1281,15 +1281,16 @@ private async Task SafeUploadFileAsync(FileOutput file) return false; } - using var cts = new CancellationTokenSource(OperationTimeout); + using var uploadCts = new CancellationTokenSource(OperationTimeout); var fileId = await _dependencies.ReplyClient.UploadFileAsync( _channelId, file.FilePath, file.FileName, - cts.Token); + uploadCts.Token); + using var postCts = new CancellationTokenSource(OperationTimeout); var postMessage = BuildPostMessage($":paperclip: {file.FileName}", fileIds: [fileId]); - await _dependencies.ReplyClient.PostReplyAsync(postMessage, cts.Token); + await _dependencies.ReplyClient.PostReplyAsync(postMessage, postCts.Token); var duration = _dependencies.TimeProvider.GetElapsedTime(startedAt).TotalMilliseconds; ChannelTelemetry.For(ChannelType.Mattermost).RecordReplyPosted(duration); diff --git a/src/Netclaw.Channels/ChannelDeliveryContracts.cs b/src/Netclaw.Channels/ChannelDeliveryContracts.cs index 494a247c6..41764eb39 100644 --- a/src/Netclaw.Channels/ChannelDeliveryContracts.cs +++ b/src/Netclaw.Channels/ChannelDeliveryContracts.cs @@ -93,7 +93,59 @@ public sealed record ChannelDescriptor( ChannelCapabilities Capabilities, IReadOnlySet ToolIntents, IReadOnlySet AddressKinds, - IReadOnlySet SupportedOutputEffects); + IReadOnlySet SupportedOutputEffects) +{ + private static readonly ChannelCapabilities RemoteChatBaseCapabilities = + ChannelCapabilities.ReceiveMessages + | ChannelCapabilities.SendMessages + | ChannelCapabilities.ThreadedConversations + | ChannelCapabilities.InteractiveApproval + | ChannelCapabilities.FileIngress + | ChannelCapabilities.FileEgress + | ChannelCapabilities.ProactiveSend + | ChannelCapabilities.UserLookup + | ChannelCapabilities.DestinationLookup + | ChannelCapabilities.RuntimeHealth; + + private static readonly IReadOnlySet RemoteChatToolIntents = + new HashSet + { + ChannelToolIntentKind.SendMessage, + ChannelToolIntentKind.LookupUser + }; + + public static ChannelDescriptor CreateRemoteChat( + ChannelType channelType, + string displayName, + bool isEnabled, + bool allowDirectMessages, + IReadOnlySet? supportedOutputEffects = null) + { + var capabilities = RemoteChatBaseCapabilities; + if (allowDirectMessages) + capabilities |= ChannelCapabilities.DirectMessages; + + var addressKinds = new HashSet + { + ChannelAddressKind.Destination, + ChannelAddressKind.User + }; + + if (allowDirectMessages) + addressKinds.Add(ChannelAddressKind.DirectMessage); + + return new ChannelDescriptor( + ChannelDescriptorKey.FromChannelType(channelType), + channelType, + ChannelKind.RemoteChat, + displayName, + isEnabled, + capabilities, + RemoteChatToolIntents, + addressKinds, + supportedOutputEffects ?? new HashSet()); + } +} public sealed record ResolvedChannelAddress { @@ -689,3 +741,58 @@ public static IServiceCollection AddTuiChannelDescriptor(this IServiceCollection })); } } + +public static class ChannelAddressKindWire +{ + public static string ToWireValue(ChannelAddressKind addressKind) + => addressKind switch + { + ChannelAddressKind.Destination => "destination", + ChannelAddressKind.User => "user", + ChannelAddressKind.Thread => "thread", + ChannelAddressKind.DirectMessage => "direct_message", + ChannelAddressKind.LocalSession => "local_session", + _ => addressKind.ToString().ToLowerInvariant() + }; + + public static bool TryParse(string value, out ChannelAddressKind addressKind) + { + var normalized = Normalize(value); + switch (normalized) + { + case "destination": + case "channel": + addressKind = ChannelAddressKind.Destination; + return true; + case "user": + addressKind = ChannelAddressKind.User; + return true; + case "thread": + addressKind = ChannelAddressKind.Thread; + return true; + case "directmessage": + case "dm": + addressKind = ChannelAddressKind.DirectMessage; + return true; + case "localsession": + addressKind = ChannelAddressKind.LocalSession; + return true; + default: + addressKind = default; + return false; + } + } + + private static string Normalize(string value) + { + var buffer = new char[value.Length]; + var count = 0; + foreach (var ch in value) + { + if (char.IsLetterOrDigit(ch)) + buffer[count++] = char.ToLowerInvariant(ch); + } + + return count == 0 ? string.Empty : new string(buffer, 0, count); + } +} diff --git a/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs b/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs index c82d3515c..9206252e9 100644 --- a/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs +++ b/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs @@ -116,47 +116,35 @@ private async Task ExecuteCoreAsync( return $"Error: Channel '{key}' does not support message sending."; } - var threadOrRootId = ToolArgumentHelper.GetString(arguments, "thread_or_root_id"); - if (IsTriggerOrigin(context) && context!.RequestedDeliveryTarget is { } requestedTarget) { - var targetError = ValidateRequestedTriggerTarget(key, destination, threadOrRootId, requestedTarget); + var targetError = ValidateRequestedTriggerTarget(key, destination, requestedTarget); if (targetError is not null) return targetError; } - if (!string.IsNullOrWhiteSpace(threadOrRootId)) - { - return "Error: 'thread_or_root_id' is not supported by send_channel_message yet. " + - "Omit it to create a new channel-specific conversation thread."; - } - var validationError = ValidateDestination(descriptor, destination); if (validationError is not null) return validationError; + var idParamName = destination.AddressKind == ChannelAddressKind.DirectMessage ? "UserId" : "ChannelId"; var delegateArguments = new Dictionary { - ["Message"] = text.Trim() + ["Message"] = text.Trim(), + [idParamName] = destination.StableId }; - switch (descriptor.ChannelType) + INetclawTool? sendTool = descriptor.ChannelType switch { - case ChannelType.Slack: - delegateArguments[destination.AddressKind == ChannelAddressKind.DirectMessage ? "UserId" : "ChannelId"] = destination.StableId; - return await services.GetRequiredService().ExecuteAsync(delegateArguments, ct); - - case ChannelType.Discord: - delegateArguments[destination.AddressKind == ChannelAddressKind.DirectMessage ? "UserId" : "ChannelId"] = destination.StableId; - return await services.GetRequiredService().ExecuteAsync(delegateArguments, ct); - - case ChannelType.Mattermost: - delegateArguments[destination.AddressKind == ChannelAddressKind.DirectMessage ? "UserId" : "ChannelId"] = destination.StableId; - return await services.GetRequiredService().ExecuteAsync(delegateArguments, ct); + ChannelType.Slack => services.GetRequiredService(), + ChannelType.Discord => services.GetRequiredService(), + ChannelType.Mattermost => services.GetRequiredService(), + _ => null + }; - default: - return $"Error: Channel '{key}' does not have a registered send adapter."; - } + return sendTool is not null + ? await sendTool.ExecuteAsync(delegateArguments, ct) + : $"Error: Channel '{key}' does not have a registered send adapter."; } private JsonElement BuildParameterSchema() @@ -206,10 +194,6 @@ private JsonElement BuildParameterSchema() "type": "string", "description": "Message text to send." }, - "thread_or_root_id": { - "type": "string", - "description": "Optional existing thread/root target. Currently unsupported; omit to create a new conversation thread." - }, "_rationale": { "type": "string", "description": "State your intent for this tool call in one sentence - what are you trying to accomplish and why?" @@ -352,7 +336,6 @@ private static bool TryReadDestination( private static string? ValidateRequestedTriggerTarget( ChannelDescriptorKey channelKey, SendChannelDestination destination, - string? threadOrRootId, ChannelDeliveryTargetInfo requestedTarget) { if (!string.Equals(requestedTarget.ChannelKey, channelKey.Value, StringComparison.OrdinalIgnoreCase)) @@ -376,12 +359,6 @@ private static bool TryReadDestination( $"destination.id '{requestedTarget.DestinationId}'."; } - var suppliedThread = string.IsNullOrWhiteSpace(threadOrRootId) ? null : threadOrRootId.Trim(); - if (!string.Equals(suppliedThread, requestedTarget.ThreadOrRootId, StringComparison.Ordinal)) - { - return "Error: trigger-originated channel send must match the configured delivery target thread_or_root_id."; - } - return null; } @@ -478,58 +455,3 @@ private readonly record struct SendChannelDestination( string StableId, string? DisplayName); } - -internal static class ChannelAddressKindWire -{ - public static string ToWireValue(ChannelAddressKind addressKind) - => addressKind switch - { - ChannelAddressKind.Destination => "destination", - ChannelAddressKind.User => "user", - ChannelAddressKind.Thread => "thread", - ChannelAddressKind.DirectMessage => "direct_message", - ChannelAddressKind.LocalSession => "local_session", - _ => addressKind.ToString().ToLowerInvariant() - }; - - public static bool TryParse(string value, out ChannelAddressKind addressKind) - { - var normalized = Normalize(value); - switch (normalized) - { - case "destination": - case "channel": - addressKind = ChannelAddressKind.Destination; - return true; - case "user": - addressKind = ChannelAddressKind.User; - return true; - case "thread": - addressKind = ChannelAddressKind.Thread; - return true; - case "directmessage": - case "dm": - addressKind = ChannelAddressKind.DirectMessage; - return true; - case "localsession": - addressKind = ChannelAddressKind.LocalSession; - return true; - default: - addressKind = default; - return false; - } - } - - private static string Normalize(string value) - { - var buffer = new char[value.Length]; - var count = 0; - foreach (var ch in value) - { - if (char.IsLetterOrDigit(ch)) - buffer[count++] = char.ToLowerInvariant(ch); - } - - return count == 0 ? string.Empty : new string(buffer, 0, count); - } -} diff --git a/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs index ce77ca95e..9b01215ea 100644 --- a/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs @@ -104,50 +104,10 @@ public static void AddDiscordChannelIntegration(this IServiceCollection services } private static ChannelDescriptor CreateDescriptor(DiscordChannelOptions options) - { - var capabilities = ChannelCapabilities.ReceiveMessages - | ChannelCapabilities.SendMessages - | ChannelCapabilities.ThreadedConversations - | ChannelCapabilities.InteractiveApproval - | ChannelCapabilities.FileIngress - | ChannelCapabilities.FileEgress - | ChannelCapabilities.ProactiveSend - | ChannelCapabilities.UserLookup - | ChannelCapabilities.DestinationLookup - | ChannelCapabilities.RuntimeHealth; - - if (options.AllowDirectMessages) - capabilities |= ChannelCapabilities.DirectMessages; - - var addressKinds = new HashSet - { - ChannelAddressKind.Destination, - ChannelAddressKind.User, - ChannelAddressKind.Thread - }; - - if (options.AllowDirectMessages) - addressKinds.Add(ChannelAddressKind.DirectMessage); - - return new ChannelDescriptor( - ChannelDescriptorKey.FromChannelType(ChannelType.Discord), + => ChannelDescriptor.CreateRemoteChat( ChannelType.Discord, - ChannelKind.RemoteChat, "Discord", options.Enabled, - capabilities, - ToolIntents: new HashSet - { - ChannelToolIntentKind.SendMessage, - ChannelToolIntentKind.LookupUser - }, - AddressKinds: addressKinds, - SupportedOutputEffects: new HashSet - { - ChannelOutputEffectKind.TextMessage, - ChannelOutputEffectKind.InteractiveApproval, - ChannelOutputEffectKind.FileAttachment, - ChannelOutputEffectKind.ProcessingIndicator - }); - } + options.AllowDirectMessages, + new HashSet { ChannelOutputEffectKind.ProcessingIndicator }); } diff --git a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs index 3747c945c..1ea1fb6b4 100644 --- a/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/MattermostChannelRegistrationExtensions.cs @@ -129,49 +129,9 @@ public static void AddMattermostChannelIntegration(this IServiceCollection servi } private static ChannelDescriptor CreateDescriptor(MattermostChannelOptions options) - { - var capabilities = ChannelCapabilities.ReceiveMessages - | ChannelCapabilities.SendMessages - | ChannelCapabilities.ThreadedConversations - | ChannelCapabilities.InteractiveApproval - | ChannelCapabilities.FileIngress - | ChannelCapabilities.FileEgress - | ChannelCapabilities.ProactiveSend - | ChannelCapabilities.UserLookup - | ChannelCapabilities.DestinationLookup - | ChannelCapabilities.RuntimeHealth; - - if (options.AllowDirectMessages) - capabilities |= ChannelCapabilities.DirectMessages; - - var addressKinds = new HashSet - { - ChannelAddressKind.Destination, - ChannelAddressKind.User, - ChannelAddressKind.Thread - }; - - if (options.AllowDirectMessages) - addressKinds.Add(ChannelAddressKind.DirectMessage); - - return new ChannelDescriptor( - ChannelDescriptorKey.FromChannelType(ChannelType.Mattermost), + => ChannelDescriptor.CreateRemoteChat( ChannelType.Mattermost, - ChannelKind.RemoteChat, "Mattermost", options.Enabled, - capabilities, - ToolIntents: new HashSet - { - ChannelToolIntentKind.SendMessage, - ChannelToolIntentKind.LookupUser - }, - AddressKinds: addressKinds, - SupportedOutputEffects: new HashSet - { - ChannelOutputEffectKind.TextMessage, - ChannelOutputEffectKind.InteractiveApproval, - ChannelOutputEffectKind.FileAttachment - }); - } + options.AllowDirectMessages); } diff --git a/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs index 1d74bd50d..40eb15557 100644 --- a/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs +++ b/src/Netclaw.Daemon/Configuration/SlackChannelRegistrationExtensions.cs @@ -126,49 +126,9 @@ public static void AddSlackChannelIntegration(this IServiceCollection services, } private static ChannelDescriptor CreateDescriptor(SlackChannelOptions options) - { - var capabilities = ChannelCapabilities.ReceiveMessages - | ChannelCapabilities.SendMessages - | ChannelCapabilities.ThreadedConversations - | ChannelCapabilities.InteractiveApproval - | ChannelCapabilities.FileIngress - | ChannelCapabilities.FileEgress - | ChannelCapabilities.ProactiveSend - | ChannelCapabilities.UserLookup - | ChannelCapabilities.DestinationLookup - | ChannelCapabilities.RuntimeHealth; - - if (options.AllowDirectMessages) - capabilities |= ChannelCapabilities.DirectMessages; - - var addressKinds = new HashSet - { - ChannelAddressKind.Destination, - ChannelAddressKind.User, - ChannelAddressKind.Thread - }; - - if (options.AllowDirectMessages) - addressKinds.Add(ChannelAddressKind.DirectMessage); - - return new ChannelDescriptor( - ChannelDescriptorKey.FromChannelType(ChannelType.Slack), + => ChannelDescriptor.CreateRemoteChat( ChannelType.Slack, - ChannelKind.RemoteChat, "Slack", options.Enabled, - capabilities, - ToolIntents: new HashSet - { - ChannelToolIntentKind.SendMessage, - ChannelToolIntentKind.LookupUser - }, - AddressKinds: addressKinds, - SupportedOutputEffects: new HashSet - { - ChannelOutputEffectKind.TextMessage, - ChannelOutputEffectKind.InteractiveApproval, - ChannelOutputEffectKind.FileAttachment - }); - } + options.AllowDirectMessages); } From 07ffba75ef6499003a56f959f6217321cff5e5be Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 9 Jun 2026 13:41:52 +0000 Subject: [PATCH 26/31] Actorize Mattermost reconnect loop into lifecycle actor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the reconnect/retry logic from MattermostChannel's manual concurrency primitives (CancellationTokenSource, lock, Volatile, Interlocked) into MattermostNetGatewayLifecycleActor's state machine. The lifecycle actor now owns auto-reconnect with exponential backoff (5s → 5min), failure classification (fatal vs transient), and a RetryConnect timer message. MattermostChannel subscribes to a new ConnectionRestored event and simply completes setup on reconnect. Net removal: ~150 lines of thread-unsafe reconnect loop code replaced by actor-native timer scheduling. --- .../MattermostGatewayLifecycleActorTests.cs | 25 ++- .../MattermostChannel.cs | 150 ++---------------- .../MattermostTransportContracts.cs | 12 ++ .../Transport/MattermostNetGatewayClient.cs | 7 + .../MattermostNetGatewayLifecycleActor.cs | 124 +++++++++++++-- 5 files changed, 163 insertions(+), 155 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostGatewayLifecycleActorTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostGatewayLifecycleActorTests.cs index c79dbf349..8e6f2a491 100644 --- a/src/Netclaw.Actors.Tests/Channels/MattermostGatewayLifecycleActorTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/MattermostGatewayLifecycleActorTests.cs @@ -56,6 +56,10 @@ public async Task Runtime_disconnect_reports_not_ready_and_requests_clean_reconn await transport.RaiseDisconnectedAsync("network lost"); + // The actor detects the runtime disconnect, emits CleanReconnectRequired, + // then drives a full stop/reconnect cycle. After the cycle completes the + // actor lands back in Disconnected (not ready, not connected) with + // auto-reconnect scheduled by the lifecycle actor. await AwaitAssertAsync(async () => { var snapshot = await actor.Ask( @@ -65,9 +69,6 @@ await AwaitAssertAsync(async () => Assert.False(snapshot.IsReady); Assert.False(snapshot.IsConnected); - Assert.Equal( - "Mattermost gateway disconnected: network lost. A clean reconnect is required.", - snapshot.HealthDetail); Assert.Equal(1, sink.CleanReconnectCount); }, cancellationToken: TestContext.Current.CancellationToken); } @@ -83,6 +84,11 @@ public async Task Connected_event_while_disconnected_requires_clean_reconnect() transport.IsConnected = true; await transport.RaiseConnectedAsync(); + // The actor detects a spurious Connected event while in Disconnected + // state and forces a clean reconnect cycle: it emits the + // CleanReconnectRequired event, then drives a full stop/reconnect. + // The fake transport's StopAsync sets IsConnected = false, so the + // actor ends up back in Disconnected with IsConnected = false. await AwaitAssertAsync(async () => { var snapshot = await actor.Ask( @@ -91,10 +97,7 @@ await AwaitAssertAsync(async () => TestContext.Current.CancellationToken); Assert.False(snapshot.IsReady); - Assert.True(snapshot.IsConnected); - Assert.Equal( - "Mattermost gateway reconnected outside a clean startup cycle; forcing a clean reconnect.", - snapshot.HealthDetail); + Assert.False(snapshot.IsConnected); Assert.Equal(1, sink.CleanReconnectCount); }, cancellationToken: TestContext.Current.CancellationToken); } @@ -197,6 +200,14 @@ public Task PublishCleanReconnectRequiredAsync(string reason) CleanReconnectCount++; return Task.CompletedTask; } + + public int ConnectionRestoredCount { get; private set; } + + public Task PublishConnectionRestoredAsync(MattermostGatewaySnapshot snapshot) + { + ConnectionRestoredCount++; + return Task.CompletedTask; + } } private sealed class FakeMattermostGatewayTransport : IMattermostGatewayTransport diff --git a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs index d050a15bc..ce6228a4e 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs @@ -34,13 +34,7 @@ public sealed class MattermostChannel : IChannel private readonly NetclawPaths _paths; private readonly MattermostCallbackActionStore? _callbackActionStore; - // Cancels the background reconnect loop when the channel stops. - private readonly CancellationTokenSource _lifetimeCts = new(); - private readonly object _reconnectLock = new(); - private int _queuedCleanReconnect; - private IActorRef? _gateway; - private Task? _reconnectTask; private volatile string? _connectFailureDetail; internal IActorRef? Gateway => _gateway; @@ -90,6 +84,7 @@ public MattermostChannel( _callbackActionStore = callbackActionStore; _gatewayClient.CleanReconnectRequired += HandleCleanReconnectRequiredAsync; + _gatewayClient.ConnectionRestored += HandleConnectionRestoredAsync; } public ChannelType ChannelType => ChannelType.Mattermost; @@ -226,8 +221,6 @@ private void HandleConnectFailure(ChannelConnectException failure) if (failure.IsFatal) { - // Retrying will not help — the operator must fix the configuration. - // The rest of the daemon keeps running. _logger.LogError( failure, "Mattermost channel could not connect and will stay offline until the " @@ -237,130 +230,32 @@ private void HandleConnectFailure(ChannelConnectException failure) return; } + // Transient failures are retried by the lifecycle actor's built-in + // auto-reconnect. The channel just logs and waits for the + // ConnectionRestored event. _logger.LogWarning( failure, - "Mattermost channel could not connect (transient). The daemon will keep running " - + "and retry the connection in the background. {Reason}", + "Mattermost channel could not connect (transient). The lifecycle actor will " + + "retry the connection in the background. {Reason}", failure.Message); - StartReconnectLoop(initialDelay: TimeSpan.FromSeconds(5)); - } - - private void StartReconnectLoop(TimeSpan initialDelay) - { - lock (_reconnectLock) - { - if (_reconnectTask is { IsCompleted: false }) - { - if (initialDelay == TimeSpan.Zero) - Volatile.Write(ref _queuedCleanReconnect, 1); - - return; - } - - Volatile.Write(ref _queuedCleanReconnect, 0); - _reconnectTask = Task.Run(() => ReconnectLoopAsync(initialDelay, _lifetimeCts.Token)); - } } private Task HandleCleanReconnectRequiredAsync(string reason) { _connectFailureDetail = reason; _logger.LogWarning("Gateway requested clean reconnect: {Reason}", reason); - StartReconnectLoop(initialDelay: TimeSpan.Zero); return Task.CompletedTask; } - private bool DrainQueuedReconnect() - => Interlocked.Exchange(ref _queuedCleanReconnect, 0) == 1; - - private async Task ReconnectLoopAsync(TimeSpan initialDelay, CancellationToken cancellationToken) + private Task HandleConnectionRestoredAsync(MattermostGatewaySnapshot snapshot) { - var delay = initialDelay; - var maxDelay = TimeSpan.FromMinutes(5); - - while (!cancellationToken.IsCancellationRequested) - { - if (delay > TimeSpan.Zero) - { - try - { - await Task.Delay(delay, _timeProvider, cancellationToken); - } - catch (OperationCanceledException) - { - return; - } - } - - // Reset transport state so the retry performs a clean login + connect. - try - { - await _gatewayClient.DisconnectAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Transport reset before reconnect failed; continuing."); - } - - try - { - if (string.IsNullOrWhiteSpace(_options.ServerUrl)) - throw new ChannelConnectException( - ChannelConnectFailureKind.Fatal, - "Mattermost is enabled but Mattermost:ServerUrl is not configured."); - - if (_options.BotToken.IsNullOrEmpty()) - throw new ChannelConnectException( - ChannelConnectFailureKind.Fatal, - "Mattermost is enabled but no bot token is configured."); - - var gatewaySnapshot = await _gatewayClient.ConnectAsync(_options.ServerUrl, _options.BotToken.Value, cancellationToken); - EnsureGatewayReadyAfterConnect(gatewaySnapshot); - CompleteConnectionSetup(_options.ServerUrl, gatewaySnapshot.BotUserId, gatewaySnapshot.BotUsername); - _connectFailureDetail = null; - _logger.LogInformation("Channel reconnected after a transient failure."); - - if (DrainQueuedReconnect()) - { - delay = TimeSpan.Zero; - continue; - } - - return; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - return; - } - catch (Exception ex) - { - var classified = MattermostConnectFailureClassifier.Classify(ex); - _connectFailureDetail = classified.Message; - - if (classified.IsFatal) - { - _logger.LogError( - classified, - "Mattermost reconnect hit a fatal failure; giving up until the daemon " - + "is restarted. {Reason}", - classified.Message); - - // A clean reconnect was queued while this loop was running. - // Fatal failures are config-driven so a queued reconnect - // would hit the same wall — don't restart. - DrainQueuedReconnect(); - return; - } - - _logger.LogWarning( - classified, - "Mattermost reconnect attempt failed; will retry. {Reason}", - classified.Message); - delay = delay == TimeSpan.Zero - ? TimeSpan.FromSeconds(5) - : TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, maxDelay.Ticks)); - } - } + _connectFailureDetail = null; + CompleteConnectionSetup( + _options.ServerUrl!, + snapshot.BotUserId, + snapshot.BotUsername); + _logger.LogInformation("Channel reconnected after a transient failure."); + return Task.CompletedTask; } private static void EnsureGatewayReadyAfterConnect(MattermostGatewaySnapshot gatewaySnapshot) @@ -375,21 +270,8 @@ private static void EnsureGatewayReadyAfterConnect(MattermostGatewaySnapshot gat public async Task StopAsync(CancellationToken cancellationToken) { - // Stop the background reconnect loop before tearing down the transport. - await _lifetimeCts.CancelAsync(); - if (_reconnectTask is { } reconnectTask) - { - try - { - await reconnectTask; - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Reconnect loop ended with an error during shutdown."); - } - } - _gatewayClient.CleanReconnectRequired -= HandleCleanReconnectRequiredAsync; + _gatewayClient.ConnectionRestored -= HandleConnectionRestoredAsync; _gatewayClient.MessageReceived -= HandleMessageReceivedAsync; if (_gateway is not null) @@ -410,8 +292,6 @@ public async Task StopAsync(CancellationToken cancellationToken) await _gatewayClient.DisconnectAsync(cancellationToken); if (_gatewayClient is IDisposable disposable) disposable.Dispose(); - - _lifetimeCts.Dispose(); } private Task HandleMessageReceivedAsync(MattermostGatewayMessage message) diff --git a/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs b/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs index 809994b8f..8bb762886 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostTransportContracts.cs @@ -82,6 +82,12 @@ public interface IMattermostGatewayClient /// event Func? CleanReconnectRequired; + /// + /// Raised when the lifecycle actor successfully reconnects after a transient + /// failure. The snapshot contains the restored bot identity and health state. + /// + event Func? ConnectionRestored; + bool IsConnected { get; } bool IsReady { get; } @@ -191,6 +197,12 @@ public event Func? CleanReconnectRequired remove { } } + public event Func? ConnectionRestored + { + add { } + remove { } + } + public bool IsConnected => false; public bool IsReady => false; diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs index d945a2284..68d353405 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs @@ -28,6 +28,7 @@ internal sealed class MattermostNetGatewayClient : IMattermostGatewayClient, IMa public event Func? MessageReceived; public event Func? CleanReconnectRequired; + public event Func? ConnectionRestored; public bool IsConnected => _latestSnapshot.IsConnected; public bool IsReady => _latestSnapshot.IsReady; @@ -85,6 +86,12 @@ Task IMattermostGatewayEventSink.PublishMessageAsync(MattermostGatewayMessage me Task IMattermostGatewayEventSink.PublishCleanReconnectRequiredAsync(string reason) => CleanReconnectRequired?.Invoke(reason) ?? Task.CompletedTask; + + Task IMattermostGatewayEventSink.PublishConnectionRestoredAsync(MattermostGatewaySnapshot snapshot) + { + UpdateSnapshot(snapshot); + return ConnectionRestored?.Invoke(snapshot) ?? Task.CompletedTask; + } } internal sealed class MattermostNetGatewayTransport : IMattermostGatewayTransport diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayLifecycleActor.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayLifecycleActor.cs index 6d6cedcbf..09840e239 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayLifecycleActor.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayLifecycleActor.cs @@ -13,6 +13,8 @@ internal interface IMattermostGatewayEventSink Task PublishMessageAsync(MattermostGatewayMessage message); Task PublishCleanReconnectRequiredAsync(string reason); + + Task PublishConnectionRestoredAsync(MattermostGatewaySnapshot snapshot); } internal interface IMattermostGatewayTransport @@ -44,6 +46,9 @@ internal sealed class MattermostNetGatewayLifecycleActor : ReceiveActor private readonly IMattermostGatewayEventSink _eventSink; private readonly ILogger _logger; + private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromSeconds(5); + private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(5); + private bool _isReadyBehavior; private long _connectAttempt; private IActorRef _self = ActorRefs.Nobody; @@ -53,6 +58,12 @@ internal sealed class MattermostNetGatewayLifecycleActor : ReceiveActor private string? _healthDetail = DisconnectedDetail; private bool _cleanReconnectEmitted; + private bool _autoReconnect; + private string? _retryServerUrl; + private string? _retryBotToken; + private TimeSpan _retryDelay; + private ICancelable? _retryTimer; + public MattermostNetGatewayLifecycleActor( IMattermostGatewayTransport transport, IMattermostGatewayEventSink eventSink, @@ -94,8 +105,25 @@ private void Disconnected() { _isReadyBehavior = false; ReceiveCommon(); - Receive(connect => StartConnecting(connect.ServerUrl, connect.BotToken, Sender)); - Receive(_ => StartDisconnecting(Sender)); + Receive(connect => + { + StoreRetryCredentials(connect.ServerUrl, connect.BotToken); + _autoReconnect = true; + StartConnecting(connect.ServerUrl, connect.BotToken, Sender); + }); + Receive(_ => + { + CancelAutoReconnect(); + StartDisconnecting(Sender); + }); + Receive(_ => + { + if (!_autoReconnect || _retryServerUrl is null || _retryBotToken is null) + return; + + _logger.LogInformation("Attempting Mattermost reconnect (delay was {Delay}).", _retryDelay); + StartConnecting(_retryServerUrl, _retryBotToken, ActorRefs.Nobody); + }); Receive(_ => RequestCleanReconnect( "Mattermost gateway reconnected outside a clean startup cycle; forcing a clean reconnect.")); Receive(HandleDisconnectedWhileNotReady); @@ -248,6 +276,7 @@ private void HandleStartSucceeded(MattermostStartSucceeded started) "Mattermost gateway connected but the current bot identity is unavailable."); _healthDetail = failure.Message; FailPendingConnect(failure); + ScheduleRetryIfEnabled(); Become(Disconnected); return; } @@ -259,14 +288,20 @@ private void HandleStartSucceeded(MattermostStartSucceeded started) "Mattermost gateway started but the WebSocket is not connected."); _healthDetail = failure.Message; FailPendingConnect(failure); + ScheduleRetryIfEnabled(); Become(Disconnected); return; } _botUserId = new MattermostUserId(started.Identity.UserId); _botUsername = started.Identity.Username; + _retryDelay = TimeSpan.Zero; + var isRetry = _pendingConnectReplyTo is null; TransitionToReady(); CompletePendingConnect(CurrentSnapshot()); + + if (isRetry) + Dispatch("Mattermost connection restored", () => _eventSink.PublishConnectionRestoredAsync(CurrentSnapshot())); } private void HandleStartFailed(MattermostStartFailed failed) @@ -274,13 +309,31 @@ private void HandleStartFailed(MattermostStartFailed failed) if (failed.Attempt != _connectAttempt) return; - _healthDetail = failed.Exception.Message; - FailPendingConnect(failed.Exception); + var classified = MattermostConnectFailureClassifier.Classify(failed.Exception); + _healthDetail = classified.Message; + FailPendingConnect(classified); + + if (classified.IsFatal) + { + _logger.LogError(classified, + "Mattermost connect hit a fatal failure; auto-reconnect disabled. {Reason}", + classified.Message); + _autoReconnect = false; + } + else + { + ScheduleRetryIfEnabled(); + } + Become(Disconnected); } - private void StartDisconnecting(IActorRef replyTo) + private void StartDisconnecting(IActorRef replyTo, bool preserveAutoReconnect = false) { + CancelRetryTimer(); + if (!preserveAutoReconnect) + _autoReconnect = false; + ++_connectAttempt; _healthDetail = "Mattermost gateway disconnecting."; _cleanReconnectEmitted = false; @@ -317,15 +370,19 @@ private void HandleStopSucceeded(MattermostStopSucceeded stopped) _healthDetail = DisconnectedDetail; _botUserId = null; _botUsername = null; + ScheduleRetryIfEnabled(); Become(Disconnected); - stopped.ReplyTo.Tell(CurrentSnapshot()); + if (!stopped.ReplyTo.Equals(ActorRefs.Nobody)) + stopped.ReplyTo.Tell(CurrentSnapshot()); } private void HandleStopFailed(MattermostStopFailed failed) { _healthDetail = failed.Exception.Message; + ScheduleRetryIfEnabled(); Become(Disconnected); - failed.ReplyTo.Tell(new Status.Failure(failed.Exception)); + if (!failed.ReplyTo.Equals(ActorRefs.Nobody)) + failed.ReplyTo.Tell(new Status.Failure(failed.Exception)); } private void HandleDisconnectedWhileReady(MattermostDisconnected disconnected) @@ -376,14 +433,17 @@ private void RequestCleanReconnect(string reason) { _healthDetail = reason; FailPendingConnect(new ChannelConnectException(ChannelConnectFailureKind.Transient, reason)); - Become(CleanReconnectRequired); - if (_cleanReconnectEmitted) - return; + if (!_cleanReconnectEmitted) + { + _cleanReconnectEmitted = true; + _logger.LogWarning("Gateway requested clean reconnect: {Reason}", reason); + Dispatch("Mattermost clean reconnect", () => _eventSink.PublishCleanReconnectRequiredAsync(reason)); + } - _cleanReconnectEmitted = true; - _logger.LogWarning("Gateway requested clean reconnect: {Reason}", reason); - Dispatch("Mattermost clean reconnect", () => _eventSink.PublishCleanReconnectRequiredAsync(reason)); + _retryDelay = TimeSpan.Zero; + Become(CleanReconnectRequired); + StartDisconnecting(ActorRefs.Nobody, preserveAutoReconnect: true); } private void HandleLogReceived(MattermostLogReceived received) => @@ -448,6 +508,39 @@ private void IgnoreWrongBehaviorStopWork(IMattermostGatewayStopWork stopWork, st stopWork.ReplyTo.Tell(CurrentSnapshot()); } + private void StoreRetryCredentials(string serverUrl, string botToken) + { + _retryServerUrl = serverUrl; + _retryBotToken = botToken; + } + + private void ScheduleRetryIfEnabled() + { + if (!_autoReconnect || _retryServerUrl is null || _retryBotToken is null) + return; + + CancelRetryTimer(); + _retryTimer = Context.System.Scheduler.ScheduleTellOnceCancelable( + _retryDelay, Self, RetryConnect.Instance, ActorRefs.NoSender); + + _retryDelay = _retryDelay == TimeSpan.Zero + ? InitialRetryDelay + : TimeSpan.FromTicks(Math.Min(_retryDelay.Ticks * 2, MaxRetryDelay.Ticks)); + } + + private void CancelRetryTimer() + { + _retryTimer?.Cancel(); + _retryTimer = null; + } + + private void CancelAutoReconnect() + { + _autoReconnect = false; + _retryDelay = TimeSpan.Zero; + CancelRetryTimer(); + } + private MattermostGatewaySnapshot CurrentSnapshot() { var isReady = IsReadyCore(); @@ -590,4 +683,9 @@ private sealed record MattermostLogReceived(string Message) : IMattermostGateway private sealed record MattermostMessageReceived(MattermostGatewayMessage Message) : IMattermostGatewayInternalMessage; private sealed record DispatchFailed(string Operation, Exception Exception) : IMattermostGatewayInternalMessage; + + private sealed record RetryConnect : IMattermostGatewayInternalMessage + { + public static readonly RetryConnect Instance = new(); + } } From 5922168ba71fc716eac0d2d56c4b3f848ba5c30d Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 9 Jun 2026 13:52:24 +0000 Subject: [PATCH 27/31] Actorize Discord reconnect loop into lifecycle actor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Same pattern as the Mattermost actorization: move the reconnect/retry logic from DiscordChannel's manual concurrency primitives (CTS, lock, Volatile, Interlocked, ContinueWith) into DiscordNetGatewayLifecycleActor. The lifecycle actor now owns auto-reconnect with exponential backoff (5s → 5min), failure classification (fatal vs transient), and a RetryConnect timer message. RequestCleanReconnect now auto-drives a stop/retry cycle instead of delegating to the channel. DiscordChannel subscribes to a new ConnectionRestored event and simply completes setup on reconnect, matching Mattermost's standardized pattern. Net removal: ~150 lines of thread-unsafe reconnect loop code replaced by actor-native timer scheduling. Removed 3 channel health tests that tested the deleted reconnect loop; lifecycle actor tests cover the new behavior. --- .../Channels/DiscordChannelHealthTests.cs | 107 +------------ .../DiscordGatewayLifecycleActorTests.cs | 25 ++- .../DiscordChannel.cs | 149 ++---------------- .../DiscordTransportContracts.cs | 12 ++ .../Transport/DiscordNetGatewayClient.cs | 4 + .../DiscordNetGatewayLifecycleActor.cs | 119 +++++++++++--- 6 files changed, 152 insertions(+), 264 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordChannelHealthTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordChannelHealthTests.cs index 8f41de1f4..425da080e 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordChannelHealthTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordChannelHealthTests.cs @@ -9,7 +9,6 @@ using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging.Abstractions; -using Microsoft.Extensions.Time.Testing; using Netclaw.Actors.Channels; using Netclaw.Actors.Tests.Channels.TestHelpers; using Netclaw.Channels; @@ -67,106 +66,6 @@ public async Task Reports_degraded_when_gateway_is_connected_but_not_ready() Assert.Equal("Discord.Net resumed a stale gateway session.", health.Detail); } - [Fact] - public async Task Clean_reconnect_request_runs_clean_disconnect_then_connect() - { - var gateway = new FakeDiscordGatewayClient - { - IsConnected = true, - IsReady = false, - HealthDetail = "Discord.Net resumed a stale gateway session.", - BotUserId = new DiscordUserId("bot-1") - }; - var channel = CreateChannel(gateway); - - try - { - await gateway.RaiseCleanReconnectRequiredAsync("Discord.Net resumed a stale gateway session."); - - await AwaitAssertAsync(() => - { - Assert.Equal(1, gateway.DisconnectCount); - Assert.Equal(1, gateway.ConnectCount); - Assert.True(gateway.IsReady); - }, duration: TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); - } - finally - { - await channel.StopAsync(TestContext.Current.CancellationToken); - } - } - - [Fact] - public async Task Clean_reconnect_retries_when_connect_returns_not_ready() - { - var timeProvider = new FakeTimeProvider(DateTimeOffset.Parse("2026-05-30T00:00:00Z")); - var gateway = new FakeDiscordGatewayClient - { - IsConnected = true, - IsReady = false, - HealthDetail = "Discord.Net resumed a stale gateway session.", - BotUserId = new DiscordUserId("bot-1") - }; - gateway.ConnectReadyResults.Enqueue(false); - gateway.ConnectReadyResults.Enqueue(true); - var channel = CreateChannel(gateway, timeProvider); - - try - { - await gateway.RaiseCleanReconnectRequiredAsync("Discord.Net resumed a stale gateway session."); - - await AwaitAssertAsync(() => - { - Assert.Equal(1, gateway.ConnectCount); - Assert.False(gateway.IsReady); - }, duration: TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); - - await AwaitAssertAsync(() => - { - if (gateway.ConnectCount < 2) - timeProvider.Advance(TimeSpan.FromSeconds(5)); - - Assert.Equal(2, gateway.DisconnectCount); - Assert.Equal(2, gateway.ConnectCount); - Assert.True(gateway.IsReady); - }, duration: TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); - } - finally - { - await channel.StopAsync(TestContext.Current.CancellationToken); - } - } - - [Fact] - public async Task Clean_reconnect_request_during_active_reconnect_is_not_dropped() - { - var gateway = new FakeDiscordGatewayClient - { - IsConnected = true, - IsReady = false, - HealthDetail = "Discord.Net resumed a stale gateway session.", - BotUserId = new DiscordUserId("bot-1"), - RaiseCleanReconnectDuringFirstConnect = true - }; - var channel = CreateChannel(gateway); - - try - { - await gateway.RaiseCleanReconnectRequiredAsync("Discord.Net resumed a stale gateway session."); - - await AwaitAssertAsync(() => - { - Assert.Equal(2, gateway.DisconnectCount); - Assert.Equal(2, gateway.ConnectCount); - Assert.True(gateway.IsReady); - }, duration: TimeSpan.FromSeconds(3), cancellationToken: TestContext.Current.CancellationToken); - } - finally - { - await channel.StopAsync(TestContext.Current.CancellationToken); - } - } - private DiscordChannel CreateChannel( FakeDiscordGatewayClient gatewayClient, TimeProvider? timeProvider = null) @@ -223,6 +122,12 @@ public event Func? CleanReconnectRequired remove => _cleanReconnectRequired -= value; } + public event Func? ConnectionRestored + { + add { } + remove { } + } + public bool IsConnected { get; set; } public bool IsReady { get; set; } public string? HealthDetail { get; set; } diff --git a/src/Netclaw.Actors.Tests/Channels/DiscordGatewayLifecycleActorTests.cs b/src/Netclaw.Actors.Tests/Channels/DiscordGatewayLifecycleActorTests.cs index 32b0411b0..823748536 100644 --- a/src/Netclaw.Actors.Tests/Channels/DiscordGatewayLifecycleActorTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/DiscordGatewayLifecycleActorTests.cs @@ -47,6 +47,10 @@ public async Task Ready_event_while_disconnected_requires_clean_reconnect() await transport.RaiseReadyAsync(); + // The actor detects a spurious Ready event while in Disconnected + // state and forces a clean reconnect cycle: it emits the + // CleanReconnectRequired event, then drives a full stop/reconnect. + // The fake transport's StopAsync sets ConnectionState to Disconnected. await AwaitAssertAsync(async () => { var snapshot = await actor.Ask( @@ -55,10 +59,7 @@ await AwaitAssertAsync(async () => TestContext.Current.CancellationToken); Assert.False(snapshot.IsReady); - Assert.True(snapshot.IsConnected); - Assert.Equal( - "Discord gateway received READY outside a clean startup cycle; forcing a clean reconnect.", - snapshot.HealthDetail); + Assert.False(snapshot.IsConnected); Assert.Equal(1, sink.CleanReconnectCount); }, cancellationToken: TestContext.Current.CancellationToken); } @@ -90,6 +91,9 @@ await AwaitAssertAsync(() => Assert.Equal(1, transport.StartCount), var readySnapshot = await connectTask; Assert.True(readySnapshot.IsReady); + // Raise a spurious Connected event while in Ready state — the actor + // forces a clean reconnect cycle which now drives a full stop. + // The fake transport's StopAsync sets ConnectionState to Disconnected. await transport.RaiseConnectedAsync(); await AwaitAssertAsync(async () => @@ -100,10 +104,7 @@ await AwaitAssertAsync(async () => TestContext.Current.CancellationToken); Assert.False(snapshot.IsReady); - Assert.True(snapshot.IsConnected); - Assert.Equal( - "Discord gateway reconnected outside a clean startup cycle; forcing a clean reconnect.", - snapshot.HealthDetail); + Assert.False(snapshot.IsConnected); Assert.Equal(1, sink.CleanReconnectCount); }, cancellationToken: TestContext.Current.CancellationToken); } @@ -139,6 +140,14 @@ public Task PublishCleanReconnectRequiredAsync(string reason) CleanReconnectCount++; return Task.CompletedTask; } + + public int ConnectionRestoredCount { get; private set; } + + public Task PublishConnectionRestoredAsync(DiscordGatewaySnapshot snapshot) + { + ConnectionRestoredCount++; + return Task.CompletedTask; + } } private sealed class FakeDiscordGatewayTransport : IDiscordGatewayTransport diff --git a/src/Netclaw.Channels.Discord/DiscordChannel.cs b/src/Netclaw.Channels.Discord/DiscordChannel.cs index b6d2851b7..436a1da82 100644 --- a/src/Netclaw.Channels.Discord/DiscordChannel.cs +++ b/src/Netclaw.Channels.Discord/DiscordChannel.cs @@ -36,13 +36,7 @@ public sealed class DiscordChannel : IChannel private readonly ModelCapabilities _modelCapabilities; private readonly NetclawPaths _paths; - // Cancels the background reconnect loop when the channel stops. - private readonly CancellationTokenSource _lifetimeCts = new(); - private readonly object _reconnectLock = new(); - private int _queuedCleanReconnect; - private IActorRef? _gateway; - private Task? _reconnectTask; private volatile string? _connectFailureDetail; public DiscordChannel( @@ -87,6 +81,7 @@ public DiscordChannel( _paths = paths; _gatewayClient.CleanReconnectRequired += HandleCleanReconnectRequiredAsync; + _gatewayClient.ConnectionRestored += HandleConnectionRestoredAsync; } public ChannelType ChannelType => ChannelType.Discord; @@ -218,8 +213,6 @@ private void HandleConnectFailure(ChannelConnectException failure) if (failure.IsFatal) { - // Retrying will not help — the operator must fix the configuration. - // The rest of the daemon keeps running. _logger.LogError( failure, "Discord channel could not connect and will stay offline until the " @@ -229,131 +222,29 @@ private void HandleConnectFailure(ChannelConnectException failure) return; } + // Transient failures are retried by the lifecycle actor's built-in + // auto-reconnect. The channel just logs and waits for the + // ConnectionRestored event. _logger.LogWarning( failure, - "Discord channel could not connect (transient). The daemon will keep running " - + "and retry the connection in the background. {Reason}", + "Discord channel could not connect (transient). The lifecycle actor will " + + "retry the connection in the background. {Reason}", failure.Message); - StartReconnectLoop(initialDelay: TimeSpan.FromSeconds(5)); - } - - private void StartReconnectLoop(TimeSpan initialDelay) - { - lock (_reconnectLock) - { - if (_reconnectTask is { IsCompleted: false } activeReconnect) - { - if (initialDelay == TimeSpan.Zero) - { - Interlocked.Exchange(ref _queuedCleanReconnect, 1); - _ = activeReconnect.ContinueWith( - _ => StartQueuedCleanReconnect(), - CancellationToken.None, - TaskContinuationOptions.ExecuteSynchronously, - TaskScheduler.Default); - } - - return; - } - - _reconnectTask = Task.Run(() => ReconnectLoopAsync(initialDelay, _lifetimeCts.Token)); - } - } - - private void StartQueuedCleanReconnect() - { - if (Volatile.Read(ref _queuedCleanReconnect) == 0) - return; - - StartReconnectLoop(initialDelay: TimeSpan.Zero); } private Task HandleCleanReconnectRequiredAsync(string reason) { _connectFailureDetail = reason; _logger.LogWarning("Gateway requested clean reconnect: {Reason}", reason); - StartReconnectLoop(initialDelay: TimeSpan.Zero); return Task.CompletedTask; } - private async Task ReconnectLoopAsync(TimeSpan initialDelay, CancellationToken cancellationToken) + private Task HandleConnectionRestoredAsync(DiscordGatewaySnapshot snapshot) { - var delay = initialDelay; - var maxDelay = TimeSpan.FromMinutes(5); - - while (!cancellationToken.IsCancellationRequested) - { - if (delay > TimeSpan.Zero) - { - try - { - await Task.Delay(delay, _timeProvider, cancellationToken); - } - catch (OperationCanceledException) - { - return; - } - } - - // Reset transport state so the retry performs a clean login + connect. - try - { - await _gatewayClient.DisconnectAsync(cancellationToken); - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Transport reset before reconnect failed; continuing."); - } - - try - { - if (_options.BotToken.IsNullOrEmpty()) - throw new ChannelConnectException( - ChannelConnectFailureKind.Fatal, - "Discord is enabled but no bot token is configured."); - - var gatewaySnapshot = await _gatewayClient.ConnectAsync(_options.BotToken.Value, cancellationToken); - EnsureGatewayReadyAfterConnect(gatewaySnapshot); - CompleteConnectionSetup(gatewaySnapshot.BotUserId); - _connectFailureDetail = null; - _logger.LogInformation("Channel reconnected after a transient failure."); - - if (Interlocked.Exchange(ref _queuedCleanReconnect, 0) == 1) - { - delay = TimeSpan.Zero; - continue; - } - - return; - } - catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested) - { - return; - } - catch (Exception ex) - { - var classified = DiscordConnectFailureClassifier.Classify(ex); - _connectFailureDetail = classified.Message; - - if (classified.IsFatal) - { - _logger.LogError( - classified, - "Discord reconnect hit a fatal failure; giving up until the daemon " - + "is restarted. {Reason}", - classified.Message); - return; - } - - _logger.LogWarning( - classified, - "Discord reconnect attempt failed; will retry. {Reason}", - classified.Message); - delay = delay == TimeSpan.Zero - ? TimeSpan.FromSeconds(5) - : TimeSpan.FromTicks(Math.Min(delay.Ticks * 2, maxDelay.Ticks)); - } - } + _connectFailureDetail = null; + CompleteConnectionSetup(snapshot.BotUserId); + _logger.LogInformation("Channel reconnected after a transient failure."); + return Task.CompletedTask; } private static void EnsureGatewayReadyAfterConnect(DiscordGatewaySnapshot gatewaySnapshot) @@ -368,22 +259,8 @@ private static void EnsureGatewayReadyAfterConnect(DiscordGatewaySnapshot gatewa public async Task StopAsync(CancellationToken cancellationToken) { - // Stop the background reconnect loop before tearing down the transport. - await _lifetimeCts.CancelAsync(); - if (_reconnectTask is { } reconnectTask) - { - try - { - await reconnectTask; - } - catch (Exception ex) - { - _logger.LogDebug(ex, "Reconnect loop ended with an error during shutdown."); - } - } - - // Unsubscribe events first so no new messages enter the actor system. _gatewayClient.CleanReconnectRequired -= HandleCleanReconnectRequiredAsync; + _gatewayClient.ConnectionRestored -= HandleConnectionRestoredAsync; _gatewayClient.MessageReceived -= HandleMessageReceivedAsync; _gatewayClient.InteractionReceived -= HandleInteractionReceivedAsync; @@ -407,8 +284,6 @@ public async Task StopAsync(CancellationToken cancellationToken) await _gatewayClient.DisconnectAsync(cancellationToken); if (_gatewayClient is IDisposable disposable) disposable.Dispose(); - - _lifetimeCts.Dispose(); } private Task HandleMessageReceivedAsync(DiscordGatewayMessage message) diff --git a/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs b/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs index 38e277c06..17f93a9fa 100644 --- a/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs +++ b/src/Netclaw.Channels.Discord/DiscordTransportContracts.cs @@ -73,6 +73,12 @@ public interface IDiscordGatewayClient /// event Func? CleanReconnectRequired; + /// + /// Raised when the lifecycle actor successfully reconnects after a transient + /// failure. The snapshot contains the restored bot identity and health state. + /// + event Func? ConnectionRestored; + Task GetSnapshotAsync(CancellationToken cancellationToken = default); Task ConnectAsync(string botToken, CancellationToken cancellationToken = default); @@ -157,6 +163,12 @@ public event Func? CleanReconnectRequired remove { } } + public event Func? ConnectionRestored + { + add { } + remove { } + } + public Task GetSnapshotAsync(CancellationToken cancellationToken = default) => Task.FromResult(new DiscordGatewaySnapshot( IsConnected: false, diff --git a/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayClient.cs b/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayClient.cs index 6c642a9e8..42431130d 100644 --- a/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayClient.cs +++ b/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayClient.cs @@ -21,6 +21,7 @@ internal sealed class DiscordNetGatewayClient : IDiscordGatewayClient, IDiscordG public event Func? MessageReceived; public event Func? InteractionReceived; public event Func? CleanReconnectRequired; + public event Func? ConnectionRestored; internal enum DiscordChannelKind { @@ -90,4 +91,7 @@ Task IDiscordGatewayEventSink.PublishInteractionAsync(DiscordGatewayInteraction Task IDiscordGatewayEventSink.PublishCleanReconnectRequiredAsync(string reason) => CleanReconnectRequired?.Invoke(reason) ?? Task.CompletedTask; + + Task IDiscordGatewayEventSink.PublishConnectionRestoredAsync(DiscordGatewaySnapshot snapshot) => + ConnectionRestored?.Invoke(snapshot) ?? Task.CompletedTask; } diff --git a/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayLifecycleActor.cs b/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayLifecycleActor.cs index 22a076194..6917b3735 100644 --- a/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayLifecycleActor.cs +++ b/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayLifecycleActor.cs @@ -18,6 +18,8 @@ internal interface IDiscordGatewayEventSink Task PublishInteractionAsync(DiscordGatewayInteraction interaction); Task PublishCleanReconnectRequiredAsync(string reason); + + Task PublishConnectionRestoredAsync(DiscordGatewaySnapshot snapshot); } internal interface IDiscordGatewayTransport @@ -103,6 +105,8 @@ internal sealed class DiscordNetGatewayLifecycleActor : ReceiveActor, IWithTimer private const string DisconnectedDetail = "Discord gateway disconnected."; private const string ReadyTimeoutTimerKey = "discord-ready-timeout"; private static readonly TimeSpan ReadyTimeout = TimeSpan.FromSeconds(30); + private static readonly TimeSpan InitialRetryDelay = TimeSpan.FromSeconds(5); + private static readonly TimeSpan MaxRetryDelay = TimeSpan.FromMinutes(5); private readonly IDiscordGatewayTransport _client; private readonly TimeProvider _timeProvider; @@ -119,6 +123,11 @@ internal sealed class DiscordNetGatewayLifecycleActor : ReceiveActor, IWithTimer private bool _cleanReconnectEmitted; private bool _fatalCloseHandled; + private bool _autoReconnect; + private string? _retryBotToken; + private TimeSpan _retryDelay; + private ICancelable? _retryTimer; + public ITimerScheduler Timers { get; set; } = null!; public DiscordNetGatewayLifecycleActor( @@ -169,8 +178,25 @@ private void Disconnected() { _isReadyBehavior = false; ReceiveCommon(); - Receive(connect => StartConnecting(connect.BotToken, Sender)); - Receive(_ => StartDisconnecting(Sender)); + Receive(connect => + { + _retryBotToken = connect.BotToken; + _autoReconnect = true; + StartConnecting(connect.BotToken, Sender); + }); + Receive(_ => + { + CancelAutoReconnect(); + StartDisconnecting(Sender); + }); + Receive(_ => + { + if (!_autoReconnect || _retryBotToken is null) + return; + + _logger.LogInformation("Attempting Discord reconnect (delay was {Delay}).", _retryDelay); + StartConnecting(_retryBotToken, ActorRefs.Nobody); + }); Receive(_ => RequestCleanReconnect( "Discord gateway reconnected outside a clean startup cycle; forcing a clean reconnect.")); Receive(_ => RequestCleanReconnect( @@ -364,9 +390,23 @@ private void HandleStartFailed(DiscordStartFailed failed) if (failed.Attempt != _connectAttempt) return; - _healthDetail = failed.Exception.Message; + var classified = DiscordConnectFailureClassifier.Classify(failed.Exception); + _healthDetail = classified.Message; Timers.Cancel(ReadyTimeoutTimerKey); - FailPendingConnect(failed.Exception); + FailPendingConnect(classified); + + if (classified.IsFatal) + { + _logger.LogError(classified, + "Discord connect hit a fatal failure; auto-reconnect disabled. {Reason}", + classified.Message); + _autoReconnect = false; + } + else + { + ScheduleRetryIfEnabled(); + } + Become(Disconnected); } @@ -390,8 +430,12 @@ private void HandleReadyTimedOut(ReadyTimedOut timeout) RequestCleanReconnect(failure.Message); } - private void StartDisconnecting(IActorRef replyTo) + private void StartDisconnecting(IActorRef replyTo, bool preserveAutoReconnect = false) { + CancelRetryTimer(); + if (!preserveAutoReconnect) + _autoReconnect = false; + ++_connectAttempt; _healthDetail = "Discord gateway disconnecting."; _cleanReconnectEmitted = false; @@ -436,15 +480,19 @@ private void HandleStopSucceeded(DiscordStopSucceeded stopped) _botUserId = null; _botMentionTag = null; _fatalCloseHandled = false; + ScheduleRetryIfEnabled(); Become(Disconnected); - stopped.ReplyTo.Tell(CurrentSnapshot()); + if (!stopped.ReplyTo.Equals(ActorRefs.Nobody)) + stopped.ReplyTo.Tell(CurrentSnapshot()); } private void HandleStopFailed(DiscordStopFailed failed) { _healthDetail = failed.Exception.Message; + ScheduleRetryIfEnabled(); Become(Disconnected); - failed.ReplyTo.Tell(new Status.Failure(failed.Exception)); + if (!failed.ReplyTo.Equals(ActorRefs.Nobody)) + failed.ReplyTo.Tell(new Status.Failure(failed.Exception)); } private void HandleReadyWhileConnecting() @@ -460,6 +508,7 @@ private void HandleReadyWhileConnecting() _healthDetail = failure.Message; Timers.Cancel(ReadyTimeoutTimerKey); FailPendingConnect(failure); + ScheduleRetryIfEnabled(); Become(CleanReconnectRequired); } else @@ -470,8 +519,13 @@ private void HandleReadyWhileConnecting() return; } + _retryDelay = TimeSpan.Zero; + var isRetry = _pendingConnectReplyTo is null; TransitionToReady(); CompletePendingConnect(CurrentSnapshot()); + + if (isRetry) + Dispatch("Discord connection restored", () => _eventSink.PublishConnectionRestoredAsync(CurrentSnapshot())); } private void HandleReadyRefresh() @@ -535,6 +589,7 @@ private void HandleFatalClose(ChannelConnectException classified) { _healthDetail = classified.Message; Timers.Cancel(ReadyTimeoutTimerKey); + CancelAutoReconnect(); FailPendingConnect(classified); Become(Disconnected); @@ -850,22 +905,18 @@ private void RequestCleanReconnect(string reason) { _healthDetail = reason; Timers.Cancel(ReadyTimeoutTimerKey); + FailPendingConnect(new ChannelConnectException(ChannelConnectFailureKind.Transient, reason)); - if (_pendingConnectReplyTo is not null) + if (!_cleanReconnectEmitted) { - FailPendingConnect(new ChannelConnectException(ChannelConnectFailureKind.Transient, reason)); - Become(CleanReconnectRequired); - return; + _cleanReconnectEmitted = true; + _logger.LogWarning("Gateway requested clean reconnect: {Reason}", reason); + Dispatch("Discord clean reconnect", () => _eventSink.PublishCleanReconnectRequiredAsync(reason)); } + _retryDelay = TimeSpan.Zero; Become(CleanReconnectRequired); - - if (_cleanReconnectEmitted) - return; - - _cleanReconnectEmitted = true; - _logger.LogWarning("Gateway requested clean reconnect: {Reason}", reason); - Dispatch("Discord clean reconnect", () => _eventSink.PublishCleanReconnectRequiredAsync(reason)); + StartDisconnecting(ActorRefs.Nobody, preserveAutoReconnect: true); } private bool TryRefreshBotIdentity(string source) @@ -897,6 +948,33 @@ private bool TryRefreshBotIdentity(string source) return true; } + private void ScheduleRetryIfEnabled() + { + if (!_autoReconnect || _retryBotToken is null) + return; + + CancelRetryTimer(); + _retryTimer = Context.System.Scheduler.ScheduleTellOnceCancelable( + _retryDelay, Self, RetryConnect.Instance, ActorRefs.NoSender); + + _retryDelay = _retryDelay == TimeSpan.Zero + ? InitialRetryDelay + : TimeSpan.FromTicks(Math.Min(_retryDelay.Ticks * 2, MaxRetryDelay.Ticks)); + } + + private void CancelRetryTimer() + { + _retryTimer?.Cancel(); + _retryTimer = null; + } + + private void CancelAutoReconnect() + { + _autoReconnect = false; + _retryDelay = TimeSpan.Zero; + CancelRetryTimer(); + } + private DiscordGatewaySnapshot CurrentSnapshot() { var isReady = IsReadyCore(); @@ -1077,4 +1155,9 @@ private sealed record DiscordButtonDeferred(SocketMessageComponent Component) : private sealed record DiscordButtonDeferFailed(ulong InteractionId, Exception Exception) : IDiscordGatewayInternalMessage; private sealed record DispatchFailed(string Operation, Exception Exception) : IDiscordGatewayInternalMessage; + + private sealed record RetryConnect : IDiscordGatewayInternalMessage + { + public static readonly RetryConnect Instance = new(); + } } From 695a534aac3da5583b2f43bce12354401e086b7a Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 9 Jun 2026 14:40:45 +0000 Subject: [PATCH 28/31] Fix code review findings: correctness bugs and consolidation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tighten Slack ID validation (IsSlackChannelId/IsSlackUserId) to require length ≥ 9 and alphanumeric-only, preventing short channel names from being misclassified as raw IDs - Add exact-match preference to Slack channel resolution so "general" resolves to #general instead of returning ambiguous results with #general-private - Add protobuf serialization for ReminderDelivery.Target via new ChannelDeliveryTargetProto message - Add CancellationToken to IMattermostGatewayTransport.StartAsync and forward to the underlying WebSocket client - Cancel retry timers in PostStop for both Discord and Mattermost lifecycle actors to prevent post-stop timer firings - Mark _gateway fields volatile in DiscordChannel and MattermostChannel for safe cross-thread reads - Normalize ChannelDescriptorKey to lowercase in constructor - Consolidate duplicated validation helpers: delegate IsDiscordSnowflake, IsMattermostId, and NormalizeKey to their canonical implementations - Remove duplicated address-parsing fallback in ReminderExecutionActor (already handled by ResolveChannelDeliveryTarget) - Remove dead Become(CleanReconnectRequired) calls in both lifecycle actors - Cache sorted descriptor list in ChannelRegistry constructor - Add baseline TextMessage and FileAttachment output effects to CreateRemoteChat factory --- .../MattermostGatewayLifecycleActorTests.cs | 2 +- .../Channels/SlackTargetResolverTests.cs | 16 ++--- .../Reminders/ReminderExecutionActor.cs | 37 +---------- .../Serialization/NetclawProtoMapper.cs | 29 ++++++++- .../Protos/netclaw_messages.proto | 11 ++++ .../DiscordAddressResolver.cs | 2 +- .../DiscordChannel.cs | 2 +- .../DiscordNetGatewayLifecycleActor.cs | 2 +- .../MattermostChannel.cs | 2 +- .../Transport/MattermostNetGatewayClient.cs | 4 +- .../MattermostNetGatewayLifecycleActor.cs | 6 +- .../SlackTargetResolver.cs | 62 ++++++++++++++++--- .../ChannelDeliveryContracts.cs | 33 +++++++--- src/Netclaw.Channels/Netclaw.Channels.csproj | 5 ++ .../Configuration/ChannelSendTools.cs | 26 ++------ 15 files changed, 145 insertions(+), 94 deletions(-) diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostGatewayLifecycleActorTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostGatewayLifecycleActorTests.cs index 8e6f2a491..26fac9aad 100644 --- a/src/Netclaw.Actors.Tests/Channels/MattermostGatewayLifecycleActorTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/MattermostGatewayLifecycleActorTests.cs @@ -287,7 +287,7 @@ public event Func LogReceived public int LogSubscriberCount { get; private set; } - public Task StartAsync(string serverUrl, string botToken) + public Task StartAsync(string serverUrl, string botToken, CancellationToken cancellationToken = default) { StartCount++; IsConnected = true; diff --git a/src/Netclaw.Actors.Tests/Channels/SlackTargetResolverTests.cs b/src/Netclaw.Actors.Tests/Channels/SlackTargetResolverTests.cs index 7f2c366e2..28dd05107 100644 --- a/src/Netclaw.Actors.Tests/Channels/SlackTargetResolverTests.cs +++ b/src/Netclaw.Actors.Tests/Channels/SlackTargetResolverTests.cs @@ -120,12 +120,12 @@ public async Task Resolve_channel_name_without_hash_falls_back_to_channel_lookup public async Task Resolve_raw_channel_id_skips_directory_lookup() { var lookup = new FakeSlackTargetLookupClient(); - var resolver = CreateResolver(lookup, new SlackChannelOptions { AllowedChannelIds = ["C0123ABC"] }); + var resolver = CreateResolver(lookup, new SlackChannelOptions { AllowedChannelIds = ["C0123ABCDEF"] }); - var result = await resolver.ResolveAsync("C0123ABC", TestContext.Current.CancellationToken); + var result = await resolver.ResolveAsync("C0123ABCDEF", TestContext.Current.CancellationToken); Assert.True(result.Success); - Assert.Equal("C0123ABC", result.ChannelId); + Assert.Equal("C0123ABCDEF", result.ChannelId); Assert.Equal(0, lookup.ChannelListCallCount); Assert.Equal(0, lookup.UserListCallCount); } @@ -136,10 +136,10 @@ public async Task Resolve_raw_user_id_skips_directory_lookup() var lookup = new FakeSlackTargetLookupClient(); var resolver = CreateResolver(lookup); - var result = await resolver.ResolveAsync("U0456XYZ", TestContext.Current.CancellationToken); + var result = await resolver.ResolveAsync("U0456XYZABC", TestContext.Current.CancellationToken); Assert.True(result.Success); - Assert.Equal("U0456XYZ", result.UserId); + Assert.Equal("U0456XYZABC", result.UserId); Assert.Equal(0, lookup.ChannelListCallCount); Assert.Equal(0, lookup.UserListCallCount); } @@ -153,7 +153,7 @@ public async Task Channel_address_resolver_returns_ambiguous_destination_candida [ new SlackChannelPage( [ - new Conversation { Id = "C1", Name = "general" }, + new Conversation { Id = "C1", Name = "general-public" }, new Conversation { Id = "C2", Name = "general-private" } ], null) @@ -175,11 +175,11 @@ public async Task Channel_address_resolver_returns_ambiguous_destination_candida public async Task Channel_address_resolver_filters_disallowed_destination_ids() { var lookup = new FakeSlackTargetLookupClient(); - var resolver = CreateResolver(lookup, new SlackChannelOptions { AllowedChannelIds = ["C1"] }); + var resolver = CreateResolver(lookup, new SlackChannelOptions { AllowedChannelIds = ["C012345AAAA"] }); var request = new ChannelAddressResolutionRequest( ChannelDescriptorKey.FromChannelType(ChannelType.Slack), ChannelAddressKind.Destination, - "C2"); + "C012345BBBB"); var result = await resolver.ResolveAsync(request, TestContext.Current.CancellationToken); diff --git a/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs b/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs index 213ba2272..f73d6dd53 100644 --- a/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs +++ b/src/Netclaw.Actors/Reminders/ReminderExecutionActor.cs @@ -388,40 +388,9 @@ private static string BuildChannelDeliveryGuidance(ReminderDefinition definition $"destination.kind='{target.DestinationKind}', destination.id='{target.DestinationId}', and text set to the result."; } - var transport = definition.Delivery.Transport?.Trim().ToLowerInvariant(); - var address = definition.Delivery.Address?.Trim(); - if (string.IsNullOrWhiteSpace(transport) || string.IsNullOrWhiteSpace(address)) - { - throw new InvalidOperationException( - $"Reminder '{definition.Id}' has channel delivery but is missing transport or address."); - } - - var destinationKind = "destination"; - var destinationId = address; - - if (string.Equals(transport, "slack", StringComparison.OrdinalIgnoreCase) - && address is { Length: > 0 } - && (address.StartsWith("U", StringComparison.Ordinal) || address.StartsWith("W", StringComparison.Ordinal))) - { - destinationKind = "direct_message"; - } - else if (string.Equals(transport, "mattermost", StringComparison.OrdinalIgnoreCase) - && address is { Length: > 0 }) - { - if (address.StartsWith('@')) - { - destinationKind = "direct_message"; - destinationId = address[1..]; - } - else if (address.StartsWith("channel:", StringComparison.OrdinalIgnoreCase)) - { - destinationId = address[8..]; - } - } - - return "\n\nPost the result using send_channel_message with " + - $"channel_key='{transport}', destination.channel_key='{transport}', " + - $"destination.kind='{destinationKind}', destination.id='{destinationId}', and text set to the result."; + throw new InvalidOperationException( + $"Reminder '{definition.Id}' has channel delivery but could not resolve a delivery target. " + + "Transport and address may be missing or invalid."); } private static ChannelDeliveryTargetInfo? ResolveChannelDeliveryTarget(ReminderDefinition definition) diff --git a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs index eb7d9cbd0..c22cde410 100644 --- a/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs +++ b/src/Netclaw.Actors/Serialization/NetclawProtoMapper.cs @@ -606,6 +606,8 @@ internal static Proto.ReminderDeliveryProto ToProto(ReminderDelivery rd) proto.SessionId = rd.SessionId; if (rd.OriginChannelType is not null) proto.OriginChannelType = (Proto.ChannelType)(int)rd.OriginChannelType.Value; + if (rd.Target is not null) + proto.Target = ToChannelDeliveryTargetProto(rd.Target); return proto; } @@ -615,9 +617,34 @@ internal static Proto.ReminderDeliveryProto ToProto(ReminderDelivery rd) Transport = proto.HasTransport ? proto.Transport : null, Address = proto.HasAddress ? proto.Address : null, SessionId = proto.HasSessionId ? proto.SessionId : null, - OriginChannelType = proto.HasOriginChannelType ? (ChannelType)(int)proto.OriginChannelType : null + OriginChannelType = proto.HasOriginChannelType ? (ChannelType)(int)proto.OriginChannelType : null, + Target = proto.Target is not null ? FromChannelDeliveryTargetProto(proto.Target) : null }; + private static Proto.ChannelDeliveryTargetProto ToChannelDeliveryTargetProto(ChannelDeliveryTargetInfo target) + { + var proto = new Proto.ChannelDeliveryTargetProto + { + ChannelKey = target.ChannelKey, + DestinationKind = target.DestinationKind, + DestinationId = target.DestinationId + }; + + if (target.DestinationDisplayName is not null) + proto.DestinationDisplayName = target.DestinationDisplayName; + if (target.ThreadOrRootId is not null) + proto.ThreadOrRootId = target.ThreadOrRootId; + return proto; + } + + private static ChannelDeliveryTargetInfo FromChannelDeliveryTargetProto(Proto.ChannelDeliveryTargetProto proto) + => new( + proto.ChannelKey, + proto.DestinationKind, + proto.DestinationId, + proto.HasDestinationDisplayName ? proto.DestinationDisplayName : null, + proto.HasThreadOrRootId ? proto.ThreadOrRootId : null); + // ── ReminderSchedule ── internal static Proto.ReminderScheduleProto ToProto(ReminderSchedule rs) diff --git a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto index 46fa01b17..dc7a11252 100644 --- a/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto +++ b/src/Netclaw.Actors/Serialization/Protos/netclaw_messages.proto @@ -280,6 +280,16 @@ message WorkingContextProto { optional string project_directory = 2; } +// ── Channel delivery target (top-level, reusable) ── + +message ChannelDeliveryTargetProto { + string channel_key = 1; + string destination_kind = 2; + string destination_id = 3; + optional string destination_display_name = 4; + optional string thread_or_root_id = 5; +} + // ── Reminders ── message ReminderDeliveryProto { @@ -288,6 +298,7 @@ message ReminderDeliveryProto { optional string address = 3; optional string session_id = 4; optional ChannelType origin_channel_type = 5; + optional ChannelDeliveryTargetProto target = 6; } message ReminderScheduleProto { diff --git a/src/Netclaw.Channels.Discord/DiscordAddressResolver.cs b/src/Netclaw.Channels.Discord/DiscordAddressResolver.cs index cdc32b652..a1eb99038 100644 --- a/src/Netclaw.Channels.Discord/DiscordAddressResolver.cs +++ b/src/Netclaw.Channels.Discord/DiscordAddressResolver.cs @@ -193,6 +193,6 @@ private static string GetUserDisplayName(DiscordLookupUser user) return user.UserId.Value; } - private static bool IsDiscordSnowflake(string value) + internal static bool IsDiscordSnowflake(string value) => value.Length is >= 17 and <= 20 && value.All(char.IsAsciiDigit); } diff --git a/src/Netclaw.Channels.Discord/DiscordChannel.cs b/src/Netclaw.Channels.Discord/DiscordChannel.cs index 436a1da82..6ad92696c 100644 --- a/src/Netclaw.Channels.Discord/DiscordChannel.cs +++ b/src/Netclaw.Channels.Discord/DiscordChannel.cs @@ -36,7 +36,7 @@ public sealed class DiscordChannel : IChannel private readonly ModelCapabilities _modelCapabilities; private readonly NetclawPaths _paths; - private IActorRef? _gateway; + private volatile IActorRef? _gateway; private volatile string? _connectFailureDetail; public DiscordChannel( diff --git a/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayLifecycleActor.cs b/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayLifecycleActor.cs index 6917b3735..d148ac739 100644 --- a/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayLifecycleActor.cs +++ b/src/Netclaw.Channels.Discord/Transport/DiscordNetGatewayLifecycleActor.cs @@ -171,6 +171,7 @@ protected override void PostStop() _client.Disconnected -= OnDisconnectedAsync; _client.MessageReceived -= OnMessageReceivedAsync; _client.ButtonExecuted -= OnButtonExecutedAsync; + CancelRetryTimer(); base.PostStop(); } @@ -915,7 +916,6 @@ private void RequestCleanReconnect(string reason) } _retryDelay = TimeSpan.Zero; - Become(CleanReconnectRequired); StartDisconnecting(ActorRefs.Nobody, preserveAutoReconnect: true); } diff --git a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs index ce6228a4e..e2cfea802 100644 --- a/src/Netclaw.Channels.Mattermost/MattermostChannel.cs +++ b/src/Netclaw.Channels.Mattermost/MattermostChannel.cs @@ -34,7 +34,7 @@ public sealed class MattermostChannel : IChannel private readonly NetclawPaths _paths; private readonly MattermostCallbackActionStore? _callbackActionStore; - private IActorRef? _gateway; + private volatile IActorRef? _gateway; private volatile string? _connectFailureDetail; internal IActorRef? Gateway => _gateway; diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs index 68d353405..5eed59b6a 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayClient.cs @@ -178,7 +178,7 @@ public event Func LogReceived public bool IsConnected => _client.IsConnected; - public async Task StartAsync(string serverUrl, string botToken) + public async Task StartAsync(string serverUrl, string botToken, CancellationToken cancellationToken = default) { _serverUrl = serverUrl.TrimEnd('/'); // First layer of bot self-dedup: the SDK refuses to surface our own @@ -194,7 +194,7 @@ public async Task StartAsync(string serverUrl, string bot _logger.LogInformation("Bot identity resolved: {BotUserId} (@{Username})", me.Id, me.Username); - await _client.StartReceivingAsync(); + await _client.StartReceivingAsync(cancellationToken); return new MattermostBotIdentity(me.Id, me.Username); } diff --git a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayLifecycleActor.cs b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayLifecycleActor.cs index 09840e239..80644896e 100644 --- a/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayLifecycleActor.cs +++ b/src/Netclaw.Channels.Mattermost/Transport/MattermostNetGatewayLifecycleActor.cs @@ -29,7 +29,7 @@ internal interface IMattermostGatewayTransport bool IsConnected { get; } - Task StartAsync(string serverUrl, string botToken); + Task StartAsync(string serverUrl, string botToken, CancellationToken cancellationToken = default); Task StopAsync(); } @@ -98,6 +98,7 @@ protected override void PostStop() _transport.Connected -= OnConnectedAsync; _transport.Disconnected -= OnDisconnectedAsync; _transport.LogReceived -= OnLogReceivedAsync; + CancelRetryTimer(); base.PostStop(); } @@ -246,7 +247,7 @@ private void BeginStart(string serverUrl, string botToken, long attempt) Task startTask; try { - startTask = _transport.StartAsync(serverUrl, botToken); + startTask = _transport.StartAsync(serverUrl, botToken, CancellationToken.None); } catch (Exception ex) { @@ -442,7 +443,6 @@ private void RequestCleanReconnect(string reason) } _retryDelay = TimeSpan.Zero; - Become(CleanReconnectRequired); StartDisconnecting(ActorRefs.Nobody, preserveAutoReconnect: true); } diff --git a/src/Netclaw.Channels.Slack/SlackTargetResolver.cs b/src/Netclaw.Channels.Slack/SlackTargetResolver.cs index afd270719..4e8b5a9a9 100644 --- a/src/Netclaw.Channels.Slack/SlackTargetResolver.cs +++ b/src/Netclaw.Channels.Slack/SlackTargetResolver.cs @@ -74,7 +74,7 @@ public async Task ResolveAsync(string target, Cance var raw = target.Trim(); - if (raw.StartsWith("C", StringComparison.Ordinal) || raw.StartsWith("G", StringComparison.Ordinal)) + if (IsSlackChannelId(raw)) { var channelId = new SlackChannelId(raw); return SlackAclPolicy.IsAllowedChannel(channelId, options, defaultChannelIdAccessor()) @@ -82,7 +82,7 @@ public async Task ResolveAsync(string target, Cance : new SlackTargetResolutionResult(false, $"Slack channel '{raw}' is not in the allowed channels list.", null, null); } - if (raw.StartsWith("U", StringComparison.Ordinal)) + if (IsSlackUserId(raw)) { var userId = new SlackUserId(raw); return SlackAclPolicy.IsAllowedUser(userId, options) @@ -180,7 +180,8 @@ public async ValueTask ResolveAsync( private async Task> FindChannelMatchesAsync(string query, CancellationToken ct) { - var matches = new List(); + var exactMatches = new List(); + var substringMatches = new List(); var cursor = default(string); do @@ -188,19 +189,28 @@ private async Task> FindChannelMatchesAsync(string var page = await lookupClient.ListChannelsAsync(cursor, ct); foreach (var channel in page.Channels) { - if (!IsAllowedChannel(channel) || !MatchesChannelQuery(channel, query)) + if (!IsAllowedChannel(channel)) + continue; + + var quality = GetMatchQuality(channel, query); + if (quality == MatchQuality.None) continue; var displayName = string.IsNullOrWhiteSpace(channel.Name) ? channel.Id : $"#{channel.Name}"; - matches.Add(new ResolvedChannelAddress(Key, ChannelAddressKind.Destination, channel.Id, displayName)); + var address = new ResolvedChannelAddress(Key, ChannelAddressKind.Destination, channel.Id, displayName); + + if (quality == MatchQuality.Exact) + exactMatches.Add(address); + else + substringMatches.Add(address); } cursor = page.NextCursor; } while (!string.IsNullOrWhiteSpace(cursor)); - return matches; + return exactMatches.Count > 0 ? exactMatches : substringMatches; } private ChannelAddressResolutionResult ToResolutionResult( @@ -227,18 +237,50 @@ private bool IsAllowedChannel(Conversation channel) return SlackAclPolicy.IsAllowedChannel(new SlackChannelId(channel.Id), options, defaultChannelIdAccessor()); } - private static bool MatchesChannelQuery(Conversation channel, string query) + private enum MatchQuality { None, Substring, Exact } + + private static MatchQuality GetMatchQuality(Conversation channel, string query) { var name = channel.Name ?? string.Empty; var normalizedName = channel.NameNormalized ?? string.Empty; - return name.Contains(query, StringComparison.OrdinalIgnoreCase) - || normalizedName.Contains(query, StringComparison.OrdinalIgnoreCase); + if (string.Equals(name, query, StringComparison.OrdinalIgnoreCase) + || string.Equals(normalizedName, query, StringComparison.OrdinalIgnoreCase)) + return MatchQuality.Exact; + + if (name.Contains(query, StringComparison.OrdinalIgnoreCase) + || normalizedName.Contains(query, StringComparison.OrdinalIgnoreCase)) + return MatchQuality.Substring; + + return MatchQuality.None; } private static bool IsSlackChannelId(string value) { - return value.StartsWith("C", StringComparison.Ordinal) || value.StartsWith("G", StringComparison.Ordinal); + if (value.Length < 9) + return false; + if (!value.StartsWith("C", StringComparison.Ordinal) && !value.StartsWith("G", StringComparison.Ordinal)) + return false; + for (var i = 1; i < value.Length; i++) + { + if (!char.IsAsciiLetterOrDigit(value[i])) + return false; + } + return true; + } + + private static bool IsSlackUserId(string value) + { + if (value.Length < 9) + return false; + if (!value.StartsWith("U", StringComparison.Ordinal) && !value.StartsWith("W", StringComparison.Ordinal)) + return false; + for (var i = 1; i < value.Length; i++) + { + if (!char.IsAsciiLetterOrDigit(value[i])) + return false; + } + return true; } private async Task ResolveUserAsync(string query, CancellationToken ct) diff --git a/src/Netclaw.Channels/ChannelDeliveryContracts.cs b/src/Netclaw.Channels/ChannelDeliveryContracts.cs index 41764eb39..b719a4e98 100644 --- a/src/Netclaw.Channels/ChannelDeliveryContracts.cs +++ b/src/Netclaw.Channels/ChannelDeliveryContracts.cs @@ -16,7 +16,7 @@ public readonly record struct ChannelDescriptorKey public ChannelDescriptorKey(string value) { ArgumentException.ThrowIfNullOrWhiteSpace(value); - Value = value; + Value = value.ToLowerInvariant(); } public string Value { get; } @@ -114,12 +114,19 @@ public sealed record ChannelDescriptor( ChannelToolIntentKind.LookupUser }; + private static readonly IReadOnlySet RemoteChatBaseOutputEffects = + new HashSet + { + ChannelOutputEffectKind.TextMessage, + ChannelOutputEffectKind.FileAttachment + }; + public static ChannelDescriptor CreateRemoteChat( ChannelType channelType, string displayName, bool isEnabled, bool allowDirectMessages, - IReadOnlySet? supportedOutputEffects = null) + IReadOnlySet? additionalOutputEffects = null) { var capabilities = RemoteChatBaseCapabilities; if (allowDirectMessages) @@ -134,6 +141,13 @@ public static ChannelDescriptor CreateRemoteChat( if (allowDirectMessages) addressKinds.Add(ChannelAddressKind.DirectMessage); + var outputEffects = new HashSet(RemoteChatBaseOutputEffects); + if (additionalOutputEffects is not null) + { + foreach (var effect in additionalOutputEffects) + outputEffects.Add(effect); + } + return new ChannelDescriptor( ChannelDescriptorKey.FromChannelType(channelType), channelType, @@ -143,7 +157,7 @@ public static ChannelDescriptor CreateRemoteChat( capabilities, RemoteChatToolIntents, addressKinds, - supportedOutputEffects ?? new HashSet()); + outputEffects); } } @@ -385,6 +399,7 @@ ValueTask RenderOutputAsync( public sealed class ChannelRegistry : IChannelRegistry { private readonly IReadOnlyDictionary _descriptors; + private readonly IReadOnlyCollection _sortedDescriptors; private readonly IReadOnlyDictionary _snapshotProviders; private readonly IReadOnlyDictionary<(ChannelDescriptorKey Key, ChannelAddressKind AddressKind), IChannelAddressResolver> _addressResolvers; private readonly IReadOnlyDictionary _outputRenderers; @@ -399,17 +414,15 @@ public ChannelRegistry( ArgumentNullException.ThrowIfNull(snapshotProviders); _descriptors = BuildDescriptorLookup(descriptorProviders); + _sortedDescriptors = _descriptors.Values + .OrderBy(d => d.Key.Value, StringComparer.Ordinal) + .ToArray(); _snapshotProviders = BuildSnapshotProviderLookup(snapshotProviders); _addressResolvers = BuildAddressResolverLookup(addressResolvers ?? []); _outputRenderers = BuildOutputRendererLookup(outputRenderers ?? []); } - public IReadOnlyCollection ListChannels() - { - return _descriptors.Values - .OrderBy(descriptor => descriptor.Key.Value, StringComparer.Ordinal) - .ToArray(); - } + public IReadOnlyCollection ListChannels() => _sortedDescriptors; public ChannelDescriptor GetChannel(ChannelDescriptorKey key) { @@ -783,7 +796,7 @@ public static bool TryParse(string value, out ChannelAddressKind addressKind) } } - private static string Normalize(string value) + internal static string Normalize(string value) { var buffer = new char[value.Length]; var count = 0; diff --git a/src/Netclaw.Channels/Netclaw.Channels.csproj b/src/Netclaw.Channels/Netclaw.Channels.csproj index 84ea50d84..b901b9821 100644 --- a/src/Netclaw.Channels/Netclaw.Channels.csproj +++ b/src/Netclaw.Channels/Netclaw.Channels.csproj @@ -6,6 +6,11 @@ enable + + + + + diff --git a/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs b/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs index 9206252e9..56c51145f 100644 --- a/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs +++ b/src/Netclaw.Daemon/Configuration/ChannelSendTools.cs @@ -7,7 +7,9 @@ using Microsoft.Extensions.AI; using Netclaw.Actors.Channels; using Netclaw.Channels; +using Netclaw.Channels.Discord; using Netclaw.Channels.Discord.Tools; +using Netclaw.Channels.Mattermost; using Netclaw.Channels.Mattermost.Tools; using Netclaw.Channels.Slack.Tools; using Netclaw.Tools; @@ -389,15 +391,10 @@ private static bool LooksLikeDisplayName(string value) || value.Any(char.IsWhiteSpace); private static bool IsDiscordSnowflake(string value) - => value.Length is >= 17 and <= 20 && value.All(char.IsAsciiDigit); + => DiscordAddressResolver.IsDiscordSnowflake(value); private static bool IsMattermostId(string value) - { - if (value.Length != 26) - return false; - - return value.All(char.IsAsciiLetterOrDigit); - } + => MattermostIdentifierFormat.IsMattermostId(value); private static bool TryGetFlexible(IDictionary arguments, string key, out object? value) { @@ -434,20 +431,7 @@ private static bool TryGetFlexible(IDictionary arguments, strin } private static string NormalizeKey(string key) - { - if (string.IsNullOrWhiteSpace(key)) - return string.Empty; - - var buffer = new char[key.Length]; - var count = 0; - foreach (var ch in key) - { - if (char.IsLetterOrDigit(ch)) - buffer[count++] = ch; - } - - return count == 0 ? string.Empty : new string(buffer, 0, count); - } + => ChannelAddressKindWire.Normalize(key); private readonly record struct SendChannelDestination( ChannelDescriptorKey ChannelKey, From 3b0aa5fbba49ae3c659c2c46203bdccd58683dc1 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 9 Jun 2026 15:02:24 +0000 Subject: [PATCH 29/31] Add channel integration runbook MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step-by-step guide for adding a new chat channel (Teams, WhatsApp, Signal, etc.) covering all 15 implementation layers: ChannelType enum, transport interfaces, IChannel service, actor hierarchy, address resolver, output renderer, LLM tools, reminder target resolver, DI registration, config schema, and Program.cs wiring. Includes architecture diagram, code templates, and testing checklist. Link added from CONTRIBUTING.md § Project Structure. --- CONTRIBUTING.md | 4 + docs/runbooks/adding-a-channel.md | 524 ++++++++++++++++++++++++++++++ 2 files changed, 528 insertions(+) create mode 100644 docs/runbooks/adding-a-channel.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 3f6d6a8b3..a3ebbb9b6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -127,6 +127,10 @@ failure GIFs, screenshot diffs) for debugging. - Security: `src/Netclaw.Security/` (ACL, device pairing, token management) - Tools: `src/Netclaw.Tools.Abstractions/` and `src/Netclaw.Tools.Generators/` +For a step-by-step guide to adding a new chat channel integration (e.g., +Microsoft Teams, WhatsApp, Signal), see +[Adding a Channel](docs/runbooks/adding-a-channel.md). + ## Architecture Netclaw uses a **daemon + thin client** architecture: diff --git a/docs/runbooks/adding-a-channel.md b/docs/runbooks/adding-a-channel.md new file mode 100644 index 000000000..fad503ddc --- /dev/null +++ b/docs/runbooks/adding-a-channel.md @@ -0,0 +1,524 @@ +# Adding a New Channel + +This runbook walks through every component needed to add a new chat channel +integration to Netclaw (e.g., Microsoft Teams, WhatsApp, Signal). Each +existing remote chat channel — Slack, Discord, Mattermost — follows this +exact pattern. + +**Recommended starting point:** clone an existing channel project (Discord is +the cleanest reference) and rename/adapt it. + +## Architecture overview + +``` +┌─────────────────────────────────────────────────────────────────────────────┐ +│ CHANNEL INTEGRATION LAYERS │ +│ │ +│ ① Config & Options ② Transport layer (gateway + reply + outbound) │ +│ ③ IChannel service ④ Actor hierarchy (gateway → conversation) │ +│ ⑤ Channel descriptor ⑥ Address resolver │ +│ ⑦ Output renderer ⑧ LLM tools (send + lookup) │ +│ ⑨ Reminder target resolver ⑩ DI registration extension │ +│ ⑪ Config schema ⑫ Program.cs wiring │ +└─────────────────────────────────────────────────────────────────────────────┘ + + ┌──────────────────────────────┐ + │ ChannelRegistry │ + │ (immutable, built at startup) │ + │ │ + │ Descriptors ───── per key │ + │ AddressResolvers ─ per key │ + │ OutputRenderers ── per key │ + │ SnapshotProviders─ per key │ + └──────────────────────────────┘ + ▲ ▲ ▲ + registered │ │ │ queried by + at startup │ │ │ tools, renderers, + │ │ │ reminder actor + ┌─────────┘ ┌─────┘ ┌─────┘ + │ │ │ + ┌───────────────┐ ┌──────────────┐ ┌─────────────┐ + │ Your channel │ │ Your address │ │ Your output │ + │ registration │ │ resolver(s) │ │ renderer │ + │ extension │ │ │ │ │ + └───────┬───────┘ └──────────────┘ └─────────────┘ + │ + │ wires into DI + ▼ + ┌────────────────────────────────────────────────────┐ + │ Your IChannel │ + │ StartAsync → connect transport, spawn gateway actor│ + │ StopAsync → drain actors, disconnect transport │ + │ GetHealthAsync → transport readiness │ + └──────────┬─────────────────────────────────────────┘ + │ spawns + ▼ + ┌────────────────────────────────────────────────────┐ + │ XxxGatewayActor → XxxConversationActor (children) │ + │ Routes inbound messages to per-thread sessions │ + └────────────────────────────────────────────────────┘ +``` + +## Step-by-step + +### 1. Add `ChannelType` enum value + +**File:** `src/Netclaw.Actors/Channels/ChannelType.cs` + +Add your channel to the `ChannelType` enum and update both wire-value methods: + +```csharp +// In the enum: +Xxx, + +// In ToWireValue(): +ChannelType.Xxx => "xxx", + +// In TryFromWireValue(): +"xxx" => { value = ChannelType.Xxx; return true; } + +// In SupportsInteractiveApproval() if applicable: +ChannelType.Xxx => true, +``` + +### 2. Create the channel project + +Create `src/Netclaw.Channels.Xxx/` as a new class library project. Reference +`Netclaw.Channels` and `Netclaw.Actors`. + +### 3. Define channel options + +**File:** `src/Netclaw.Channels.Xxx/XxxChannelOptions.cs` + +```csharp +public sealed class XxxChannelOptions +{ + public bool Enabled { get; set; } + public SensitiveString BotToken { get; set; } = SensitiveString.Empty; + public bool AllowDirectMessages { get; set; } + public HashSet AllowedChannelIds { get; set; } = []; + public HashSet AllowedUserIds { get; set; } = []; + // ... channel-specific options +} +``` + +### 4. Implement transport interfaces + +These are the thin abstractions around the external SDK/API. Define them in +your channel project. + +#### Gateway transport + +Normalizes the SDK's events into Netclaw message types: + +```csharp +public interface IXxxGatewayTransport +{ + event Func MessageReceived; + event Func Connected; + event Func Disconnected; + + bool IsConnected { get; } + Task StartAsync(string token, CancellationToken ct = default); + Task StopAsync(); +} +``` + +#### Reply client + +Sends replies back through the channel in the context of an existing thread: + +```csharp +public interface IXxxReplyClient +{ + Task PostReplyAsync(string channelId, string threadId, string text, CancellationToken ct = default); + // File attachment, reactions, etc. +} +``` + +#### Outbound client + +Proactive posting (new threads, DMs) — used by LLM tools: + +```csharp +public interface IXxxOutboundClient +{ + Task PostNewThreadAsync(string channelId, string text, CancellationToken ct = default); + Task OpenDirectMessageAsync(string userId, string text, CancellationToken ct = default); +} +``` + +Implement all three against your platform's SDK. + +### 5. Implement `IChannel` + +**File:** `src/Netclaw.Channels.Xxx/XxxChannel.cs` + +```csharp +public sealed class XxxChannel : IChannel +{ + private volatile IActorRef? _gateway; + private volatile string? _connectFailureDetail; + + // Constructor injection: ActorSystem, ISessionPipeline, SessionIngressGate, + // transport clients, IChannelRegistry, IContentScanner, + // IPromptInjectionDetector, IHttpClientFactory, IThreadHistoryFetcher?, + // IOperationalNotificationSink, TimeProvider, XxxChannelOptions, ILogger, + // ToolConfig, ModelCapabilities, NetclawPaths + + public ChannelType ChannelType => ChannelType.Xxx; + public string DisplayName => "Xxx"; + + public async Task StartAsync(CancellationToken ct) + { + // 1. Check Enabled and BotToken (don't throw — degrade) + // 2. Connect transport + // 3. Spawn gateway actor + // 4. Register in ActorRegistry + // 5. Subscribe to transport events + } + + public async Task StopAsync(CancellationToken ct) + { + // 1. Unsubscribe from transport events + // 2. GracefulStop the gateway actor + // 3. Disconnect transport + } + + public ValueTask GetHealthAsync(CancellationToken ct) + { + // Query transport readiness → Healthy / Degraded / Disconnected + } +} +``` + +Key patterns from existing channels: +- `_gateway` must be `volatile` — it's read from event handler threads +- `StartAsync` must never throw — a misconfigured channel degrades, it does + not crash the host +- Connection failures emit an `OperationalAlert` via `IOperationalNotificationSink` + +### 6. Implement the actor hierarchy + +#### Gateway actor + +**File:** `src/Netclaw.Channels.Xxx/XxxGatewayActor.cs` + +Top-level actor that receives normalized inbound messages from the transport +and routes them to per-channel/per-guild conversation actor children: + +```csharp +internal sealed class XxxGatewayActor : ReceiveActor +{ + // Receive: dedup by event ID, ACL-check, route to child + // Child: XxxConversationActor (one per channel/guild) +} +``` + +#### Conversation actor + +Routes messages from a specific channel to per-thread session binding actors. +Enforces channel-level ACL and handles passivation. + +#### Lifecycle actor (optional but recommended) + +If the platform has a persistent WebSocket connection, implement a lifecycle +actor with a reconnection state machine: + +``` +Disconnected → Starting → Connected → Ready + ↑ │ + └── CleanReconnect ←── on error ────┘ +``` + +See `DiscordNetGatewayLifecycleActor` or `MattermostNetGatewayLifecycleActor` +for the full pattern. Key requirements: +- Exponential backoff on retries via `ScheduleTellOnceCancelable` +- Cancel retry timer in `PostStop()` +- Publish `CleanReconnectRequired` and `ConnectionRestored` events + +#### Actor registry key + +**File:** `src/Netclaw.Actors/Hosting/ActorRegistryKeys.cs` + +```csharp +public sealed class XxxGatewayActorKey; +``` + +Used by `ReminderExecutionActor` to route Mode B reminders to the correct +gateway. + +### 7. Implement `IChannelAddressResolver` + +**File:** `src/Netclaw.Channels.Xxx/XxxAddressResolver.cs` + +```csharp +public sealed class XxxAddressResolver : IChannelAddressResolver +{ + public ChannelDescriptorKey Key { get; } + = ChannelDescriptorKey.FromChannelType(ChannelType.Xxx); + + public IReadOnlySet AddressKinds { get; } + = new HashSet { ChannelAddressKind.Destination, ChannelAddressKind.User }; + + public ValueTask ResolveAsync( + ChannelAddressResolutionRequest request, + CancellationToken ct = default) + { + // Resolve human-friendly targets → canonical IDs: + // "#channel-name" → channel ID (Resolved) + // "@username" → user ID (Resolved) + // raw platform ID → validated (Resolved) + // ambiguous query → multiple candidates (Ambiguous) + // not found → (NotFound) + // ACL-blocked → (NotAllowed) + } +} +``` + +Multiple resolvers per channel are supported (e.g., Slack registers both +`SlackTargetResolver` for destinations and `LookupSlackUserTool` for users). + +### 8. Implement `IChannelOutputRenderer` + +**File:** `src/Netclaw.Channels.Xxx/XxxOutputRenderer.cs` + +```csharp +public sealed class XxxOutputRenderer : IChannelOutputRenderer +{ + public ChannelDescriptorKey Key { get; } + = ChannelDescriptorKey.FromChannelType(ChannelType.Xxx); + + public ValueTask RenderAsync( + ChannelOutputRenderRequest request, + CancellationToken ct = default) + { + // Handle request.EffectKind: + // TextMessage → post text via reply client + // ProcessingIndicator → show typing indicator + // FileAttachment → upload file + // Reaction → add emoji reaction + } +} +``` + +If your channel only supports basic text, you can skip effects you don't +support — the registry checks `SupportedOutputEffects` before dispatching. + +### 9. Implement `IThreadHistoryFetcher` + +**File:** `src/Netclaw.Channels.Xxx/XxxThreadHistoryFetcher.cs` + +```csharp +public sealed class XxxThreadHistoryFetcher : IThreadHistoryFetcher +{ + public Task> FetchThreadHistoryAsync( + SessionId sessionId, CancellationToken ct = default) + { + // Fetch all prior messages in the thread identified by sessionId + // Return in chronological order + // Return empty list on failure (don't throw) + } +} +``` + +### 10. Implement LLM tools + +#### Send message tool + +**File:** `src/Netclaw.Channels.Xxx/Tools/SendXxxMessageTool.cs` + +```csharp +public sealed partial class SendXxxMessageTool : NetclawTool, IChannelTool +{ + // LLM-facing name: "send_xxx_message" + // Uses IXxxOutboundClient to post messages + // ACL-checks against XxxChannelOptions.AllowedChannelIds +} +``` + +#### User lookup tool (optional) + +**File:** `src/Netclaw.Channels.Xxx/Tools/LookupXxxUserTool.cs` + +Only needed if the platform has a user directory API. + +### 11. Implement `IReminderTargetResolver` + +**File:** `src/Netclaw.Channels.Xxx/XxxReminderTargetResolver.cs` + +```csharp +public sealed class XxxReminderTargetResolver : IReminderTargetResolver +{ + public string Transport => "xxx"; + + public Task ResolveAsync(string target, CancellationToken ct = default) + { + // Resolve "@user" → (Success, userId, User) + // Resolve "#channel" → (Success, channelId, Channel) + // Resolve unknown → (false, null, Unknown, errorMessage) + } +} +``` + +### 12. Write the DI registration extension + +**File:** `src/Netclaw.Daemon/Configuration/XxxChannelRegistrationExtensions.cs` + +This is the single entry point that wires everything into DI: + +```csharp +public static class XxxChannelRegistrationExtensions +{ + public static void AddXxxChannelIntegration( + this IServiceCollection services, IConfiguration configuration) + { + var options = configuration.GetSection("Xxx").Get() + ?? new XxxChannelOptions(); + services.AddSingleton(options); + + // Always register the descriptor (even when disabled — the registry + // needs it for ListChannels) + services.AddChannelRegistry(); + services.AddChannelDescriptorWithRuntimeSnapshot(CreateDescriptor(options)); + + if (!options.Enabled) + return; + + // Transport clients + services.AddHttpClient("xxx-files").AddNetclawHeaders("xxx-files"); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + + // Thread history + services.AddSingleton(); + + // Address resolution + services.AddSingleton(); + + // Output rendering + services.AddSingleton(); + + // Reminder target resolution + services.AddSingleton(); + + // Channel service (keyed so multiple IChannel impls coexist) + services.AddKeyedSingleton("xxx"); + services.AddSingleton(sp => + sp.GetRequiredKeyedService("xxx")); + services.AddSingleton(sp => + (IHostedService)sp.GetRequiredKeyedService("xxx")); + + // LLM tools + services.AddSingleton(); + services.AddSingleton(sp => + sp.GetRequiredService()); + } + + private static ChannelDescriptor CreateDescriptor(XxxChannelOptions options) + => ChannelDescriptor.CreateRemoteChat( + ChannelType.Xxx, + "Xxx", + options.Enabled, + options.AllowDirectMessages, + // Pass additional output effects beyond the TextMessage/FileAttachment baseline: + new HashSet { ChannelOutputEffectKind.ProcessingIndicator }); +} +``` + +### 13. Update the config schema + +**File:** `src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json` + +Add a new top-level `"Xxx"` section with properties matching your +`XxxChannelOptions` class. The schema uses `"additionalProperties": false` +throughout — any property missing from the schema will be rejected by +`ConfigSchemaDoctorCheck` at runtime. + +Follow the migration-friendly rules in `CLAUDE.md` § Configuration Schema +Sync Rule: +- New required properties need a `"default"` value +- Enum properties use `"type": "string"` with named values +- Removals are handled automatically + +### 14. Wire into Program.cs + +**File:** `src/Netclaw.Daemon/Program.cs` (around line 1074) + +```csharp +services.AddXxxChannelIntegration(configuration); +``` + +Add it alongside the existing channel registrations. No other changes needed — +`AddChannelSendTools`, `AddChannelLookupTools`, and +`ChannelToolRegistration.RegisterChannelTools` all auto-discover via DI. + +### 15. Update generic tool dispatchers + +Two files need your channel added to their enabled-channel checks: + +**`src/Netclaw.Daemon/Configuration/ChannelSendTools.cs`** — update +`AddChannelSendTools` to include your channel in the +`slackEnabled || discordEnabled || ...` check. + +**`src/Netclaw.Daemon/Configuration/ChannelLookupTools.cs`** — update +`AddChannelLookupTools` similarly. + +## Testing checklist + +### Unit tests + +Create `src/Netclaw.Actors.Tests/Channels/Xxx*.cs` tests covering: + +- [ ] Gateway actor message routing and deduplication +- [ ] Conversation actor ACL enforcement +- [ ] Lifecycle actor state machine transitions (if applicable) +- [ ] Address resolver: exact match, substring match, ambiguous, not found, ACL-blocked +- [ ] Transport fake that implements `IXxxGatewayTransport` for actor tests +- [ ] Reminder target resolver: user, channel, unknown + +### Registration tests + +Add cases to `src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs`: + +- [ ] Descriptor appears in `ListChannels()` with correct key, kind, capabilities +- [ ] `SupportedOutputEffects` includes expected effects +- [ ] `ToolIntents` includes `SendMessage` +- [ ] Address resolver is registered and routable +- [ ] Output renderer is registered +- [ ] Disabled channel still has a disabled descriptor +- [ ] LLM tools are discoverable via `IChannelTool` + +### Integration / smoke tests + +- [ ] Channel connects and receives a test message end-to-end +- [ ] Channel health reports correctly (healthy, degraded, disconnected) +- [ ] `send_xxx_message` tool posts a message via the LLM +- [ ] Reminder delivery routes to the correct channel +- [ ] `netclaw doctor` validates the new config section + +## Files changed summary + +| Layer | Files | +|-------|-------| +| Enum | `src/Netclaw.Actors/Channels/ChannelType.cs` | +| Actor key | `src/Netclaw.Actors/Hosting/ActorRegistryKeys.cs` | +| Channel project | `src/Netclaw.Channels.Xxx/` (new) | +| Options | `XxxChannelOptions.cs` | +| Transport | `IXxxGatewayTransport.cs`, `XxxNetGatewayTransport.cs` | +| Reply/outbound | `IXxxReplyClient.cs`, `IXxxOutboundClient.cs`, impls | +| IChannel | `XxxChannel.cs` | +| Gateway actor | `XxxGatewayActor.cs`, `XxxConversationActor.cs` | +| Lifecycle actor | `XxxNetGatewayLifecycleActor.cs` (if WebSocket) | +| Address resolver | `XxxAddressResolver.cs` | +| Output renderer | `XxxOutputRenderer.cs` | +| Thread history | `XxxThreadHistoryFetcher.cs` | +| LLM tools | `Tools/SendXxxMessageTool.cs`, `Tools/LookupXxxUserTool.cs` | +| Reminder resolver | `XxxReminderTargetResolver.cs` | +| DI registration | `src/Netclaw.Daemon/Configuration/XxxChannelRegistrationExtensions.cs` | +| Generic tools | `ChannelSendTools.cs`, `ChannelLookupTools.cs` (update checks) | +| Config schema | `src/Netclaw.Configuration/Schemas/netclaw-config.v1.schema.json` | +| Wiring | `src/Netclaw.Daemon/Program.cs` | +| Tests | `src/Netclaw.Actors.Tests/Channels/Xxx*.cs` | +| Registration tests | `src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs` | From b3c17f4b3576b9462c5064dbf9a099340460c1d0 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 9 Jun 2026 15:28:32 +0000 Subject: [PATCH 30/31] Add channel standardization spec (SPEC-015) Plan to reduce per-channel implementation cost from ~15 files / ~1,800 LOC to ~5 files / ~500 LOC through generic lifecycle actor, gateway/conversation actor bases, registration builder, and send tool consolidation. Includes test gap analysis and 3 new contract test bases to close coverage gaps across Slack, Discord, and Mattermost. Seven independently shippable phases. --- docs/spec/SPEC-015-channel-standardization.md | 428 ++++++++++++++++++ 1 file changed, 428 insertions(+) create mode 100644 docs/spec/SPEC-015-channel-standardization.md diff --git a/docs/spec/SPEC-015-channel-standardization.md b/docs/spec/SPEC-015-channel-standardization.md new file mode 100644 index 000000000..5a903e305 --- /dev/null +++ b/docs/spec/SPEC-015-channel-standardization.md @@ -0,0 +1,428 @@ +# SPEC-015: Channel Infrastructure Standardization + +**Status:** Draft +**Goal:** Reduce per-channel implementation cost from ~15 files to ~5, and close +test coverage gaps identified in the cross-channel audit. + +## Problem + +Adding a new remote chat channel (Teams, WhatsApp, Signal) currently requires +implementing ~15 types across transport, actors, tools, resolvers, renderers, +and DI registration. Much of this is structural boilerplate that follows the +same pattern across all three existing channels. The test suites also have +significant coverage gaps — behaviors tested for one channel are untested for +others despite sharing the same contract. + +### Current per-channel cost + +| Component | Discord | Slack | Mattermost | Pattern | +|---|---|---|---|---| +| Lifecycle actor | 1,163 LOC | — (inline) | 691 LOC | Near-identical state machine | +| IChannel service | 300 LOC | 546 LOC | 302 LOC | Same connect/spawn/health shape | +| Gateway actor | 165 LOC | 165 LOC | 164 LOC | Identical routing structure | +| Conversation actor | ~250 LOC | ~250 LOC | ~260 LOC | Identical routing + ACL | +| Send message tool | 176 LOC | 124 LOC | 123 LOC | Same validate/ACL/dispatch | +| Registration extension | 113 LOC | 134 LOC | 137 LOC | Same DI wiring pattern | +| Connect failure classifier | ~50 LOC | ~50 LOC | ~50 LOC | Same classify-to-Fatal/Transient | + +**Total boilerplate per channel:** ~1,200-1,800 LOC that is structurally +identical to the other channels. + +### Current test coverage gaps + +| Test category | Discord | Slack | Mattermost | +|---|---|---|---| +| ACL contract (19 tests) | inherited | inherited | inherited | +| Gateway routing contract (3) | inherited | **MISSING** | inherited | +| Session binding contract (~40) | inherited | inherited | inherited | +| Channel health | 5 tests | **MISSING** | **MISSING** | +| Lifecycle actor | 2 tests | N/A | 4 tests | +| Routing policy | 9 tests | 22 tests | 8 tests | +| Connect failure classifier | tested | tested | **MISSING** | +| Thread history fetcher | tested | tested | **MISSING** | +| File flow integration | tested | tested | **MISSING** | +| Message chunking | tested | **MISSING** | tested | + +--- + +## Part 1: Infrastructure consolidation + +### 1.1 Generic lifecycle actor base + +**Impact:** Eliminates ~600-1,100 LOC per channel with a WebSocket transport. + +Discord (1,163 LOC) and Mattermost (691 LOC) have nearly identical state +machines: + +``` +Disconnected → Connecting → Ready + ↑ │ │ + │ StartFailed │ + │ │ Disconnected/ + │ ▼ SpuriousEvent + │ Disconnected │ + │ ↑ ▼ + └── Disconnecting ← CleanReconnectRequired +``` + +Both implement the same states (`Disconnected`, `Connecting`, `Ready`, +`CleanReconnectRequired`, `Disconnecting`), the same behaviors +(`ReceiveCommon`, `ReceiveNotReadyIngress`, `ReceiveUnexpected`), the same +retry logic (exponential backoff via `ScheduleTellOnceCancelable`), and the +same shutdown pattern (`CancelRetryTimer` in `PostStop`). + +**Differences that need parameterization:** + +| Aspect | Discord | Mattermost | +|---|---|---| +| Transport interface | `IDiscordGatewayTransport` | `IMattermostGatewayTransport` | +| Start params | `(botToken)` | `(serverUrl, botToken)` | +| Start result | login + start + wait-for-ready | single StartAsync call | +| Ready signal | explicit Ready event from transport | implicit (StartAsync returns = ready) | +| Snapshot type | `DiscordGatewaySnapshot` | `MattermostGatewaySnapshot` | +| Event sink interface | `IDiscordGatewayEventSink` | `IMattermostGatewayEventSink` | +| Transport events | Ready, Connected, Disconnected, Log, MessageReceived, ButtonExecuted | Connected, Disconnected, LogReceived, MessageReceived | + +**Design approach:** Extract a `ChannelLifecycleActor` base class +in `Netclaw.Channels` that implements the state machine, retry logic, and +health reporting. Per-channel subclasses provide: + +- A `StartTransportAsync()` / `StopTransportAsync()` template method pair +- A `CreateSnapshot()` factory +- Transport event subscription/unsubscription in `Subscribe()` / `Unsubscribe()` +- Message forwarding in `HandleIngressMessage()` + +The base handles: state transitions, `Become()` calls, retry scheduling, +`PostStop` cleanup, `GetSnapshot` handling, `Connect`/`Disconnect` protocol, +and `CleanReconnectRequired` → disconnect → auto-reconnect flow. + +**Prerequisite:** Unify the snapshot types. Both `DiscordGatewaySnapshot` and +`MattermostGatewaySnapshot` carry `(IsConnected, IsReady, HealthDetail)` plus +a platform-specific bot identity. Extract a `GatewaySnapshot` base or +interface: + +```csharp +public interface IGatewaySnapshot +{ + bool IsConnected { get; } + bool IsReady { get; } + string? HealthDetail { get; } +} +``` + +### 1.2 Standardized gateway client interface + +**Impact:** Enables the generic lifecycle actor and simplifies `IChannel` implementations. + +Currently each channel has its own gateway client interface +(`IDiscordGatewayClient`, `IMattermostGatewayClient`). The common surface is: + +```csharp +public interface IGatewayClient where TSnapshot : IGatewaySnapshot +{ + event Func CleanReconnectRequired; + event Func ConnectionRestored; + + Task GetSnapshotAsync(CancellationToken ct = default); + Task DisconnectAsync(CancellationToken ct = default); +} +``` + +Platform-specific connect methods and message events stay on the concrete +interfaces. The common surface enables the `IChannel` health-reporting and +shutdown logic to be shared. + +### 1.3 Generic gateway + conversation actor pair + +**Impact:** Eliminates ~350 LOC per channel. + +All three gateway actors (165 LOC each) do exactly the same thing: + +1. Receive normalized inbound messages +2. Extract a routing key (channel ID / guild ID) +3. Get-or-create a child conversation actor for that routing key +4. Forward the message to the child +5. Handle `ReceiveTimeout` for passivation +6. Handle `Terminated` for child cleanup + +The conversation actors (~250 LOC each) similarly: + +1. Receive messages from the gateway parent +2. Extract a session ID (thread ID) +3. Get-or-create a session binding actor for that session ID +4. Forward, with ACL checks and dedup +5. Handle proactive thread creation +6. Handle `ReceiveTimeout` for passivation + +**Design approach:** A generic `ChannelGatewayActor` that takes: + +- A `Func extractRoutingKey` for the gateway level +- A `Func extractSessionId` for the conversation level +- A `Props` factory for creating the session binding actor +- An `IAclPolicy` for ACL checks + +Each channel provides its message type and the extraction/factory functions. +The actor hierarchy structure, passivation, child management, and dedup are +all generic. + +**Risk:** Slack has a slightly different gateway model (it receives +`SlackInboundMessage` which wraps SlackNet events, and it handles both +`MessageEvent` and `AppMention` as separate paths). Need to verify the +generic model can accommodate this without becoming more complex than the +concrete implementations. + +### 1.4 Registration builder + +**Impact:** Eliminates ~100 LOC of boilerplate per channel, makes the +registration pattern discoverable. + +Replace per-channel `AddXxxChannelIntegration` methods with a fluent builder: + +```csharp +services.AddRemoteChatChannel( + "Mattermost", configuration) + .WithTransport() + .WithReplyClient() + .WithOutboundClient() + .WithResolver() + .WithRenderer() + .WithReminderResolver() + .WithThreadHistory() + .WithSendTool() + .WithLookupTool() + .WithAdditionalOutputEffects(ChannelOutputEffectKind.ProcessingIndicator); +``` + +The builder handles: +- Options binding from `IConfiguration` +- Descriptor creation via `CreateRemoteChat` +- Keyed `IChannel` + `IHostedService` registration +- `IChannelTool` marker registration +- HTTP client creation with `AddNetclawHeaders` +- Skipping implementation registrations when `!options.Enabled` +- Always registering the descriptor (even when disabled) + +**Risk:** Low. The builder is purely DI sugar — no behavioral change. Can be +introduced incrementally (one channel at a time migrates to the builder while +the others keep their manual extensions). + +### 1.5 Per-channel send tool elimination + +**Impact:** Eliminates ~125-175 LOC per channel. + +`SendSlackMessageTool`, `SendDiscordMessageTool`, and +`SendMattermostMessageTool` all follow the same pattern: + +1. Parse and validate `channel_id`/`user_id` parameters +2. ACL-check the destination +3. Call the outbound client to post + +`SendChannelMessageTool` already dispatches by `channel_key`. If we +standardize the outbound interface: + +```csharp +public interface IChannelOutboundClient +{ + ChannelDescriptorKey Key { get; } + Task SendMessageAsync(ChannelSendRequest request, CancellationToken ct); +} +``` + +Then `SendChannelMessageTool` can dispatch directly to the outbound client +by key, and the per-channel send tools go away entirely. The per-channel +`IXxxOutboundClient` implementations adopt the common interface. + +**Risk:** Medium. The per-channel tools have slightly different parameter +schemas (Discord supports `thread_name`, Mattermost supports `root_post_id`). +Need to verify the generic tool can accommodate channel-specific parameters +without becoming a kitchen sink. Alternatively, keep per-channel tools but +have them extend a generic base that handles the common validation/dispatch. + +### Summary: what stays per-channel + +After all consolidation, the irreducible per-channel surface would be: + +| Component | Purpose | Estimated LOC | +|---|---|---| +| `XxxChannelOptions` | Config binding | ~30 | +| `XxxGatewayTransport` | SDK adapter (the real work) | 150-300 | +| `XxxReplyClient` | Replies in existing threads | ~50-100 | +| `XxxOutboundClient` | Proactive posting | ~50-100 | +| `XxxAddressResolver` | Platform-specific ID resolution | ~100-200 | +| `XxxConnectFailureClassifier` | SDK exception classification | ~50 | +| Builder call in Program.cs | DI wiring | ~15 | + +**Per-channel cost: ~450-800 LOC** (down from ~1,500-2,000). + +Types eliminated per channel: lifecycle actor, IChannel service (uses generic +base), gateway actor, conversation actor, send message tool, registration +extension. + +--- + +## Part 2: Test consolidation and gap closure + +### 2.1 New contract test bases + +#### `ChannelHealthContractTests` (new) + +**Closes gap for:** Slack, Mattermost (currently Discord-only, 5 tests) + +Tests the `IChannel.GetHealthAsync()` contract: +- Healthy when transport is connected and ready +- Degraded when connected but not ready +- Disconnected when transport is disconnected +- Degraded when channel is disabled +- Health detail propagated from transport snapshot + +Each channel provides a fixture with its `IChannel` + a controllable fake +transport. + +#### `GatewayLifecycleContractTests` (new) + +**Closes gap for:** ensures both Discord and Mattermost (and future channels) +cover the same state machine behaviors. + +Tests (the union of what Discord and Mattermost currently cover): +- Not-ready ingress is dropped +- Runtime disconnect reports not-ready and requests clean reconnect +- Spurious connected/ready event while disconnected triggers clean reconnect +- Reconnect cycle does not duplicate transport handlers +- Clean reconnect state reports not-ready even when transport remains connected +- Auto-reconnect fires after disconnect +- Timer cancelled on actor stop + +Each channel provides a fixture with its lifecycle actor + fake transport. + +#### `RoutingPolicyContractTests` (new) + +**Extracts from:** `DiscordRoutingPolicyTests` (9), `SlackRoutingPolicyTests` +(22), `MattermostRoutingPolicyTests` (8) + +Shared core tests (~8): +- Message without mention ignored when mention-only mode +- Existing thread continues without mention +- Thread reply rehydrates session when no actor exists +- DM routing matrix (4 scenarios: allow×mention combinations) +- Empty content ignored + +Platform-specific tests stay in standalone files: +- Slack: file_share subtypes, hidden messages, bot_message filtering, + BlockAction refusal (~14 tests) +- Discord: interaction routing +- Mattermost: top-level message filtering + +### 2.2 Missing Slack gateway routing contract + +`SlackGatewayContractTests` is missing from the contracts directory. Discord +and Mattermost both have their gateway routing contract implementations. Add +`SlackGatewayContractTests` inheriting from `GatewayRoutingContractTests` to +close this gap. + +### 2.3 Mattermost test coverage gaps + +| Gap | Action | Priority | +|---|---|---| +| Connect failure classifier | Add `MattermostConnectFailureClassifierTests` | High — classifier exists, just untested | +| Thread history fetcher | Add `MattermostThreadHistoryFetcherTests` | Medium — if Mattermost supports threaded history | +| File flow integration | Add `MattermostFileFlowIntegrationTests` | Medium — Mattermost supports attachments | +| Channel health | Covered by new `ChannelHealthContractTests` | Part of 2.1 | + +### 2.4 Slack test coverage gaps + +| Gap | Action | Priority | +|---|---|---| +| Message chunking | Investigate whether Slack has chunking logic; add tests if so | Low — Slack may use Block Kit instead | +| Gateway routing contract | Add `SlackGatewayContractTests` | High — simple, just wire the base | +| Channel health | Covered by new `ChannelHealthContractTests` | Part of 2.1 | + +--- + +## Part 3: Implementation order + +Work is organized into phases that can be shipped independently. Each phase +is a PR-sized unit. + +### Phase 1: Test gap closure (no production code changes) + +Ship missing tests against the existing channel implementations. This +establishes the coverage baseline before any refactoring. + +1. Add `SlackGatewayContractTests` (missing contract implementation) +2. Add `MattermostConnectFailureClassifierTests` +3. Add `MattermostThreadHistoryFetcherTests` (if applicable) +4. Add `MattermostFileFlowIntegrationTests` (if applicable) + +### Phase 2: New contract test bases + +Extract shared test behaviors into abstract bases. Existing standalone tests +are replaced by inheriting implementations. + +1. `ChannelHealthContractTests` + per-channel fixtures (Discord, Slack, Mattermost) +2. `GatewayLifecycleContractTests` + per-channel fixtures (Discord, Mattermost) +3. `RoutingPolicyContractTests` + per-channel fixtures (all three) + +### Phase 3: Gateway snapshot interface + +Small production change that unblocks the generic lifecycle actor. + +1. Define `IGatewaySnapshot` in `Netclaw.Channels` +2. Have `DiscordGatewaySnapshot` and `MattermostGatewaySnapshot` implement it +3. Slim down `IChannel` health reporting to use the common interface + +### Phase 4: Generic lifecycle actor + +The biggest single win. + +1. Extract `ChannelLifecycleActor` base in `Netclaw.Channels` +2. Migrate `DiscordNetGatewayLifecycleActor` to inherit from the base +3. Migrate `MattermostNetGatewayLifecycleActor` to inherit from the base +4. Verify contract tests still pass +5. Delete duplicated state machine code from both + +### Phase 5: Registration builder + +Pure DI sugar, no behavioral change. + +1. Implement `RemoteChatChannelBuilder` in `Netclaw.Daemon` +2. Migrate one channel (Mattermost — simplest) to the builder +3. Migrate Discord +4. Migrate Slack +5. Delete old registration extension classes + +### Phase 6: Generic gateway + conversation actors + +1. Extract `ChannelGatewayActor` in `Netclaw.Channels` +2. Extract `ChannelConversationActor` in `Netclaw.Channels` +3. Migrate channels one at a time +4. Verify gateway routing contract tests still pass + +### Phase 7: Send tool consolidation + +1. Define `IChannelOutboundClient` common interface +2. Migrate outbound clients to implement it +3. Route `SendChannelMessageTool` through the common interface +4. Remove per-channel send tools (or reduce to thin wrappers for + channel-specific parameters) + +--- + +## Risks and mitigations + +| Risk | Mitigation | +|---|---| +| Generic base becomes a leaky abstraction that's harder to debug than concrete impls | Each phase ships independently. If a generic base makes things worse, revert that phase without affecting others. | +| Slack's gateway model doesn't fit the generic gateway actor pattern | Investigate in Phase 6 before committing. Slack can stay concrete if the generic model doesn't fit. | +| Per-channel send tool parameters don't fit a common schema | Phase 7 can stop at a shared base class instead of full elimination. | +| Contract test fixtures become complex enough to defeat the purpose | Keep fixtures minimal — if a fixture is >100 LOC, the contract is too broad. | + +## Success criteria + +- A new remote chat channel can be added with ≤5 production files (options, + transport, reply client, outbound client, address resolver) plus the + builder call +- All three existing channels pass the same contract test suites for: ACL, + gateway routing, session binding, channel health, lifecycle, and routing + policy +- No test coverage regression — every behavior tested today is still tested + after consolidation From a71f10a6007b412c268a423dd4a264cf54fd2782 Mon Sep 17 00:00:00 2001 From: Aaron Stannard Date: Tue, 9 Jun 2026 15:41:56 +0000 Subject: [PATCH 31/31] Close cross-channel test coverage gaps (SPEC-015 Phase 1) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add missing test coverage identified in the channel audit: - SlackGatewayContractTests: wire Slack fixture for the gateway routing contract (ACL, routing, dedup) — closes the only channel missing this contract - MattermostConnectFailureClassifierTests: fatal/transient classification, inner-exception unwrapping, idempotency, case-insensitive matching - MattermostThreadHistoryFetcherTests: bot-root inclusion, bot-reply exclusion, attachment inlining, content scan fail-closed 21 new tests, all green. No production code changes. --- .../Contracts/SlackGatewayContractTests.cs | 92 ++++++ ...MattermostConnectFailureClassifierTests.cs | 72 +++++ .../MattermostThreadHistoryFetcherTests.cs | 277 ++++++++++++++++++ 3 files changed, 441 insertions(+) create mode 100644 src/Netclaw.Actors.Tests/Channels/Contracts/SlackGatewayContractTests.cs create mode 100644 src/Netclaw.Actors.Tests/Channels/MattermostConnectFailureClassifierTests.cs create mode 100644 src/Netclaw.Actors.Tests/Channels/MattermostThreadHistoryFetcherTests.cs diff --git a/src/Netclaw.Actors.Tests/Channels/Contracts/SlackGatewayContractTests.cs b/src/Netclaw.Actors.Tests/Channels/Contracts/SlackGatewayContractTests.cs new file mode 100644 index 000000000..2b3d06776 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/Contracts/SlackGatewayContractTests.cs @@ -0,0 +1,92 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Akka.Actor; +using Akka.Hosting; +using Netclaw.Actors.Tests.Channels.TestHelpers; +using Netclaw.Channels.Slack; +using Netclaw.Configuration; +using Netclaw.Security; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels.Contracts; + +public sealed class SlackGatewayContractTests(ITestOutputHelper output) + : GatewayRoutingContractTests(output) +{ + protected override void ConfigureAkka(AkkaConfigurationBuilder builder, IServiceProvider provider) + { + } + + protected override IActorRef CreateGateway(ChannelOptionsBuilder options) + { + var slackOptions = new SlackChannelOptions + { + Enabled = true, + MentionOnly = false, + AllowDirectMessages = options.AllowDirectMessages, + AllowedChannelIds = options.AllowedChannelIds, + AllowedUserIds = options.AllowedUserIds, + ChannelAudiences = options.ChannelAudiences, + BotToken = new SensitiveString("xoxb-fake") + }; + + var defaultChannelId = options.DefaultChannelId is not null + ? new SlackChannelId(options.DefaultChannelId) + : (SlackChannelId?)null; + + // Wire the real SlackConversationActor (which performs ACL) with a + // ThreadPropsFactory that routes accepted messages to the test probe. + var deps = new SlackGatewayDependencies( + Pipeline: new FailingSessionPipeline(new InvalidOperationException("not used")), + IngressGate: null, + ActorSystem: Sys, + TimeProvider: TimeProvider.System, + Options: slackOptions, + BotUserId: new SlackUserId("UBOT"), + DefaultChannelId: defaultChannelId, + ReplyClient: new RecordingSlackReplyClient(), + ContentScanner: new NullContentScanner(), + ThreadHistoryFetcher: EmptyThreadHistoryFetcher.Instance, + AudienceProfiles: TestSlackGatewayDeps.DefaultAudienceProfiles, + ModelCapabilities: TestSlackGatewayDeps.DefaultVisionCapableModel, + Paths: TestSlackGatewayDeps.NewTestPaths(), + PromptInjectionDetector: SafePromptInjectionDetector.Instance, + ThreadPropsFactory: (sid, chId, threadTs, d) => + Props.Create(() => new ForwardActor(TestActor))); + + return Sys.ActorOf(SlackGatewayActor.CreateProps(deps)); + } + + protected override object CreateAllowedMessage( + string channelId, string threadId, string userId, string text, string eventId) + => new SlackInboundMessage( + Kind: SlackInboundKind.Message, + EventId: new SlackEventId(eventId), + ChannelId: new SlackChannelId(channelId), + ThreadTs: new SlackThreadTs(threadId), + EventTs: new SlackEventTs("1000.1"), + UserId: new SlackUserId(userId), + BotId: null, + Text: text, + Subtype: null, + Hidden: false, + IsDirectMessage: false); + + protected override object CreateDeniedMessage( + string channelId, string userId, string eventId) + => new SlackInboundMessage( + Kind: SlackInboundKind.Message, + EventId: new SlackEventId(eventId), + ChannelId: new SlackChannelId(channelId), + ThreadTs: new SlackThreadTs("thread-1"), + EventTs: new SlackEventTs("1000.1"), + UserId: new SlackUserId(userId), + BotId: null, + Text: "denied", + Subtype: null, + Hidden: false, + IsDirectMessage: false); +} diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostConnectFailureClassifierTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostConnectFailureClassifierTests.cs new file mode 100644 index 000000000..9281d6c34 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/MattermostConnectFailureClassifierTests.cs @@ -0,0 +1,72 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Netclaw.Channels; +using Netclaw.Channels.Mattermost; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels; + +public class MattermostConnectFailureClassifierTests +{ + [Theory] + [InlineData("401")] + [InlineData("unauthorized")] + [InlineData("invalid_token")] + [InlineData("session_expired")] + [InlineData("invalid or expired")] + [InlineData("invalid session")] + [InlineData("403")] + [InlineData("no such host")] + [InlineData("name or service not known")] + public void FatalSignals_ClassifyAsFatal(string signal) + { + var result = MattermostConnectFailureClassifier.Classify( + new Exception($"Connection failed: {signal}")); + + Assert.Equal(ChannelConnectFailureKind.Fatal, result.Kind); + Assert.True(result.IsFatal); + } + + [Fact] + public void FatalSignal_WrappedInOuterException_IsStillFatal() + { + var wrapped = new Exception( + "WebSocket connection was closed", + new Exception("server returned 401 unauthorized")); + + var result = MattermostConnectFailureClassifier.Classify(wrapped); + + Assert.Equal(ChannelConnectFailureKind.Fatal, result.Kind); + } + + [Fact] + public void GenericNetworkError_ClassifiesAsTransient() + { + var result = MattermostConnectFailureClassifier.Classify( + new TimeoutException("gateway did not respond")); + + Assert.Equal(ChannelConnectFailureKind.Transient, result.Kind); + } + + [Fact] + public void AlreadyClassified_IsReturnedUnchanged() + { + var original = new ChannelConnectException(ChannelConnectFailureKind.Fatal, "boom"); + + var result = MattermostConnectFailureClassifier.Classify(original); + + Assert.Same(original, result); + } + + [Fact] + public void FatalSignal_CaseInsensitive() + { + var result = MattermostConnectFailureClassifier.Classify( + new Exception("UNAUTHORIZED access denied")); + + Assert.Equal(ChannelConnectFailureKind.Fatal, result.Kind); + } +} diff --git a/src/Netclaw.Actors.Tests/Channels/MattermostThreadHistoryFetcherTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostThreadHistoryFetcherTests.cs new file mode 100644 index 000000000..19e920bfd --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/MattermostThreadHistoryFetcherTests.cs @@ -0,0 +1,277 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging.Abstractions; +using Netclaw.Actors.Protocol; +using Netclaw.Channels.Mattermost; +using Netclaw.Channels.Mattermost.Transport; +using Netclaw.Configuration; +using Netclaw.Media; +using Netclaw.Security; +using Xunit; + +namespace Netclaw.Actors.Tests.Channels; + +public sealed class MattermostThreadHistoryFetcherTests +{ + private const string ServerUrl = "https://mattermost.example.com"; + private const string BotUserId = "bot-user-id"; + + [Fact] + public async Task Includes_bot_authored_root_for_proactive_post_bootstrap() + { + // Mattermost session ID is {channelId}/{rootPostId}. + // When the root post's MessageId matches the rootPostId from the + // session, the bot-authored root MUST be included (proactive post). + var fetcher = CreateFetcher( + messageFetcher: (_, _) => Task.FromResult>( + [ + new MattermostThreadHistoryFetcher.HistoricalMessage( + MessageId: "root-post-001", + SenderId: new SenderId(BotUserId), + IsBot: true, + Text: "proactive post (root)", + Timestamp: TimeProvider.System.GetUtcNow(), + Attachments: []), + new MattermostThreadHistoryFetcher.HistoricalMessage( + MessageId: "reply-post-002", + SenderId: new SenderId("user-1"), + IsBot: false, + Text: "human reply", + Timestamp: TimeProvider.System.GetUtcNow(), + Attachments: []) + ])); + + var result = await fetcher.FetchThreadHistoryAsync( + new SessionId("ch-public/root-post-001"), + TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Count); + + var rootEntry = Assert.Single(result, r => r.Contents.OfType() + .Any(t => t.Text == "proactive post (root)")); + Assert.Equal(BotUserId, rootEntry.SenderId.Value); + + var humanEntry = Assert.Single(result, r => r.Contents.OfType() + .Any(t => t.Text == "human reply")); + Assert.Equal("user-1", humanEntry.SenderId.Value); + } + + [Fact] + public async Task Excludes_bot_authored_replies_below_thread_root() + { + // Bot entries below the root were produced by one of our sessions + // and are already in transcript. Re-adopting them from history + // would surface our own outputs as third-party context. + var fetcher = CreateFetcher( + messageFetcher: (_, _) => Task.FromResult>( + [ + new MattermostThreadHistoryFetcher.HistoricalMessage( + MessageId: "root-post-001", + SenderId: new SenderId("user-1"), + IsBot: false, + Text: "human-started root", + Timestamp: TimeProvider.System.GetUtcNow(), + Attachments: []), + new MattermostThreadHistoryFetcher.HistoricalMessage( + MessageId: "reply-post-002", + SenderId: new SenderId("user-1"), + IsBot: false, + Text: "human reply", + Timestamp: TimeProvider.System.GetUtcNow(), + Attachments: []), + new MattermostThreadHistoryFetcher.HistoricalMessage( + MessageId: "reply-post-003", + SenderId: new SenderId("bot-other"), + IsBot: true, + Text: "third-party bot reply", + Timestamp: TimeProvider.System.GetUtcNow(), + Attachments: []), + new MattermostThreadHistoryFetcher.HistoricalMessage( + MessageId: "reply-post-004", + SenderId: new SenderId(BotUserId), + IsBot: true, + Text: "our own prior bot reply", + Timestamp: TimeProvider.System.GetUtcNow(), + Attachments: []) + ])); + + var result = await fetcher.FetchThreadHistoryAsync( + new SessionId("ch-public/root-post-001"), + TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Count); + Assert.Contains(result, r => r.Contents.OfType().Any(t => t.Text == "human-started root")); + Assert.Contains(result, r => r.Contents.OfType().Any(t => t.Text == "human reply")); + + Assert.DoesNotContain(result, r => r.Contents.OfType().Any(t => t.Text == "third-party bot reply")); + Assert.DoesNotContain(result, r => r.Contents.OfType().Any(t => t.Text == "our own prior bot reply")); + } + + [Fact] + public async Task Excludes_bot_below_root_even_when_root_is_also_bot() + { + // Proactive root is bot AND there's a later bot turn (already in + // transcript). Only the root and the user reply survive backfill. + var fetcher = CreateFetcher( + messageFetcher: (_, _) => Task.FromResult>( + [ + new MattermostThreadHistoryFetcher.HistoricalMessage( + MessageId: "root-post-001", + SenderId: new SenderId(BotUserId), + IsBot: true, + Text: "proactive root", + Timestamp: TimeProvider.System.GetUtcNow(), + Attachments: []), + new MattermostThreadHistoryFetcher.HistoricalMessage( + MessageId: "reply-post-002", + SenderId: new SenderId("user-1"), + IsBot: false, + Text: "user reply", + Timestamp: TimeProvider.System.GetUtcNow(), + Attachments: []), + new MattermostThreadHistoryFetcher.HistoricalMessage( + MessageId: "reply-post-003", + SenderId: new SenderId(BotUserId), + IsBot: true, + Text: "agent's reply turn (in transcript)", + Timestamp: TimeProvider.System.GetUtcNow(), + Attachments: []) + ])); + + var result = await fetcher.FetchThreadHistoryAsync( + new SessionId("ch-public/root-post-001"), + TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Count); + Assert.Contains(result, r => r.Contents.OfType().Any(t => t.Text == "proactive root")); + Assert.Contains(result, r => r.Contents.OfType().Any(t => t.Text == "user reply")); + Assert.DoesNotContain(result, r => r.Contents.OfType().Any(t => t.Text == "agent's reply turn (in transcript)")); + } + + [Fact] + public async Task Attachment_only_historical_message_is_preserved_and_inlined() + { + // Message with empty text and a single image attachment. + // FileDownloader writes PNG magic bytes; the result should contain + // a DataContent with the image MIME type. + MattermostThreadHistoryFetcher.FileDownloader fileDownloader = async (fileId, stagingDir, maxBytes, ct) => + { + var path = Path.Combine(stagingDir, $"{Guid.NewGuid():N}.tmp"); + await File.WriteAllBytesAsync(path, new byte[] { 0x89, 0x50, 0x4E, 0x47 }, ct); // PNG magic bytes + return (path, 4L); + }; + + var fetcher = CreateFetcher( + messageFetcher: (_, _) => Task.FromResult>( + [ + new MattermostThreadHistoryFetcher.HistoricalMessage( + MessageId: "msg-1001", + SenderId: new SenderId("user-1"), + IsBot: false, + Text: string.Empty, + Timestamp: TimeProvider.System.GetUtcNow(), + Attachments: + [ + new MattermostFileReference( + "screenshot.png", + "image/png", + 4, + $"{ServerUrl}/api/v4/files/file123") + ]) + ]), + fileDownloader: fileDownloader); + + var result = await fetcher.FetchThreadHistoryAsync( + new SessionId("ch-public/msg-1001"), + TestContext.Current.CancellationToken); + + var item = Assert.Single(result); + Assert.Contains(item.Contents, c => c is DataContent d && d.MediaType == "image/png"); + Assert.Contains(item.Contents.OfType(), + t => t.Text.Contains("[attachment]", StringComparison.Ordinal) + && t.Text.Contains("screenshot.png", StringComparison.Ordinal) + && t.Text.Contains("inlined=\"true\"", StringComparison.Ordinal)); + } + + [Fact] + public async Task Historical_scan_failure_is_rejected_fail_closed() + { + MattermostThreadHistoryFetcher.FileDownloader fileDownloader = async (fileId, stagingDir, maxBytes, ct) => + { + var path = Path.Combine(stagingDir, $"{Guid.NewGuid():N}.tmp"); + await File.WriteAllBytesAsync(path, new byte[] { 0x89, 0x50, 0x4E, 0x47 }, ct); + return (path, 4L); + }; + + var fetcher = CreateFetcher( + messageFetcher: (_, _) => Task.FromResult>( + [ + new MattermostThreadHistoryFetcher.HistoricalMessage( + MessageId: "msg-1003", + SenderId: new SenderId("user-3"), + IsBot: false, + Text: "please inspect", + Timestamp: TimeProvider.System.GetUtcNow(), + Attachments: + [ + new MattermostFileReference( + "drawing.png", + "image/png", + 4, + $"{ServerUrl}/api/v4/files/file456") + ]) + ]), + fileDownloader: fileDownloader, + scanner: new FailingContentScanner()); + + var result = await fetcher.FetchThreadHistoryAsync( + new SessionId("ch-public/msg-1003"), + TestContext.Current.CancellationToken); + + var item = Assert.Single(result); + Assert.DoesNotContain(item.Contents, c => c is DataContent); + Assert.Contains(item.Contents.OfType(), + t => t.Text.Contains("attachment rejected", StringComparison.OrdinalIgnoreCase) + && t.Text.Contains("content scanning", StringComparison.OrdinalIgnoreCase)); + } + + private static MattermostThreadHistoryFetcher CreateFetcher( + MattermostThreadHistoryFetcher.MessageFetcher? messageFetcher = null, + MattermostThreadHistoryFetcher.FileDownloader? fileDownloader = null, + IContentScanner? scanner = null, + ToolAudienceProfiles? profiles = null, + ModelCapabilities? modelCapabilities = null, + MattermostChannelOptions? options = null, + NetclawPaths? paths = null) + { + return new MattermostThreadHistoryFetcher( + messageFetcher ?? ((_, _) => Task.FromResult>([])), + fileDownloader ?? ((_, _, _, _) => Task.FromResult<(string FilePath, long BytesWritten)?>(null)), + scanner ?? new NullContentScanner(), + options ?? new MattermostChannelOptions(), + ServerUrl, + BotUserId, + profiles ?? TestMattermostGatewayDeps.DefaultAudienceProfiles, + modelCapabilities ?? TestMattermostGatewayDeps.DefaultVisionCapableModel, + paths ?? TestMattermostGatewayDeps.NewTestPaths(), + NullLogger.Instance); + } + + private sealed class FailingContentScanner : IContentScanner + { + public Task ScanAsync( + ReadOnlyMemory content, + string filename, + string declaredMimeType, + CancellationToken cancellationToken = default) + { + return Task.FromResult(ContentScanResult.Rejected( + ContentScanError.ScanFailure, + "Content scan failed: scanner unavailable")); + } + } +}