diff --git a/CHANGELOG.md b/CHANGELOG.md index 18ef0efa6e..81529cae1a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -29,6 +29,7 @@ Docs: https://docs.clawd.bot - Agents: surface concrete API error details instead of generic AI service errors. - Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj. - Agents: avoid sanitizing tool call IDs for OpenAI responses to preserve Pi pairing. +- Mattermost (plugin): enforce pairing/allowlist gating, keep @username targets, and clarify plugin-only docs. (#1428) Thanks @damoahdominic. - Docs: fix gog auth services example to include docs scope. (#1454) Thanks @zerone0x. - macOS: prefer linked channels in gateway summary to avoid false “not linked” status. diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift index a43e7d56b2..79dd97cf9f 100644 --- a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift +++ b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift @@ -40,17 +40,6 @@ 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 } @@ -96,15 +85,6 @@ 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…" } @@ -213,38 +193,6 @@ 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 } @@ -296,7 +244,7 @@ extension ChannelsSettings { } var orderedChannels: [ChannelItem] { - let fallback = ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage"] + let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"] let order = self.store.snapshot?.channelOrder ?? fallback let channels = order.enumerated().map { index, id in ChannelItem( @@ -359,8 +307,6 @@ extension ChannelsSettings { return self.telegramTint case "discord": return self.discordTint - case "mattermost": - return self.mattermostTint case "signal": return self.signalTint case "imessage": @@ -380,8 +326,6 @@ extension ChannelsSettings { return self.telegramSummary case "discord": return self.discordSummary - case "mattermost": - return self.mattermostSummary case "signal": return self.signalSummary case "imessage": @@ -401,8 +345,6 @@ extension ChannelsSettings { return self.telegramDetails case "discord": return self.discordDetails - case "mattermost": - return self.mattermostDetails case "signal": return self.signalDetails case "imessage": @@ -435,10 +377,6 @@ 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) @@ -473,10 +411,6 @@ 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 810261b9e4..e62e737a42 100644 --- a/apps/macos/Sources/Clawdbot/ChannelsStore.swift +++ b/apps/macos/Sources/Clawdbot/ChannelsStore.swift @@ -85,40 +85,6 @@ 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 0f0024001e..9feb98ba97 100644 --- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift +++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift @@ -12,7 +12,6 @@ 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 08c05a77c6..2b1eced848 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/ChannelsSettingsSmokeTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/ChannelsSettingsSmokeTests.swift @@ -12,11 +12,10 @@ struct ChannelsSettingsSmokeTests { let store = ChannelsStore(isPreview: true) store.snapshot = ChannelsStatusSnapshot( ts: 1_700_000_000_000, - channelOrder: ["whatsapp", "telegram", "mattermost", "signal", "imessage"], + channelOrder: ["whatsapp", "telegram", "signal", "imessage"], channelLabels: [ "whatsapp": "WhatsApp", "telegram": "Telegram", - "mattermost": "Mattermost", "signal": "Signal", "imessage": "iMessage", ], @@ -58,21 +57,6 @@ 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", @@ -98,7 +82,6 @@ struct ChannelsSettingsSmokeTests { channelDefaultAccountId: [ "whatsapp": "default", "telegram": "default", - "mattermost": "default", "signal": "default", "imessage": "default", ]) @@ -115,11 +98,10 @@ struct ChannelsSettingsSmokeTests { let store = ChannelsStore(isPreview: true) store.snapshot = ChannelsStatusSnapshot( ts: 1_700_000_000_000, - channelOrder: ["whatsapp", "telegram", "mattermost", "signal", "imessage"], + channelOrder: ["whatsapp", "telegram", "signal", "imessage"], channelLabels: [ "whatsapp": "WhatsApp", "telegram": "Telegram", - "mattermost": "Mattermost", "signal": "Signal", "imessage": "iMessage", ], @@ -146,19 +128,6 @@ 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", @@ -185,7 +154,6 @@ 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 a19c49bfc7..bf72af7e50 100644 --- a/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift +++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift @@ -11,7 +11,6 @@ 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 a5c24abd86..33fec7219c 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` / `mattermost` / `signal` / `imessage` / `last` +- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` (plugin) / `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/Mattermost targets should use explicit prefixes (e.g. `channel:`, `user:`) to avoid ambiguity. +- Slack/Discord/Mattermost (plugin) 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 4556b41113..0828483d2f 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`, `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. +- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost` (plugin), `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 (plugin), 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 e7e012233e..00b33ac079 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -15,7 +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. +- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs (plugin, installed separately). - [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 index c117de8cb0..a528befb9a 100644 --- a/docs/channels/mattermost.md +++ b/docs/channels/mattermost.md @@ -5,12 +5,33 @@ read_when: - Debugging Mattermost routing --- -# Mattermost +# Mattermost (plugin) + +Status: supported via plugin (bot token + WebSocket events). Channels, groups, and DMs are supported. + +## Plugin required +Mattermost ships as a plugin and is not bundled with the core install. + +Install via CLI (npm registry): +```bash +clawdbot plugins install @clawdbot/mattermost +``` + +Local checkout (when running from a git repo): +```bash +clawdbot plugins install ./extensions/mattermost +``` + +If you choose Mattermost during configure/onboarding and a git checkout is detected, +Clawdbot will offer the local install path automatically. + +Details: [Plugins](/plugin) ## 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. +1) Install the Mattermost plugin. +2) Create a Mattermost bot account and copy the **bot token**. +3) Copy the Mattermost **base URL** (e.g., `https://chat.example.com`). +4) Configure Clawdbot and start the gateway. Minimal config: ```json5 @@ -19,7 +40,8 @@ Minimal config: mattermost: { enabled: true, botToken: "mm-token", - baseUrl: "https://chat.example.com" + baseUrl: "https://chat.example.com", + dmPolicy: "pairing" } } } @@ -56,6 +78,18 @@ Notes: - `onchar` still responds to explicit @mentions. - `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred. +## Access control (DMs) +- Default: `channels.mattermost.dmPolicy = "pairing"` (unknown senders get a pairing code). +- Approve via: + - `clawdbot pairing list mattermost` + - `clawdbot pairing approve mattermost ` +- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`. + +## Channels (groups) +- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated). +- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`). +- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated). + ## Targets for outbound delivery Use these target formats with `clawdbot message send` or cron/webhooks: diff --git a/docs/cli/channels.md b/docs/cli/channels.md index fd74aabbd3..48ed043a28 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/Mattermost/Signal/iMessage) + - You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/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 cc3078a5ad..46f6d173e3 100644 --- a/docs/cli/index.md +++ b/docs/cli/index.md @@ -352,7 +352,7 @@ Options: ## Channel helpers ### `channels` -Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost/Signal/iMessage/MS Teams). +Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams). Subcommands: - `channels list`: show configured channels and auth profiles (Claude Code + Codex CLI OAuth sync included). diff --git a/docs/cli/message.md b/docs/cli/message.md index 43d8206659..7cb9ae673d 100644 --- a/docs/cli/message.md +++ b/docs/cli/message.md @@ -8,7 +8,7 @@ read_when: # `clawdbot message` Single outbound command for sending messages and channel actions -(Discord/Slack/Mattermost/Telegram/WhatsApp/Signal/iMessage/MS Teams). +(Discord/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/MS Teams). ## Usage @@ -19,14 +19,14 @@ clawdbot message [flags] Channel selection: - `--channel` required if more than one channel is configured. - If exactly one channel is configured, it becomes the default. -- Values: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams` +- Values: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams` (Mattermost requires plugin) Target formats (`--target`): - WhatsApp: E.164 or group JID - Telegram: chat id or `@username` - Discord: `channel:` or `user:` (or `<@id>` mention; raw numeric ids are treated as channels) - Slack: `channel:` or `user:` (raw channel id is accepted) -- Mattermost: `channel:`, `user:`, or `@username` (bare ids are treated as channels) +- Mattermost (plugin): `channel:`, `user:`, or `@username` (bare ids are treated as channels) - Signal: `+E.164`, `group:`, `signal:+E.164`, `signal:group:`, or `username:`/`u:` - iMessage: handle, `chat_id:`, `chat_guid:`, or `chat_identifier:` - MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:` or `user:` @@ -50,7 +50,7 @@ Name lookup: ### Core - `send` - - Channels: WhatsApp/Telegram/Discord/Slack/Mattermost/Signal/iMessage/MS Teams + - Channels: WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams - Required: `--target`, plus `--message` or `--media` - Optional: `--media`, `--reply-to`, `--thread-id`, `--gif-playback` - Telegram only: `--buttons` (requires `channels.telegram.capabilities.inlineButtons` to allow it) diff --git a/docs/gateway/configuration.md b/docs/gateway/configuration.md index bb11495ab9..ddbbb205b9 100644 --- a/docs/gateway/configuration.md +++ b/docs/gateway/configuration.md @@ -1206,6 +1206,9 @@ Slack action groups (gate `slack` tool actions): ### `channels.mattermost` (bot token) +Mattermost ships as a plugin and is not bundled with the core install. +Install it first: `clawdbot plugins install @clawdbot/mattermost` (or `./extensions/mattermost` from a git checkout). + Mattermost requires a bot token plus the base URL for your server: ```json5 @@ -1215,6 +1218,7 @@ Mattermost requires a bot token plus the base URL for your server: enabled: true, botToken: "mm-token", baseUrl: "https://chat.example.com", + dmPolicy: "pairing", chatmode: "oncall", // oncall | onmessage | onchar oncharPrefixes: [">", "!"], textChunkLimit: 4000 @@ -1230,6 +1234,11 @@ Chat modes: - `onmessage`: respond to every channel message. - `onchar`: respond when a message starts with a trigger prefix (`channels.mattermost.oncharPrefixes`, default `[">", "!"]`). +Access control: +- Default DMs: `channels.mattermost.dmPolicy="pairing"` (unknown senders get a pairing code). +- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`. +- Groups: `channels.mattermost.groupPolicy="allowlist"` by default (mention-gated). Use `channels.mattermost.groupAllowFrom` to restrict senders. + Multi-account support lives under `channels.mattermost.accounts` (see the multi-account section above). Env vars only apply to the default account. Use `channel:` or `user:` (or `@username`) when specifying delivery targets; bare ids are treated as channel ids. diff --git a/docs/index.md b/docs/index.md index 3b45b6626f..a5b7dbc518 100644 --- a/docs/index.md +++ b/docs/index.md @@ -12,7 +12,8 @@ read_when:

