mirror of
https://github.com/fabro-sh/fabro.git
synced 2026-10-06 02:48:25 +00:00
docs: add Mattermost integration design spec
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
4083c3ef10
commit
b4f4dd379f
1 changed files with 401 additions and 0 deletions
|
|
@ -0,0 +1,401 @@
|
|||
# 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<String> }` variant (parallel to `Principal::Slack`)
|
||||
- Add `MattermostIntegrationSettings` to `ServerIntegrationsSettings` beside `slack`
|
||||
- Add `mattermost: Option<NotificationProviderSettings>` field to `NotificationRouteSettings` (reuses existing struct)
|
||||
- Add `mattermost: Option<InterviewProviderSettings>` 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<Arc<MattermostService>>` 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<InterpString> — Mattermost server base URL
|
||||
team = "myteam" # Option<InterpString> — team name for channel resolution
|
||||
default_channel = "fabro-alerts" # Option<InterpString> — mirrors SlackIntegrationSettings.default_channel
|
||||
```
|
||||
|
||||
`MattermostIntegrationSettings` in `fabro-types/src/settings/server.rs`:
|
||||
```rust
|
||||
pub struct MattermostIntegrationSettings {
|
||||
pub enabled: bool,
|
||||
pub url: Option<InterpString>,
|
||||
pub team: Option<InterpString>,
|
||||
pub default_channel: Option<InterpString>,
|
||||
}
|
||||
// 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<HashMap<String, String>>, // "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: <option_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://<host>/api/v4/websocket")`
|
||||
2. Authenticate: send `{ "seq": 1, "action": "authentication_challenge", "data": { "token": "<FABRO_MATTERMOST_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<MattermostAnswerSubmission>),
|
||||
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=<secret>` 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<MattermostAnswerSubmission>`:
|
||||
- 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<Arc<MattermostService>>,
|
||||
mattermost_started: AtomicBool,
|
||||
```
|
||||
|
||||
Added immediately after `slack_service` / `slack_started`.
|
||||
|
||||
### `MattermostService`
|
||||
|
||||
```rust
|
||||
struct MattermostService {
|
||||
client: MattermostClient,
|
||||
team: String,
|
||||
default_channel: Option<String>,
|
||||
posted_messages: Arc<Mutex<HashMap<(RunId, String), PostedMessage>>>,
|
||||
thread_registry: Arc<ThreadRegistry>,
|
||||
connection: Arc<Mutex<MattermostConnectionRuntimeState>>,
|
||||
}
|
||||
```
|
||||
|
||||
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<String>,
|
||||
},
|
||||
```
|
||||
|
||||
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.
|
||||
Loading…
Add table
Reference in a new issue