diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift index 79dd97cf9..a43e7d56b 100644 --- a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift +++ b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift @@ -40,6 +40,17 @@ extension ChannelsSettings { return .orange } + var mattermostTint: Color { + guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self) + else { return .secondary } + if !status.configured { return .secondary } + if status.lastError != nil { return .orange } + if status.probe?.ok == false { return .orange } + if status.connected == true { return .green } + if status.running { return .orange } + return .orange + } + var signalTint: Color { guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) else { return .secondary } @@ -85,6 +96,15 @@ extension ChannelsSettings { return "Configured" } + var mattermostSummary: String { + guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self) + else { return "Checking…" } + if !status.configured { return "Not configured" } + if status.connected == true { return "Connected" } + if status.running { return "Running" } + return "Configured" + } + var signalSummary: String { guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) else { return "Checking…" } @@ -193,6 +213,38 @@ extension ChannelsSettings { return lines.isEmpty ? nil : lines.joined(separator: " · ") } + var mattermostDetails: String? { + guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self) + else { return nil } + var lines: [String] = [] + if let source = status.botTokenSource { + lines.append("Token source: \(source)") + } + if let baseUrl = status.baseUrl, !baseUrl.isEmpty { + lines.append("Base URL: \(baseUrl)") + } + if let probe = status.probe { + if probe.ok { + if let name = probe.bot?.username { + lines.append("Bot: @\(name)") + } + if let elapsed = probe.elapsedMs { + lines.append("Probe \(Int(elapsed))ms") + } + } else { + let code = probe.status.map { String($0) } ?? "unknown" + lines.append("Probe failed (\(code))") + } + } + if let last = self.date(fromMs: status.lastProbeAt ?? status.lastConnectedAt) { + lines.append("Last probe \(relativeAge(from: last))") + } + if let err = status.lastError, !err.isEmpty { + lines.append("Error: \(err)") + } + return lines.isEmpty ? nil : lines.joined(separator: " · ") + } + var signalDetails: String? { guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) else { return nil } @@ -244,7 +296,7 @@ extension ChannelsSettings { } var orderedChannels: [ChannelItem] { - let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"] + let fallback = ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage"] let order = self.store.snapshot?.channelOrder ?? fallback let channels = order.enumerated().map { index, id in ChannelItem( @@ -307,6 +359,8 @@ extension ChannelsSettings { return self.telegramTint case "discord": return self.discordTint + case "mattermost": + return self.mattermostTint case "signal": return self.signalTint case "imessage": @@ -326,6 +380,8 @@ extension ChannelsSettings { return self.telegramSummary case "discord": return self.discordSummary + case "mattermost": + return self.mattermostSummary case "signal": return self.signalSummary case "imessage": @@ -345,6 +401,8 @@ extension ChannelsSettings { return self.telegramDetails case "discord": return self.discordDetails + case "mattermost": + return self.mattermostDetails case "signal": return self.signalDetails case "imessage": @@ -377,6 +435,10 @@ extension ChannelsSettings { return self .date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)? .lastProbeAt) + case "mattermost": + guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self) + else { return nil } + return self.date(fromMs: status.lastProbeAt ?? status.lastConnectedAt) case "signal": return self .date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt) @@ -411,6 +473,10 @@ extension ChannelsSettings { guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self) else { return false } return status.lastError?.isEmpty == false || status.probe?.ok == false + case "mattermost": + guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self) + else { return false } + return status.lastError?.isEmpty == false || status.probe?.ok == false case "signal": guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self) else { return false } diff --git a/apps/macos/Sources/Clawdbot/ChannelsStore.swift b/apps/macos/Sources/Clawdbot/ChannelsStore.swift index e62e737a4..810261b9e 100644 --- a/apps/macos/Sources/Clawdbot/ChannelsStore.swift +++ b/apps/macos/Sources/Clawdbot/ChannelsStore.swift @@ -85,6 +85,40 @@ struct ChannelsStatusSnapshot: Codable { let lastProbeAt: Double? } + struct MattermostBot: Codable { + let id: String? + let username: String? + } + + struct MattermostProbe: Codable { + let ok: Bool + let status: Int? + let error: String? + let elapsedMs: Double? + let bot: MattermostBot? + } + + struct MattermostDisconnect: Codable { + let at: Double + let status: Int? + let error: String? + } + + struct MattermostStatus: Codable { + let configured: Bool + let botTokenSource: String? + let running: Bool + let connected: Bool? + let lastConnectedAt: Double? + let lastDisconnect: MattermostDisconnect? + let lastStartAt: Double? + let lastStopAt: Double? + let lastError: String? + let baseUrl: String? + let probe: MattermostProbe? + let lastProbeAt: Double? + } + struct SignalProbe: Codable { let ok: Bool let status: Int? diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift index 36e8fc62b..6a69d7860 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -12,6 +12,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable { case telegram case discord case slack + case mattermost case signal case imessage case msteams diff --git a/apps/macos/Tests/ClawdbotIPCTests/ChannelsSettingsSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ChannelsSettingsSmokeTests.swift index 2b1eced84..08c05a77c 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/ChannelsSettingsSmokeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/ChannelsSettingsSmokeTests.swift @@ -12,10 +12,11 @@ struct ChannelsSettingsSmokeTests { let store = ChannelsStore(isPreview: true) store.snapshot = ChannelsStatusSnapshot( ts: 1_700_000_000_000, - channelOrder: ["whatsapp", "telegram", "signal", "imessage"], + channelOrder: ["whatsapp", "telegram", "mattermost", "signal", "imessage"], channelLabels: [ "whatsapp": "WhatsApp", "telegram": "Telegram", + "mattermost": "Mattermost", "signal": "Signal", "imessage": "iMessage", ], @@ -57,6 +58,21 @@ struct ChannelsSettingsSmokeTests { ], "lastProbeAt": 1_700_000_050_000, ]), + "mattermost": SnapshotAnyCodable([ + "configured": true, + "botTokenSource": "env", + "running": true, + "connected": true, + "baseUrl": "https://chat.example.com", + "lastStartAt": 1_700_000_000_000, + "probe": [ + "ok": true, + "status": 200, + "elapsedMs": 95, + "bot": ["id": "bot-123", "username": "clawdbot"], + ], + "lastProbeAt": 1_700_000_050_000, + ]), "signal": SnapshotAnyCodable([ "configured": true, "baseUrl": "http://127.0.0.1:8080", @@ -82,6 +98,7 @@ struct ChannelsSettingsSmokeTests { channelDefaultAccountId: [ "whatsapp": "default", "telegram": "default", + "mattermost": "default", "signal": "default", "imessage": "default", ]) @@ -98,10 +115,11 @@ struct ChannelsSettingsSmokeTests { let store = ChannelsStore(isPreview: true) store.snapshot = ChannelsStatusSnapshot( ts: 1_700_000_000_000, - channelOrder: ["whatsapp", "telegram", "signal", "imessage"], + channelOrder: ["whatsapp", "telegram", "mattermost", "signal", "imessage"], channelLabels: [ "whatsapp": "WhatsApp", "telegram": "Telegram", + "mattermost": "Mattermost", "signal": "Signal", "imessage": "iMessage", ], @@ -128,6 +146,19 @@ struct ChannelsSettingsSmokeTests { ], "lastProbeAt": 1_700_000_100_000, ]), + "mattermost": SnapshotAnyCodable([ + "configured": false, + "running": false, + "lastError": "bot token missing", + "baseUrl": "https://chat.example.com", + "probe": [ + "ok": false, + "status": 401, + "error": "unauthorized", + "elapsedMs": 110, + ], + "lastProbeAt": 1_700_000_150_000, + ]), "signal": SnapshotAnyCodable([ "configured": false, "baseUrl": "http://127.0.0.1:8080", @@ -154,6 +185,7 @@ struct ChannelsSettingsSmokeTests { channelDefaultAccountId: [ "whatsapp": "default", "telegram": "default", + "mattermost": "default", "signal": "default", "imessage": "default", ]) diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift index bf72af7e5..a19c49bfc 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift @@ -11,6 +11,7 @@ import Testing #expect(GatewayAgentChannel.last.shouldDeliver(true) == true) #expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true) #expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true) + #expect(GatewayAgentChannel.mattermost.shouldDeliver(true) == true) #expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true) #expect(GatewayAgentChannel.last.shouldDeliver(false) == false) } diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md index 882cf816a..a5c24abd8 100644 --- a/docs/automation/cron-jobs.md +++ b/docs/automation/cron-jobs.md @@ -121,7 +121,7 @@ Resolution priority: ### Delivery (channel + target) Isolated jobs can deliver output to a channel. The job payload can specify: -- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage` / `last` +- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` / `signal` / `imessage` / `last` - `to`: channel-specific recipient target If `channel` or `to` is omitted, cron can fall back to the main session’s “last route” @@ -133,7 +133,7 @@ Delivery notes: - Use `deliver: false` to keep output internal even if a `to` is present. Target format reminders: -- Slack/Discord targets should use explicit prefixes (e.g. `channel:`, `user:`) to avoid ambiguity. +- Slack/Discord/Mattermost targets should use explicit prefixes (e.g. `channel:`, `user:`) to avoid ambiguity. - Telegram topics should use the `:topic:` form (see below). #### Telegram delivery targets (topics / forum threads) diff --git a/docs/automation/webhook.md b/docs/automation/webhook.md index f2a62b4e3..4556b4111 100644 --- a/docs/automation/webhook.md +++ b/docs/automation/webhook.md @@ -71,8 +71,8 @@ Payload: - `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:`. Using a consistent key allows for a multi-turn conversation within the hook context. - `wakeMode` optional (`now` | `next-heartbeat`): Whether to trigger an immediate heartbeat (default `now`) or wait for the next periodic check. - `deliver` optional (boolean): If `true`, the agent's response will be sent to the messaging channel. Defaults to `true`. Responses that are only heartbeat acknowledgments are automatically skipped. -- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `signal`, `imessage`, `msteams`. Defaults to `last`. -- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack, conversation ID for MS Teams). Defaults to the last recipient in the main session. +- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost`, `signal`, `imessage`, `msteams`. Defaults to `last`. +- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost, conversation ID for MS Teams). Defaults to the last recipient in the main session. - `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted. - `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`). - `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds. diff --git a/docs/channels/index.md b/docs/channels/index.md index af1d5bfee..e7e012233 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -15,6 +15,7 @@ Text is supported everywhere; media and reactions vary by channel. - [Telegram](/channels/telegram) — Bot API via grammY; supports groups. - [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs. - [Slack](/channels/slack) — Bolt SDK; workspace apps. +- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs. - [Signal](/channels/signal) — signal-cli; privacy-focused. - [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe). - [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups). diff --git a/docs/channels/mattermost.md b/docs/channels/mattermost.md new file mode 100644 index 000000000..c117de8cb --- /dev/null +++ b/docs/channels/mattermost.md @@ -0,0 +1,87 @@ +--- +summary: "Mattermost bot setup and Clawdbot config" +read_when: + - Setting up Mattermost + - Debugging Mattermost routing +--- + +# Mattermost + +## Quick setup +1) Create a Mattermost bot account and copy the **bot token**. +2) Copy the Mattermost **base URL** (e.g., `https://chat.example.com`). +3) Configure Clawdbot and start the gateway. + +Minimal config: +```json5 +{ + channels: { + mattermost: { + enabled: true, + botToken: "mm-token", + baseUrl: "https://chat.example.com" + } + } +} +``` + +## Environment variables (default account) +Set these on the gateway host if you prefer env vars: + +- `MATTERMOST_BOT_TOKEN=...` +- `MATTERMOST_URL=https://chat.example.com` + +Env vars apply only to the **default** account (`default`). Other accounts must use config values. + +## Chat modes +Mattermost responds to DMs automatically. Channel behavior is controlled by `chatmode`: + +- `oncall` (default): respond only when @mentioned in channels. +- `onmessage`: respond to every channel message. +- `onchar`: respond when a message starts with a trigger prefix. + +Config example: +```json5 +{ + channels: { + mattermost: { + chatmode: "onchar", + oncharPrefixes: [">", "!"] + } + } +} +``` + +Notes: +- `onchar` still responds to explicit @mentions. +- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred. + +## Targets for outbound delivery +Use these target formats with `clawdbot message send` or cron/webhooks: + +- `channel:` for a channel +- `user:` for a DM +- `@username` for a DM (resolved via the Mattermost API) + +Bare IDs are treated as channels. + +## Multi-account +Mattermost supports multiple accounts under `channels.mattermost.accounts`: + +```json5 +{ + channels: { + mattermost: { + accounts: { + default: { name: "Primary", botToken: "mm-token", baseUrl: "https://chat.example.com" }, + alerts: { name: "Alerts", botToken: "mm-token-2", baseUrl: "https://alerts.example.com" } + } + } + } +} +``` + +## Troubleshooting +- No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`. +- Auth errors: check the bot token, base URL, and whether the account is enabled. +- Multi-account issues: env vars only apply to the `default` account. diff --git a/docs/cli/channels.md b/docs/cli/channels.md index 55214ae63..fd74aabbd 100644 --- a/docs/cli/channels.md +++ b/docs/cli/channels.md @@ -1,7 +1,7 @@ --- summary: "CLI reference for `clawdbot channels` (accounts, status, login/logout, logs)" read_when: - - You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage) + - You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost/Signal/iMessage) - You want to check channel status or tail channel logs --- diff --git a/docs/cli/index.md b/docs/cli/index.md index d12b5e9f8..3b6d50f2b 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -370,7 +370,7 @@ Options: ## Channel helpers ### `channels` -Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams). +Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost/Signal/iMessage/MS Teams). Subcommands: - `channels list`: show configured channels and auth profiles (Claude Code + Codex CLI OAuth sync included). @@ -383,7 +383,7 @@ Subcommands: - `channels logout`: log out of a channel session (if supported). Common options: -- `--channel `: `whatsapp|telegram|discord|slack|signal|imessage|msteams` +- `--channel `: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams` - `--account `: channel account id (default `default`) - `--name