- Any OS + WhatsApp/Telegram/Discord/Mattermost/iMessage gateway for AI agents (Pi).
+ Any OS + WhatsApp/Telegram/Discord/iMessage gateway for AI agents (Pi).
+ Plugins add Mattermost and more. Send a message, get an agent response — from your pocket.

@@ -23,7 +24,7 @@ read_when: Clawdbot assistant setup

-Clawdbot bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / channels.discord.js), Mattermost (Bot API + WebSocket), and iMessage (imsg CLI) to coding agents like [Pi](https://github.com/badlogic/pi-mono). +Clawdbot bridges WhatsApp (via WhatsApp Web / Baileys), Telegram (Bot API / grammY), Discord (Bot API / channels.discord.js), and iMessage (imsg CLI) to coding agents like [Pi](https://github.com/badlogic/pi-mono). Plugins add Mattermost (Bot API + WebSocket) and more. Clawdbot also powers [Clawd](https://clawd.me), the space‑lobster assistant. ## Start here @@ -44,7 +45,7 @@ Remote access: [Web surfaces](/web) and [Tailscale](/gateway/tailscale) ## How it works ``` -WhatsApp / Telegram / Discord / Mattermost +WhatsApp / Telegram / Discord / iMessage (+ plugins) │ ▼ ┌───────────────────────────┐ @@ -79,7 +80,7 @@ Most operations flow through the **Gateway** (`clawdbot gateway`), a single long - 📱 **WhatsApp Integration** — Uses Baileys for WhatsApp Web protocol - ✈️ **Telegram Bot** — DMs + groups via grammY - 🎮 **Discord Bot** — DMs + guild channels via channels.discord.js -- 🧩 **Mattermost Bot** — Bot token + WebSocket events +- 🧩 **Mattermost Bot (plugin)** — Bot token + WebSocket events - 💬 **iMessage** — Local imsg CLI integration (macOS) - 🤖 **Agent bridge** — Pi (RPC mode) with tool streaming - ⏱️ **Streaming + chunking** — Block streaming + Telegram draft streaming details ([/concepts/streaming](/concepts/streaming)) @@ -191,7 +192,7 @@ Example: - [Control UI (browser)](/web/control-ui) - [Telegram](/channels/telegram) - [Discord](/channels/discord) - - [Mattermost](/channels/mattermost) + - [Mattermost (plugin)](/channels/mattermost) - [iMessage](/channels/imessage) - [Groups](/concepts/groups) - [WhatsApp group messages](/concepts/group-messages) diff --git a/docs/providers/index.md b/docs/providers/index.md index 983dd860f6..6f66fe726c 100644 --- a/docs/providers/index.md +++ b/docs/providers/index.md @@ -9,7 +9,7 @@ read_when: Clawdbot can use many LLM providers. Pick a provider, authenticate, then set the default model as `provider/model`. -Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost/etc.)? See [Channels](/channels). +Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/etc.)? See [Channels](/channels). ## Quick start diff --git a/docs/start/clawd.md b/docs/start/clawd.md index dd1224a507..106c9c05c5 100644 --- a/docs/start/clawd.md +++ b/docs/start/clawd.md @@ -6,14 +6,14 @@ read_when: --- # Building a personal assistant with Clawdbot (Clawd-style) -Clawdbot is a WhatsApp + Telegram + Discord + Mattermost gateway for **Pi** agents. This guide is the "personal assistant" setup: one dedicated WhatsApp number that behaves like your always-on agent. +Clawdbot is a WhatsApp + Telegram + Discord + iMessage gateway for **Pi** agents. Plugins add Mattermost. This guide is the "personal assistant" setup: one dedicated WhatsApp number that behaves like your always-on agent. ## ⚠️ Safety first You’re putting an agent in a position to: - run commands on your machine (depending on your Pi tool setup) - read/write files in your workspace -- send messages back out via WhatsApp/Telegram/Discord/Mattermost +- send messages back out via WhatsApp/Telegram/Discord/Mattermost (plugin) Start conservative: - Always set `channels.whatsapp.allowFrom` (never run open-to-the-world on your personal Mac). diff --git a/docs/start/faq.md b/docs/start/faq.md index 50a320a260..38defb953d 100644 --- a/docs/start/faq.md +++ b/docs/start/faq.md @@ -178,7 +178,7 @@ Quick answers plus deeper troubleshooting for real-world setups (local dev, VPS, ### What is Clawdbot, in one paragraph? -Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost, Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product. +Clawdbot is a personal AI assistant you run on your own devices. It replies on the messaging surfaces you already use (WhatsApp, Telegram, Slack, Mattermost (plugin), Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the always-on control plane; the assistant is the product. ## Quick start and first-run setup @@ -235,7 +235,7 @@ Node **>= 22** is required. `pnpm` is recommended. Bun is **not recommended** fo - **Model/auth setup** (Anthropic **setup-token** recommended for Claude subscriptions, OpenAI Codex OAuth supported, API keys optional, LM Studio local models supported) - **Workspace** location + bootstrap files - **Gateway settings** (bind/port/auth/tailscale) -- **Providers** (WhatsApp, Telegram, Discord, Mattermost, Signal, iMessage) +- **Providers** (WhatsApp, Telegram, Discord, Mattermost (plugin), Signal, iMessage) - **Daemon install** (LaunchAgent on macOS; systemd user unit on Linux/WSL2) - **Health checks** and **skills** selection @@ -363,7 +363,7 @@ lowest friction and you’re okay with sleep/restarts, run it locally. - **Pros:** always‑on, stable network, no laptop sleep issues, easier to keep running. - **Cons:** often run headless (use screenshots), remote file access only, you must SSH for updates. -**Clawdbot-specific note:** WhatsApp/Telegram/Slack/Mattermost/Discord all work fine from a VPS. The only real trade-off is **headless browser** vs a visible window. See [Browser](/tools/browser). +**Clawdbot-specific note:** WhatsApp/Telegram/Slack/Mattermost (plugin)/Discord all work fine from a VPS. The only real trade-off is **headless browser** vs a visible window. See [Browser](/tools/browser). **Recommended default:** VPS if you had gateway disconnects before. Local is great when you’re actively using the Mac and want local file access or UI automation with a visible browser. diff --git a/docs/start/getting-started.md b/docs/start/getting-started.md index 861e7ad122..449bb76e80 100644 --- a/docs/start/getting-started.md +++ b/docs/start/getting-started.md @@ -12,7 +12,7 @@ Goal: go from **zero** → **first working chat** (with sane defaults) as quickl Recommended path: use the **CLI onboarding wizard** (`clawdbot onboard`). It sets up: - model/auth (OAuth recommended) - gateway settings -- channels (WhatsApp/Telegram/Discord/Mattermost/...) +- channels (WhatsApp/Telegram/Discord/Mattermost (plugin)/...) - pairing defaults (secure DMs) - workspace bootstrap + skills - optional background service @@ -80,7 +80,7 @@ clawdbot onboard --install-daemon What you’ll choose: - **Local vs Remote** gateway - **Auth**: OpenAI Code (Codex) subscription (OAuth) or API keys. For Anthropic we recommend an API key; `claude setup-token` is also supported. -- **Providers**: WhatsApp QR login, Telegram/Discord/Mattermost bot tokens, etc. +- **Providers**: WhatsApp QR login, Telegram/Discord bot tokens, Mattermost plugin tokens, etc. - **Daemon**: background install (launchd/systemd; WSL2 uses systemd) - **Runtime**: Node (recommended; required for WhatsApp/Telegram). Bun is **not recommended**. - **Gateway token**: the wizard generates one by default (even on loopback) and stores it in `gateway.auth.token`. @@ -140,7 +140,7 @@ WhatsApp doc: [WhatsApp](/channels/whatsapp) The wizard can write tokens/config for you. If you prefer manual config, start with: - Telegram: [Telegram](/channels/telegram) - Discord: [Discord](/channels/discord) -- Mattermost: [Mattermost](/channels/mattermost) +- Mattermost (plugin): [Mattermost](/channels/mattermost) **Telegram DM tip:** your first DM returns a pairing code. Approve it (see next step) or the bot won’t respond. diff --git a/docs/start/hubs.md b/docs/start/hubs.md index ee279fc4e6..5c8b49d244 100644 --- a/docs/start/hubs.md +++ b/docs/start/hubs.md @@ -67,7 +67,7 @@ Use these hubs to discover every page, including deep dives and reference docs t - [Telegram (grammY notes)](/channels/grammy) - [Slack](/channels/slack) - [Discord](/channels/discord) -- [Mattermost](/channels/mattermost) +- [Mattermost](/channels/mattermost) (plugin) - [Signal](/channels/signal) - [iMessage](/channels/imessage) - [Location parsing](/channels/location) diff --git a/docs/start/wizard.md b/docs/start/wizard.md index 7116be71cd..d18bfea6f3 100644 --- a/docs/start/wizard.md +++ b/docs/start/wizard.md @@ -48,7 +48,7 @@ The wizard starts with **QuickStart** (defaults) vs **Advanced** (full control). - Model/auth (OpenAI Code (Codex) subscription OAuth, Anthropic API key (recommended) or setup-token (paste), plus MiniMax/GLM/Moonshot/AI Gateway options) - Workspace location + bootstrap files - Gateway settings (port/bind/auth/tailscale) -- Providers (Telegram, WhatsApp, Discord, Mattermost, Signal) +- Providers (Telegram, WhatsApp, Discord, Mattermost (plugin), Signal) - Daemon install (LaunchAgent / systemd user unit) - Health check - Skills (recommended) @@ -117,7 +117,7 @@ Tip: `--json` does **not** imply non-interactive mode. Use `--non-interactive` ( - WhatsApp: optional QR login. - Telegram: bot token. - Discord: bot token. - - Mattermost: bot token + base URL. + - Mattermost (plugin): bot token + base URL. - Signal: optional `signal-cli` install + account config. - iMessage: local `imsg` CLI path + DB access. - DM security: default is pairing. First DM sends a code; approve via `clawdbot pairing approve ` or use allowlists. diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md index 7f5a3dac4a..ede005259b 100644 --- a/docs/web/control-ui.md +++ b/docs/web/control-ui.md @@ -30,7 +30,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on ## What it can do (today) - Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`) - Stream tool calls + live tool output cards in Chat (agent events) -- Channels: WhatsApp/Telegram/Discord/Slack/Mattermost status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`) +- Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`) - Instances: presence list + refresh (`system-presence`) - Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`) - Cron jobs: list/add/run/enable/disable + run history (`cron.*`) diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts new file mode 100644 index 0000000000..c31b603ca8 --- /dev/null +++ b/extensions/mattermost/src/channel.test.ts @@ -0,0 +1,43 @@ +import { describe, expect, it } from "vitest"; + +import { mattermostPlugin } from "./channel.js"; + +describe("mattermostPlugin", () => { + describe("messaging", () => { + it("keeps @username targets", () => { + const normalize = mattermostPlugin.messaging?.normalizeTarget; + if (!normalize) return; + + expect(normalize("@Alice")).toBe("@Alice"); + expect(normalize("@alice")).toBe("@alice"); + }); + + it("normalizes mattermost: prefix to user:", () => { + const normalize = mattermostPlugin.messaging?.normalizeTarget; + if (!normalize) return; + + expect(normalize("mattermost:USER123")).toBe("user:USER123"); + }); + }); + + describe("pairing", () => { + it("normalizes allowlist entries", () => { + const normalize = mattermostPlugin.pairing?.normalizeAllowEntry; + if (!normalize) return; + + expect(normalize("@Alice")).toBe("alice"); + expect(normalize("user:USER123")).toBe("user123"); + }); + }); + + describe("config", () => { + it("formats allowFrom entries", () => { + const formatAllowFrom = mattermostPlugin.config.formatAllowFrom; + + const formatted = formatAllowFrom({ + allowFrom: ["@Alice", "user:USER123", "mattermost:BOT999"], + }); + expect(formatted).toEqual(["@alice", "user123", "bot999"]); + }); + }); +}); diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts index b365fc61e0..5d08374239 100644 --- a/extensions/mattermost/src/channel.ts +++ b/extensions/mattermost/src/channel.ts @@ -3,6 +3,7 @@ import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, + formatPairingApproveHint, migrateBaseNameToDefaultAccount, normalizeAccountId, setAccountEnabledInConfigSection, @@ -38,14 +39,40 @@ const meta = { blurb: "self-hosted Slack-style chat; install the plugin to enable.", systemImage: "bubble.left.and.bubble.right", order: 65, + quickstartAllowFrom: true, } as const; +function normalizeAllowEntry(entry: string): string { + return entry + .trim() + .replace(/^(mattermost|user):/i, "") + .replace(/^@/, "") + .toLowerCase(); +} + +function formatAllowEntry(entry: string): string { + const trimmed = entry.trim(); + if (!trimmed) return ""; + if (trimmed.startsWith("@")) { + const username = trimmed.slice(1).trim(); + return username ? `@${username.toLowerCase()}` : ""; + } + return trimmed.replace(/^(mattermost|user):/i, "").toLowerCase(); +} + export const mattermostPlugin: ChannelPlugin = { id: "mattermost", meta: { ...meta, }, onboarding: mattermostOnboardingAdapter, + pairing: { + idLabel: "mattermostUserId", + normalizeAllowEntry: (entry) => normalizeAllowEntry(entry), + notifyApproval: async ({ id }) => { + console.log(`[mattermost] User ${id} approved for pairing`); + }, + }, capabilities: { chatTypes: ["direct", "channel", "group", "thread"], threads: true, @@ -84,6 +111,39 @@ export const mattermostPlugin: ChannelPlugin = { botTokenSource: account.botTokenSource, baseUrl: account.baseUrl, }), + resolveAllowFrom: ({ cfg, accountId }) => + (resolveMattermostAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) => + String(entry), + ), + formatAllowFrom: ({ allowFrom }) => + allowFrom + .map((entry) => formatAllowEntry(String(entry))) + .filter(Boolean), + }, + security: { + resolveDmPolicy: ({ cfg, accountId, account }) => { + const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; + const useAccountPath = Boolean(cfg.channels?.mattermost?.accounts?.[resolvedAccountId]); + const basePath = useAccountPath + ? `channels.mattermost.accounts.${resolvedAccountId}.` + : "channels.mattermost."; + return { + policy: account.config.dmPolicy ?? "pairing", + allowFrom: account.config.allowFrom ?? [], + policyPath: `${basePath}dmPolicy`, + allowFromPath: basePath, + approveHint: formatPairingApproveHint("mattermost"), + normalizeEntry: (raw) => normalizeAllowEntry(raw), + }; + }, + collectWarnings: ({ account, cfg }) => { + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + if (groupPolicy !== "open") return []; + return [ + `- Mattermost channels: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.mattermost.groupPolicy="allowlist" + channels.mattermost.groupAllowFrom to restrict senders.`, + ]; + }, }, groups: { resolveRequireMention: resolveMattermostGroupRequireMention, @@ -105,23 +165,21 @@ export const mattermostPlugin: ChannelPlugin = { return { ok: false, error: new Error( - "Delivering to Mattermost requires --to ", + "Delivering to Mattermost requires --to ", ), }; } return { ok: true, to: trimmed }; }, - sendText: async ({ to, text, accountId, deps, replyToId }) => { - const send = deps?.sendMattermost ?? sendMessageMattermost; - const result = await send(to, text, { + sendText: async ({ to, text, accountId, replyToId }) => { + const result = await sendMessageMattermost(to, text, { accountId: accountId ?? undefined, replyToId: replyToId ?? undefined, }); return { channel: "mattermost", ...result }; }, - sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => { - const send = deps?.sendMattermost ?? sendMessageMattermost; - const result = await send(to, text, { + sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => { + const result = await sendMessageMattermost(to, text, { accountId: accountId ?? undefined, mediaUrl, replyToId: replyToId ?? undefined, diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts index 3cbecaf34f..6187479951 100644 --- a/extensions/mattermost/src/config-schema.ts +++ b/extensions/mattermost/src/config-schema.ts @@ -1,8 +1,13 @@ import { z } from "zod"; -import { BlockStreamingCoalesceSchema } from "clawdbot/plugin-sdk"; +import { + BlockStreamingCoalesceSchema, + DmPolicySchema, + GroupPolicySchema, + requireOpenAllowFrom, +} from "clawdbot/plugin-sdk"; -const MattermostAccountSchema = z +const MattermostAccountSchemaBase = z .object({ name: z.string().optional(), capabilities: z.array(z.string()).optional(), @@ -13,12 +18,36 @@ const MattermostAccountSchema = z chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(), oncharPrefixes: z.array(z.string()).optional(), requireMention: z.boolean().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + allowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), textChunkLimit: z.number().int().positive().optional(), blockStreaming: z.boolean().optional(), blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), }) .strict(); -export const MattermostConfigSchema = MattermostAccountSchema.extend({ - accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(), +const MattermostAccountSchema = MattermostAccountSchemaBase.superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"', + }); +}); + +export const MattermostConfigSchema = MattermostAccountSchemaBase.extend({ + accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"', + }); }); diff --git a/extensions/mattermost/src/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts index 773e655ffe..b3fbc7e4f8 100644 --- a/extensions/mattermost/src/group-mentions.ts +++ b/extensions/mattermost/src/group-mentions.ts @@ -11,4 +11,4 @@ export function resolveMattermostGroupRequireMention( }); if (typeof account.requireMention === "boolean") return account.requireMention; return true; -} +} \ No newline at end of file diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts index e75f345938..6af1b3e4c0 100644 --- a/extensions/mattermost/src/mattermost/accounts.ts +++ b/extensions/mattermost/src/mattermost/accounts.ts @@ -112,4 +112,4 @@ export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMatt return listMattermostAccountIds(cfg) .map((accountId) => resolveMattermostAccount({ cfg, accountId })) .filter((account) => account.enabled); -} +} \ No newline at end of file diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts index 6b63f830fa..277139d5df 100644 --- a/extensions/mattermost/src/mattermost/client.ts +++ b/extensions/mattermost/src/mattermost/client.ts @@ -205,4 +205,4 @@ export async function uploadMattermostFile( throw new Error("Mattermost file upload failed"); } return info; -} +} \ No newline at end of file diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts index 8c68a4f255..2aa00f1586 100644 --- a/extensions/mattermost/src/mattermost/monitor-helpers.ts +++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts @@ -147,4 +147,4 @@ export function resolveThreadSessionKeys(params: { ? `${params.baseSessionKey}:thread:${threadId}` : params.baseSessionKey; return { sessionKey, parentSessionKey: params.parentSessionKey }; -} +} \ No newline at end of file diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts index 7c0d98fcab..7e5079ecb3 100644 --- a/extensions/mattermost/src/mattermost/monitor.ts +++ b/extensions/mattermost/src/mattermost/monitor.ts @@ -141,6 +141,39 @@ function channelChatType(kind: "dm" | "group" | "channel"): "direct" | "group" | return "channel"; } +function normalizeAllowEntry(entry: string): string { + const trimmed = entry.trim(); + if (!trimmed) return ""; + if (trimmed === "*") return "*"; + return trimmed + .replace(/^(mattermost|user):/i, "") + .replace(/^@/, "") + .toLowerCase(); +} + +function normalizeAllowList(entries: Array): string[] { + const normalized = entries + .map((entry) => normalizeAllowEntry(String(entry))) + .filter(Boolean); + return Array.from(new Set(normalized)); +} + +function isSenderAllowed(params: { + senderId: string; + senderName?: string; + allowFrom: string[]; +}): boolean { + const allowFrom = params.allowFrom; + if (allowFrom.length === 0) return false; + if (allowFrom.includes("*")) return true; + const normalizedSenderId = normalizeAllowEntry(params.senderId); + const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : ""; + return allowFrom.some( + (entry) => + entry === normalizedSenderId || (normalizedSenderName && entry === normalizedSenderName), + ); +} + type MattermostMediaInfo = { path: string; contentType?: string; @@ -346,6 +379,122 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const kind = channelKind(channelType); const chatType = channelChatType(kind); + const senderName = + payload.data?.sender_name?.trim() || + (await resolveUserInfo(senderId))?.username?.trim() || + senderId; + const rawText = post.message?.trim() || ""; + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []); + const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []); + const storeAllowFrom = normalizeAllowList( + await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []), + ); + const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom])); + const effectiveGroupAllowFrom = Array.from( + new Set([ + ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom), + ...storeAllowFrom, + ]), + ); + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg, + surface: "mattermost", + }); + const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg); + const useAccessGroups = cfg.commands?.useAccessGroups !== false; + const senderAllowedForCommands = isSenderAllowed({ + senderId, + senderName, + allowFrom: effectiveAllowFrom, + }); + const groupAllowedForCommands = isSenderAllowed({ + senderId, + senderName, + allowFrom: effectiveGroupAllowFrom, + }); + const commandAuthorized = + kind === "dm" + ? dmPolicy === "open" || senderAllowedForCommands + : core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [ + { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands }, + { + configured: effectiveGroupAllowFrom.length > 0, + allowed: groupAllowedForCommands, + }, + ], + }); + + if (kind === "dm") { + if (dmPolicy === "disabled") { + logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`); + return; + } + if (dmPolicy !== "open" && !senderAllowedForCommands) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: "mattermost", + id: senderId, + meta: { name: senderName }, + }); + logVerboseMessage( + `mattermost: pairing request sender=${senderId} created=${created}`, + ); + if (created) { + try { + await sendMessageMattermost( + `user:${senderId}`, + core.channel.pairing.buildPairingReply({ + channel: "mattermost", + idLine: `Your Mattermost user id: ${senderId}`, + code, + }), + { accountId: account.accountId }, + ); + opts.statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + logVerboseMessage( + `mattermost: pairing reply failed for ${senderId}: ${String(err)}`, + ); + } + } + } else { + logVerboseMessage( + `mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`, + ); + } + return; + } + } else { + if (groupPolicy === "disabled") { + logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)"); + return; + } + if (groupPolicy === "allowlist") { + if (effectiveGroupAllowFrom.length === 0) { + logVerboseMessage("mattermost: drop group message (no group allowlist)"); + return; + } + if (!groupAllowedForCommands) { + logVerboseMessage( + `mattermost: drop group sender=${senderId} (not in groupAllowFrom)`, + ); + return; + } + } + } + + if (kind !== "dm" && isControlCommand && !commandAuthorized) { + logVerboseMessage( + `mattermost: drop control command from unauthorized sender ${senderId}`, + ); + return; + } + const teamId = payload.data?.team_id ?? channelInfo?.team_id ?? undefined; const channelName = payload.data?.channel_name ?? channelInfo?.name ?? ""; const channelDisplay = @@ -374,7 +523,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const historyKey = kind === "dm" ? null : sessionKey; const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId); - const rawText = post.message?.trim() || ""; const wasMentioned = kind !== "dm" && ((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) || @@ -384,7 +532,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} (post.file_ids?.length ? `[Mattermost ${post.file_ids.length === 1 ? "file" : "files"}]` : ""); - const pendingSender = payload.data?.sender_name?.trim() || senderId; + const pendingSender = senderName; const recordPendingHistory = () => { if (!historyKey || historyLimit <= 0) return; const trimmed = pendingBody.trim(); @@ -402,11 +550,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} }); }; - const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ - cfg, - surface: "mattermost", - }); - const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg); const oncharEnabled = account.chatmode === "onchar" && kind !== "dm"; const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : []; const oncharResult = oncharEnabled @@ -414,8 +557,16 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} : { triggered: false, stripped: rawText }; const oncharTriggered = oncharResult.triggered; - const shouldRequireMention = kind === "channel" && (account.requireMention ?? true); - const shouldBypassMention = isControlCommand && shouldRequireMention && !wasMentioned; + const shouldRequireMention = + kind !== "dm" && + core.channel.groups.resolveRequireMention({ + cfg, + channel: "mattermost", + accountId: account.accountId, + groupId: channelId, + }) !== false; + const shouldBypassMention = + isControlCommand && shouldRequireMention && !wasMentioned && commandAuthorized; const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered; const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0; @@ -424,17 +575,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} return; } - if (kind === "channel" && shouldRequireMention && canDetectMention) { + if (kind !== "dm" && shouldRequireMention && canDetectMention) { if (!effectiveWasMentioned) { recordPendingHistory(); return; } } - - const senderName = - payload.data?.sender_name?.trim() || - (await resolveUserInfo(senderId))?.username?.trim() || - senderId; const mediaList = await resolveMattermostMedia(post.file_ids); const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList); const bodySource = oncharTriggered ? oncharResult.stripped : rawText; @@ -499,10 +645,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {} const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`; const mediaPayload = buildMattermostMediaPayload(mediaList); - const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ - useAccessGroups: cfg.commands?.useAccessGroups ?? false, - authorizers: [], - }); const ctxPayload = core.channel.reply.finalizeInboundContext({ Body: combinedBody, RawBody: bodyText, diff --git a/extensions/mattermost/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts index c0fa8ae633..0286979f6f 100644 --- a/extensions/mattermost/src/mattermost/probe.ts +++ b/extensions/mattermost/src/mattermost/probe.ts @@ -67,4 +67,4 @@ export async function probeMattermost( } finally { if (timer) clearTimeout(timer); } -} +} \ No newline at end of file diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts index f5b22c7684..c2a2a251c8 100644 --- a/extensions/mattermost/src/mattermost/send.ts +++ b/extensions/mattermost/src/mattermost/send.ts @@ -205,4 +205,4 @@ export async function sendMessageMattermost( messageId: post.id ?? "unknown", channelId, }; -} +} \ No newline at end of file diff --git a/extensions/mattermost/src/normalize.ts b/extensions/mattermost/src/normalize.ts index 80366420f3..b3318fe11b 100644 --- a/extensions/mattermost/src/normalize.ts +++ b/extensions/mattermost/src/normalize.ts @@ -20,7 +20,7 @@ export function normalizeMattermostMessagingTarget(raw: string): string | undefi } if (trimmed.startsWith("@")) { const id = trimmed.slice(1).trim(); - return id ? `user:${id}` : undefined; + return id ? `@${id}` : undefined; } if (trimmed.startsWith("#")) { const id = trimmed.slice(1).trim(); diff --git a/extensions/mattermost/src/onboarding-helpers.ts b/extensions/mattermost/src/onboarding-helpers.ts index 8a5d1f5852..f442992226 100644 --- a/extensions/mattermost/src/onboarding-helpers.ts +++ b/extensions/mattermost/src/onboarding-helpers.ts @@ -39,4 +39,4 @@ export async function promptAccountId(params: PromptAccountIdParams): Promise; + /** Allowlist for group messages (user ids or @usernames). */ + groupAllowFrom?: Array; + /** Group message policy (allowlist/open/disabled). */ + groupPolicy?: GroupPolicy; /** Outbound text chunk size (chars). Default: 4000. */ textChunkLimit?: number; /** Disable block streaming for this account. */ diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts index 820b53bf05..7394fa30fa 100644 --- a/src/commands/channels/resolve.ts +++ b/src/commands/channels/resolve.ts @@ -35,7 +35,7 @@ function detectAutoKind(input: string): ChannelResolveKind { if (!trimmed) return "group"; if (trimmed.startsWith("@")) return "user"; if (/^<@!?/.test(trimmed)) return "user"; - if (/^(user|discord|slack|mattermost|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) { + if (/^(user|discord|slack|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) { return "user"; } return "group"; diff --git a/src/config/io.ts b/src/config/io.ts index 03b9583cf8..6994e44852 100644 --- a/src/config/io.ts +++ b/src/config/io.ts @@ -52,8 +52,6 @@ const SHELL_ENV_EXPECTED_KEYS = [ "DISCORD_BOT_TOKEN", "SLACK_BOT_TOKEN", "SLACK_APP_TOKEN", - "MATTERMOST_BOT_TOKEN", - "MATTERMOST_URL", "CLAWDBOT_GATEWAY_TOKEN", "CLAWDBOT_GATEWAY_PASSWORD", ]; diff --git a/src/config/legacy.migrations.part-1.ts b/src/config/legacy.migrations.part-1.ts index 4b38a4be53..f537c3ce8b 100644 --- a/src/config/legacy.migrations.part-1.ts +++ b/src/config/legacy.migrations.part-1.ts @@ -124,7 +124,6 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [ "telegram", "discord", "slack", - "mattermost", "signal", "imessage", "msteams", diff --git a/src/config/legacy.rules.ts b/src/config/legacy.rules.ts index 388083ae7e..1ec76bc793 100644 --- a/src/config/legacy.rules.ts +++ b/src/config/legacy.rules.ts @@ -17,10 +17,6 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [ path: ["slack"], message: "slack config moved to channels.slack (auto-migrated on load).", }, - { - path: ["mattermost"], - message: "mattermost config moved to channels.mattermost (auto-migrated on load).", - }, { path: ["signal"], message: "signal config moved to channels.signal (auto-migrated on load).", diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts index 19ac014dd6..ac98e20dec 100644 --- a/src/config/types.channels.ts +++ b/src/config/types.channels.ts @@ -1,6 +1,5 @@ import type { DiscordConfig } from "./types.discord.js"; import type { IMessageConfig } from "./types.imessage.js"; -import type { MattermostConfig } from "./types.mattermost.js"; import type { MSTeamsConfig } from "./types.msteams.js"; import type { SignalConfig } from "./types.signal.js"; import type { SlackConfig } from "./types.slack.js"; @@ -18,7 +17,6 @@ export type ChannelsConfig = { telegram?: TelegramConfig; discord?: DiscordConfig; slack?: SlackConfig; - mattermost?: MattermostConfig; signal?: SignalConfig; imessage?: IMessageConfig; msteams?: MSTeamsConfig; diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts index 2a5bf0f2f5..03e9250b22 100644 --- a/src/config/types.hooks.ts +++ b/src/config/types.hooks.ts @@ -24,7 +24,6 @@ export type HookMappingConfig = { | "telegram" | "discord" | "slack" - | "mattermost" | "signal" | "imessage" | "msteams"; diff --git a/src/config/types.mattermost.ts b/src/config/types.mattermost.ts deleted file mode 100644 index b87bdfabe1..0000000000 --- a/src/config/types.mattermost.ts +++ /dev/null @@ -1,40 +0,0 @@ -import type { BlockStreamingCoalesceConfig } from "./types.base.js"; - -export type MattermostChatMode = "oncall" | "onmessage" | "onchar"; - -export type MattermostAccountConfig = { - /** Optional display name for this account (used in CLI/UI lists). */ - name?: string; - /** Optional provider capability tags used for agent/runtime guidance. */ - capabilities?: string[]; - /** Allow channel-initiated config writes (default: true). */ - configWrites?: boolean; - /** If false, do not start this Mattermost account. Default: true. */ - enabled?: boolean; - /** Bot token for Mattermost. */ - botToken?: string; - /** Base URL for the Mattermost server (e.g., https://chat.example.com). */ - baseUrl?: string; - /** - * Controls when channel messages trigger replies. - * - "oncall": only respond when mentioned - * - "onmessage": respond to every channel message - * - "onchar": respond when a trigger character prefixes the message - */ - chatmode?: MattermostChatMode; - /** Prefix characters that trigger onchar mode (default: [">", "!"]). */ - oncharPrefixes?: string[]; - /** Require @mention to respond in channels. Default: true. */ - requireMention?: boolean; - /** Outbound text chunk size (chars). Default: 4000. */ - textChunkLimit?: number; - /** Disable block streaming for this account. */ - blockStreaming?: boolean; - /** Merge streamed block replies before sending. */ - blockStreamingCoalesce?: BlockStreamingCoalesceConfig; -}; - -export type MattermostConfig = { - /** Optional per-account Mattermost configuration (multi-account). */ - accounts?: Record; -} & MattermostAccountConfig; diff --git a/src/config/types.queue.ts b/src/config/types.queue.ts index 6289e7c56f..0afeb52322 100644 --- a/src/config/types.queue.ts +++ b/src/config/types.queue.ts @@ -13,7 +13,6 @@ export type QueueModeByProvider = { telegram?: QueueMode; discord?: QueueMode; slack?: QueueMode; - mattermost?: QueueMode; signal?: QueueMode; imessage?: QueueMode; msteams?: QueueMode; diff --git a/src/config/types.ts b/src/config/types.ts index 46e79eacab..3686182628 100644 --- a/src/config/types.ts +++ b/src/config/types.ts @@ -14,7 +14,6 @@ export * from "./types.hooks.js"; export * from "./types.imessage.js"; export * from "./types.messages.js"; export * from "./types.models.js"; -export * from "./types.mattermost.js"; export * from "./types.msteams.js"; export * from "./types.plugins.js"; export * from "./types.queue.js"; diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts index 66664a9811..d341659076 100644 --- a/src/config/zod-schema.agent-runtime.ts +++ b/src/config/zod-schema.agent-runtime.ts @@ -29,7 +29,6 @@ export const HeartbeatSchema = z z.literal("telegram"), z.literal("discord"), z.literal("slack"), - z.literal("mattermost"), z.literal("msteams"), z.literal("signal"), z.literal("imessage"), diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts index 9153aa1300..140e861dd1 100644 --- a/src/config/zod-schema.hooks.ts +++ b/src/config/zod-schema.hooks.ts @@ -23,7 +23,6 @@ export const HookMappingSchema = z z.literal("telegram"), z.literal("discord"), z.literal("slack"), - z.literal("mattermost"), z.literal("signal"), z.literal("imessage"), z.literal("msteams"), diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts index 96bd04e10d..68806c61f3 100644 --- a/src/config/zod-schema.providers-core.ts +++ b/src/config/zod-schema.providers-core.ts @@ -367,27 +367,6 @@ export const SlackConfigSchema = SlackAccountSchema.extend({ } }); -export const MattermostAccountSchema = z - .object({ - name: z.string().optional(), - capabilities: z.array(z.string()).optional(), - enabled: z.boolean().optional(), - configWrites: z.boolean().optional(), - botToken: z.string().optional(), - baseUrl: z.string().optional(), - chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(), - oncharPrefixes: z.array(z.string()).optional(), - requireMention: z.boolean().optional(), - textChunkLimit: z.number().int().positive().optional(), - blockStreaming: z.boolean().optional(), - blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), - }) - .strict(); - -export const MattermostConfigSchema = MattermostAccountSchema.extend({ - accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(), -}); - export const SignalAccountSchemaBase = z .object({ name: z.string().optional(), diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts index aa5eb7737c..a581197020 100644 --- a/src/config/zod-schema.providers.ts +++ b/src/config/zod-schema.providers.ts @@ -4,7 +4,6 @@ import { BlueBubblesConfigSchema, DiscordConfigSchema, IMessageConfigSchema, - MattermostConfigSchema, MSTeamsConfigSchema, SignalConfigSchema, SlackConfigSchema, @@ -28,7 +27,6 @@ export const ChannelsSchema = z telegram: TelegramConfigSchema.optional(), discord: DiscordConfigSchema.optional(), slack: SlackConfigSchema.optional(), - mattermost: MattermostConfigSchema.optional(), signal: SignalConfigSchema.optional(), imessage: IMessageConfigSchema.optional(), bluebubbles: BlueBubblesConfigSchema.optional(), diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts index 2d874d7e90..21fffe8071 100644 --- a/src/infra/outbound/deliver.ts +++ b/src/infra/outbound/deliver.ts @@ -28,18 +28,11 @@ type SendMatrixMessage = ( opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number }, ) => Promise<{ messageId: string; roomId: string }>; -type SendMattermostMessage = ( - to: string, - text: string, - opts?: { accountId?: string; mediaUrl?: string; replyToId?: string }, -) => Promise<{ messageId: string; channelId: string }>; - export type OutboundSendDeps = { sendWhatsApp?: typeof sendMessageWhatsApp; sendTelegram?: typeof sendMessageTelegram; sendDiscord?: typeof sendMessageDiscord; sendSlack?: typeof sendMessageSlack; - sendMattermost?: SendMattermostMessage; sendSignal?: typeof sendMessageSignal; sendIMessage?: typeof sendMessageIMessage; sendMatrix?: SendMatrixMessage; diff --git a/src/utils/message-channel.ts b/src/utils/message-channel.ts index c09436ac8d..ecd1f713b4 100644 --- a/src/utils/message-channel.ts +++ b/src/utils/message-channel.ts @@ -22,7 +22,6 @@ const MARKDOWN_CAPABLE_CHANNELS = new Set([ "telegram", "signal", "discord", - "mattermost", "tui", INTERNAL_MESSAGE_CHANNEL, ]); diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts index 8fb44c485c..be278b8e5d 100644 --- a/ui/src/ui/types.ts +++ b/ui/src/ui/types.ts @@ -164,39 +164,6 @@ export type SlackStatus = { lastProbeAt?: number | null; }; -export type MattermostBot = { - id?: string | null; - username?: string | null; -}; - -export type MattermostProbe = { - ok: boolean; - status?: number | null; - error?: string | null; - elapsedMs?: number | null; - bot?: MattermostBot | null; -}; - -export type MattermostStatus = { - configured: boolean; - botTokenSource?: string | null; - running: boolean; - connected?: boolean | null; - lastConnectedAt?: number | null; - lastDisconnect?: { - at: number; - status?: number | null; - error?: string | null; - loggedOut?: boolean | null; - } | null; - lastStartAt?: number | null; - lastStopAt?: number | null; - lastError?: string | null; - baseUrl?: string | null; - probe?: MattermostProbe | null; - lastProbeAt?: number | null; -}; - export type SignalProbe = { ok: boolean; status?: number | null; @@ -415,7 +382,6 @@ export type CronPayload = | "telegram" | "discord" | "slack" - | "mattermost" | "signal" | "imessage" | "msteams"; diff --git a/ui/src/ui/views/channels.mattermost.ts b/ui/src/ui/views/channels.mattermost.ts deleted file mode 100644 index c2513ed44c..0000000000 --- a/ui/src/ui/views/channels.mattermost.ts +++ /dev/null @@ -1,70 +0,0 @@ -import { html, nothing } from "lit"; - -import { formatAgo } from "../format"; -import type { MattermostStatus } from "../types"; -import type { ChannelsProps } from "./channels.types"; -import { renderChannelConfigSection } from "./channels.config"; - -export function renderMattermostCard(params: { - props: ChannelsProps; - mattermost?: MattermostStatus | null; - accountCountLabel: unknown; -}) { - const { props, mattermost, accountCountLabel } = params; - - return html` -
-
Mattermost
-
Bot token + WebSocket status and configuration.
- ${accountCountLabel} - -
-
- Configured - ${mattermost?.configured ? "Yes" : "No"} -
-
- Running - ${mattermost?.running ? "Yes" : "No"} -
-
- Connected - ${mattermost?.connected ? "Yes" : "No"} -
-
- Base URL - ${mattermost?.baseUrl || "n/a"} -
-
- Last start - ${mattermost?.lastStartAt ? formatAgo(mattermost.lastStartAt) : "n/a"} -
-
- Last probe - ${mattermost?.lastProbeAt ? formatAgo(mattermost.lastProbeAt) : "n/a"} -
-
- - ${mattermost?.lastError - ? html`
- ${mattermost.lastError} -
` - : nothing} - - ${mattermost?.probe - ? html`
- Probe ${mattermost.probe.ok ? "ok" : "failed"} - - ${mattermost.probe.status ?? ""} ${mattermost.probe.error ?? ""} -
` - : nothing} - - ${renderChannelConfigSection({ channelId: "mattermost", props })} - -
- -
-
- `; -} diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts index d9f1487644..232cf2c85a 100644 --- a/ui/src/ui/views/channels.ts +++ b/ui/src/ui/views/channels.ts @@ -7,7 +7,6 @@ import type { ChannelsStatusSnapshot, DiscordStatus, IMessageStatus, - MattermostStatus, NostrProfile, NostrStatus, SignalStatus, @@ -24,7 +23,6 @@ import { channelEnabled, renderChannelAccountCount } from "./channels.shared"; import { renderChannelConfigSection } from "./channels.config"; import { renderDiscordCard } from "./channels.discord"; import { renderIMessageCard } from "./channels.imessage"; -import { renderMattermostCard } from "./channels.mattermost"; import { renderNostrCard } from "./channels.nostr"; import { renderSignalCard } from "./channels.signal"; import { renderSlackCard } from "./channels.slack"; @@ -41,7 +39,6 @@ export function renderChannels(props: ChannelsProps) { | undefined; const discord = (channels?.discord ?? null) as DiscordStatus | null; const slack = (channels?.slack ?? null) as SlackStatus | null; - const mattermost = (channels?.mattermost ?? null) as MattermostStatus | null; const signal = (channels?.signal ?? null) as SignalStatus | null; const imessage = (channels?.imessage ?? null) as IMessageStatus | null; const nostr = (channels?.nostr ?? null) as NostrStatus | null; @@ -65,7 +62,6 @@ export function renderChannels(props: ChannelsProps) { telegram, discord, slack, - mattermost, signal, imessage, nostr, @@ -139,12 +135,6 @@ function renderChannel( slack: data.slack, accountCountLabel, }); - case "mattermost": - return renderMattermostCard({ - props, - mattermost: data.mattermost, - accountCountLabel, - }); case "signal": return renderSignalCard({ props, diff --git a/ui/src/ui/views/channels.types.ts b/ui/src/ui/views/channels.types.ts index d3a98d44e2..43576d54a2 100644 --- a/ui/src/ui/views/channels.types.ts +++ b/ui/src/ui/views/channels.types.ts @@ -4,7 +4,6 @@ import type { ConfigUiHints, DiscordStatus, IMessageStatus, - MattermostStatus, NostrProfile, NostrStatus, SignalStatus, @@ -54,7 +53,6 @@ export type ChannelsChannelData = { telegram?: TelegramStatus; discord?: DiscordStatus | null; slack?: SlackStatus | null; - mattermost?: MattermostStatus | null; signal?: SignalStatus | null; imessage?: IMessageStatus | null; nostr?: NostrStatus | null;