feat: add Mattermost channel support

Add Mattermost as a supported messaging channel with bot API and WebSocket integration. Includes channel state tracking (tint, summary, details), multi-account support, and delivery target routing. Update documentation and tests to include Mattermost alongside existing channels.
This commit is contained in:
Dominic Damoah
2026-01-21 18:40:56 -05:00
committed by Peter Steinberger
parent e872f5335b
commit 0ea3994d9e
62 changed files with 2522 additions and 33 deletions

View File

@@ -40,6 +40,17 @@ extension ChannelsSettings {
return .orange
}
var mattermostTint: Color {
guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
else { return .secondary }
if !status.configured { return .secondary }
if status.lastError != nil { return .orange }
if status.probe?.ok == false { return .orange }
if status.connected == true { return .green }
if status.running { return .orange }
return .orange
}
var signalTint: Color {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return .secondary }
@@ -85,6 +96,15 @@ extension ChannelsSettings {
return "Configured"
}
var mattermostSummary: String {
guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
else { return "Checking…" }
if !status.configured { return "Not configured" }
if status.connected == true { return "Connected" }
if status.running { return "Running" }
return "Configured"
}
var signalSummary: String {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return "Checking…" }
@@ -193,6 +213,38 @@ extension ChannelsSettings {
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
var mattermostDetails: String? {
guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
else { return nil }
var lines: [String] = []
if let source = status.botTokenSource {
lines.append("Token source: \(source)")
}
if let baseUrl = status.baseUrl, !baseUrl.isEmpty {
lines.append("Base URL: \(baseUrl)")
}
if let probe = status.probe {
if probe.ok {
if let name = probe.bot?.username {
lines.append("Bot: @\(name)")
}
if let elapsed = probe.elapsedMs {
lines.append("Probe \(Int(elapsed))ms")
}
} else {
let code = probe.status.map { String($0) } ?? "unknown"
lines.append("Probe failed (\(code))")
}
}
if let last = self.date(fromMs: status.lastProbeAt ?? status.lastConnectedAt) {
lines.append("Last probe \(relativeAge(from: last))")
}
if let err = status.lastError, !err.isEmpty {
lines.append("Error: \(err)")
}
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
var signalDetails: String? {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return nil }
@@ -244,7 +296,7 @@ extension ChannelsSettings {
}
var orderedChannels: [ChannelItem] {
let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
let fallback = ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage"]
let order = self.store.snapshot?.channelOrder ?? fallback
let channels = order.enumerated().map { index, id in
ChannelItem(
@@ -307,6 +359,8 @@ extension ChannelsSettings {
return self.telegramTint
case "discord":
return self.discordTint
case "mattermost":
return self.mattermostTint
case "signal":
return self.signalTint
case "imessage":
@@ -326,6 +380,8 @@ extension ChannelsSettings {
return self.telegramSummary
case "discord":
return self.discordSummary
case "mattermost":
return self.mattermostSummary
case "signal":
return self.signalSummary
case "imessage":
@@ -345,6 +401,8 @@ extension ChannelsSettings {
return self.telegramDetails
case "discord":
return self.discordDetails
case "mattermost":
return self.mattermostDetails
case "signal":
return self.signalDetails
case "imessage":
@@ -377,6 +435,10 @@ extension ChannelsSettings {
return self
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt)
case "mattermost":
guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
else { return nil }
return self.date(fromMs: status.lastProbeAt ?? status.lastConnectedAt)
case "signal":
return self
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
@@ -411,6 +473,10 @@ extension ChannelsSettings {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case "mattermost":
guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
case "signal":
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false }

View File

@@ -85,6 +85,40 @@ struct ChannelsStatusSnapshot: Codable {
let lastProbeAt: Double?
}
struct MattermostBot: Codable {
let id: String?
let username: String?
}
struct MattermostProbe: Codable {
let ok: Bool
let status: Int?
let error: String?
let elapsedMs: Double?
let bot: MattermostBot?
}
struct MattermostDisconnect: Codable {
let at: Double
let status: Int?
let error: String?
}
struct MattermostStatus: Codable {
let configured: Bool
let botTokenSource: String?
let running: Bool
let connected: Bool?
let lastConnectedAt: Double?
let lastDisconnect: MattermostDisconnect?
let lastStartAt: Double?
let lastStopAt: Double?
let lastError: String?
let baseUrl: String?
let probe: MattermostProbe?
let lastProbeAt: Double?
}
struct SignalProbe: Codable {
let ok: Bool
let status: Int?

View File

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

View File

@@ -12,10 +12,11 @@ struct ChannelsSettingsSmokeTests {
let store = ChannelsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelOrder: ["whatsapp", "telegram", "mattermost", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"mattermost": "Mattermost",
"signal": "Signal",
"imessage": "iMessage",
],
@@ -57,6 +58,21 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_050_000,
]),
"mattermost": SnapshotAnyCodable([
"configured": true,
"botTokenSource": "env",
"running": true,
"connected": true,
"baseUrl": "https://chat.example.com",
"lastStartAt": 1_700_000_000_000,
"probe": [
"ok": true,
"status": 200,
"elapsedMs": 95,
"bot": ["id": "bot-123", "username": "clawdbot"],
],
"lastProbeAt": 1_700_000_050_000,
]),
"signal": SnapshotAnyCodable([
"configured": true,
"baseUrl": "http://127.0.0.1:8080",
@@ -82,6 +98,7 @@ struct ChannelsSettingsSmokeTests {
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"mattermost": "default",
"signal": "default",
"imessage": "default",
])
@@ -98,10 +115,11 @@ struct ChannelsSettingsSmokeTests {
let store = ChannelsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
channelOrder: ["whatsapp", "telegram", "mattermost", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
"mattermost": "Mattermost",
"signal": "Signal",
"imessage": "iMessage",
],
@@ -128,6 +146,19 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_100_000,
]),
"mattermost": SnapshotAnyCodable([
"configured": false,
"running": false,
"lastError": "bot token missing",
"baseUrl": "https://chat.example.com",
"probe": [
"ok": false,
"status": 401,
"error": "unauthorized",
"elapsedMs": 110,
],
"lastProbeAt": 1_700_000_150_000,
]),
"signal": SnapshotAnyCodable([
"configured": false,
"baseUrl": "http://127.0.0.1:8080",
@@ -154,6 +185,7 @@ struct ChannelsSettingsSmokeTests {
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
"mattermost": "default",
"signal": "default",
"imessage": "default",
])

View File

@@ -11,6 +11,7 @@ import Testing
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.mattermost.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
}

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` / `signal` / `imessage` / `last`
- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` / `signal` / `imessage` / `last`
- `to`: channel-specific recipient target
If `channel` or `to` is omitted, cron can fall back to the main 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 targets should use explicit prefixes (e.g. `channel:<id>`, `user:<id>`) to avoid ambiguity.
- Slack/Discord/Mattermost 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`, `signal`, `imessage`, `msteams`. Defaults to `last`.
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack, conversation ID for MS Teams). Defaults to the last recipient in the main session.
- `channel` optional (string): The messaging channel for delivery. One of: `last`, `whatsapp`, `telegram`, `discord`, `slack`, `mattermost`, `signal`, `imessage`, `msteams`. Defaults to `last`.
- `to` optional (string): The recipient identifier for the channel (e.g., phone number for WhatsApp/Signal, chat ID for Telegram, channel ID for Discord/Slack/Mattermost, conversation ID for MS Teams). Defaults to the last recipient in the main session.
- `model` optional (string): Model override (e.g., `anthropic/claude-3-5-sonnet` or an alias). Must be in the allowed model list if restricted.
- `thinking` optional (string): Thinking level override (e.g., `low`, `medium`, `high`).
- `timeoutSeconds` optional (number): Maximum duration for the agent run in seconds.

View File

@@ -15,6 +15,7 @@ Text is supported everywhere; media and reactions vary by channel.
- [Telegram](/channels/telegram) — Bot API via grammY; supports groups.
- [Discord](/channels/discord) — Discord Bot API + Gateway; supports servers, channels, and DMs.
- [Slack](/channels/slack) — Bolt SDK; workspace apps.
- [Mattermost](/channels/mattermost) — Bot API + WebSocket; channels, groups, DMs.
- [Signal](/channels/signal) — signal-cli; privacy-focused.
- [BlueBubbles](/channels/bluebubbles) — **Recommended for iMessage**; uses the BlueBubbles macOS server REST API with full feature support (edit, unsend, effects, reactions, group management — edit currently broken on macOS 26 Tahoe).
- [iMessage](/channels/imessage) — macOS only; native integration via imsg (legacy, consider BlueBubbles for new setups).

View File

@@ -0,0 +1,87 @@
---
summary: "Mattermost bot setup and Clawdbot config"
read_when:
- Setting up Mattermost
- Debugging Mattermost routing
---
# Mattermost
## Quick setup
1) Create a Mattermost bot account and copy the **bot token**.
2) Copy the Mattermost **base URL** (e.g., `https://chat.example.com`).
3) Configure Clawdbot and start the gateway.
Minimal config:
```json5
{
channels: {
mattermost: {
enabled: true,
botToken: "mm-token",
baseUrl: "https://chat.example.com"
}
}
}
```
## Environment variables (default account)
Set these on the gateway host if you prefer env vars:
- `MATTERMOST_BOT_TOKEN=...`
- `MATTERMOST_URL=https://chat.example.com`
Env vars apply only to the **default** account (`default`). Other accounts must use config values.
## Chat modes
Mattermost responds to DMs automatically. Channel behavior is controlled by `chatmode`:
- `oncall` (default): respond only when @mentioned in channels.
- `onmessage`: respond to every channel message.
- `onchar`: respond when a message starts with a trigger prefix.
Config example:
```json5
{
channels: {
mattermost: {
chatmode: "onchar",
oncharPrefixes: [">", "!"]
}
}
}
```
Notes:
- `onchar` still responds to explicit @mentions.
- `channels.mattermost.requireMention` is honored for legacy configs but `chatmode` is preferred.
## Targets for outbound delivery
Use these target formats with `clawdbot message send` or cron/webhooks:
- `channel:<id>` for a channel
- `user:<id>` for a DM
- `@username` for a DM (resolved via the Mattermost API)
Bare IDs are treated as channels.
## Multi-account
Mattermost supports multiple accounts under `channels.mattermost.accounts`:
```json5
{
channels: {
mattermost: {
accounts: {
default: { name: "Primary", botToken: "mm-token", baseUrl: "https://chat.example.com" },
alerts: { name: "Alerts", botToken: "mm-token-2", baseUrl: "https://alerts.example.com" }
}
}
}
}
```
## Troubleshooting
- No replies in channels: ensure the bot is in the channel and mention it (oncall), use a trigger prefix (onchar), or set `chatmode: "onmessage"`.
- Auth errors: check the bot token, base URL, and whether the account is enabled.
- Multi-account issues: env vars only apply to the `default` account.

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/Signal/iMessage)
- You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost/Signal/iMessage)
- You want to check channel status or tail channel logs
---

View File

@@ -352,7 +352,7 @@ Options:
## Channel helpers
### `channels`
Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams).
Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost/Signal/iMessage/MS Teams).
Subcommands:
- `channels list`: show configured channels and auth profiles (Claude Code + Codex CLI OAuth sync included).
@@ -365,7 +365,7 @@ Subcommands:
- `channels logout`: log out of a channel session (if supported).
Common options:
- `--channel <name>`: `whatsapp|telegram|discord|slack|signal|imessage|msteams`
- `--channel <name>`: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams`
- `--account <id>`: channel account id (default `default`)
- `--name <label>`: display name for the account
@@ -472,7 +472,7 @@ Options:
- `--session-id <id>`
- `--thinking <off|minimal|low|medium|high|xhigh>` (GPT-5.2 + Codex models only)
- `--verbose <on|full|off>`
- `--channel <whatsapp|telegram|discord|slack|signal|imessage>`
- `--channel <whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams>`
- `--local`
- `--deliver`
- `--json`

View File

@@ -8,7 +8,7 @@ read_when:
# `clawdbot message`
Single outbound command for sending messages and channel actions
(Discord/Slack/Telegram/WhatsApp/Signal/iMessage/MS Teams).
(Discord/Slack/Mattermost/Telegram/WhatsApp/Signal/iMessage/MS Teams).
## Usage
@@ -19,13 +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|signal|imessage|msteams`
- Values: `whatsapp|telegram|discord|slack|mattermost|signal|imessage|msteams`
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)
- 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>`
@@ -49,7 +50,7 @@ Name lookup:
### Core
- `send`
- Channels: WhatsApp/Telegram/Discord/Slack/Signal/iMessage/MS Teams
- Channels: WhatsApp/Telegram/Discord/Slack/Mattermost/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

@@ -133,6 +133,14 @@
"source": "/message/",
"destination": "/cli/message"
},
{
"source": "/mattermost",
"destination": "/channels/mattermost"
},
{
"source": "/mattermost/",
"destination": "/channels/mattermost"
},
{
"source": "/providers/discord",
"destination": "/channels/discord"
@@ -165,6 +173,14 @@
"source": "/providers/location/",
"destination": "/channels/location"
},
{
"source": "/providers/mattermost",
"destination": "/channels/mattermost"
},
{
"source": "/providers/mattermost/",
"destination": "/channels/mattermost"
},
{
"source": "/providers/msteams",
"destination": "/channels/msteams"
@@ -930,6 +946,7 @@
"channels/grammy",
"channels/discord",
"channels/slack",
"channels/mattermost",
"channels/signal",
"channels/imessage",
"channels/msteams",

View File

@@ -543,7 +543,7 @@ Notes:
- Outbound commands default to account `default` if present; otherwise the first configured account id (sorted).
- The legacy single-account Baileys auth dir is migrated by `clawdbot doctor` into `whatsapp/default`.
### `channels.telegram.accounts` / `channels.discord.accounts` / `channels.slack.accounts` / `channels.signal.accounts` / `channels.imessage.accounts`
### `channels.telegram.accounts` / `channels.discord.accounts` / `channels.slack.accounts` / `channels.mattermost.accounts` / `channels.signal.accounts` / `channels.imessage.accounts`
Run multiple accounts per channel (each account has its own `accountId` and optional `name`):
@@ -1204,6 +1204,35 @@ Slack action groups (gate `slack` tool actions):
| memberInfo | enabled | Member info |
| emojiList | enabled | Custom emoji list |
### `channels.mattermost` (bot token)
Mattermost requires a bot token plus the base URL for your server:
```json5
{
channels: {
mattermost: {
enabled: true,
botToken: "mm-token",
baseUrl: "https://chat.example.com",
chatmode: "oncall", // oncall | onmessage | onchar
oncharPrefixes: [">", "!"],
textChunkLimit: 4000
}
}
}
```
Clawdbot starts Mattermost when the account is configured (bot token + base URL) and enabled. The token + base URL are resolved from `channels.mattermost.botToken` + `channels.mattermost.baseUrl` or `MATTERMOST_BOT_TOKEN` + `MATTERMOST_URL` for the default account (unless `channels.mattermost.enabled` is `false`).
Chat modes:
- `oncall` (default): respond to channel messages only when @mentioned.
- `onmessage`: respond to every channel message.
- `onchar`: respond when a message starts with a trigger prefix (`channels.mattermost.oncharPrefixes`, default `[">", "!"]`).
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.
### `channels.signal` (signal-cli)
Signal reactions can emit system events (shared reaction tooling):
@@ -1735,8 +1764,9 @@ Block streaming:
with `maxChars` capped to the channel text limit. Signal/Slack/Discord default
to `minChars: 1500` unless overridden.
Channel overrides: `channels.whatsapp.blockStreamingCoalesce`, `channels.telegram.blockStreamingCoalesce`,
`channels.discord.blockStreamingCoalesce`, `channels.slack.blockStreamingCoalesce`, `channels.signal.blockStreamingCoalesce`,
`channels.imessage.blockStreamingCoalesce`, `channels.msteams.blockStreamingCoalesce` (and per-account variants).
`channels.discord.blockStreamingCoalesce`, `channels.slack.blockStreamingCoalesce`, `channels.mattermost.blockStreamingCoalesce`,
`channels.signal.blockStreamingCoalesce`, `channels.imessage.blockStreamingCoalesce`, `channels.msteams.blockStreamingCoalesce`
(and per-account variants).
- `agents.defaults.humanDelay`: randomized pause between **block replies** after the first.
Modes: `off` (default), `natural` (8002500ms), `custom` (use `minMs`/`maxMs`).
Per-agent override: `agents.list[].humanDelay`.

View File

@@ -12,7 +12,7 @@ read_when:
</p>
<p align="center">
<strong>Any OS + WhatsApp/Telegram/Discord/iMessage gateway for AI agents (Pi).</strong><br />
<strong>Any OS + WhatsApp/Telegram/Discord/Mattermost/iMessage gateway for AI agents (Pi).</strong><br />
Send a message, get an agent response — from your pocket.
</p>
@@ -23,7 +23,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), 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), Mattermost (Bot API + WebSocket), and iMessage (imsg CLI) to coding agents like [Pi](https://github.com/badlogic/pi-mono).
Clawdbot also powers [Clawd](https://clawd.me), the spacelobster assistant.
## Start here
@@ -44,7 +44,7 @@ Remote access: [Web surfaces](/web) and [Tailscale](/gateway/tailscale)
## How it works
```
WhatsApp / Telegram / Discord
WhatsApp / Telegram / Discord / Mattermost
┌───────────────────────────┐
@@ -79,6 +79,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
- 💬 **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))
@@ -190,6 +191,7 @@ Example:
- [Control UI (browser)](/web/control-ui)
- [Telegram](/channels/telegram)
- [Discord](/channels/discord)
- [Mattermost](/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/etc.)? See [Channels](/channels).
Looking for chat channel docs (WhatsApp/Telegram/Discord/Slack/Mattermost/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 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 + Mattermost gateway for **Pi** agents. 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
- send messages back out via WhatsApp/Telegram/Discord/Mattermost
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, Discord, Signal, iMessage, WebChat) and can also do voice + a live Canvas on supported platforms. The **Gateway** is the alwayson 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, 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, Signal, iMessage)
- **Providers** (WhatsApp, Telegram, Discord, Mattermost, 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.
**Clawdbotspecific note:** WhatsApp/Telegram/Slack/Discord all work fine from a VPS. The only real tradeoff is **headless browser** vs a visible window. See [Browser](/tools/browser).
**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).
**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/)
- channels (WhatsApp/Telegram/Discord/Mattermost/...)
- 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 bot tokens, etc.
- **Providers**: WhatsApp QR login, Telegram/Discord/Mattermost bot 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,6 +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)
**Telegram DM tip:** your first DM returns a pairing code. Approve it (see next step) or the bot wont respond.

