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..0f19950d7 --- /dev/null +++ b/openspec/changes/standardize-channel-delivery-contracts/design.md @@ -0,0 +1,716 @@ +# 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. + +## 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 +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); +``` + +### 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 +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. + +## 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 +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 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. +- `lookup_channel_user`: channel key, query, optional exact-only flag. +- `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 +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 +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. + +## 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. +- 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..0fb2683ac --- /dev/null +++ b/openspec/changes/standardize-channel-delivery-contracts/specs/netclaw-input-adapters/spec.md @@ -0,0 +1,324 @@ +## 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. + +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** `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 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 + +### 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 +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..f6a4ccfd9 --- /dev/null +++ b/openspec/changes/standardize-channel-delivery-contracts/tasks.md @@ -0,0 +1,85 @@ +## 1. OpenSpec planning artifacts + +- [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 + +- [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 + +- [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 + +- [x] 4.1 Register channel descriptors for Slack, Discord, Mattermost, and TUI or explicitly mark unsupported/not-configured output channels. +- [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. +- [ ] 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 + +- [ ] 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.3 Ensure reminders and webhooks do not register channel descriptors or channel snapshot providers. + +## 6. Descriptor-driven observability + +- [x] 6.1 Change daemon runtime status to enumerate the channel registry instead of hard-coding individual channel adapters. +- [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 + +- [x] 7.1 Add a standard channel address resolver contract for users and destinations. +- [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. +- [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.7 Wire Mattermost lookup to its channel-scoped resolver. + +## 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. +- [ ] 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 + +- [ ] 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. 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. + +## 11. Validation and quality gates + +- [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.Actors.Tests/Channels/MattermostProactiveThreadTests.cs b/src/Netclaw.Actors.Tests/Channels/MattermostProactiveThreadTests.cs new file mode 100644 index 000000000..9a759f772 --- /dev/null +++ b/src/Netclaw.Actors.Tests/Channels/MattermostProactiveThreadTests.cs @@ -0,0 +1,259 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +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; + +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)); + } + } +} + +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.Channels/ChannelDeliveryContracts.cs b/src/Netclaw.Channels/ChannelDeliveryContracts.cs new file mode 100644 index 000000000..6617f90ba --- /dev/null +++ b/src/Netclaw.Channels/ChannelDeliveryContracts.cs @@ -0,0 +1,585 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Netclaw.Actors.Channels; +using Netclaw.Channels.Telemetry; + +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 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( + 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 IChannelAddressResolver +{ + ChannelDescriptorKey Key { get; } + + IReadOnlySet AddressKinds { get; } + + ValueTask ResolveAsync( + ChannelAddressResolutionRequest request, + CancellationToken cancellationToken = default); +} + +public interface IChannelRegistry +{ + IReadOnlyCollection ListChannels(); + + ChannelDescriptor GetChannel(ChannelDescriptorKey key); + + 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? addressResolvers = null) + { + ArgumentNullException.ThrowIfNull(descriptorProviders); + ArgumentNullException.ThrowIfNull(snapshotProviders); + + _descriptors = BuildDescriptorLookup(descriptorProviders); + _snapshotProviders = BuildSnapshotProviderLookup(snapshotProviders); + _addressResolvers = BuildAddressResolverLookup(addressResolvers ?? []); + } + + 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); + } + + 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) + { + 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; + } + + private static IReadOnlyDictionary<(ChannelDescriptorKey Key, ChannelAddressKind AddressKind), IChannelAddressResolver> 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 +{ + 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) + { + 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 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 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); + + return services.AddChannelDescriptorWithRuntimeSnapshot(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..343c80be6 --- /dev/null +++ b/src/Netclaw.Daemon.Tests/Configuration/ChannelRegistryRegistrationTests.cs @@ -0,0 +1,418 @@ +// ----------------------------------------------------------------------- +// +// Copyright (C) 2026 - 2026 Petabridge, LLC +// +// ----------------------------------------------------------------------- +using Microsoft.Extensions.Configuration; +using Microsoft.Extensions.DependencyInjection; +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; +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 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)); + Assert.DoesNotContain(services, descriptor => descriptor.ServiceType == typeof(IChannelAddressResolver)); + 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_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() + { + 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() + { + 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() + { + 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); + } + + [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) + { + using var provider = BuildProvider(settings); + return provider.GetRequiredService() + .ListChannels() + .ToDictionary(descriptor => descriptor.Key.Value, StringComparer.Ordinal); + } + + private static ServiceProvider BuildProvider(IReadOnlyDictionary settings) + { + return BuildServices(settings).BuildServiceProvider(); + } + + private static ServiceCollection BuildServices(IReadOnlyDictionary settings) + { + var services = new ServiceCollection(); + services.AddLogging(); + services.AddSingleton(TimeProvider.System); + services.AddChannelRegistry(); + services.AddTuiChannelDescriptor(); + + var configuration = new ConfigurationBuilder() + .AddInMemoryCollection(settings) + .Build(); + + services.AddSlackChannelIntegration(configuration); + services.AddDiscordChannelIntegration(configuration); + services.AddMattermostChannelIntegration(configuration); + + return services; + } + + 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, + 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); + + 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.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.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.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 @@ + + diff --git a/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs b/src/Netclaw.Daemon/Configuration/DiscordChannelRegistrationExtensions.cs index e4c2d341c..87cacc215 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.AddChannelDescriptorWithRuntimeSnapshot(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..f34c61512 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.AddChannelDescriptorWithRuntimeSnapshot(CreateDescriptor(mattermostOptions)); if (!mattermostOptions.Enabled) return; @@ -90,6 +92,15 @@ public static void AddMattermostChannelIntegration(this IServiceCollection servi sp.GetRequiredKeyedService(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 @@ -108,12 +119,59 @@ 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)); } + + 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..b2717ff50 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.AddChannelDescriptorWithRuntimeSnapshot(CreateDescriptor(slackOptions)); if (!slackOptions.Enabled) return; @@ -56,7 +58,6 @@ public static void AddSlackChannelIntegration(this IServiceCollection services, }); services.AddSingleton(); services.AddSingleton(); - services.AddSingleton(); services.AddSingleton(); services.AddSingleton(); services.AddKeyedSingleton(SlackChannelKey); @@ -64,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 => { @@ -107,8 +120,56 @@ 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)); } + + 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/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 }; } 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)) 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);