fix: harden Mattermost plugin gating (#1428) (thanks @damoahdominic)

This commit is contained in:
Peter Steinberger
2026-01-23 00:19:23 +00:00
parent 1d658109a8
commit 279f799388
55 changed files with 403 additions and 413 deletions

View File

@@ -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.

View File

@@ -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 }

View File

@@ -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?

View File

@@ -12,7 +12,6 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case telegram
case discord
case slack
case mattermost
case signal
case imessage
case msteams

View File

@@ -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",
])

View File

@@ -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)
}

View File

@@ -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 sessions “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:<id>`, `user:<id>`) to avoid ambiguity.
- Slack/Discord/Mattermost (plugin) targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
- Telegram topics should use the `:topic:` form (see below).
#### Telegram delivery targets (topics / forum threads)

View File

@@ -71,8 +71,8 @@ Payload:
- `sessionKey` optional (string): The key used to identify the agent's session. Defaults to a random `hook:<uuid>`. 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.

View File

@@ -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).

View File

@@ -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 <CODE>`
- 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:

View File

@@ -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
---

View File

@@ -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).

View File

@@ -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 <subcommand> [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:<id>` or `user:<id>` (or `<@id>` mention; raw numeric ids are treated as channels)
- Slack: `channel:<id>` or `user:<id>` (raw channel id is accepted)
- Mattermost: `channel:<id>`, `user:<id>`, or `@username` (bare ids are treated as channels)
- Mattermost (plugin): `channel:<id>`, `user:<id>`, or `@username` (bare ids are treated as channels)
- Signal: `+E.164`, `group:<id>`, `signal:+E.164`, `signal:group:<id>`, or `username:<name>`/`u:<name>`
- iMessage: handle, `chat_id:<id>`, `chat_guid:<guid>`, or `chat_identifier:<id>`
- MS Teams: conversation id (`19:...@thread.tacv2`) or `conversation:<id>` or `user:<aad-object-id>`
@@ -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)

View File

@@ -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:<id>` or `user:<id>` (or `@username`) when specifying delivery targets; bare ids are treated as channel ids.

View File

@@ -12,7 +12,8 @@ read_when:
</p>
<p align="center">
<strong>Any OS + WhatsApp/Telegram/Discord/Mattermost/iMessage gateway for AI agents (Pi).</strong><br />
<strong>Any OS + WhatsApp/Telegram/Discord/iMessage gateway for AI agents (Pi).</strong><br />
Plugins add Mattermost and more.
Send a message, get an agent response — from your pocket.
</p>
@@ -23,7 +24,7 @@ read_when:
<a href="/start/clawd">Clawdbot assistant setup</a>
</p>
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 spacelobster 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)

View File

@@ -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

View File

@@ -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
Youre 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).

View File

@@ -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 youre okay with sleep/restarts, run it locally.
- **Pros:** alwayson, 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 youre actively using the Mac and want local file access or UI automation with a visible browser.

View File

@@ -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 youll 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 wont respond.

View File

@@ -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)

View File

@@ -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 <channel> <code>` or use allowlists.

View File

@@ -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.*`)

View File

@@ -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"]);
});
});
});

View File

@@ -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<ResolvedMattermostAccount> = {
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<ResolvedMattermostAccount> = {
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<ResolvedMattermostAccount> = {
return {
ok: false,
error: new Error(
"Delivering to Mattermost requires --to <channelId|user:ID|channel:ID>",
"Delivering to Mattermost requires --to <channelId|@username|user:ID|channel:ID>",
),
};
}
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,

View File

@@ -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 "*"',
});
});

View File

@@ -11,4 +11,4 @@ export function resolveMattermostGroupRequireMention(
});
if (typeof account.requireMention === "boolean") return account.requireMention;
return true;
}
}

View File

@@ -112,4 +112,4 @@ export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMatt
return listMattermostAccountIds(cfg)
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
}

View File

@@ -205,4 +205,4 @@ export async function uploadMattermostFile(
throw new Error("Mattermost file upload failed");
}
return info;
}
}

View File

@@ -147,4 +147,4 @@ export function resolveThreadSessionKeys(params: {
? `${params.baseSessionKey}:thread:${threadId}`
: params.baseSessionKey;
return { sessionKey, parentSessionKey: params.parentSessionKey };
}
}

View File

@@ -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 | number>): 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,