View File

@@ -67,6 +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)
- [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, Signal)
- Providers (Telegram, WhatsApp, Discord, Mattermost, Signal)
- Daemon install (LaunchAgent / systemd user unit)
- Health check
- Skills (recommended)
@@ -117,6 +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.
- 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 status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
- Channels: WhatsApp/Telegram/Discord/Slack/Mattermost 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,11 @@
{
"id": "mattermost",
"channels": [
"mattermost"
],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,18 @@
import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk";
import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk";
import { mattermostPlugin } from "./src/channel.js";
import { setMattermostRuntime } from "./src/runtime.js";
const plugin = {
id: "mattermost",
name: "Mattermost",
description: "Mattermost channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: ClawdbotPluginApi) {
setMattermostRuntime(api.runtime);
api.registerChannel({ plugin: mattermostPlugin });
},
};
export default plugin;

View File

@@ -0,0 +1,11 @@
{
"name": "@clawdbot/mattermost",
"version": "2026.1.20-2",
"type": "module",
"description": "Clawdbot Mattermost channel plugin",
"clawdbot": {
"extensions": [
"./index.ts"
]
}
}

View File

@@ -0,0 +1,270 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
getChatChannelMeta,
listMattermostAccountIds,
looksLikeMattermostTargetId,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
normalizeMattermostBaseUrl,
normalizeMattermostMessagingTarget,
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
resolveMattermostGroupRequireMention,
setAccountEnabledInConfigSection,
mattermostOnboardingAdapter,
MattermostConfigSchema,
type ChannelPlugin,
type ResolvedMattermostAccount,
} from "clawdbot/plugin-sdk";
import { getMattermostRuntime } from "./runtime.js";
const meta = getChatChannelMeta("mattermost");
export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
id: "mattermost",
meta: {
...meta,
},
onboarding: mattermostOnboardingAdapter,
capabilities: {
chatTypes: ["direct", "channel", "group", "thread"],
threads: true,
media: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.mattermost"] },
configSchema: buildChannelConfigSchema(MattermostConfigSchema),
config: {
listAccountIds: (cfg) => listMattermostAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultMattermostAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "mattermost",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "mattermost",
accountId,
clearBaseFields: ["botToken", "baseUrl", "name"],
}),
isConfigured: (account) => Boolean(account.botToken && account.baseUrl),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.botToken && account.baseUrl),
botTokenSource: account.botTokenSource,
baseUrl: account.baseUrl,
}),
},
groups: {
resolveRequireMention: resolveMattermostGroupRequireMention,
},
messaging: {
normalizeTarget: normalizeMattermostMessagingTarget,
targetResolver: {
looksLikeId: looksLikeMattermostTargetId,
hint: "<channelId|user:ID|channel:ID>",
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to Mattermost requires --to <channelId|user:ID|channel:ID>",
),
};
}
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId }) => {
const send =
deps?.sendMattermost ?? getMattermostRuntime().channel.mattermost.sendMessageMattermost;
const result = await send(to, text, {
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
});
return { channel: "mattermost", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
const send =
deps?.sendMattermost ?? getMattermostRuntime().channel.mattermost.sendMessageMattermost;
const result = await send(to, text, {
accountId: accountId ?? undefined,
mediaUrl,
replyToId: replyToId ?? undefined,
});
return { channel: "mattermost", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
connected: false,
lastConnectedAt: null,
lastDisconnect: null,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
botTokenSource: snapshot.botTokenSource ?? "none",
running: snapshot.running ?? false,
connected: snapshot.connected ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
baseUrl: snapshot.baseUrl ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) => {
const token = account.botToken?.trim();
const baseUrl = account.baseUrl?.trim();
if (!token || !baseUrl) {
return { ok: false, error: "bot token or baseUrl missing" };
}
return await getMattermostRuntime().channel.mattermost.probeMattermost(
baseUrl,
token,
timeoutMs,
);
},
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.botToken && account.baseUrl),
botTokenSource: account.botTokenSource,
baseUrl: account.baseUrl,
running: runtime?.running ?? false,
connected: runtime?.connected ?? false,
lastConnectedAt: runtime?.lastConnectedAt ?? null,
lastDisconnect: runtime?.lastDisconnect ?? null,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: "mattermost",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "Mattermost env vars can only be used for the default account.";
}
const token = input.botToken ?? input.token;
const baseUrl = input.httpUrl;
if (!input.useEnv && (!token || !baseUrl)) {
return "Mattermost requires --bot-token and --http-url (or --use-env).";
}
if (baseUrl && !normalizeMattermostBaseUrl(baseUrl)) {
return "Mattermost --http-url must include a valid base URL.";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const token = input.botToken ?? input.token;
const baseUrl = input.httpUrl?.trim();
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: "mattermost",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "mattermost",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
mattermost: {
...next.channels?.mattermost,
enabled: true,
...(input.useEnv
? {}
: {
...(token ? { botToken: token } : {}),
...(baseUrl ? { baseUrl } : {}),
}),
},
},
};
}
return {
...next,
channels: {
...next.channels,
mattermost: {
...next.channels?.mattermost,
enabled: true,
accounts: {
...next.channels?.mattermost?.accounts,
[accountId]: {
...next.channels?.mattermost?.accounts?.[accountId],
enabled: true,
...(token ? { botToken: token } : {}),
...(baseUrl ? { baseUrl } : {}),
},
},
},
},
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
baseUrl: account.baseUrl,
botTokenSource: account.botTokenSource,
});
ctx.log?.info(`[${account.accountId}] starting channel`);
return getMattermostRuntime().channel.mattermost.monitorMattermostProvider({
botToken: account.botToken ?? undefined,
baseUrl: account.baseUrl ?? undefined,
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
});
},
},
};

