diff --git a/CHANGELOG.md b/CHANGELOG.md index 871c19857..c19037cd3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -31,6 +31,7 @@ Docs: https://docs.clawd.bot - Exec approvals: allow per-segment allowlists for chained shell commands on gateway + node hosts. (#1458) Thanks @czekaj. - Agents: make OpenAI sessions image-sanitize-only; gate tool-id/repair sanitization by provider. - Agents: make tool summaries more readable and only show optional params when set. +- 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 a43e7d56b..79dd97cf9 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 810261b9e..e62e737a4 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 0f0024001..9feb98ba9 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 08c05a77c..2b1eced84 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 a19c49bfc..bf72af7e5 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 a5c24abd8..33fec7219 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 4556b4111..0828483d2 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 e7e012233..00b33ac07 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 c117de8cb..a528befb9 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 fd74aabbd..48ed043a2 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 cc3078a5a..46f6d173e 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 43d820665..7cb9ae673 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 bb11495ab..ddbbb205b 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 3b45b6626..a5b7dbc51 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 983dd860f..6f66fe726 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 dd1224a50..106c9c05c 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 50a320a26..38defb953 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 861e7ad12..449bb76e8 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 284a76f6a..ce7008275 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 7116be71c..d18bfea6f 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 7f5a3dac4..ede005259 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 000000000..c31b603ca --- /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 b365fc61e..5d0837423 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 3cbecaf34..618747995 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 773e655ff..b3fbc7e4f 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 e75f34593..6af1b3e4c 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 6b63f830f..277139d5d 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 8c68a4f25..2aa00f158 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 7c0d98fca..7e5079ecb 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 c0fa8ae63..0286979f6 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 f5b22c768..c2a2a251c 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 80366420f..b3318fe11 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 8a5d1f585..f44299222 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 820b53bf0..7394fa30f 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 03b9583cf..6994e4485 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 4b38a4be5..f537c3ce8 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 388083ae7..1ec76bc79 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 19ac014dd..ac98e20de 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 2a5bf0f2f..03e9250b2 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 b87bdfabe..000000000 --- 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 6289e7c56..0afeb5232 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 46e79eaca..368618262 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 66664a981..d34165907 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 9153aa130..140e861dd 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 96bd04e10..68806c61f 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 aa5eb7737..a58119702 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 2d874d7e9..21fffe807 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 c09436ac8..ecd1f713b 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 8fb44c485..be278b8e5 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 c2513ed44..000000000 --- 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 d9f148764..232cf2c85 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 d3a98d44e..43576d54a 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;