From e81f8d8813da7c081909e57f514d4b41231c2839 Mon Sep 17 00:00:00 2001 From: Marshall Yount <132798+marshally@users.noreply.github.com> Date: Sat, 13 Jun 2026 07:03:26 -0700 Subject: [PATCH] docs: replace Mattermost spec with ChatService trait and providers design Supersedes the earlier Mattermost-only design. Introduces a ChatService trait (modelled on SandboxProvider), Slack refactor as preparatory step, full Mattermost implementation, and MS Teams stubs to validate the trait contract against a pure-inbound-HTTP provider. Co-Authored-By: Claude Sonnet 4.6 --- ...026-06-10-mattermost-integration-design.md | 401 ------------ ...-06-13-chat-service-trait-and-providers.md | 596 ++++++++++++++++++ 2 files changed, 596 insertions(+), 401 deletions(-) delete mode 100644 docs/superpowers/specs/2026-06-10-mattermost-integration-design.md create mode 100644 docs/superpowers/specs/2026-06-13-chat-service-trait-and-providers.md diff --git a/docs/superpowers/specs/2026-06-10-mattermost-integration-design.md b/docs/superpowers/specs/2026-06-10-mattermost-integration-design.md deleted file mode 100644 index a81f768b0..000000000 --- a/docs/superpowers/specs/2026-06-10-mattermost-integration-design.md +++ /dev/null @@ -1,401 +0,0 @@ -# Mattermost Integration Design - -**Date:** 2026-06-10 -**Status:** Approved, pending implementation - -## Overview - -Add Mattermost as a first-class notification and interview backend, feature-parallel to the -existing Slack integration. A Fabro server can drive run lifecycle notifications and interactive -human interview prompts through Mattermost without using Slack at all. - -This is a native Mattermost backend using the Mattermost REST API (`/api/v4`) and WebSocket -event API directly — not a Slack-compatibility proxy. - -## Design Principles - -Every design decision defaults to mirroring the existing Slack implementation. Divergences from -Slack's design are noted explicitly and are driven solely by Mattermost API differences, not by -preference. - -## Architecture - -### New crate: `lib/crates/fabro-mattermost/` - -Mirrors `lib/crates/fabro-slack/` module for module. - -| Module | Purpose | Slack parallel | -|---|---|---| -| `lib.rs` | `MattermostOptions`, `MattermostCredentials`, `MattermostCredentialResolution`, vault credential resolution | `config.rs` | -| `client.rs` | `MattermostClient`: REST operations + channel-name-to-ID cache | `client.rs` | -| `attachments.rs` | Build Mattermost `props.attachments` JSON for interview prompts and lifecycle events | `blocks.rs` | -| `connection.rs` | WebSocket loop: authenticate, read `posted` events, reconnect with backoff | `connection.rs` | -| `dispatch.rs` | Classify incoming WS events → `DispatchAction` | `dispatch.rs` | -| `threads.rs` | `ThreadRegistry`: post-ID → `(run_id, qid)` for thread-reply correlation | `threads.rs` | -| `webhook.rs` | Parse and token-verify inbound button action HTTP payloads | (no direct parallel — Slack uses Socket Mode for this) | - -### Changes to existing crates - -**`fabro-types`** -- Add `Principal::Mattermost { team_id: String, user_id: String, user_name: Option }` variant (parallel to `Principal::Slack`) -- Add `MattermostIntegrationSettings` to `ServerIntegrationsSettings` beside `slack` -- Add `mattermost: Option` field to `NotificationRouteSettings` (reuses existing struct) -- Add `mattermost: Option` field to `RunInterviewsSettings` (reuses existing struct) - -**`fabro-static`** -- Add `FABRO_MATTERMOST_TOKEN` and `FABRO_MATTERMOST_WEBHOOK_SECRET` to `EnvVars` -- Register both in `OPTIONAL_VAULT_SECRETS` in `secret_registry.rs` - -**`fabro-server`** -- Add `MattermostService` struct (private, mirrors `SlackService`) -- Add `mattermost_service: Option>` and `mattermost_started: AtomicBool` to `AppState` -- Add `start_optional_mattermost_service()` function -- Add `POST /api/v1/webhooks/mattermost` route -- Call both from `build_router_with_options()` - -## Config & Secrets - -### Server config (`settings.toml`) - -```toml -[server.integrations.mattermost] -enabled = true # bool, defaults to true (mirrors SlackIntegrationSettings) -url = "https://mm.example.com" # Option — Mattermost server base URL -team = "myteam" # Option — team name for channel resolution -default_channel = "fabro-alerts" # Option — mirrors SlackIntegrationSettings.default_channel -``` - -`MattermostIntegrationSettings` in `fabro-types/src/settings/server.rs`: -```rust -pub struct MattermostIntegrationSettings { - pub enabled: bool, - pub url: Option, - pub team: Option, - pub default_channel: Option, -} -// Default: enabled = true, all others None -``` - -### Vault secrets - -Set via `fabro secret set`. Registered in `OPTIONAL_VAULT_SECRETS`. - -| Secret | Purpose | -|---|---| -| `FABRO_MATTERMOST_TOKEN` | Bot/personal access token. Used for all REST API calls (`Authorization: Bearer`) and WebSocket authentication. Single token — no equivalent of Slack's separate app token. | -| `FABRO_MATTERMOST_WEBHOOK_SECRET` | Random token embedded as `?token=` in every action button's `integration.url`. Handler verifies with constant-time compare before processing any inbound action. | - -Credential resolution follows the same pattern as Slack: -```rust -pub enum MattermostCredentialResolution { - Configured(MattermostCredentials), - Missing { env_vars: Vec<&'static str> }, -} -``` -Both secrets must be present for the integration to be `Configured`. Missing either disables it -with a startup log line listing which are absent. - -### Per-run settings - -Reuses `NotificationProviderSettings` and `InterviewProviderSettings` structs unchanged: - -```toml -[run.notifications.on-start] -provider = "mattermost" -events = ["run.started"] -[run.notifications.on-start.mattermost] -channel = "fabro-alerts" - -[run.notifications.on-finish] -provider = "mattermost" -events = ["run.completed", "run.failed"] -[run.notifications.on-finish.mattermost] -channel = "fabro-alerts" - -[run.interviews] -provider = "mattermost" -[run.interviews.mattermost] -channel = "fabro-reviews" -``` - -### Startup log lines - -``` -info!(default_channel_configured = ..., "Mattermost integration enabled") -info!("Mattermost integration disabled by server configuration") -info!(missing_env_vars = %..., "Mattermost integration disabled; missing credentials") -``` - -## Outbound: REST Client & Message Formatting - -### `MattermostClient` (`client.rs`) - -```rust -pub struct MattermostClient { - token: String, - base_url: String, - http: fabro_http::HttpClient, - channel_cache: Mutex>, // "team/name" -> channel_id -} -``` - -Methods: -- `post_message(channel_id, message, attachments, root_id?)` → `POST /api/v4/posts` - - Body: `{ channel_id, message, props: { attachments }, root_id }` (`root_id` omitted when `None`) - - Returns `PostedMessage { post_id, channel_id }` -- `update_post(post_id, message, attachments)` → `PUT /api/v4/posts/{post_id}` - - *Diverges from Slack*: Mattermost uses `PUT` to a post-ID URL; Slack uses `POST /chat.update` with channel+ts. -- `resolve_channel(team, name)` → `GET /api/v4/teams/name/{team}/channels/name/{name}` - - Result cached in `channel_cache` keyed on `"{team}/{name}"`. Cache is populated on first use; no invalidation needed (channel IDs are stable). - -`PostedMessage { post_id: String, channel_id: String }` — parallel to Slack's `PostedMessage { channel_id: String, ts: String }`. `post_id` serves the same role as `ts`: the key for looking up the original message to update it. - -`MattermostApiError` mirrors `SlackApiError`: -```rust -pub enum MattermostApiError { - Http(String), - Api(String), -} -``` - -### `attachments.rs` - -Mattermost uses `props.attachments` (Slack-compatible attachment objects) instead of Block Kit. -Same conceptual outputs, different JSON wire format. - -**`question_to_attachments(run_id, qid, question, run_web_url)`** - -Builds one attachment: -- `color`: `"#0072C6"` (neutral blue for questions) -- `title`: question text (truncated to Mattermost's 200-char attachment title limit) -- `text`: `context_display` if present (truncated) -- `actions`: array of button objects - - Yes/No: two buttons with `integration.context: { run_id, qid, kind: "yes"|"no" }` - - Multiple-choice: one button per option, `kind: "selected"`, `key: ` - - Freeform: no buttons — user replies in thread - - Multi-select: note in `text` asking for comma-separated reply (thread-based; no button UX) -- Each button's `integration.url` is `{server_web_url}/api/v1/webhooks/mattermost?token={FABRO_MATTERMOST_WEBHOOK_SECRET}` - -**`run_lifecycle_attachments(kind, details)`** - -One attachment per lifecycle event: -- `color`: `"#36a64f"` (started/completed), `"#cc0000"` (failed) -- `title`: event label + run name -- `fields`: workflow label, duration (completed/failed only), PR link if present - -**`answered_attachments(question_text, answer_text)`** - -Replaces the original question attachment after an answer is received. Plain text showing what was asked and what was answered. - -## Inbound: WebSocket (thread replies) - -### `connection.rs` - -Mirrors `fabro-slack/src/connection.rs` structurally. - -1. Connect: `tokio_tungstenite::connect_async("wss:///api/v4/websocket")` -2. Authenticate: send `{ "seq": 1, "action": "authentication_challenge", "data": { "token": "" } }` -3. Loop: read messages, dispatch, handle `Message::Ping` with `Pong`, break on `Close` -4. On `posted` event: parse `data.post` (a JSON-encoded string within the event), extract `root_id` and `message` -5. Look up `root_id` in `ThreadRegistry` → `(run_id, qid)` → dispatch `SubmitAnswer` - -*Diverges from Slack*: Mattermost encodes the post as a JSON string inside the event's `data` -field (`data.post` is `"{\"id\":\"...\",\"root_id\":\"...\",\"message\":\"...\"}"`), requiring -a second `serde_json::from_str` parse step. Slack's Socket Mode sends structured JSON directly. - -Reconnect/backoff loop (`run()` / `run_with_status()`) is structurally identical to Slack's: 1s initial, doubling, 30s max. - -`ConnectionStatusUpdate`, `ConnectionStatusSink`, `ProcessOutcome` types mirror Slack exactly. - -### `dispatch.rs` - -```rust -pub enum DispatchAction { - Connected, - SubmitAnswer(Box), - Reconnect, - Ignored, -} -``` - -Classifies events: `hello` → `Connected`, `posted` with registered `root_id` → `SubmitAnswer`, `goodbye` → `Reconnect`, everything else → `Ignored`. - -### `threads.rs` - -`ThreadRegistry` is a straight structural copy of `fabro-slack/src/threads.rs`, keyed on -Mattermost's `post_id` (root post ID) instead of Slack's `ts`. Registered when an interview -question is posted (freeform or `allow_freeform` questions); looked up on `posted` events. - -## Inbound: Button Webhook - -### Route - -`POST /api/v1/webhooks/mattermost` added to `build_router_with_options()`. - -### Security - -`FABRO_MATTERMOST_WEBHOOK_SECRET` is embedded as `?token=` in the `integration.url` -of every action button at message-build time. The handler extracts the `token` query parameter -and performs a constant-time byte comparison against the vault secret before touching the body. -Requests with missing or mismatched tokens return `401`. - -*Diverges from Slack*: Slack uses Socket Mode (the server never receives inbound HTTP for -interactions). Mattermost has no Socket Mode; interactive button actions require an inbound -HTTP endpoint. The token-in-URL pattern is the standard Mattermost integration security model. - -### `webhook.rs` - -Mattermost POSTs `application/json` to `integration.url` when a button is clicked: - -```json -{ - "channel_id": "...", - "team_id": "...", - "user_id": "...", - "user_name": "...", - "context": { - "run_id": "...", - "qid": "...", - "kind": "yes|no|selected|submit_multi", - "key": "..." - } -} -``` - -`parse_action(payload)` → `Option`: -- Extracts `context.run_id`, `context.qid`, `context.kind`, `context.key` -- Builds `Answer` (yes/no/selected/multi-selected) from `kind` + `key` -- Builds `actor: Principal::Mattermost { team_id, user_id, user_name }` - -`MattermostAnswerSubmission { run_id, qid, answer, actor }` — parallel to `SlackAnswerSubmission`. - -## Server Wiring (`fabro-server`) - -### `AppState` additions - -```rust -mattermost_service: Option>, -mattermost_started: AtomicBool, -``` - -Added immediately after `slack_service` / `slack_started`. - -### `MattermostService` - -```rust -struct MattermostService { - client: MattermostClient, - team: String, - default_channel: Option, - posted_messages: Arc>>, - thread_registry: Arc, - connection: Arc>, -} -``` - -Methods (all parallel to `SlackService`): -- `handle_event(state, envelope, run_web_url)` — dispatches on `EventBody` variant -- `handle_lifecycle_event(state, envelope, run_web_url)` — filters `route.provider == "mattermost"`, reads `route.mattermost.channel`, resolves channel ID, posts lifecycle attachment -- `finish_interview(run_id, qid, question, answer_text)` — updates original post with `answered_attachments` -- `submit_answer(state, submission)` — routes to `submit_pending_interview_answer` -- `connection_status()` → `IntegrationConnectionStatus` -- `status_sink()` → `ConnectionStatusSink` - -Interview routing uses `default_channel` (from server config) for questions, matching the -current Slack behavior. The `[run.interviews.mattermost].channel` config field is added to the -schema for forward-compatibility, but is not read by the server — identical to how Slack's -`[run.interviews.slack].channel` field exists in the types but is currently unused by the -server's interview dispatch logic. - -### `start_optional_mattermost_service()` - -Spawns two Tokio tasks, parallel to `start_optional_slack_service()`: -1. Event broadcast listener → `service.handle_event()` -2. WebSocket loop → `mattermost_connection::run_with_status()` - -### `build_router_with_options()` - -```rust -start_optional_slack_service(&state); -start_optional_mattermost_service(&state); // added -``` - -Webhook route added to the API router: -```rust -.route("/api/v1/webhooks/mattermost", post(handler::mattermost_webhook)) -``` - -### AppState construction - -Follows the same pattern as Slack credential resolution: -```rust -let mattermost_service = { - let mm_settings = ¤t_server_settings.server.integrations.mattermost; - if mm_settings.enabled { - // resolve url, team, default_channel from settings - // resolve credentials from vault - // log enabled / disabled-missing-credentials - } else { - info!("Mattermost integration disabled by server configuration"); - None - } -}; -``` - -## `Principal::Mattermost` - -Added to `fabro-types/src/principal.rs`: - -```rust -Mattermost { - team_id: String, - user_id: String, - #[serde(default, skip_serializing_if = "Option::is_none")] - user_name: Option, -}, -``` - -Placed immediately after `Principal::Slack` in the enum. Derives the same traits. Added to -the round-trip test in that file. - -## Tests - -### `fabro-mattermost` unit tests - -- `config.rs`: credential resolution with all combinations of present/absent/empty secrets -- `client.rs`: `parse_post_message_response`, `resolve_channel` cache behavior -- `attachments.rs`: `question_to_attachments` for each `QuestionType`, `run_lifecycle_attachments` for each `RunLifecycleKind`, `answered_attachments`; truncation at Mattermost limits -- `connection.rs`: `process_message` for `hello`, `posted` (registered + unregistered thread), `goodbye`, malformed JSON -- `dispatch.rs`: table-driven over all `DispatchAction` variants -- `webhook.rs`: `parse_action` for yes/no/selected; missing fields return `None` - -### `fabro-server` unit tests - -- Webhook handler: correct token → 200 + answer dispatched; wrong token → 401; missing token → 401 -- Lifecycle routing: `route.provider == "mattermost"` routes to Mattermost service; `"slack"` routes to Slack service; each is independent - -### Manual integration test plan - -Against `docker run --name mattermost-preview -p 8065:8065 mattermost/mattermost-preview`: -1. Create a bot account, generate a personal access token → set `FABRO_MATTERMOST_TOKEN` -2. Generate a random webhook secret → set `FABRO_MATTERMOST_WEBHOOK_SECRET` -3. Configure `[server.integrations.mattermost]` pointing at `http://localhost:8065`, team `ad-1`, channel `town-square` -4. Start Fabro server; confirm "Mattermost integration enabled" in logs and WebSocket connects -5. Trigger a run; confirm lifecycle notification appears in `town-square` -6. Trigger a run with a yes/no interview; confirm question with buttons appears; click Yes; confirm answer is recorded and post updates -7. Trigger a run with a freeform interview; confirm question appears; reply in thread; confirm answer is recorded -8. Confirm Slack integration is unaffected throughout - -## Docs - -- New page: `docs/public/integrations/mattermost.mdx` (mirrors the Slack integration page) -- Updated: `docs/public/administration/server-configuration.mdx` — add `[server.integrations.mattermost]` section and both new secrets to the secrets table -- Updated: `docs/public/llms.txt` — add Mattermost integration page entry -- Updated: `.env.example` — add `FABRO_MATTERMOST_TOKEN` and `FABRO_MATTERMOST_WEBHOOK_SECRET` (commented out) - -## Out of Scope - -- Generic "chat provider" trait abstraction. No trait is introduced; both Slack and Mattermost remain concrete sibling implementations. -- Mattermost OAuth app flow. Personal access token / bot token only. -- Multi-team routing. One `team` per Fabro server; all channels must be in that team. -- Mattermost slash commands or outgoing webhooks as an alternative inbound path. diff --git a/docs/superpowers/specs/2026-06-13-chat-service-trait-and-providers.md b/docs/superpowers/specs/2026-06-13-chat-service-trait-and-providers.md new file mode 100644 index 000000000..ae266fdc8 --- /dev/null +++ b/docs/superpowers/specs/2026-06-13-chat-service-trait-and-providers.md @@ -0,0 +1,596 @@ +# Chat Service Trait and Providers Design + +**Date:** 2026-06-13 +**Status:** Approved, pending implementation +**Supersedes:** `2026-06-10-mattermost-integration-design.md` + +## Overview + +Introduce a `ChatService` trait that abstracts over chat platform integrations (Slack, +Mattermost, MS Teams), then deliver Mattermost as a full implementation and MS Teams as a +verified stub. The Slack integration is refactored to implement the trait as the preparatory +step. This replaces the earlier design that proposed sibling concrete services with no shared +abstraction. + +The immediate deliverable is Mattermost parity with Slack: run lifecycle notifications and +interactive human interview prompts. The trait exists so future providers (Teams, and others) +do not require changes to `fabro-server`'s core wiring. + +## Design Principles + +- Mirror the existing `SandboxProvider` pattern: shared trait crate, concrete implementations + in provider crates, server holds `Vec>`. +- Keep `axum` a dependency of `fabro-server` only. Provider crates use `http` crate types + (`HeaderMap`, status codes) but never Axum router types. +- Every design decision for Mattermost defaults to mirroring Slack. Divergences are driven + solely by API differences. +- MS Teams stubs must be real enough to prove the trait contract holds for a pure-inbound-HTTP + provider (no outbound WebSocket). + +## Crate Structure + +### New crates + +``` +lib/crates/fabro-chat/ — ChatService trait and shared types +lib/crates/fabro-teams/ — MS Teams stub implementation +``` + +### Dependency graph + +``` +fabro-types + ↑ +fabro-interview + ↑ +fabro-chat ← ChatService trait, AnswerSubmission, ChatProviderKind, WebhookOutcome + ↑ ↑ ↑ +fabro-slack fabro-mattermost fabro-teams + ↑ ↑ ↑ + fabro-server +``` + +`fabro-chat` depends on `fabro-interview` (for `Answer`) and `fabro-types` (for `Principal`, +`RunId`, `EventEnvelope`). It does not depend on `axum`. + +`fabro-server` replaces `Option>` with `Vec>`. + +## `fabro-chat` — Trait and Shared Types + +### `ChatService` trait + +```rust +#[async_trait] +pub trait ChatService: Send + Sync { + fn kind(&self) -> ChatProviderKind; + + /// Start the provider's inbound event loop. + /// Slack: spawns Socket Mode WebSocket loop. + /// Mattermost: spawns WebSocket loop. + /// Teams: no-op — Teams delivers events via inbound HTTP only. + async fn start(&self, on_submit: Arc); + + /// Handle an outbound Fabro event (send notifications, post interview questions). + async fn handle_event( + &self, + envelope: &EventEnvelope, + context: &dyn ChatEventContext, + ); + + /// Handle an inbound HTTP webhook (button actions, Teams activities). + /// Slack always returns 404 — it has no inbound HTTP surface. + async fn handle_webhook( + &self, + sub_path: &str, + headers: &HeaderMap, + body: &Bytes, + ) -> WebhookOutcome; + + fn connection_status(&self) -> IntegrationConnectionStatus; +} +``` + +### `ChatEventContext` trait + +Breaks the circular dependency between `fabro-chat` and `fabro-server`. `AppState` implements +this in `fabro-server`; provider crates never see `AppState`. + +```rust +#[async_trait] +pub trait ChatEventContext: Send + Sync { + async fn get_run_projection(&self, run_id: RunId) -> Option>; + fn resolve_env(&self, name: &str) -> Option; +} +``` + +### Shared types + +**`ChatProviderKind`** — enum used for webhook dispatch and logging: +```rust +#[derive(Debug, Clone, Copy, PartialEq, Eq, strum::Display, strum::IntoStaticStr)] +#[strum(serialize_all = "snake_case")] +pub enum ChatProviderKind { Slack, Mattermost, Teams } +``` + +**`AnswerSubmission`** — unified replacement for `SlackAnswerSubmission` and the planned +`MattermostAnswerSubmission`: +```rust +pub struct AnswerSubmission { + pub run_id: String, + pub qid: String, + pub answer: Answer, + pub actor: Principal, +} +``` + +**`WebhookOutcome`** — avoids Axum types in provider crates: +```rust +pub struct WebhookOutcome { + pub status: u16, + pub body: Option, +} +``` + +### Test support + +`fabro-chat/src/test_support.rs` behind `#[cfg(any(test, feature = "test-support"))]`: +- `MockChatEventContext` — configurable fake projection and env vars for testing `handle_event` + in provider crates without a database. +- `assert_chat_service_contract(service: &dyn ChatService)` — thin conformance checker: + `kind()` returns a valid variant, `connection_status()` doesn't panic, `handle_webhook` with + an empty body returns a valid status code. No setup hidden in the helper. + +## Phase 1: Slack Refactor + +### What moves + +`SlackService` (currently a large private struct in `fabro-server/src/server.rs`) moves to +`lib/crates/fabro-slack/src/service.rs` and implements `ChatService`. + +The internal modules (`client`, `connection`, `dispatch`, `blocks`, `threads`, `payload`, +`socket`) are unchanged. + +### `impl ChatService for SlackService` + +| Method | Implementation | +|---|---| +| `kind()` | `ChatProviderKind::Slack` | +| `start(on_submit)` | existing Socket Mode WebSocket loop | +| `handle_event(envelope, context)` | existing logic; `state.store.get_cached_run()` → `context.get_run_projection()`; `state.env_lookup` → `context.resolve_env()` | +| `handle_webhook(...)` | returns `WebhookOutcome { status: 404, body: None }` — Slack has no inbound HTTP surface | +| `connection_status()` | existing method, moved as-is | + +The only meaningful code change is threading `ChatEventContext` through `handle_event`. Every +call to `state.store.get_cached_run()` becomes `context.get_run_projection()`, and +`(state.env_lookup)(name)` becomes `context.resolve_env(name)`. + +## Phase 2: Mattermost Implementation + +### Config & secrets + +**Server config:** +```toml +[server.integrations.mattermost] +enabled = true +url = "https://mm.example.com" # Mattermost server base URL +team = "myteam" # team name for channel-name → ID resolution +default_channel = "fabro-alerts" # channel for interview questions +``` + +`MattermostIntegrationSettings` in `fabro-types/src/settings/server.rs`: +```rust +pub struct MattermostIntegrationSettings { + pub enabled: bool, // default: true + pub url: Option, + pub team: Option, + pub default_channel: Option, +} +``` + +**Vault secrets** (registered in `OPTIONAL_VAULT_SECRETS`): + +| Secret | Purpose | +|---|---| +| `FABRO_MATTERMOST_TOKEN` | Bot/personal access token for REST API (`Authorization: Bearer`) and WebSocket authentication. Single token — no equivalent of Slack's separate app token. | +| `FABRO_MATTERMOST_WEBHOOK_SECRET` | Random token embedded as `?token=` in every button's `integration.url`. Handler verifies with constant-time compare before processing. | + +Both must be present for the integration to be `Configured`. Missing either disables it with a +startup log line listing which are absent, parallel to Slack's credential resolution. + +**Per-run settings** (reuses existing structs unchanged): +```toml +[run.notifications.on-start] +provider = "mattermost" +events = ["run.started"] +[run.notifications.on-start.mattermost] +channel = "fabro-alerts" + +[run.interviews] +provider = "mattermost" +[run.interviews.mattermost] +channel = "fabro-reviews" # exists in schema; unused by server today (mirrors Slack behavior) +``` + +### `fabro-mattermost` modules + +| Module | Purpose | Slack parallel | +|---|---|---| +| `service.rs` | `MattermostService` struct + `impl ChatService` | `SlackService` | +| `client.rs` | `MattermostClient`: REST operations + channel-name-to-ID cache | `client.rs` | +| `attachments.rs` | Build `props.attachments` JSON for interview prompts and lifecycle events | `blocks.rs` | +| `connection.rs` | WebSocket loop: authenticate, read `posted` events, reconnect with backoff | `connection.rs` | +| `dispatch.rs` | Classify incoming WS events → `DispatchAction` | `dispatch.rs` | +| `threads.rs` | `ThreadRegistry`: post-ID → `(run_id, qid)` for thread-reply correlation | `threads.rs` | +| `webhook.rs` | Parse and token-verify inbound button action HTTP payloads | (no Slack parallel — Slack uses Socket Mode) | +| `lib.rs` | `MattermostCredentials`, `MattermostCredentialResolution`, credential resolution | `config.rs` | + +### REST client (`client.rs`) + +```rust +pub struct MattermostClient { + token: String, + base_url: String, + http: fabro_http::HttpClient, + channel_cache: Mutex>, // "team/name" → channel_id +} +``` + +Methods: +- `post_message(channel_id, message, attachments, root_id?)` → `POST /api/v4/posts` + - Body: `{ channel_id, message, props: { attachments }, root_id }` (`root_id` omitted when `None`) + - Returns `PostedMessage { post_id, channel_id }` +- `update_post(post_id, message, attachments)` → `PUT /api/v4/posts/{post_id}` + - *Diverges from Slack*: Mattermost uses `PUT` to a post-ID URL; Slack uses `POST /chat.update` with channel+ts. +- `resolve_channel(team, name)` → `GET /api/v4/teams/name/{team}/channels/name/{name}` + - Result cached keyed on `"{team}/{name}"`. Cache populated on first use; no invalidation (channel IDs are stable). + +`PostedMessage { post_id: String, channel_id: String }` — parallel to Slack's +`PostedMessage { channel_id: String, ts: String }`. `post_id` serves the same role as `ts`. + +### Message formatting (`attachments.rs`) + +Mattermost uses `props.attachments` (Slack-compatible attachment objects) instead of Block Kit. + +**`question_to_attachments(run_id, qid, question, run_web_url, webhook_url)`** + +One attachment: +- `color`: `"#0072C6"` (neutral blue) +- `title`: question text, truncated to 200 chars (Mattermost attachment title limit) +- `text`: `context_display` if present, truncated to 8000 chars +- `actions`: button objects + - Yes/No: two buttons, `integration.context: { run_id, qid, kind: "yes"|"no" }` + - Multiple-choice: one button per option, `kind: "selected"`, `key: ` + - Freeform: no buttons — user replies in thread + - Multi-select: note in `text`, thread-based reply + - Each button's `integration.url` is derived at event-handling time from `state.canonical_origin()` + `?token={FABRO_MATTERMOST_WEBHOOK_SECRET}` + +**`run_lifecycle_attachments(kind, details)`** + +One attachment per event: +- `color`: `"#36a64f"` started/completed, `"#cc0000"` failed +- `title`: event label + workflow name +- `text`: workflow label, run ID, result (completed/failed), duration, PR link if present + +**`answered_attachments(question_text, answer_text)`** + +Replaces the original question attachment after an answer is received. + +### WebSocket inbound — thread replies (`connection.rs`) + +1. Connect: `tokio_tungstenite::connect_async("wss:///api/v4/websocket")` +2. Authenticate: send `{ "seq": 1, "action": "authentication_challenge", "data": { "token": "" } }` +3. Loop: read messages, dispatch, respond to `Ping` with `Pong`, break on `Close` +4. On `posted` event: `data.post` is a JSON-encoded string (a second `serde_json::from_str` + parse is required — *diverges from Slack*, which sends structured JSON directly) +5. Ignore posts where `type` is non-empty (system messages) +6. Look up `root_id` in `ThreadRegistry` → `(run_id, qid)` → call `on_submit` + +Reconnect/backoff: 1s initial, 2× doubling, 30s max — structurally identical to Slack. + +`ConnectionStatusUpdate { Connecting, Connected, Error(String) }` and `ConnectionStatusSink` +mirror Slack exactly. + +### WS dispatch (`dispatch.rs`) + +```rust +pub enum DispatchAction { + Connected, + SubmitAnswer(Box), + Reconnect, + Ignored, +} +``` + +`hello` → `Connected`, `posted` with registered `root_id` → `SubmitAnswer`, `goodbye` → +`Reconnect`, everything else → `Ignored`. + +### Thread registry (`threads.rs`) + +Structural copy of `fabro-slack/src/threads.rs`, keyed on Mattermost `post_id` (root post ID) +instead of Slack `ts`. Registered when a freeform or `allow_freeform` interview question is +posted; removed when the interview is completed or timed out. + +### Button webhook (`webhook.rs`) + +Mattermost POSTs `application/json` to `integration.url` when a button is clicked: + +```json +{ + "channel_id": "...", "team_id": "...", "user_id": "...", "user_name": "...", + "context": { "run_id": "...", "qid": "...", "kind": "yes|no|selected", "key": "..." } +} +``` + +`parse_action(payload)` → `Option`: +- Extracts `context.run_id`, `context.qid`, `context.kind`, `context.key` +- Builds `Answer` from `kind` + `key`; returns `None` for unknown kinds +- Builds `actor: Principal::Mattermost { team_id, user_id, user_name }` + +`constant_time_eq(a: &[u8], b: &[u8]) -> bool` — constant-time byte comparison for token +verification. The handler extracts the `token` query parameter and compares against the vault +secret before touching the body. Mismatched or missing tokens → `WebhookOutcome { status: 401 }`. + +### `MattermostService` (`service.rs`) + +```rust +pub struct MattermostService { + client: MattermostClient, + team: String, + default_channel: Option, + webhook_secret: String, + posted_messages: Arc>>, + thread_registry: Arc, + connection: Arc>, + on_submit: Arc>>>, +} +``` + +`on_submit` is stored after `start()` is called so `handle_webhook` can invoke it when a button +is clicked (the only path where button-action answers arrive, separate from the WS loop). + +`handle_event` dispatches on `EventBody`: +- `InterviewStarted` → resolve channel, post question attachment, register thread (freeform/allow_freeform), store `PostedMessage` +- `InterviewCompleted / Timeout / Interrupted` → remove from `posted_messages`, remove from `thread_registry`, update post with `answered_attachments` +- `RunStarted / RunCompleted / RunFailed` → filter notification routes where `provider == "mattermost"` and event matches, resolve channel, post lifecycle attachment + +## Phase 3: MS Teams Stubs + +### Purpose + +The stub must be complete enough to prove the `ChatService` trait contract holds for a +provider with no outbound connection. It exercises the `start()` no-op path and the +`handle_webhook()` as sole event delivery mechanism. + +### Config & secrets + +```toml +[server.integrations.teams] +enabled = true +``` + +`TeamsIntegrationSettings` added to `ServerIntegrationsSettings` beside `slack` and +`mattermost`: + +```rust +pub struct TeamsIntegrationSettings { + pub enabled: bool, // default: true +} +``` + +Vault secrets: +| Secret | Purpose | +|---|---| +| `FABRO_TEAMS_WEBHOOK_SECRET` | Future: validate Microsoft JWT bearer tokens. Stub: unused but required for credential resolution to return `Configured`. | + +### `fabro-teams` modules + +Minimal crate with `service.rs` and `lib.rs` only: +- `lib.rs` — `TeamsCredentials`, `TeamsCredentialResolution`, `resolve_credentials_status_with_lookup` +- `service.rs` — `TeamsService` struct + `impl ChatService` + +### `impl ChatService for TeamsService` + +| Method | Implementation | +|---|---| +| `kind()` | `ChatProviderKind::Teams` | +| `start(on_submit)` | documented no-op; logs `"Teams: no outbound connection (pure inbound HTTP)"` | +| `handle_event(envelope, context)` | logs `"Teams notifications not yet implemented"` and returns | +| `handle_webhook(sub_path, headers, body)` | returns `WebhookOutcome { status: 200, body: None }` — accepts any POST without validation (stub behavior) | +| `connection_status()` | always returns `IntegrationConnectionStatus { status: Connected, .. }` — no connection to track | + +The stub confirms: the trait contract is valid for a pure-inbound-HTTP provider. Real Teams +JWT validation, Adaptive Card formatting, and event routing are deferred. + +## Server Wiring (`fabro-server`) + +### `AppState` changes + +**Removed:** +```rust +slack_service: Option>, +slack_started: AtomicBool, +``` + +**Added:** +```rust +chat_services: Vec>, +chat_services_started: AtomicBool, +``` + +### `AppState` implements `ChatEventContext` + +```rust +#[async_trait] +impl ChatEventContext for AppState { + async fn get_run_projection(&self, run_id: RunId) -> Option> { + self.store.get_cached_run(&run_id).await.ok().flatten().map(|r| r.projection) + } + fn resolve_env(&self, name: &str) -> Option { + (self.env_lookup)(name) + } +} +``` + +### Service startup + +Single `start_chat_services(state: &Arc)` function replaces +`start_optional_slack_service` and `start_optional_mattermost_service`. For each service in +`chat_services` it spawns two Tokio tasks: + +1. **Event listener** — subscribes to `global_event_tx`, calls `service.handle_event(envelope, state.as_ref())` for each event. +2. **Inbound loop** — calls `service.start(on_submit)`. For Slack and Mattermost this runs + forever (WebSocket with reconnect). For Teams it returns immediately. + +### Webhook dispatch + +Single route outside the `principal_layer` nest: + +``` +POST /api/v1/webhooks/:provider/*rest +``` + +Handler: +1. Extract `:provider` path segment +2. Find service in `chat_services` where `service.kind().as_str() == provider` +3. Call `service.handle_webhook(rest, &headers, &body).await` +4. Convert `WebhookOutcome` to HTTP response + +Unknown provider → 404. Slack → 404 (its `handle_webhook` always returns 404). + +### `http_log_middleware` + +`Principal::Mattermost` and `Principal::Teams` arms added alongside `Principal::Slack`. + +### AppState construction + +For each provider: resolve integration settings, resolve credentials from vault, log enabled / +disabled / missing-credentials, push `Arc` into `chat_services`. + +## `fabro-types` changes + +### `Principal` + +Add immediately after `Principal::Slack`: +```rust +Mattermost { + team_id: String, + user_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + user_name: Option, +}, +Teams { + tenant_id: String, + user_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + user_name: Option, +}, +``` + +`kind()` and `display()` updated for both variants. + +### Settings + +`ServerIntegrationsSettings`: +```rust +pub struct ServerIntegrationsSettings { + pub github: GithubIntegrationSettings, + pub slack: SlackIntegrationSettings, + pub mattermost: MattermostIntegrationSettings, + pub teams: TeamsIntegrationSettings, +} +``` + +`NotificationRouteSettings` and `RunInterviewsSettings`: add `mattermost` and `teams` +optional provider settings fields parallel to existing `slack` fields. + +## `fabro-static` changes + +New constants in `EnvVars`, registered in `OPTIONAL_VAULT_SECRETS` (alphabetical order): +- `FABRO_MATTERMOST_TOKEN` +- `FABRO_MATTERMOST_WEBHOOK_SECRET` +- `FABRO_TEAMS_WEBHOOK_SECRET` + +## Testing Strategy + +### Layers + +All chat integration tests are implementation-facing and belong at the unit / crate-level layer. +No CLI integration tests (`cmd/*`, `workflow/*`, `scenario/*`) are required — there is no +user-visible CLI surface for chat integrations. + +### `fabro-chat` + +- Shared types round-trip: `AnswerSubmission`, `WebhookOutcome`, `ChatProviderKind` serialize + and deserialize correctly. +- `MockChatEventContext` is available from `test-support` feature; verified in `fabro-slack` + and `fabro-mattermost` unit tests. + +### `fabro-slack` + +- `assert_chat_service_contract(&slack_service)` passes. +- `handle_event` with `MockChatEventContext` for each `EventBody` variant of interest. +- `handle_webhook` returns `WebhookOutcome { status: 404 }`. +- Connection status transitions: `Connecting` → `Connected` → `Error` → `Connecting`. + +### `fabro-mattermost` + +- `assert_chat_service_contract(&mattermost_service)` passes. +- Credential resolution: all combinations of present/absent/empty secrets. +- `client.rs`: `parse_post_response`, channel cache hit on second call. +- `attachments.rs`: `question_to_attachments` for each `QuestionType`, `run_lifecycle_attachments` + for each `RunLifecycleKind`, `answered_attachments`, truncation at limits — all asserted with + `insta` inline JSON snapshots. +- `connection.rs`: `process_message` for `hello`, `posted` (registered + unregistered thread), + `goodbye`, malformed JSON; `wss_url_from_base` for `https://` and `http://` inputs. +- `dispatch.rs`: table-driven over all `DispatchAction` variants. +- `webhook.rs`: `parse_action` for yes/no/selected; missing fields return `None`; + `constant_time_eq` correct/incorrect/different-length inputs. +- `handle_webhook`: correct token → `200`, wrong token → `401`, missing token → `401` — + asserted with `expect_axum_status` helpers from `fabro-test`, not raw `assert_eq!`. + +Live provider tests requiring a real Mattermost instance: +```rust +#[e2e_test(live("FABRO_MATTERMOST_TOKEN"))] +async fn mattermost_posts_lifecycle_notification() { ... } +``` + +### `fabro-teams` + +- `assert_chat_service_contract(&teams_service)` passes. +- `start()` returns immediately without spawning tasks. +- `handle_webhook` returns `WebhookOutcome { status: 200 }` for any input. +- `connection_status()` always returns `Connected`. + +### `fabro-server` + +- Webhook dispatch route: `POST /api/v1/webhooks/mattermost` with correct token → 200; wrong + token → 401; `POST /api/v1/webhooks/slack` → 404; unknown provider → 404. +- Existing conformance test updated to include `/api/v1/webhooks/:provider/*rest` route. +- HTTP assertions use `expect_axum_status` / `expect_axum_ok_json` from `fabro-test`. + +### Manual integration test (Mattermost) + +Against `docker run --name mattermost-preview -p 8065:8065 mattermost/mattermost-preview`: +1. Create a bot account, generate a personal access token → `fabro secret set FABRO_MATTERMOST_TOKEN ` +2. Generate a random secret → `fabro secret set FABRO_MATTERMOST_WEBHOOK_SECRET ` +3. Configure `[server.integrations.mattermost]` pointing at `http://localhost:8065`, team `ad-1`, channel `town-square` +4. Start Fabro server; confirm "Mattermost integration enabled" in logs and WebSocket connects +5. Trigger a run; confirm lifecycle notification appears in `town-square` +6. Trigger a run with a yes/no interview; confirm question with buttons; click Yes; confirm post updates and answer is recorded +7. Trigger a run with a freeform interview; reply in thread; confirm answer is recorded +8. Confirm Slack integration is unaffected throughout + +## Docs + +- New: `docs/public/integrations/mattermost.mdx` (mirrors Slack integration page) +- New: `docs/public/integrations/teams.mdx` (stub — notes Teams support is coming) +- Updated: `docs/public/administration/server-configuration.mdx` — add Mattermost and Teams sections and new secrets to the secrets table +- Updated: `.env.example` — add `FABRO_MATTERMOST_TOKEN`, `FABRO_MATTERMOST_WEBHOOK_SECRET`, `FABRO_TEAMS_WEBHOOK_SECRET` (commented out) + +## Out of Scope + +- Full MS Teams implementation (Adaptive Cards, JWT validation, Bot Framework activity routing). +- Mattermost OAuth app flow. Personal access token / bot token only. +- Multi-team routing. One `team` per Fabro server; all channels must be in that team. +- Mattermost slash commands or outgoing webhooks as an alternative inbound path. +- A fourth chat provider beyond Slack, Mattermost, and Teams.