View File

@@ -67,4 +67,4 @@ export async function probeMattermost(
} finally {
if (timer) clearTimeout(timer);
}
}
}

View File

@@ -205,4 +205,4 @@ export async function sendMessageMattermost(
messageId: post.id ?? "unknown",
channelId,
};
}
}

View File

@@ -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();

View File

@@ -39,4 +39,4 @@ export async function promptAccountId(params: PromptAccountIdParams): Promise<st
);
}
return normalized;
}
}

View File

@@ -184,4 +184,4 @@ export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
mattermost: { ...cfg.channels?.mattermost, enabled: false },
},
}),
};
};

View File

@@ -1,4 +1,4 @@
import type { BlockStreamingCoalesceConfig } from "clawdbot/plugin-sdk";
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "clawdbot/plugin-sdk";
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
@@ -26,6 +26,14 @@ export type MattermostAccountConfig = {
oncharPrefixes?: string[];
/** Require @mention to respond in channels. Default: true. */
requireMention?: boolean;
/** Direct message policy (pairing/allowlist/open/disabled). */
dmPolicy?: DmPolicy;
/** Allowlist for direct messages (user ids or @usernames). */
allowFrom?: Array<string | number>;
/** Allowlist for group messages (user ids or @usernames). */
groupAllowFrom?: Array<string | number>;
/** Group message policy (allowlist/open/disabled). */
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Disable block streaming for this account. */

View File

@@ -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";

View File

@@ -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",
];

View File

@@ -124,7 +124,6 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
"telegram",
"discord",
"slack",
"mattermost",
"signal",
"imessage",
"msteams",

View File

@@ -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).",

View File

@@ -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;

View File

@@ -24,7 +24,6 @@ export type HookMappingConfig = {
| "telegram"
| "discord"
| "slack"
| "mattermost"
| "signal"
| "imessage"
| "msteams";

View File

@@ -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<string, MattermostAccountConfig>;
} & MattermostAccountConfig;

View File

@@ -13,7 +13,6 @@ export type QueueModeByProvider = {
telegram?: QueueMode;
discord?: QueueMode;
slack?: QueueMode;
mattermost?: QueueMode;
signal?: QueueMode;
imessage?: QueueMode;
msteams?: QueueMode;

View File

@@ -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";

View File

@@ -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"),

View File

@@ -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"),

View File

@@ -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(),

View File

@@ -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(),

View File

@@ -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;

View File

@@ -22,7 +22,6 @@ const MARKDOWN_CAPABLE_CHANNELS = new Set<string>([
"telegram",
"signal",
"discord",
"mattermost",
"tui",
INTERNAL_MESSAGE_CHANNEL,
]);

View File

@@ -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";

View File

@@ -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`
<div class="card">
<div class="card-title">Mattermost</div>
<div class="card-sub">Bot token + WebSocket status and configuration.</div>
${accountCountLabel}
<div class="status-list" style="margin-top: 16px;">
<div>
<span class="label">Configured</span>
<span>${mattermost?.configured ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Running</span>
<span>${mattermost?.running ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Connected</span>
<span>${mattermost?.connected ? "Yes" : "No"}</span>
</div>
<div>
<span class="label">Base URL</span>
<span>${mattermost?.baseUrl || "n/a"}</span>
</div>
<div>
<span class="label">Last start</span>
<span>${mattermost?.lastStartAt ? formatAgo(mattermost.lastStartAt) : "n/a"}</span>
</div>
<div>
<span class="label">Last probe</span>
<span>${mattermost?.lastProbeAt ? formatAgo(mattermost.lastProbeAt) : "n/a"}</span>
</div>
</div>
${mattermost?.lastError
? html`<div class="callout danger" style="margin-top: 12px;">
${mattermost.lastError}
</div>`
: nothing}
${mattermost?.probe
? html`<div class="callout" style="margin-top: 12px;">
Probe ${mattermost.probe.ok ? "ok" : "failed"} -
${mattermost.probe.status ?? ""} ${mattermost.probe.error ?? ""}
</div>`
: nothing}
${renderChannelConfigSection({ channelId: "mattermost", props })}
<div class="row" style="margin-top: 12px;">
<button class="btn" @click=${() => props.onRefresh(true)}>
Probe
</button>
</div>
</div>
`;
}

View File

@@ -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,

View File

@@ -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;