View File

@@ -0,0 +1,14 @@
import type { PluginRuntime } from "clawdbot/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setMattermostRuntime(next: PluginRuntime) {
runtime = next;
}
export function getMattermostRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Mattermost runtime not initialized");
}
return runtime;
}

2
pnpm-lock.yaml generated
View File

@@ -321,6 +321,8 @@ importers:
specifier: ^11.10.6
version: 11.10.6
extensions/mattermost: {}
extensions/memory-core:
dependencies:
clawdbot:

View File

@@ -157,7 +157,10 @@ export async function runPreparedReply(
const isFirstTurnInSession = isNewSession || !currentSystemSent;
const isGroupChat = sessionCtx.ChatType === "group";
const wasMentioned = ctx.WasMentioned === true;
const originatingChannel =
(ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider)?.toString().toLowerCase() ?? "";
const wasMentioned =
ctx.WasMentioned === true || (originatingChannel === "mattermost" && isGroupChat);
const isHeartbeat = opts?.isHeartbeat === true;
const typingMode = resolveTypingMode({
configured: sessionCfg?.typingMode ?? agentCfg?.typingMode,

View File

@@ -12,6 +12,7 @@ import { requireActivePluginRegistry } from "../plugins/runtime.js";
import {
resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention,
resolveMattermostGroupRequireMention,
resolveSlackGroupRequireMention,
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
@@ -230,6 +231,30 @@ const DOCKS: Record<ChatChannelId, ChannelDock> = {
buildToolContext: (params) => buildSlackThreadingToolContext(params),
},
},
mattermost: {
id: "mattermost",
capabilities: {
chatTypes: ["direct", "channel", "group", "thread"],
media: true,
threads: true,
},
outbound: { textChunkLimit: 4000 },
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
groups: {
resolveRequireMention: resolveMattermostGroupRequireMention,
},
threading: {
buildToolContext: ({ context, hasRepliedRef }) => ({
currentChannelId: context.To?.startsWith("channel:")
? context.To.slice("channel:".length)
: undefined,
currentThreadTs: context.ReplyToId,
hasRepliedRef,
}),
},
},
signal: {
id: "signal",
capabilities: {

View File

@@ -1,6 +1,7 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveChannelGroupRequireMention } from "../../config/group-policy.js";
import type { DiscordConfig } from "../../config/types.js";
import { resolveMattermostAccount } from "../../mattermost/accounts.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
type GroupMentionParams = {
@@ -184,6 +185,15 @@ export function resolveSlackGroupRequireMention(params: GroupMentionParams): boo
return true;
}
export function resolveMattermostGroupRequireMention(params: GroupMentionParams): boolean {
const account = resolveMattermostAccount({
cfg: params.cfg,
accountId: params.accountId,
});
if (typeof account.requireMention === "boolean") return account.requireMention;
return true;
}
export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams): boolean {
return resolveChannelGroupRequireMention({
cfg: params.cfg,

View File

@@ -0,0 +1,38 @@
export function normalizeMattermostMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) return undefined;
const lower = trimmed.toLowerCase();
if (lower.startsWith("channel:")) {
const id = trimmed.slice("channel:".length).trim();
return id ? `channel:${id}` : undefined;
}
if (lower.startsWith("group:")) {
const id = trimmed.slice("group:".length).trim();
return id ? `channel:${id}` : undefined;
}
if (lower.startsWith("user:")) {
const id = trimmed.slice("user:".length).trim();
return id ? `user:${id}` : undefined;
}
if (lower.startsWith("mattermost:")) {
const id = trimmed.slice("mattermost:".length).trim();
return id ? `user:${id}` : undefined;
}
if (trimmed.startsWith("@")) {
const id = trimmed.slice(1).trim();
return id ? `user:${id}` : undefined;
}
if (trimmed.startsWith("#")) {
const id = trimmed.slice(1).trim();
return id ? `channel:${id}` : undefined;
}
return `channel:${trimmed}`;
}
export function looksLikeMattermostTargetId(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) return false;
if (/^(user|channel|group|mattermost):/i.test(trimmed)) return true;
if (/^[@#]/.test(trimmed)) return true;
return /^[a-z0-9]{8,}$/i.test(trimmed);
}

View File

@@ -0,0 +1,189 @@
import type { ClawdbotConfig } from "../../../config/config.js";
import {
listMattermostAccountIds,
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
} from "../../../mattermost/accounts.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter } from "../onboarding-types.js";
import { promptAccountId } from "./helpers.js";
const channel = "mattermost" as const;
async function noteMattermostSetup(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Mattermost System Console -> Integrations -> Bot Accounts",
"2) Create a bot + copy its token",
"3) Use your server base URL (e.g., https://chat.example.com)",
"Tip: the bot must be a member of any channel you want it to monitor.",
`Docs: ${formatDocsLink("/channels/mattermost", "mattermost")}`,
].join("\n"),
"Mattermost bot token",
);
}
export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const configured = listMattermostAccountIds(cfg).some((accountId) => {
const account = resolveMattermostAccount({ cfg, accountId });
return Boolean(account.botToken && account.baseUrl);
});
return {
channel,
configured,
statusLines: [`Mattermost: ${configured ? "configured" : "needs token + url"}`],
selectionHint: configured ? "configured" : "needs setup",
quickstartScore: configured ? 2 : 1,
};
},
configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
const override = accountOverrides.mattermost?.trim();
const defaultAccountId = resolveDefaultMattermostAccountId(cfg);
let accountId = override ? normalizeAccountId(override) : defaultAccountId;
if (shouldPromptAccountIds && !override) {
accountId = await promptAccountId({
cfg,
prompter,
label: "Mattermost",
currentId: accountId,
listAccountIds: listMattermostAccountIds,
defaultAccountId,
});
}
let next = cfg;
const resolvedAccount = resolveMattermostAccount({
cfg: next,
accountId,
});
const accountConfigured = Boolean(resolvedAccount.botToken && resolvedAccount.baseUrl);
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const canUseEnv =
allowEnv &&
Boolean(process.env.MATTERMOST_BOT_TOKEN?.trim()) &&
Boolean(process.env.MATTERMOST_URL?.trim());
const hasConfigValues =
Boolean(resolvedAccount.config.botToken) || Boolean(resolvedAccount.config.baseUrl);
let botToken: string | null = null;
let baseUrl: string | null = null;
if (!accountConfigured) {
await noteMattermostSetup(prompter);
}
if (canUseEnv && !hasConfigValues) {
const keepEnv = await prompter.confirm({
message: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?",
initialValue: true,
});
if (keepEnv) {
next = {
...next,
channels: {
...next.channels,
mattermost: {
...next.channels?.mattermost,
enabled: true,
},
},
};
} else {
botToken = String(
await prompter.text({
message: "Enter Mattermost bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
baseUrl = String(
await prompter.text({
message: "Enter Mattermost base URL",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else if (accountConfigured) {
const keep = await prompter.confirm({
message: "Mattermost credentials already configured. Keep them?",
initialValue: true,
});
if (!keep) {
botToken = String(
await prompter.text({
message: "Enter Mattermost bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
baseUrl = String(
await prompter.text({
message: "Enter Mattermost base URL",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
} else {
botToken = String(
await prompter.text({
message: "Enter Mattermost bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
baseUrl = String(
await prompter.text({
message: "Enter Mattermost base URL",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
if (botToken || baseUrl) {
if (accountId === DEFAULT_ACCOUNT_ID) {
next = {
...next,
channels: {
...next.channels,
mattermost: {
...next.channels?.mattermost,
enabled: true,
...(botToken ? { botToken } : {}),
...(baseUrl ? { baseUrl } : {}),
},
},
};
} else {
next = {
...next,
channels: {
...next.channels,
mattermost: {
...next.channels?.mattermost,
enabled: true,
accounts: {
...next.channels?.mattermost?.accounts,
[accountId]: {
...next.channels?.mattermost?.accounts?.[accountId],
enabled: next.channels?.mattermost?.accounts?.[accountId]?.enabled ?? true,
...(botToken ? { botToken } : {}),
...(baseUrl ? { baseUrl } : {}),
},
},
},
},
};
}
}
return { cfg: next, accountId };
},
disable: (cfg: ClawdbotConfig) => ({
...cfg,
channels: {
...cfg.channels,
mattermost: { ...cfg.channels?.mattermost, enabled: false },
},
}),
};

View File

@@ -9,6 +9,7 @@ export const CHAT_CHANNEL_ORDER = [
"whatsapp",
"discord",
"slack",
"mattermost",
"signal",
"imessage",
] as const;
@@ -67,6 +68,16 @@ const CHAT_CHANNEL_META: Record<ChatChannelId, ChannelMeta> = {
blurb: "supported (Socket Mode).",
systemImage: "number",
},
mattermost: {
id: "mattermost",
label: "Mattermost",
selectionLabel: "Mattermost (Bot Token)",
detailLabel: "Mattermost Bot",
docsPath: "/channels/mattermost",
docsLabel: "mattermost",
blurb: "self-hosted Slack-style chat (bot token + URL).",
systemImage: "bubble.left.and.bubble.right",
},
signal: {
id: "signal",
label: "Signal",

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|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
if (/^(user|discord|slack|mattermost|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
return "user";
}
return "group";

View File

@@ -52,6 +52,8 @@ 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,6 +124,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
"telegram",
"discord",
"slack",
"mattermost",
"signal",
"imessage",
"msteams",

View File

@@ -17,6 +17,10 @@ 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,5 +1,6 @@
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";
@@ -17,6 +18,7 @@ export type ChannelsConfig = {
telegram?: TelegramConfig;
discord?: DiscordConfig;
slack?: SlackConfig;
mattermost?: MattermostConfig;
signal?: SignalConfig;
imessage?: IMessageConfig;
msteams?: MSTeamsConfig;

View File

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

View File

@@ -0,0 +1,40 @@
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,6 +13,7 @@ export type QueueModeByProvider = {
telegram?: QueueMode;
discord?: QueueMode;
slack?: QueueMode;
mattermost?: QueueMode;
signal?: QueueMode;
imessage?: QueueMode;
msteams?: QueueMode;

View File

@@ -14,6 +14,7 @@ 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,6 +29,7 @@ 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,6 +23,7 @@ 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,6 +367,27 @@ 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,6 +4,7 @@ import {
BlueBubblesConfigSchema,
DiscordConfigSchema,
IMessageConfigSchema,
MattermostConfigSchema,
MSTeamsConfigSchema,
SignalConfigSchema,
SlackConfigSchema,
@@ -27,6 +28,7 @@ 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

@@ -6,6 +6,7 @@ import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { sendMessageDiscord } from "../../discord/send.js";
import type { sendMessageIMessage } from "../../imessage/send.js";
import type { sendMessageMattermost } from "../../mattermost/send.js";
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
import { sendMessageSignal } from "../../signal/send.js";
import type { sendMessageSlack } from "../../slack/send.js";
@@ -33,6 +34,7 @@ export type OutboundSendDeps = {
sendTelegram?: typeof sendMessageTelegram;
sendDiscord?: typeof sendMessageDiscord;
sendSlack?: typeof sendMessageSlack;
sendMattermost?: typeof sendMessageMattermost;
sendSignal?: typeof sendMessageSignal;
sendIMessage?: typeof sendMessageIMessage;
sendMatrix?: SendMatrixMessage;

114
src/mattermost/accounts.ts Normal file
View File

@@ -0,0 +1,114 @@
import type { ClawdbotConfig } from "../config/config.js";
import type { MattermostAccountConfig, MattermostChatMode } from "../config/types.js";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
import { normalizeMattermostBaseUrl } from "./client.js";
export type MattermostTokenSource = "env" | "config" | "none";
export type MattermostBaseUrlSource = "env" | "config" | "none";
export type ResolvedMattermostAccount = {
accountId: string;
enabled: boolean;
name?: string;
botToken?: string;
baseUrl?: string;
botTokenSource: MattermostTokenSource;
baseUrlSource: MattermostBaseUrlSource;
config: MattermostAccountConfig;
chatmode?: MattermostChatMode;
oncharPrefixes?: string[];
requireMention?: boolean;
textChunkLimit?: number;
blockStreaming?: boolean;
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
};
function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
const accounts = cfg.channels?.mattermost?.accounts;
if (!accounts || typeof accounts !== "object") return [];
return Object.keys(accounts).filter(Boolean);
}
export function listMattermostAccountIds(cfg: ClawdbotConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
return ids.sort((a, b) => a.localeCompare(b));
}
export function resolveDefaultMattermostAccountId(cfg: ClawdbotConfig): string {
const ids = listMattermostAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): MattermostAccountConfig | undefined {
const accounts = cfg.channels?.mattermost?.accounts;
if (!accounts || typeof accounts !== "object") return undefined;
return accounts[accountId] as MattermostAccountConfig | undefined;
}
function mergeMattermostAccountConfig(
cfg: ClawdbotConfig,
accountId: string,
): MattermostAccountConfig {
const { accounts: _ignored, ...base } = (cfg.channels?.mattermost ??
{}) as MattermostAccountConfig & { accounts?: unknown };
const account = resolveAccountConfig(cfg, accountId) ?? {};
return { ...base, ...account };
}
function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
if (config.chatmode === "oncall") return true;
if (config.chatmode === "onmessage") return false;
if (config.chatmode === "onchar") return true;
return config.requireMention;
}
export function resolveMattermostAccount(params: {
cfg: ClawdbotConfig;
accountId?: string | null;
}): ResolvedMattermostAccount {
const accountId = normalizeAccountId(params.accountId);
const baseEnabled = params.cfg.channels?.mattermost?.enabled !== false;
const merged = mergeMattermostAccountConfig(params.cfg, accountId);
const accountEnabled = merged.enabled !== false;
const enabled = baseEnabled && accountEnabled;
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
const envToken = allowEnv ? process.env.MATTERMOST_BOT_TOKEN?.trim() : undefined;
const envUrl = allowEnv ? process.env.MATTERMOST_URL?.trim() : undefined;
const configToken = merged.botToken?.trim();
const configUrl = merged.baseUrl?.trim();
const botToken = configToken || envToken;
const baseUrl = normalizeMattermostBaseUrl(configUrl || envUrl);
const requireMention = resolveMattermostRequireMention(merged);
const botTokenSource: MattermostTokenSource = configToken ? "config" : envToken ? "env" : "none";
const baseUrlSource: MattermostBaseUrlSource = configUrl ? "config" : envUrl ? "env" : "none";
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
botToken,
baseUrl,
botTokenSource,
baseUrlSource,
config: merged,
chatmode: merged.chatmode,
oncharPrefixes: merged.oncharPrefixes,
requireMention,
textChunkLimit: merged.textChunkLimit,
blockStreaming: merged.blockStreaming,
blockStreamingCoalesce: merged.blockStreamingCoalesce,
};
}
export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMattermostAccount[] {
return listMattermostAccountIds(cfg)
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

208
src/mattermost/client.ts Normal file
View File

@@ -0,0 +1,208 @@
export type MattermostClient = {
baseUrl: string;
apiBaseUrl: string;
token: string;
request: <T>(path: string, init?: RequestInit) => Promise<T>;
};
export type MattermostUser = {
id: string;
username?: string | null;
nickname?: string | null;
first_name?: string | null;
last_name?: string | null;
};
export type MattermostChannel = {
id: string;
name?: string | null;
display_name?: string | null;
type?: string | null;
team_id?: string | null;
};
export type MattermostPost = {
id: string;
user_id?: string | null;
channel_id?: string | null;
message?: string | null;
file_ids?: string[] | null;
type?: string | null;
root_id?: string | null;
create_at?: number | null;
props?: Record<string, unknown> | null;
};
export type MattermostFileInfo = {
id: string;
name?: string | null;
mime_type?: string | null;
size?: number | null;
};
export function normalizeMattermostBaseUrl(raw?: string | null): string | undefined {
const trimmed = raw?.trim();
if (!trimmed) return undefined;
const withoutTrailing = trimmed.replace(/\/+$/, "");
return withoutTrailing.replace(/\/api\/v4$/i, "");
}
function buildMattermostApiUrl(baseUrl: string, path: string): string {
const normalized = normalizeMattermostBaseUrl(baseUrl);
if (!normalized) throw new Error("Mattermost baseUrl is required");
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${normalized}/api/v4${suffix}`;
}
async function readMattermostError(res: Response): Promise<string> {
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const data = (await res.json()) as { message?: string } | undefined;
if (data?.message) return data.message;
return JSON.stringify(data);
}
return await res.text();
}
export function createMattermostClient(params: {
baseUrl: string;
botToken: string;
fetchImpl?: typeof fetch;
}): MattermostClient {
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl);
if (!baseUrl) throw new Error("Mattermost baseUrl is required");
const apiBaseUrl = `${baseUrl}/api/v4`;
const token = params.botToken.trim();
const fetchImpl = params.fetchImpl ?? fetch;
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
const url = buildMattermostApiUrl(baseUrl, path);
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${token}`);
if (typeof init?.body === "string" && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const res = await fetchImpl(url, { ...init, headers });
if (!res.ok) {
const detail = await readMattermostError(res);
throw new Error(
`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`,
);
}
return (await res.json()) as T;
};
return { baseUrl, apiBaseUrl, token, request };
}
export async function fetchMattermostMe(client: MattermostClient): Promise<MattermostUser> {
return await client.request<MattermostUser>("/users/me");
}
export async function fetchMattermostUser(
client: MattermostClient,
userId: string,
): Promise<MattermostUser> {
return await client.request<MattermostUser>(`/users/${userId}`);
}
export async function fetchMattermostUserByUsername(
client: MattermostClient,
username: string,
): Promise<MattermostUser> {
return await client.request<MattermostUser>(`/users/username/${encodeURIComponent(username)}`);
}
export async function fetchMattermostChannel(
client: MattermostClient,
channelId: string,
): Promise<MattermostChannel> {
return await client.request<MattermostChannel>(`/channels/${channelId}`);
}
export async function sendMattermostTyping(
client: MattermostClient,
params: { channelId: string; parentId?: string },
): Promise<void> {
const payload: Record<string, string> = {
channel_id: params.channelId,
};
const parentId = params.parentId?.trim();
if (parentId) payload.parent_id = parentId;
await client.request<Record<string, unknown>>("/users/me/typing", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function createMattermostDirectChannel(
client: MattermostClient,
userIds: string[],
): Promise<MattermostChannel> {
return await client.request<MattermostChannel>("/channels/direct", {
method: "POST",
body: JSON.stringify(userIds),
});
}
export async function createMattermostPost(
client: MattermostClient,
params: {
channelId: string;
message: string;
rootId?: string;
fileIds?: string[];
},
): Promise<MattermostPost> {
const payload: Record<string, string> = {
channel_id: params.channelId,
message: params.message,
};
if (params.rootId) payload.root_id = params.rootId;
if (params.fileIds?.length) {
(payload as Record<string, unknown>).file_ids = params.fileIds;
}
return await client.request<MattermostPost>("/posts", {
method: "POST",
body: JSON.stringify(payload),
});
}
export async function uploadMattermostFile(
client: MattermostClient,
params: {
channelId: string;
buffer: Buffer;
fileName: string;
contentType?: string;
},
): Promise<MattermostFileInfo> {
const form = new FormData();
const fileName = params.fileName?.trim() || "upload";
const bytes = Uint8Array.from(params.buffer);
const blob = params.contentType
? new Blob([bytes], { type: params.contentType })
: new Blob([bytes]);
form.append("files", blob, fileName);
form.append("channel_id", params.channelId);
const res = await fetch(`${client.apiBaseUrl}/files`, {
method: "POST",
headers: {
Authorization: `Bearer ${client.token}`,
},
body: form,
});
if (!res.ok) {
const detail = await readMattermostError(res);
throw new Error(`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`);
}
const data = (await res.json()) as { file_infos?: MattermostFileInfo[] };
const info = data.file_infos?.[0];
if (!info?.id) {
throw new Error("Mattermost file upload failed");
}
return info;
}

9
src/mattermost/index.ts Normal file
View File

@@ -0,0 +1,9 @@
export {
listEnabledMattermostAccounts,
listMattermostAccountIds,
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
} from "./accounts.js";
export { monitorMattermostProvider } from "./monitor.js";
export { probeMattermost } from "./probe.js";
export { sendMessageMattermost } from "./send.js";

774
src/mattermost/monitor.ts Normal file
View File

@@ -0,0 +1,774 @@
import WebSocket from "ws";
import {
resolveEffectiveMessagesConfig,
resolveHumanDelayConfig,
resolveIdentityName,
} from "../agents/identity.js";
import { chunkMarkdownText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
import { hasControlCommand } from "../auto-reply/command-detection.js";
import { shouldHandleTextCommands } from "../auto-reply/commands-registry.js";
import { formatInboundEnvelope, formatInboundFromLabel } from "../auto-reply/envelope.js";
import {
createInboundDebouncer,
resolveInboundDebounceMs,
} from "../auto-reply/inbound-debounce.js";
import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntry,
type HistoryEntry,
} from "../auto-reply/reply/history.js";
import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
import {
extractShortModelName,
type ResponsePrefixContext,
} from "../auto-reply/reply/response-prefix-template.js";
import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
import type { ReplyPayload } from "../auto-reply/types.js";
import type { ClawdbotConfig } from "../config/config.js";
import { loadConfig } from "../config/config.js";
import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
import { createDedupeCache } from "../infra/dedupe.js";
import { rawDataToString } from "../infra/ws.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
import { enqueueSystemEvent } from "../infra/system-events.js";
import { getChildLogger } from "../logging.js";
import { mediaKindFromMime, type MediaKind } from "../media/constants.js";
import { fetchRemoteMedia, type FetchLike } from "../media/fetch.js";
import { saveMediaBuffer } from "../media/store.js";
import { resolveAgentRoute } from "../routing/resolve-route.js";
import { resolveThreadSessionKeys } from "../routing/session-key.js";
import type { RuntimeEnv } from "../runtime.js";
import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
import { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
fetchMattermostChannel,
fetchMattermostMe,
fetchMattermostUser,
normalizeMattermostBaseUrl,
sendMattermostTyping,
type MattermostChannel,
type MattermostPost,
type MattermostUser,
} from "./client.js";
import { sendMessageMattermost } from "./send.js";
export type MonitorMattermostOpts = {
botToken?: string;
baseUrl?: string;
accountId?: string;
config?: ClawdbotConfig;
runtime?: RuntimeEnv;
abortSignal?: AbortSignal;
statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
};
type MattermostEventPayload = {
event?: string;
data?: {
post?: string;
channel_id?: string;
channel_name?: string;
channel_display_name?: string;
channel_type?: string;
sender_name?: string;
team_id?: string;
};
broadcast?: {
channel_id?: string;
team_id?: string;
user_id?: string;
};
};
const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000;
const RECENT_MATTERMOST_MESSAGE_MAX = 2000;
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
const USER_CACHE_TTL_MS = 10 * 60_000;
const DEFAULT_ONCHAR_PREFIXES = [">", "!"];
const recentInboundMessages = createDedupeCache({
ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS,
maxSize: RECENT_MATTERMOST_MESSAGE_MAX,
});
function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv {
return (
opts.runtime ?? {
log: console.log,
error: console.error,
exit: (code: number): never => {
throw new Error(`exit ${code}`);
},
}
);
}
function normalizeMention(text: string, mention: string | undefined): string {
if (!mention) return text.trim();
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const re = new RegExp(`@${escaped}\\b`, "gi");
return text.replace(re, " ").replace(/\s+/g, " ").trim();
}
function resolveOncharPrefixes(prefixes: string[] | undefined): string[] {
const cleaned = prefixes?.map((entry) => entry.trim()).filter(Boolean) ?? DEFAULT_ONCHAR_PREFIXES;
return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES;
}
function stripOncharPrefix(
text: string,
prefixes: string[],
): { triggered: boolean; stripped: string } {
const trimmed = text.trimStart();
for (const prefix of prefixes) {
if (!prefix) continue;
if (trimmed.startsWith(prefix)) {
return {
triggered: true,
stripped: trimmed.slice(prefix.length).trimStart(),
};
}
}
return { triggered: false, stripped: text };
}
function isSystemPost(post: MattermostPost): boolean {
const type = post.type?.trim();
return Boolean(type);
}
function channelKind(channelType?: string | null): "dm" | "group" | "channel" {
if (!channelType) return "channel";
const normalized = channelType.trim().toUpperCase();
if (normalized === "D") return "dm";
if (normalized === "G") return "group";
return "channel";
}
function channelChatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "channel" {
if (kind === "dm") return "direct";
if (kind === "group") return "group";
return "channel";
}
type MattermostMediaInfo = {
path: string;
contentType?: string;
kind: MediaKind;
};
function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string {
if (mediaList.length === 0) return "";
if (mediaList.length === 1) {
const kind = mediaList[0].kind === "unknown" ? "document" : mediaList[0].kind;
return `<media:${kind}>`;
}
const allImages = mediaList.every((media) => media.kind === "image");
const label = allImages ? "image" : "file";
const suffix = mediaList.length === 1 ? label : `${label}s`;
const tag = allImages ? "<media:image>" : "<media:document>";
return `${tag} (${mediaList.length} ${suffix})`;
}
function buildMattermostMediaPayload(mediaList: MattermostMediaInfo[]): {
MediaPath?: string;
MediaType?: string;
MediaUrl?: string;
MediaPaths?: string[];
MediaUrls?: string[];
MediaTypes?: string[];
} {
const first = mediaList[0];
const mediaPaths = mediaList.map((media) => media.path);
const mediaTypes = mediaList.map((media) => media.contentType).filter(Boolean) as string[];
return {
MediaPath: first?.path,
MediaType: first?.contentType,
MediaUrl: first?.path,
MediaPaths: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaUrls: mediaPaths.length > 0 ? mediaPaths : undefined,
MediaTypes: mediaTypes.length > 0 ? mediaTypes : undefined,
};
}
function buildMattermostWsUrl(baseUrl: string): string {
const normalized = normalizeMattermostBaseUrl(baseUrl);
if (!normalized) throw new Error("Mattermost baseUrl is required");
const wsBase = normalized.replace(/^http/i, "ws");
return `${wsBase}/api/v4/websocket`;
}
export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise<void> {
const runtime = resolveRuntime(opts);
const cfg = opts.config ?? loadConfig();
const account = resolveMattermostAccount({
cfg,
accountId: opts.accountId,
});
const botToken = opts.botToken?.trim() || account.botToken?.trim();
if (!botToken) {
throw new Error(
`Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
);
}
const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
if (!baseUrl) {
throw new Error(
`Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
);
}
const client = createMattermostClient({ baseUrl, botToken });
const botUser = await fetchMattermostMe(client);
const botUserId = botUser.id;
const botUsername = botUser.username?.trim() || undefined;
runtime.log?.(`mattermost connected as ${botUsername ? `@${botUsername}` : botUserId}`);
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
const logger = getChildLogger({ module: "mattermost" });
const mediaMaxBytes =
resolveChannelMediaMaxBytes({
cfg,
resolveChannelLimitMb: () => undefined,
accountId: account.accountId,
}) ?? 8 * 1024 * 1024;
const historyLimit = Math.max(
0,
cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
);
const channelHistories = new Map<string, HistoryEntry[]>();
const fetchWithAuth: FetchLike = (input, init) => {
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${client.token}`);
return fetch(input, { ...init, headers });
};
const resolveMattermostMedia = async (
fileIds?: string[] | null,
): Promise<MattermostMediaInfo[]> => {
const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean) as string[];
if (ids.length === 0) return [];
const out: MattermostMediaInfo[] = [];
for (const fileId of ids) {
try {
const fetched = await fetchRemoteMedia({
url: `${client.apiBaseUrl}/files/${fileId}`,
fetchImpl: fetchWithAuth,
filePathHint: fileId,
maxBytes: mediaMaxBytes,
});
const saved = await saveMediaBuffer(
fetched.buffer,
fetched.contentType ?? undefined,
"inbound",
mediaMaxBytes,
);
const contentType = saved.contentType ?? fetched.contentType ?? undefined;
out.push({
path: saved.path,
contentType,
kind: mediaKindFromMime(contentType),
});
} catch (err) {
logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
}
}
return out;
};
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
try {
await sendMattermostTyping(client, { channelId, parentId });
} catch (err) {
logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`);
}
};
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
const cached = channelCache.get(channelId);
if (cached && cached.expiresAt > Date.now()) return cached.value;
try {
const info = await fetchMattermostChannel(client, channelId);
channelCache.set(channelId, {
value: info,
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
});
return info;
} catch (err) {
logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`);
channelCache.set(channelId, {
value: null,
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
});
return null;
}
};
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
const cached = userCache.get(userId);
if (cached && cached.expiresAt > Date.now()) return cached.value;
try {
const info = await fetchMattermostUser(client, userId);
userCache.set(userId, {
value: info,
expiresAt: Date.now() + USER_CACHE_TTL_MS,
});
return info;
} catch (err) {
logger.debug?.(`mattermost: user lookup failed: ${String(err)}`);
userCache.set(userId, {
value: null,
expiresAt: Date.now() + USER_CACHE_TTL_MS,
});
return null;
}
};
const handlePost = async (
post: MattermostPost,
payload: MattermostEventPayload,
messageIds?: string[],
) => {
const channelId = post.channel_id ?? payload.data?.channel_id ?? payload.broadcast?.channel_id;
if (!channelId) return;
const allMessageIds = messageIds?.length ? messageIds : post.id ? [post.id] : [];
if (allMessageIds.length === 0) return;
const dedupeEntries = allMessageIds.map((id) =>
recentInboundMessages.check(`${account.accountId}:${id}`),
);
if (dedupeEntries.length > 0 && dedupeEntries.every(Boolean)) return;
const senderId = post.user_id ?? payload.broadcast?.user_id;
if (!senderId) return;
if (senderId === botUserId) return;
if (isSystemPost(post)) return;
const channelInfo = await resolveChannelInfo(channelId);
const channelType = payload.data?.channel_type ?? channelInfo?.type ?? undefined;
const kind = channelKind(channelType);
const chatType = channelChatType(kind);
const teamId = payload.data?.team_id ?? channelInfo?.team_id ?? undefined;
const channelName = payload.data?.channel_name ?? channelInfo?.name ?? "";
const channelDisplay =
payload.data?.channel_display_name ?? channelInfo?.display_name ?? channelName;
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
const route = resolveAgentRoute({
cfg,
channel: "mattermost",
accountId: account.accountId,
teamId,
peer: {
kind,
id: kind === "dm" ? senderId : channelId,
},
});
const baseSessionKey = route.sessionKey;
const threadRootId = post.root_id?.trim() || undefined;
const threadKeys = resolveThreadSessionKeys({
baseSessionKey,
threadId: threadRootId,
parentSessionKey: threadRootId ? baseSessionKey : undefined,
});
const sessionKey = threadKeys.sessionKey;
const historyKey = kind === "dm" ? null : sessionKey;
const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
const rawText = post.message?.trim() || "";
const wasMentioned =
kind !== "dm" &&
((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
matchesMentionPatterns(rawText, mentionRegexes));
const pendingBody =
rawText ||
(post.file_ids?.length
? `[Mattermost ${post.file_ids.length === 1 ? "file" : "files"}]`
: "");
const pendingSender = payload.data?.sender_name?.trim() || senderId;
const recordPendingHistory = () => {
if (!historyKey || historyLimit <= 0) return;
const trimmed = pendingBody.trim();
if (!trimmed) return;
recordPendingHistoryEntry({
historyMap: channelHistories,
historyKey,
limit: historyLimit,
entry: {
sender: pendingSender,
body: trimmed,
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
messageId: post.id ?? undefined,
},
});
};
const allowTextCommands = shouldHandleTextCommands({
cfg,
surface: "mattermost",
});
const isControlCommand = allowTextCommands && hasControlCommand(rawText, cfg);
const oncharEnabled = account.chatmode === "onchar" && kind !== "dm";
const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
const oncharResult = oncharEnabled
? stripOncharPrefix(rawText, oncharPrefixes)
: { triggered: false, stripped: rawText };
const oncharTriggered = oncharResult.triggered;
const shouldRequireMention = kind === "channel" && (account.requireMention ?? true);
const shouldBypassMention = isControlCommand && shouldRequireMention && !wasMentioned;
const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
if (oncharEnabled && !oncharTriggered && !wasMentioned && !isControlCommand) {
recordPendingHistory();
return;
}
if (kind === "channel" && 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;
const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim();
const bodyText = normalizeMention(baseText, botUsername);
if (!bodyText) return;
recordChannelActivity({
channel: "mattermost",
accountId: account.accountId,
direction: "inbound",
});
const fromLabel = formatInboundFromLabel({
isGroup: kind !== "dm",
groupLabel: channelDisplay || roomLabel,
groupId: channelId,
groupFallback: roomLabel || "Channel",
directLabel: senderName,
directId: senderId,
});
const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
const inboundLabel =
kind === "dm"
? `Mattermost DM from ${senderName}`
: `Mattermost message in ${roomLabel} from ${senderName}`;
enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey,
contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`,
});
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
const body = formatInboundEnvelope({
channel: "Mattermost",
from: fromLabel,
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
body: textWithId,
chatType,
sender: { name: senderName, id: senderId },
});
let combinedBody = body;
if (historyKey && historyLimit > 0) {
combinedBody = buildPendingHistoryContextFromMap({
historyMap: channelHistories,
historyKey,
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
formatInboundEnvelope({
channel: "Mattermost",
from: fromLabel,
timestamp: entry.timestamp,
body: `${entry.body}${
entry.messageId ? ` [id:${entry.messageId} channel:${channelId}]` : ""
}`,
chatType,
senderLabel: entry.sender,
}),
});
}
const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`;
const mediaPayload = buildMattermostMediaPayload(mediaList);
const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: cfg.commands?.useAccessGroups ?? false,
authorizers: [],
});
const ctxPayload = finalizeInboundContext({
Body: combinedBody,
RawBody: bodyText,
CommandBody: bodyText,
From:
kind === "dm"
? `mattermost:${senderId}`
: kind === "group"
? `mattermost:group:${channelId}`
: `mattermost:channel:${channelId}`,
To: to,
SessionKey: sessionKey,
ParentSessionKey: threadKeys.parentSessionKey,
AccountId: route.accountId,
ChatType: chatType,
ConversationLabel: fromLabel,
GroupSubject: kind !== "dm" ? channelDisplay || roomLabel : undefined,
GroupChannel: channelName ? `#${channelName}` : undefined,
GroupSpace: teamId,
SenderName: senderName,
SenderId: senderId,
Provider: "mattermost" as const,
Surface: "mattermost" as const,
MessageSid: post.id ?? undefined,
MessageSids: allMessageIds.length > 1 ? allMessageIds : undefined,
MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined,
MessageSidLast:
allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined,
ReplyToId: threadRootId,
MessageThreadId: threadRootId,
Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
WasMentioned: kind !== "dm" ? effectiveWasMentioned : undefined,
CommandAuthorized: commandAuthorized,
OriginatingChannel: "mattermost" as const,
OriginatingTo: to,
...mediaPayload,
});
if (kind === "dm") {
const sessionCfg = cfg.session;
const storePath = resolveStorePath(sessionCfg?.store, {
agentId: route.agentId,
});
await updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
deliveryContext: {
channel: "mattermost",
to,
accountId: route.accountId,
},
});
}
if (shouldLogVerbose()) {
const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
logVerbose(
`mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
);
}
const textLimit = resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
fallbackLimit: account.textChunkLimit ?? 4000,
});
let prefixContext: ResponsePrefixContext = {
identityName: resolveIdentityName(cfg, route.agentId),
};
const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
responsePrefixContextProvider: () => prefixContext,
humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
deliver: async (payload: ReplyPayload) => {
const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
const text = payload.text ?? "";
if (mediaUrls.length === 0) {
const chunks = chunkMarkdownText(text, textLimit);
for (const chunk of chunks.length > 0 ? chunks : [text]) {
if (!chunk) continue;
await sendMessageMattermost(to, chunk, {
accountId: account.accountId,
replyToId: threadRootId,
});
}
} else {
let first = true;
for (const mediaUrl of mediaUrls) {
const caption = first ? text : "";
first = false;
await sendMessageMattermost(to, caption, {
accountId: account.accountId,
mediaUrl,
replyToId: threadRootId,
});
}
}
runtime.log?.(`delivered reply to ${to}`);
},
onError: (err, info) => {
runtime.error?.(danger(`mattermost ${info.kind} reply failed: ${String(err)}`));
},
onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
});
await dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
onModelSelected: (ctx) => {
prefixContext.provider = ctx.provider;
prefixContext.model = extractShortModelName(ctx.model);
prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
},
},
});
markDispatchIdle();
if (historyKey && historyLimit > 0) {
clearHistoryEntries({ historyMap: channelHistories, historyKey });
}
};
const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "mattermost" });
const debouncer = createInboundDebouncer<{
post: MattermostPost;
payload: MattermostEventPayload;
}>({
debounceMs: inboundDebounceMs,
buildKey: (entry) => {
const channelId =
entry.post.channel_id ??
entry.payload.data?.channel_id ??
entry.payload.broadcast?.channel_id;
if (!channelId) return null;
const threadId = entry.post.root_id?.trim();
const threadKey = threadId ? `thread:${threadId}` : "channel";
return `mattermost:${account.accountId}:${channelId}:${threadKey}`;
},
shouldDebounce: (entry) => {
if (entry.post.file_ids && entry.post.file_ids.length > 0) return false;
const text = entry.post.message?.trim() ?? "";
if (!text) return false;
return !hasControlCommand(text, cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
if (!last) return;
if (entries.length === 1) {
await handlePost(last.post, last.payload);
return;
}
const combinedText = entries
.map((entry) => entry.post.message?.trim() ?? "")
.filter(Boolean)
.join("\n");
const mergedPost: MattermostPost = {
...last.post,
message: combinedText,
file_ids: [],
};
const ids = entries.map((entry) => entry.post.id).filter(Boolean) as string[];
await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
},
onError: (err) => {
runtime.error?.(danger(`mattermost debounce flush failed: ${String(err)}`));
},
});
const wsUrl = buildMattermostWsUrl(baseUrl);
let seq = 1;
const connectOnce = async (): Promise<void> => {
const ws = new WebSocket(wsUrl);
const onAbort = () => ws.close();
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
return await new Promise((resolve) => {
ws.on("open", () => {
opts.statusSink?.({
connected: true,
lastConnectedAt: Date.now(),
lastError: null,
});
ws.send(
JSON.stringify({
seq: seq++,
action: "authentication_challenge",
data: { token: botToken },
}),
);
});
ws.on("message", async (data) => {
const raw = rawDataToString(data);
let payload: MattermostEventPayload;
try {
payload = JSON.parse(raw) as MattermostEventPayload;
} catch {
return;
}
if (payload.event !== "posted") return;
const postData = payload.data?.post;
if (!postData) return;
let post: MattermostPost | null = null;
if (typeof postData === "string") {
try {
post = JSON.parse(postData) as MattermostPost;
} catch {
return;
}
} else if (typeof postData === "object") {
post = postData as MattermostPost;
}
if (!post) return;
try {
await debouncer.enqueue({ post, payload });
} catch (err) {
runtime.error?.(danger(`mattermost handler failed: ${String(err)}`));
}
});
ws.on("close", (code, reason) => {
const message = reason.length > 0 ? reason.toString("utf8") : "";
opts.statusSink?.({
connected: false,
lastDisconnect: {
at: Date.now(),
status: code,
error: message || undefined,
},
});
opts.abortSignal?.removeEventListener("abort", onAbort);
resolve();
});
ws.on("error", (err) => {
runtime.error?.(danger(`mattermost websocket error: ${String(err)}`));
opts.statusSink?.({
lastError: String(err),
});
});
});
};
while (!opts.abortSignal?.aborted) {
await connectOnce();
if (opts.abortSignal?.aborted) return;
await new Promise((resolve) => setTimeout(resolve, 2000));
}
}

70
src/mattermost/probe.ts Normal file
View File

@@ -0,0 +1,70 @@
import { normalizeMattermostBaseUrl, type MattermostUser } from "./client.js";
export type MattermostProbe = {
ok: boolean;
status?: number | null;
error?: string | null;
elapsedMs?: number | null;
bot?: MattermostUser;
};
async function readMattermostError(res: Response): Promise<string> {
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const data = (await res.json()) as { message?: string } | undefined;
if (data?.message) return data.message;
return JSON.stringify(data);
}
return await res.text();
}
export async function probeMattermost(
baseUrl: string,
botToken: string,
timeoutMs = 2500,
): Promise<MattermostProbe> {
const normalized = normalizeMattermostBaseUrl(baseUrl);
if (!normalized) {
return { ok: false, error: "baseUrl missing" };
}
const url = `${normalized}/api/v4/users/me`;
const start = Date.now();
const controller = timeoutMs > 0 ? new AbortController() : undefined;
let timer: NodeJS.Timeout | null = null;
if (controller) {
timer = setTimeout(() => controller.abort(), timeoutMs);
}
try {
const res = await fetch(url, {
headers: { Authorization: `Bearer ${botToken}` },
signal: controller?.signal,
});
const elapsedMs = Date.now() - start;
if (!res.ok) {
const detail = await readMattermostError(res);
return {
ok: false,
status: res.status,
error: detail || res.statusText,
elapsedMs,
};
}
const bot = (await res.json()) as MattermostUser;
return {
ok: true,
status: res.status,
elapsedMs,
bot,
};
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
return {
ok: false,
status: null,
error: message,
elapsedMs: Date.now() - start,
};
} finally {
if (timer) clearTimeout(timer);
}
}

207
src/mattermost/send.ts Normal file
View File

@@ -0,0 +1,207 @@
import { loadConfig } from "../config/config.js";
import { logVerbose, shouldLogVerbose } from "../globals.js";
import { recordChannelActivity } from "../infra/channel-activity.js";
import { loadWebMedia } from "../web/media.js";
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
createMattermostDirectChannel,
createMattermostPost,
fetchMattermostMe,
fetchMattermostUserByUsername,
normalizeMattermostBaseUrl,
uploadMattermostFile,
type MattermostUser,
} from "./client.js";
export type MattermostSendOpts = {
botToken?: string;
baseUrl?: string;
accountId?: string;
mediaUrl?: string;
replyToId?: string;
};
export type MattermostSendResult = {
messageId: string;
channelId: string;
};
type MattermostTarget =
| { kind: "channel"; id: string }
| { kind: "user"; id?: string; username?: string };
const botUserCache = new Map<string, MattermostUser>();
const userByNameCache = new Map<string, MattermostUser>();
function cacheKey(baseUrl: string, token: string): string {
return `${baseUrl}::${token}`;
}
function normalizeMessage(text: string, mediaUrl?: string): string {
const trimmed = text.trim();
const media = mediaUrl?.trim();
return [trimmed, media].filter(Boolean).join("\n");
}
function isHttpUrl(value: string): boolean {
return /^https?:\/\//i.test(value);
}
function parseMattermostTarget(raw: string): MattermostTarget {
const trimmed = raw.trim();
if (!trimmed) throw new Error("Recipient is required for Mattermost sends");
const lower = trimmed.toLowerCase();
if (lower.startsWith("channel:")) {
const id = trimmed.slice("channel:".length).trim();
if (!id) throw new Error("Channel id is required for Mattermost sends");
return { kind: "channel", id };
}
if (lower.startsWith("user:")) {
const id = trimmed.slice("user:".length).trim();
if (!id) throw new Error("User id is required for Mattermost sends");
return { kind: "user", id };
}
if (lower.startsWith("mattermost:")) {
const id = trimmed.slice("mattermost:".length).trim();
if (!id) throw new Error("User id is required for Mattermost sends");
return { kind: "user", id };
}
if (trimmed.startsWith("@")) {
const username = trimmed.slice(1).trim();
if (!username) {
throw new Error("Username is required for Mattermost sends");
}
return { kind: "user", username };
}
return { kind: "channel", id: trimmed };
}
async function resolveBotUser(baseUrl: string, token: string): Promise<MattermostUser> {
const key = cacheKey(baseUrl, token);
const cached = botUserCache.get(key);
if (cached) return cached;
const client = createMattermostClient({ baseUrl, botToken: token });
const user = await fetchMattermostMe(client);
botUserCache.set(key, user);
return user;
}
async function resolveUserIdByUsername(params: {
baseUrl: string;
token: string;
username: string;
}): Promise<string> {
const { baseUrl, token, username } = params;
const key = `${cacheKey(baseUrl, token)}::${username.toLowerCase()}`;
const cached = userByNameCache.get(key);
if (cached?.id) return cached.id;
const client = createMattermostClient({ baseUrl, botToken: token });
const user = await fetchMattermostUserByUsername(client, username);
userByNameCache.set(key, user);
return user.id;
}
async function resolveTargetChannelId(params: {
target: MattermostTarget;
baseUrl: string;
token: string;
}): Promise<string> {
if (params.target.kind === "channel") return params.target.id;
const userId = params.target.id
? params.target.id
: await resolveUserIdByUsername({
baseUrl: params.baseUrl,
token: params.token,
username: params.target.username ?? "",
});
const botUser = await resolveBotUser(params.baseUrl, params.token);
const client = createMattermostClient({
baseUrl: params.baseUrl,
botToken: params.token,
});
const channel = await createMattermostDirectChannel(client, [botUser.id, userId]);
return channel.id;
}
export async function sendMessageMattermost(
to: string,
text: string,
opts: MattermostSendOpts = {},
): Promise<MattermostSendResult> {
const cfg = loadConfig();
const account = resolveMattermostAccount({
cfg,
accountId: opts.accountId,
});
const token = opts.botToken?.trim() || account.botToken?.trim();
if (!token) {
throw new Error(
`Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
);
}
const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
if (!baseUrl) {
throw new Error(
`Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
);
}
const target = parseMattermostTarget(to);
const channelId = await resolveTargetChannelId({
target,
baseUrl,
token,
});
const client = createMattermostClient({ baseUrl, botToken: token });
let message = text?.trim() ?? "";
let fileIds: string[] | undefined;
let uploadError: Error | undefined;
const mediaUrl = opts.mediaUrl?.trim();
if (mediaUrl) {
try {
const media = await loadWebMedia(mediaUrl);
const fileInfo = await uploadMattermostFile(client, {
channelId,
buffer: media.buffer,
fileName: media.fileName ?? "upload",
contentType: media.contentType ?? undefined,
});
fileIds = [fileInfo.id];
} catch (err) {
uploadError = err instanceof Error ? err : new Error(String(err));
if (shouldLogVerbose()) {
logVerbose(
`mattermost send: media upload failed, falling back to URL text: ${String(err)}`,
);
}
message = normalizeMessage(message, isHttpUrl(mediaUrl) ? mediaUrl : "");
}
}
if (!message && (!fileIds || fileIds.length === 0)) {
if (uploadError) {
throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
}
throw new Error("Mattermost message is empty");
}
const post = await createMattermostPost(client, {
channelId,
message,
rootId: opts.replyToId,
fileIds,
});
recordChannelActivity({
channel: "mattermost",
accountId: account.accountId,
direction: "outbound",
});
return {
messageId: post.id ?? "unknown",
channelId,
};
}

View File

@@ -81,6 +81,7 @@ export type {
export {
DiscordConfigSchema,
IMessageConfigSchema,
MattermostConfigSchema,
MSTeamsConfigSchema,
SignalConfigSchema,
SlackConfigSchema,
@@ -120,6 +121,7 @@ export {
resolveBlueBubblesGroupRequireMention,
resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention,
resolveMattermostGroupRequireMention,
resolveSlackGroupRequireMention,
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
@@ -239,6 +241,21 @@ export {
} from "../channels/plugins/normalize/slack.js";
export { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
// Channel: Mattermost
export {
listEnabledMattermostAccounts,
listMattermostAccountIds,
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
type ResolvedMattermostAccount,
} from "../mattermost/accounts.js";
export { normalizeMattermostBaseUrl } from "../mattermost/client.js";
export { mattermostOnboardingAdapter } from "../channels/plugins/onboarding/mattermost.js";
export {
looksLikeMattermostTargetId,
normalizeMattermostMessagingTarget,
} from "../channels/plugins/normalize/mattermost.js";
// Channel: Telegram
export {
listTelegramAccountIds,

View File

@@ -57,6 +57,9 @@ import { enqueueSystemEvent } from "../../infra/system-events.js";
import { monitorIMessageProvider } from "../../imessage/monitor.js";
import { probeIMessage } from "../../imessage/probe.js";
import { sendMessageIMessage } from "../../imessage/send.js";
import { monitorMattermostProvider } from "../../mattermost/monitor.js";
import { probeMattermost } from "../../mattermost/probe.js";
import { sendMessageMattermost } from "../../mattermost/send.js";
import { shouldLogVerbose } from "../../globals.js";
import { getChildLogger } from "../../logging.js";
import { normalizeLogLevel } from "../../logging/levels.js";
@@ -230,6 +233,11 @@ export function createPluginRuntime(): PluginRuntime {
monitorSlackProvider,
handleSlackAction,
},
mattermost: {
probeMattermost,
sendMessageMattermost,
monitorMattermostProvider,
},
telegram: {
auditGroupMembership: auditTelegramGroupMembership,
collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds,

View File

@@ -98,6 +98,10 @@ type ResolveSlackUserAllowlist =
type SendMessageSlack = typeof import("../../slack/send.js").sendMessageSlack;
type MonitorSlackProvider = typeof import("../../slack/index.js").monitorSlackProvider;
type HandleSlackAction = typeof import("../../agents/tools/slack-actions.js").handleSlackAction;
type ProbeMattermost = typeof import("../../mattermost/probe.js").probeMattermost;
type SendMessageMattermost = typeof import("../../mattermost/send.js").sendMessageMattermost;
type MonitorMattermostProvider =
typeof import("../../mattermost/monitor.js").monitorMattermostProvider;
type AuditTelegramGroupMembership =
typeof import("../../telegram/audit.js").auditTelegramGroupMembership;
type CollectTelegramUnmentionedGroupIds =
@@ -242,6 +246,11 @@ export type PluginRuntime = {
monitorSlackProvider: MonitorSlackProvider;
handleSlackAction: HandleSlackAction;
};
mattermost: {
probeMattermost: ProbeMattermost;
sendMessageMattermost: SendMessageMattermost;
monitorMattermostProvider: MonitorMattermostProvider;
};
telegram: {
auditGroupMembership: AuditTelegramGroupMembership;
collectUnmentionedGroupIds: CollectTelegramUnmentionedGroupIds;

View File

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

View File

@@ -164,6 +164,39 @@ 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;
@@ -382,6 +415,7 @@ export type CronPayload =
| "telegram"
| "discord"
| "slack"
| "mattermost"
| "signal"
| "imessage"
| "msteams";

View File

@@ -0,0 +1,70 @@
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,6 +7,7 @@ import type {
ChannelsStatusSnapshot,
DiscordStatus,
IMessageStatus,
MattermostStatus,
NostrProfile,
NostrStatus,
SignalStatus,
@@ -23,6 +24,7 @@ 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";
@@ -39,6 +41,7 @@ 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;
@@ -62,6 +65,7 @@ export function renderChannels(props: ChannelsProps) {
telegram,
discord,
slack,
mattermost,
signal,
imessage,
nostr,
@@ -97,7 +101,7 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe
if (snapshot?.channelOrder?.length) {
return snapshot.channelOrder;
}
return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "nostr"];
return ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage", "nostr"];
}
function renderChannel(
@@ -135,6 +139,12 @@ function renderChannel(
slack: data.slack,
accountCountLabel,
});
case "mattermost":
return renderMattermostCard({
props,
mattermost: data.mattermost,
accountCountLabel,
});
case "signal":
return renderSignalCard({
props,

View File

@@ -4,6 +4,7 @@ import type {
ConfigUiHints,
DiscordStatus,
IMessageStatus,
MattermostStatus,
NostrProfile,
NostrStatus,
SignalStatus,
@@ -53,6 +54,7 @@ export type ChannelsChannelData = {
telegram?: TelegramStatus;
discord?: DiscordStatus | null;
slack?: SlackStatus | null;
mattermost?: MattermostStatus | null;
signal?: SignalStatus | null;
imessage?: IMessageStatus | null;
nostr?: NostrStatus | null;