diff --git a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift
index 79dd97cf9..a43e7d56b 100644
--- a/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift
+++ b/apps/macos/Sources/Clawdbot/ChannelsSettings+ChannelState.swift
@@ -40,6 +40,17 @@ extension ChannelsSettings {
return .orange
}
+ var mattermostTint: Color {
+ guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
+ else { return .secondary }
+ if !status.configured { return .secondary }
+ if status.lastError != nil { return .orange }
+ if status.probe?.ok == false { return .orange }
+ if status.connected == true { return .green }
+ if status.running { return .orange }
+ return .orange
+ }
+
var signalTint: Color {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return .secondary }
@@ -85,6 +96,15 @@ extension ChannelsSettings {
return "Configured"
}
+ var mattermostSummary: String {
+ guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
+ else { return "Checking…" }
+ if !status.configured { return "Not configured" }
+ if status.connected == true { return "Connected" }
+ if status.running { return "Running" }
+ return "Configured"
+ }
+
var signalSummary: String {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return "Checking…" }
@@ -193,6 +213,38 @@ extension ChannelsSettings {
return lines.isEmpty ? nil : lines.joined(separator: " · ")
}
+ var mattermostDetails: String? {
+ guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
+ else { return nil }
+ var lines: [String] = []
+ if let source = status.botTokenSource {
+ lines.append("Token source: \(source)")
+ }
+ if let baseUrl = status.baseUrl, !baseUrl.isEmpty {
+ lines.append("Base URL: \(baseUrl)")
+ }
+ if let probe = status.probe {
+ if probe.ok {
+ if let name = probe.bot?.username {
+ lines.append("Bot: @\(name)")
+ }
+ if let elapsed = probe.elapsedMs {
+ lines.append("Probe \(Int(elapsed))ms")
+ }
+ } else {
+ let code = probe.status.map { String($0) } ?? "unknown"
+ lines.append("Probe failed (\(code))")
+ }
+ }
+ if let last = self.date(fromMs: status.lastProbeAt ?? status.lastConnectedAt) {
+ lines.append("Last probe \(relativeAge(from: last))")
+ }
+ if let err = status.lastError, !err.isEmpty {
+ lines.append("Error: \(err)")
+ }
+ return lines.isEmpty ? nil : lines.joined(separator: " · ")
+ }
+
var signalDetails: String? {
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return nil }
@@ -244,7 +296,7 @@ extension ChannelsSettings {
}
var orderedChannels: [ChannelItem] {
- let fallback = ["whatsapp", "telegram", "discord", "slack", "signal", "imessage"]
+ let fallback = ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage"]
let order = self.store.snapshot?.channelOrder ?? fallback
let channels = order.enumerated().map { index, id in
ChannelItem(
@@ -307,6 +359,8 @@ extension ChannelsSettings {
return self.telegramTint
case "discord":
return self.discordTint
+ case "mattermost":
+ return self.mattermostTint
case "signal":
return self.signalTint
case "imessage":
@@ -326,6 +380,8 @@ extension ChannelsSettings {
return self.telegramSummary
case "discord":
return self.discordSummary
+ case "mattermost":
+ return self.mattermostSummary
case "signal":
return self.signalSummary
case "imessage":
@@ -345,6 +401,8 @@ extension ChannelsSettings {
return self.telegramDetails
case "discord":
return self.discordDetails
+ case "mattermost":
+ return self.mattermostDetails
case "signal":
return self.signalDetails
case "imessage":
@@ -377,6 +435,10 @@ extension ChannelsSettings {
return self
.date(fromMs: self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)?
.lastProbeAt)
+ case "mattermost":
+ guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
+ else { return nil }
+ return self.date(fromMs: status.lastProbeAt ?? status.lastConnectedAt)
case "signal":
return self
.date(fromMs: self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)?.lastProbeAt)
@@ -411,6 +473,10 @@ extension ChannelsSettings {
guard let status = self.channelStatus("discord", as: ChannelsStatusSnapshot.DiscordStatus.self)
else { return false }
return status.lastError?.isEmpty == false || status.probe?.ok == false
+ case "mattermost":
+ guard let status = self.channelStatus("mattermost", as: ChannelsStatusSnapshot.MattermostStatus.self)
+ else { return false }
+ return status.lastError?.isEmpty == false || status.probe?.ok == false
case "signal":
guard let status = self.channelStatus("signal", as: ChannelsStatusSnapshot.SignalStatus.self)
else { return false }
diff --git a/apps/macos/Sources/Clawdbot/ChannelsStore.swift b/apps/macos/Sources/Clawdbot/ChannelsStore.swift
index e62e737a4..810261b9e 100644
--- a/apps/macos/Sources/Clawdbot/ChannelsStore.swift
+++ b/apps/macos/Sources/Clawdbot/ChannelsStore.swift
@@ -85,6 +85,40 @@ struct ChannelsStatusSnapshot: Codable {
let lastProbeAt: Double?
}
+ struct MattermostBot: Codable {
+ let id: String?
+ let username: String?
+ }
+
+ struct MattermostProbe: Codable {
+ let ok: Bool
+ let status: Int?
+ let error: String?
+ let elapsedMs: Double?
+ let bot: MattermostBot?
+ }
+
+ struct MattermostDisconnect: Codable {
+ let at: Double
+ let status: Int?
+ let error: String?
+ }
+
+ struct MattermostStatus: Codable {
+ let configured: Bool
+ let botTokenSource: String?
+ let running: Bool
+ let connected: Bool?
+ let lastConnectedAt: Double?
+ let lastDisconnect: MattermostDisconnect?
+ let lastStartAt: Double?
+ let lastStopAt: Double?
+ let lastError: String?
+ let baseUrl: String?
+ let probe: MattermostProbe?
+ let lastProbeAt: Double?
+ }
+
struct SignalProbe: Codable {
let ok: Bool
let status: Int?
diff --git a/apps/macos/Sources/Clawdbot/GatewayConnection.swift b/apps/macos/Sources/Clawdbot/GatewayConnection.swift
index 9feb98ba9..0f0024001 100644
--- a/apps/macos/Sources/Clawdbot/GatewayConnection.swift
+++ b/apps/macos/Sources/Clawdbot/GatewayConnection.swift
@@ -12,6 +12,7 @@ enum GatewayAgentChannel: String, Codable, CaseIterable, Sendable {
case telegram
case discord
case slack
+ case mattermost
case signal
case imessage
case msteams
diff --git a/apps/macos/Tests/ClawdbotIPCTests/ChannelsSettingsSmokeTests.swift b/apps/macos/Tests/ClawdbotIPCTests/ChannelsSettingsSmokeTests.swift
index 2b1eced84..08c05a77c 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/ChannelsSettingsSmokeTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/ChannelsSettingsSmokeTests.swift
@@ -12,10 +12,11 @@ struct ChannelsSettingsSmokeTests {
let store = ChannelsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
- channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
+ channelOrder: ["whatsapp", "telegram", "mattermost", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
+ "mattermost": "Mattermost",
"signal": "Signal",
"imessage": "iMessage",
],
@@ -57,6 +58,21 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_050_000,
]),
+ "mattermost": SnapshotAnyCodable([
+ "configured": true,
+ "botTokenSource": "env",
+ "running": true,
+ "connected": true,
+ "baseUrl": "https://chat.example.com",
+ "lastStartAt": 1_700_000_000_000,
+ "probe": [
+ "ok": true,
+ "status": 200,
+ "elapsedMs": 95,
+ "bot": ["id": "bot-123", "username": "clawdbot"],
+ ],
+ "lastProbeAt": 1_700_000_050_000,
+ ]),
"signal": SnapshotAnyCodable([
"configured": true,
"baseUrl": "http://127.0.0.1:8080",
@@ -82,6 +98,7 @@ struct ChannelsSettingsSmokeTests {
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
+ "mattermost": "default",
"signal": "default",
"imessage": "default",
])
@@ -98,10 +115,11 @@ struct ChannelsSettingsSmokeTests {
let store = ChannelsStore(isPreview: true)
store.snapshot = ChannelsStatusSnapshot(
ts: 1_700_000_000_000,
- channelOrder: ["whatsapp", "telegram", "signal", "imessage"],
+ channelOrder: ["whatsapp", "telegram", "mattermost", "signal", "imessage"],
channelLabels: [
"whatsapp": "WhatsApp",
"telegram": "Telegram",
+ "mattermost": "Mattermost",
"signal": "Signal",
"imessage": "iMessage",
],
@@ -128,6 +146,19 @@ struct ChannelsSettingsSmokeTests {
],
"lastProbeAt": 1_700_000_100_000,
]),
+ "mattermost": SnapshotAnyCodable([
+ "configured": false,
+ "running": false,
+ "lastError": "bot token missing",
+ "baseUrl": "https://chat.example.com",
+ "probe": [
+ "ok": false,
+ "status": 401,
+ "error": "unauthorized",
+ "elapsedMs": 110,
+ ],
+ "lastProbeAt": 1_700_000_150_000,
+ ]),
"signal": SnapshotAnyCodable([
"configured": false,
"baseUrl": "http://127.0.0.1:8080",
@@ -154,6 +185,7 @@ struct ChannelsSettingsSmokeTests {
channelDefaultAccountId: [
"whatsapp": "default",
"telegram": "default",
+ "mattermost": "default",
"signal": "default",
"imessage": "default",
])
diff --git a/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift b/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift
index bf72af7e5..a19c49bfc 100644
--- a/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift
+++ b/apps/macos/Tests/ClawdbotIPCTests/GatewayAgentChannelTests.swift
@@ -11,6 +11,7 @@ import Testing
#expect(GatewayAgentChannel.last.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.whatsapp.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.telegram.shouldDeliver(true) == true)
+ #expect(GatewayAgentChannel.mattermost.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.bluebubbles.shouldDeliver(true) == true)
#expect(GatewayAgentChannel.last.shouldDeliver(false) == false)
}
diff --git a/docs/automation/cron-jobs.md b/docs/automation/cron-jobs.md
index 882cf816a..a5c24abd8 100644
--- a/docs/automation/cron-jobs.md
+++ b/docs/automation/cron-jobs.md
@@ -121,7 +121,7 @@ Resolution priority:
### Delivery (channel + target)
Isolated jobs can deliver output to a channel. The job payload can specify:
-- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `signal` / `imessage` / `last`
+- `channel`: `whatsapp` / `telegram` / `discord` / `slack` / `mattermost` / `signal` / `imessage` / `last`
- `to`: channel-specific recipient target
If `channel` or `to` is omitted, cron can fall back to the main session’s “last route”
@@ -133,7 +133,7 @@ Delivery notes:
- Use `deliver: false` to keep output internal even if a `to` is present.
Target format reminders:
-- Slack/Discord targets should use explicit prefixes (e.g. `channel:
- Any OS + WhatsApp/Telegram/Discord/iMessage gateway for AI agents (Pi).
+ Any OS + WhatsApp/Telegram/Discord/Mattermost/iMessage gateway for AI agents (Pi).
Send a message, get an agent response — from your pocket.
` or use allowlists.
diff --git a/docs/web/control-ui.md b/docs/web/control-ui.md
index bcead1b7a..7f5a3dac4 100644
--- a/docs/web/control-ui.md
+++ b/docs/web/control-ui.md
@@ -30,7 +30,7 @@ The onboarding wizard generates a gateway token by default, so paste it here on
## What it can do (today)
- Chat with the model via Gateway WS (`chat.history`, `chat.send`, `chat.abort`, `chat.inject`)
- Stream tool calls + live tool output cards in Chat (agent events)
-- Channels: WhatsApp/Telegram 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.*`)
diff --git a/extensions/mattermost/clawdbot.plugin.json b/extensions/mattermost/clawdbot.plugin.json
new file mode 100644
index 000000000..ddb3f8160
--- /dev/null
+++ b/extensions/mattermost/clawdbot.plugin.json
@@ -0,0 +1,11 @@
+{
+ "id": "mattermost",
+ "channels": [
+ "mattermost"
+ ],
+ "configSchema": {
+ "type": "object",
+ "additionalProperties": false,
+ "properties": {}
+ }
+}
diff --git a/extensions/mattermost/index.ts b/extensions/mattermost/index.ts
new file mode 100644
index 000000000..f3bf17ad5
--- /dev/null
+++ b/extensions/mattermost/index.ts
@@ -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;
diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json
new file mode 100644
index 000000000..f98f3c446
--- /dev/null
+++ b/extensions/mattermost/package.json
@@ -0,0 +1,25 @@
+{
+ "name": "@clawdbot/mattermost",
+ "version": "2026.1.20-2",
+ "type": "module",
+ "description": "Clawdbot Mattermost channel plugin",
+ "clawdbot": {
+ "extensions": [
+ "./index.ts"
+ ],
+ "channel": {
+ "id": "mattermost",
+ "label": "Mattermost",
+ "selectionLabel": "Mattermost (plugin)",
+ "docsPath": "/channels/mattermost",
+ "docsLabel": "mattermost",
+ "blurb": "self-hosted Slack-style chat; install the plugin to enable.",
+ "order": 65
+ },
+ "install": {
+ "npmSpec": "@clawdbot/mattermost",
+ "localPath": "extensions/mattermost",
+ "defaultChoice": "npm"
+ }
+ }
+}
diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts
new file mode 100644
index 000000000..b365fc61e
--- /dev/null
+++ b/extensions/mattermost/src/channel.ts
@@ -0,0 +1,280 @@
+import {
+ applyAccountNameToChannelSection,
+ buildChannelConfigSchema,
+ DEFAULT_ACCOUNT_ID,
+ deleteAccountFromConfigSection,
+ migrateBaseNameToDefaultAccount,
+ normalizeAccountId,
+ setAccountEnabledInConfigSection,
+ type ChannelPlugin,
+} from "clawdbot/plugin-sdk";
+
+import { MattermostConfigSchema } from "./config-schema.js";
+import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
+import {
+ looksLikeMattermostTargetId,
+ normalizeMattermostMessagingTarget,
+} from "./normalize.js";
+import { mattermostOnboardingAdapter } from "./onboarding.js";
+import {
+ listMattermostAccountIds,
+ resolveDefaultMattermostAccountId,
+ resolveMattermostAccount,
+ type ResolvedMattermostAccount,
+} from "./mattermost/accounts.js";
+import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
+import { monitorMattermostProvider } from "./mattermost/monitor.js";
+import { probeMattermost } from "./mattermost/probe.js";
+import { sendMessageMattermost } from "./mattermost/send.js";
+import { getMattermostRuntime } from "./runtime.js";
+
+const meta = {
+ id: "mattermost",
+ label: "Mattermost",
+ selectionLabel: "Mattermost (plugin)",
+ detailLabel: "Mattermost Bot",
+ docsPath: "/channels/mattermost",
+ docsLabel: "mattermost",
+ blurb: "self-hosted Slack-style chat; install the plugin to enable.",
+ systemImage: "bubble.left.and.bubble.right",
+ order: 65,
+} as const;
+
+export const mattermostPlugin: ChannelPlugin = {
+ 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: "",
+ },
+ },
+ 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 ",
+ ),
+ };
+ }
+ return { ok: true, to: trimmed };
+ },
+ sendText: async ({ to, text, accountId, deps, replyToId }) => {
+ const send = deps?.sendMattermost ?? 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 ?? 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 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 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 }),
+ });
+ },
+ },
+};
diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts
new file mode 100644
index 000000000..3cbecaf34
--- /dev/null
+++ b/extensions/mattermost/src/config-schema.ts
@@ -0,0 +1,24 @@
+import { z } from "zod";
+
+import { BlockStreamingCoalesceSchema } from "clawdbot/plugin-sdk";
+
+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(),
+});
diff --git a/extensions/mattermost/src/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts
new file mode 100644
index 000000000..773e655ff
--- /dev/null
+++ b/extensions/mattermost/src/group-mentions.ts
@@ -0,0 +1,14 @@
+import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
+
+import { resolveMattermostAccount } from "./mattermost/accounts.js";
+
+export function resolveMattermostGroupRequireMention(
+ params: ChannelGroupContext,
+): boolean | undefined {
+ const account = resolveMattermostAccount({
+ cfg: params.cfg,
+ accountId: params.accountId,
+ });
+ if (typeof account.requireMention === "boolean") return account.requireMention;
+ return true;
+}
diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts
new file mode 100644
index 000000000..e75f34593
--- /dev/null
+++ b/extensions/mattermost/src/mattermost/accounts.ts
@@ -0,0 +1,115 @@
+import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
+import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
+
+import type { MattermostAccountConfig, MattermostChatMode } from "../types.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);
+}
diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts
new file mode 100644
index 000000000..6b63f830f
--- /dev/null
+++ b/extensions/mattermost/src/mattermost/client.ts
@@ -0,0 +1,208 @@
+export type MattermostClient = {
+ baseUrl: string;
+ apiBaseUrl: string;
+ token: string;
+ request: (path: string, init?: RequestInit) => Promise;
+};
+
+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 | 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 {
+ 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 (path: string, init?: RequestInit): Promise => {
+ 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 {
+ return await client.request("/users/me");
+}
+
+export async function fetchMattermostUser(
+ client: MattermostClient,
+ userId: string,
+): Promise {
+ return await client.request(`/users/${userId}`);
+}
+
+export async function fetchMattermostUserByUsername(
+ client: MattermostClient,
+ username: string,
+): Promise {
+ return await client.request(`/users/username/${encodeURIComponent(username)}`);
+}
+
+export async function fetchMattermostChannel(
+ client: MattermostClient,
+ channelId: string,
+): Promise {
+ return await client.request(`/channels/${channelId}`);
+}
+
+export async function sendMattermostTyping(
+ client: MattermostClient,
+ params: { channelId: string; parentId?: string },
+): Promise {
+ const payload: Record = {
+ channel_id: params.channelId,
+ };
+ const parentId = params.parentId?.trim();
+ if (parentId) payload.parent_id = parentId;
+ await client.request>("/users/me/typing", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+}
+
+export async function createMattermostDirectChannel(
+ client: MattermostClient,
+ userIds: string[],
+): Promise {
+ return await client.request("/channels/direct", {
+ method: "POST",
+ body: JSON.stringify(userIds),
+ });
+}
+
+export async function createMattermostPost(
+ client: MattermostClient,
+ params: {
+ channelId: string;
+ message: string;
+ rootId?: string;
+ fileIds?: string[];
+ },
+): Promise {
+ const payload: Record = {
+ channel_id: params.channelId,
+ message: params.message,
+ };
+ if (params.rootId) payload.root_id = params.rootId;
+ if (params.fileIds?.length) {
+ (payload as Record).file_ids = params.fileIds;
+ }
+ return await client.request("/posts", {
+ method: "POST",
+ body: JSON.stringify(payload),
+ });
+}
+
+export async function uploadMattermostFile(
+ client: MattermostClient,
+ params: {
+ channelId: string;
+ buffer: Buffer;
+ fileName: string;
+ contentType?: string;
+ },
+): Promise {
+ 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;
+}
diff --git a/extensions/mattermost/src/mattermost/index.ts b/extensions/mattermost/src/mattermost/index.ts
new file mode 100644
index 000000000..9d09fc402
--- /dev/null
+++ b/extensions/mattermost/src/mattermost/index.ts
@@ -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";
diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts
new file mode 100644
index 000000000..8c68a4f25
--- /dev/null
+++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts
@@ -0,0 +1,150 @@
+import { Buffer } from "node:buffer";
+
+import type WebSocket from "ws";
+
+import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
+
+export type ResponsePrefixContext = {
+ model?: string;
+ modelFull?: string;
+ provider?: string;
+ thinkingLevel?: string;
+ identityName?: string;
+};
+
+export function extractShortModelName(fullModel: string): string {
+ const slash = fullModel.lastIndexOf("/");
+ const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel;
+ return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, "");
+}
+
+export function formatInboundFromLabel(params: {
+ isGroup: boolean;
+ groupLabel?: string;
+ groupId?: string;
+ directLabel: string;
+ directId?: string;
+ groupFallback?: string;
+}): string {
+ if (params.isGroup) {
+ const label = params.groupLabel?.trim() || params.groupFallback || "Group";
+ const id = params.groupId?.trim();
+ return id ? `${label} id:${id}` : label;
+ }
+
+ const directLabel = params.directLabel.trim();
+ const directId = params.directId?.trim();
+ if (!directId || directId === directLabel) return directLabel;
+ return `${directLabel} id:${directId}`;
+}
+
+type DedupeCache = {
+ check: (key: string | undefined | null, now?: number) => boolean;
+};
+
+export function createDedupeCache(options: { ttlMs: number; maxSize: number }): DedupeCache {
+ const ttlMs = Math.max(0, options.ttlMs);
+ const maxSize = Math.max(0, Math.floor(options.maxSize));
+ const cache = new Map();
+
+ const touch = (key: string, now: number) => {
+ cache.delete(key);
+ cache.set(key, now);
+ };
+
+ const prune = (now: number) => {
+ const cutoff = ttlMs > 0 ? now - ttlMs : undefined;
+ if (cutoff !== undefined) {
+ for (const [entryKey, entryTs] of cache) {
+ if (entryTs < cutoff) {
+ cache.delete(entryKey);
+ }
+ }
+ }
+ if (maxSize <= 0) {
+ cache.clear();
+ return;
+ }
+ while (cache.size > maxSize) {
+ const oldestKey = cache.keys().next().value as string | undefined;
+ if (!oldestKey) break;
+ cache.delete(oldestKey);
+ }
+ };
+
+ return {
+ check: (key, now = Date.now()) => {
+ if (!key) return false;
+ const existing = cache.get(key);
+ if (existing !== undefined && (ttlMs <= 0 || now - existing < ttlMs)) {
+ touch(key, now);
+ return true;
+ }
+ touch(key, now);
+ prune(now);
+ return false;
+ },
+ };
+}
+
+export function rawDataToString(
+ data: WebSocket.RawData,
+ encoding: BufferEncoding = "utf8",
+): string {
+ if (typeof data === "string") return data;
+ if (Buffer.isBuffer(data)) return data.toString(encoding);
+ if (Array.isArray(data)) return Buffer.concat(data).toString(encoding);
+ if (data instanceof ArrayBuffer) {
+ return Buffer.from(data).toString(encoding);
+ }
+ return Buffer.from(String(data)).toString(encoding);
+}
+
+function normalizeAgentId(value: string | undefined | null): string {
+ const trimmed = (value ?? "").trim();
+ if (!trimmed) return "main";
+ if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
+ return (
+ trimmed
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, "-")
+ .replace(/^-+/, "")
+ .replace(/-+$/, "")
+ .slice(0, 64) || "main"
+ );
+}
+
+type AgentEntry = NonNullable["list"]>[number];
+
+function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
+ const list = cfg.agents?.list;
+ if (!Array.isArray(list)) return [];
+ return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
+}
+
+function resolveAgentEntry(cfg: ClawdbotConfig, agentId: string): AgentEntry | undefined {
+ const id = normalizeAgentId(agentId);
+ return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
+}
+
+export function resolveIdentityName(cfg: ClawdbotConfig, agentId: string): string | undefined {
+ const entry = resolveAgentEntry(cfg, agentId);
+ return entry?.identity?.name?.trim() || undefined;
+}
+
+export function resolveThreadSessionKeys(params: {
+ baseSessionKey: string;
+ threadId?: string | null;
+ parentSessionKey?: string;
+ useSuffix?: boolean;
+}): { sessionKey: string; parentSessionKey?: string } {
+ const threadId = (params.threadId ?? "").trim();
+ if (!threadId) {
+ return { sessionKey: params.baseSessionKey, parentSessionKey: undefined };
+ }
+ const useSuffix = params.useSuffix ?? true;
+ const sessionKey = useSuffix
+ ? `${params.baseSessionKey}:thread:${threadId}`
+ : params.baseSessionKey;
+ return { sessionKey, parentSessionKey: params.parentSessionKey };
+}
diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts
new file mode 100644
index 000000000..7c0d98fca
--- /dev/null
+++ b/extensions/mattermost/src/mattermost/monitor.ts
@@ -0,0 +1,763 @@
+import WebSocket from "ws";
+
+import type {
+ ChannelAccountSnapshot,
+ ClawdbotConfig,
+ ReplyPayload,
+ RuntimeEnv,
+} from "clawdbot/plugin-sdk";
+import {
+ buildPendingHistoryContextFromMap,
+ clearHistoryEntries,
+ DEFAULT_GROUP_HISTORY_LIMIT,
+ recordPendingHistoryEntry,
+ resolveChannelMediaMaxBytes,
+ type HistoryEntry,
+} from "clawdbot/plugin-sdk";
+
+import { getMattermostRuntime } from "../runtime.js";
+import { resolveMattermostAccount } from "./accounts.js";
+import {
+ createMattermostClient,
+ fetchMattermostChannel,
+ fetchMattermostMe,
+ fetchMattermostUser,
+ normalizeMattermostBaseUrl,
+ sendMattermostTyping,
+ type MattermostChannel,
+ type MattermostPost,
+ type MattermostUser,
+} from "./client.js";
+import {
+ createDedupeCache,
+ extractShortModelName,
+ formatInboundFromLabel,
+ rawDataToString,
+ resolveIdentityName,
+ resolveThreadSessionKeys,
+ type ResponsePrefixContext,
+} from "./monitor-helpers.js";
+import { sendMessageMattermost } from "./send.js";
+
+export type MonitorMattermostOpts = {
+ botToken?: string;
+ baseUrl?: string;
+ accountId?: string;
+ config?: ClawdbotConfig;
+ runtime?: RuntimeEnv;
+ abortSignal?: AbortSignal;
+ statusSink?: (patch: Partial) => void;
+};
+
+type FetchLike = typeof fetch;
+type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
+
+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 ``;
+ }
+ const allImages = mediaList.every((media) => media.kind === "image");
+ const label = allImages ? "image" : "file";
+ const suffix = mediaList.length === 1 ? label : `${label}s`;
+ const tag = allImages ? "" : "";
+ 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 {
+ const core = getMattermostRuntime();
+ const runtime = resolveRuntime(opts);
+ const cfg = opts.config ?? core.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();
+ const userCache = new Map();
+ const logger = core.logging.getChildLogger({ module: "mattermost" });
+ const logVerboseMessage = (message: string) => {
+ if (!core.logging.shouldLogVerbose()) return;
+ logger.debug?.(message);
+ };
+ 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();
+
+ 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 => {
+ 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 core.channel.media.fetchRemoteMedia({
+ url: `${client.apiBaseUrl}/files/${fileId}`,
+ fetchImpl: fetchWithAuth,
+ filePathHint: fileId,
+ maxBytes: mediaMaxBytes,
+ });
+ const saved = await core.channel.media.saveMediaBuffer(
+ fetched.buffer,
+ fetched.contentType ?? undefined,
+ "inbound",
+ mediaMaxBytes,
+ );
+ const contentType = saved.contentType ?? fetched.contentType ?? undefined;
+ out.push({
+ path: saved.path,
+ contentType,
+ kind: core.media.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 => {
+ 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 => {
+ 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 = core.channel.routing.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 = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
+ const rawText = post.message?.trim() || "";
+ const wasMentioned =
+ kind !== "dm" &&
+ ((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
+ core.channel.mentions.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 = core.channel.commands.shouldHandleTextCommands({
+ cfg,
+ surface: "mattermost",
+ });
+ const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
+ const oncharEnabled = account.chatmode === "onchar" && kind !== "dm";
+ const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
+ const oncharResult = oncharEnabled
+ ? 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;
+
+ core.channel.activity.record({
+ 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}`;
+ core.system.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 = core.channel.reply.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) =>
+ core.channel.reply.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 = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
+ useAccessGroups: cfg.commands?.useAccessGroups ?? false,
+ authorizers: [],
+ });
+ const ctxPayload = core.channel.reply.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 = core.channel.session.resolveStorePath(sessionCfg?.store, {
+ agentId: route.agentId,
+ });
+ await core.channel.session.updateLastRoute({
+ storePath,
+ sessionKey: route.mainSessionKey,
+ deliveryContext: {
+ channel: "mattermost",
+ to,
+ accountId: route.accountId,
+ },
+ });
+ }
+
+ const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
+ logVerboseMessage(
+ `mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
+ );
+
+ const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
+ fallbackLimit: account.textChunkLimit ?? 4000,
+ });
+
+ let prefixContext: ResponsePrefixContext = {
+ identityName: resolveIdentityName(cfg, route.agentId),
+ };
+
+ const { dispatcher, replyOptions, markDispatchIdle } =
+ core.channel.reply.createReplyDispatcherWithTyping({
+ responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
+ .responsePrefix,
+ responsePrefixContextProvider: () => prefixContext,
+ humanDelay: core.channel.reply.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 = core.channel.text.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?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
+ },
+ onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
+ });
+
+ await core.channel.reply.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 = core.channel.debounce.resolveInboundDebounceMs({
+ cfg,
+ channel: "mattermost",
+ });
+ const debouncer = core.channel.debounce.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 !core.channel.text.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?.(`mattermost debounce flush failed: ${String(err)}`);
+ },
+ });
+
+ const wsUrl = buildMattermostWsUrl(baseUrl);
+ let seq = 1;
+
+ const connectOnce = async (): Promise => {
+ 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?.(`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?.(`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));
+ }
+}
diff --git a/extensions/mattermost/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts
new file mode 100644
index 000000000..c0fa8ae63
--- /dev/null
+++ b/extensions/mattermost/src/mattermost/probe.ts
@@ -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 {
+ 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 {
+ 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);
+ }
+}
diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts
new file mode 100644
index 000000000..f5b22c768
--- /dev/null
+++ b/extensions/mattermost/src/mattermost/send.ts
@@ -0,0 +1,208 @@
+import { getMattermostRuntime } from "../runtime.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();
+const userByNameCache = new Map();
+
+const getCore = () => getMattermostRuntime();
+
+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 {
+ 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 {
+ 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 {
+ 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 {
+ const core = getCore();
+ const logger = core.logging.getChildLogger({ module: "mattermost" });
+ const cfg = core.config.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 core.media.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 (core.logging.shouldLogVerbose()) {
+ logger.debug?.(
+ `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,
+ });
+
+ core.channel.activity.record({
+ channel: "mattermost",
+ accountId: account.accountId,
+ direction: "outbound",
+ });
+
+ return {
+ messageId: post.id ?? "unknown",
+ channelId,
+ };
+}
diff --git a/extensions/mattermost/src/normalize.ts b/extensions/mattermost/src/normalize.ts
new file mode 100644
index 000000000..80366420f
--- /dev/null
+++ b/extensions/mattermost/src/normalize.ts
@@ -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);
+}
diff --git a/extensions/mattermost/src/onboarding-helpers.ts b/extensions/mattermost/src/onboarding-helpers.ts
new file mode 100644
index 000000000..8a5d1f585
--- /dev/null
+++ b/extensions/mattermost/src/onboarding-helpers.ts
@@ -0,0 +1,42 @@
+import type { ClawdbotConfig, WizardPrompter } from "clawdbot/plugin-sdk";
+import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
+
+type PromptAccountIdParams = {
+ cfg: ClawdbotConfig;
+ prompter: WizardPrompter;
+ label: string;
+ currentId?: string;
+ listAccountIds: (cfg: ClawdbotConfig) => string[];
+ defaultAccountId: string;
+};
+
+export async function promptAccountId(params: PromptAccountIdParams): Promise {
+ const existingIds = params.listAccountIds(params.cfg);
+ const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
+ const choice = (await params.prompter.select({
+ message: `${params.label} account`,
+ options: [
+ ...existingIds.map((id) => ({
+ value: id,
+ label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
+ })),
+ { value: "__new__", label: "Add a new account" },
+ ],
+ initialValue: initial,
+ })) as string;
+
+ if (choice !== "__new__") return normalizeAccountId(choice);
+
+ const entered = await params.prompter.text({
+ message: `New ${params.label} account id`,
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ });
+ const normalized = normalizeAccountId(String(entered));
+ if (String(entered).trim() !== normalized) {
+ await params.prompter.note(
+ `Normalized account id to "${normalized}".`,
+ `${params.label} account`,
+ );
+ }
+ return normalized;
+}
diff --git a/extensions/mattermost/src/onboarding.ts b/extensions/mattermost/src/onboarding.ts
new file mode 100644
index 000000000..431c648ae
--- /dev/null
+++ b/extensions/mattermost/src/onboarding.ts
@@ -0,0 +1,187 @@
+import type { ChannelOnboardingAdapter, ClawdbotConfig, WizardPrompter } from "clawdbot/plugin-sdk";
+import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
+
+import {
+ listMattermostAccountIds,
+ resolveDefaultMattermostAccountId,
+ resolveMattermostAccount,
+} from "./mattermost/accounts.js";
+import { promptAccountId } from "./onboarding-helpers.js";
+
+const channel = "mattermost" as const;
+
+async function noteMattermostSetup(prompter: WizardPrompter): Promise {
+ 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: https://docs.clawd.bot/channels/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 },
+ },
+ }),
+};
diff --git a/extensions/mattermost/src/runtime.ts b/extensions/mattermost/src/runtime.ts
new file mode 100644
index 000000000..3d0ad283f
--- /dev/null
+++ b/extensions/mattermost/src/runtime.ts
@@ -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;
+}
diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts
new file mode 100644
index 000000000..43e509763
--- /dev/null
+++ b/extensions/mattermost/src/types.ts
@@ -0,0 +1,40 @@
+import type { BlockStreamingCoalesceConfig } from "clawdbot/plugin-sdk";
+
+export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
+
+export type MattermostAccountConfig = {
+ /** Optional display name for this account (used in CLI/UI lists). */
+ name?: string;
+ /** Optional provider capability tags used for agent/runtime guidance. */
+ capabilities?: string[];
+ /** Allow channel-initiated config writes (default: true). */
+ configWrites?: boolean;
+ /** If false, do not start this Mattermost account. Default: true. */
+ enabled?: boolean;
+ /** Bot token for Mattermost. */
+ botToken?: string;
+ /** Base URL for the Mattermost server (e.g., https://chat.example.com). */
+ baseUrl?: string;
+ /**
+ * Controls when channel messages trigger replies.
+ * - "oncall": only respond when mentioned
+ * - "onmessage": respond to every channel message
+ * - "onchar": respond when a trigger character prefixes the message
+ */
+ chatmode?: MattermostChatMode;
+ /** Prefix characters that trigger onchar mode (default: [">", "!"]). */
+ oncharPrefixes?: string[];
+ /** Require @mention to respond in channels. Default: true. */
+ requireMention?: boolean;
+ /** Outbound text chunk size (chars). Default: 4000. */
+ textChunkLimit?: number;
+ /** Disable block streaming for this account. */
+ blockStreaming?: boolean;
+ /** Merge streamed block replies before sending. */
+ blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
+};
+
+export type MattermostConfig = {
+ /** Optional per-account Mattermost configuration (multi-account). */
+ accounts?: Record;
+} & MattermostAccountConfig;
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index f66d3c25f..529005132 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -321,6 +321,8 @@ importers:
specifier: ^11.10.6
version: 11.10.6
+ extensions/mattermost: {}
+
extensions/memory-core:
dependencies:
clawdbot:
diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts
index 7394fa30f..820b53bf0 100644
--- a/src/commands/channels/resolve.ts
+++ b/src/commands/channels/resolve.ts
@@ -35,7 +35,7 @@ function detectAutoKind(input: string): ChannelResolveKind {
if (!trimmed) return "group";
if (trimmed.startsWith("@")) return "user";
if (/^<@!?/.test(trimmed)) return "user";
- if (/^(user|discord|slack|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";
diff --git a/src/config/io.ts b/src/config/io.ts
index 6994e4485..03b9583cf 100644
--- a/src/config/io.ts
+++ b/src/config/io.ts
@@ -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",
];
diff --git a/src/config/legacy.migrations.part-1.ts b/src/config/legacy.migrations.part-1.ts
index f537c3ce8..4b38a4be5 100644
--- a/src/config/legacy.migrations.part-1.ts
+++ b/src/config/legacy.migrations.part-1.ts
@@ -124,6 +124,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
"telegram",
"discord",
"slack",
+ "mattermost",
"signal",
"imessage",
"msteams",
diff --git a/src/config/legacy.rules.ts b/src/config/legacy.rules.ts
index 1ec76bc79..388083ae7 100644
--- a/src/config/legacy.rules.ts
+++ b/src/config/legacy.rules.ts
@@ -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).",
diff --git a/src/config/schema.ts b/src/config/schema.ts
index 5404e6538..79a03c8e6 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -279,6 +279,7 @@ const FIELD_LABELS: Record = {
"channels.telegram.customCommands": "Telegram Custom Commands",
"channels.discord": "Discord",
"channels.slack": "Slack",
+ "channels.mattermost": "Mattermost",
"channels.signal": "Signal",
"channels.imessage": "iMessage",
"channels.bluebubbles": "BlueBubbles",
@@ -316,6 +317,11 @@ const FIELD_LABELS: Record = {
"channels.slack.userTokenReadOnly": "Slack User Token Read Only",
"channels.slack.thread.historyScope": "Slack Thread History Scope",
"channels.slack.thread.inheritParent": "Slack Thread Parent Inheritance",
+ "channels.mattermost.botToken": "Mattermost Bot Token",
+ "channels.mattermost.baseUrl": "Mattermost Base URL",
+ "channels.mattermost.chatmode": "Mattermost Chat Mode",
+ "channels.mattermost.oncharPrefixes": "Mattermost Onchar Prefixes",
+ "channels.mattermost.requireMention": "Mattermost Require Mention",
"channels.signal.account": "Signal Account",
"channels.imessage.cliPath": "iMessage CLI Path",
"agents.list[].identity.avatar": "Agent Avatar",
@@ -429,6 +435,15 @@ const FIELD_HELP: Record = {
'Scope for Slack thread history context ("thread" isolates per thread; "channel" reuses channel history).',
"channels.slack.thread.inheritParent":
"If true, Slack thread sessions inherit the parent channel transcript (default: false).",
+ "channels.mattermost.botToken":
+ "Bot token from Mattermost System Console -> Integrations -> Bot Accounts.",
+ "channels.mattermost.baseUrl":
+ "Base URL for your Mattermost server (e.g., https://chat.example.com).",
+ "channels.mattermost.chatmode":
+ 'Reply to channel messages on mention ("oncall"), on trigger chars (">" or "!") ("onchar"), or on every message ("onmessage").',
+ "channels.mattermost.oncharPrefixes": 'Trigger prefixes for onchar mode (default: [">", "!"]).',
+ "channels.mattermost.requireMention":
+ "Require @mention in channels before responding (default: true).",
"auth.profiles": "Named auth profiles (provider + mode + optional email).",
"auth.order": "Ordered auth profile IDs per provider (used for automatic failover).",
"auth.cooldowns.billingBackoffHours":
@@ -550,6 +565,8 @@ const FIELD_HELP: Record = {
"Allow Telegram to write config in response to channel events/commands (default: true).",
"channels.slack.configWrites":
"Allow Slack to write config in response to channel events/commands (default: true).",
+ "channels.mattermost.configWrites":
+ "Allow Mattermost to write config in response to channel events/commands (default: true).",
"channels.discord.configWrites":
"Allow Discord to write config in response to channel events/commands (default: true).",
"channels.whatsapp.configWrites":
@@ -624,6 +641,7 @@ const FIELD_PLACEHOLDERS: Record = {
"gateway.remote.tlsFingerprint": "sha256:ab12cd34…",
"gateway.remote.sshTarget": "user@host",
"gateway.controlUi.basePath": "/clawdbot",
+ "channels.mattermost.baseUrl": "https://chat.example.com",
"agents.list[].identity.avatar": "avatars/clawd.png",
};
diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts
index d4bac779c..491cc1003 100644
--- a/src/config/types.agent-defaults.ts
+++ b/src/config/types.agent-defaults.ts
@@ -177,13 +177,14 @@ export type AgentDefaultsConfig = {
model?: string;
/** Session key for heartbeat runs ("main" or explicit session key). */
session?: string;
- /** Delivery target (last|whatsapp|telegram|discord|slack|msteams|signal|imessage|none). */
+ /** Delivery target (last|whatsapp|telegram|discord|slack|mattermost|msteams|signal|imessage|none). */
target?:
| "last"
| "whatsapp"
| "telegram"
| "discord"
| "slack"
+ | "mattermost"
| "msteams"
| "signal"
| "imessage"
diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts
index ac98e20de..19ac014dd 100644
--- a/src/config/types.channels.ts
+++ b/src/config/types.channels.ts
@@ -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;
diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts
index 03e9250b2..2a5bf0f2f 100644
--- a/src/config/types.hooks.ts
+++ b/src/config/types.hooks.ts
@@ -24,6 +24,7 @@ export type HookMappingConfig = {
| "telegram"
| "discord"
| "slack"
+ | "mattermost"
| "signal"
| "imessage"
| "msteams";
diff --git a/src/config/types.mattermost.ts b/src/config/types.mattermost.ts
new file mode 100644
index 000000000..b87bdfabe
--- /dev/null
+++ b/src/config/types.mattermost.ts
@@ -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;
+} & MattermostAccountConfig;
diff --git a/src/config/types.queue.ts b/src/config/types.queue.ts
index 0afeb5232..6289e7c56 100644
--- a/src/config/types.queue.ts
+++ b/src/config/types.queue.ts
@@ -13,6 +13,7 @@ export type QueueModeByProvider = {
telegram?: QueueMode;
discord?: QueueMode;
slack?: QueueMode;
+ mattermost?: QueueMode;
signal?: QueueMode;
imessage?: QueueMode;
msteams?: QueueMode;
diff --git a/src/config/types.ts b/src/config/types.ts
index 368618262..46e79eaca 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -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";
diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts
index d34165907..66664a981 100644
--- a/src/config/zod-schema.agent-runtime.ts
+++ b/src/config/zod-schema.agent-runtime.ts
@@ -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"),
diff --git a/src/config/zod-schema.core.ts b/src/config/zod-schema.core.ts
index a60d2434d..01427ab86 100644
--- a/src/config/zod-schema.core.ts
+++ b/src/config/zod-schema.core.ts
@@ -209,6 +209,7 @@ export const QueueModeBySurfaceSchema = z
telegram: QueueModeSchema.optional(),
discord: QueueModeSchema.optional(),
slack: QueueModeSchema.optional(),
+ mattermost: QueueModeSchema.optional(),
signal: QueueModeSchema.optional(),
imessage: QueueModeSchema.optional(),
msteams: QueueModeSchema.optional(),
diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts
index 140e861dd..9153aa130 100644
--- a/src/config/zod-schema.hooks.ts
+++ b/src/config/zod-schema.hooks.ts
@@ -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"),
diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts
index 68806c61f..96bd04e10 100644
--- a/src/config/zod-schema.providers-core.ts
+++ b/src/config/zod-schema.providers-core.ts
@@ -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(),
diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts
index a58119702..aa5eb7737 100644
--- a/src/config/zod-schema.providers.ts
+++ b/src/config/zod-schema.providers.ts
@@ -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(),
diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts
index 21fffe807..2d874d7e9 100644
--- a/src/infra/outbound/deliver.ts
+++ b/src/infra/outbound/deliver.ts
@@ -28,11 +28,18 @@ type SendMatrixMessage = (
opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number },
) => Promise<{ messageId: string; roomId: string }>;
+type SendMattermostMessage = (
+ to: string,
+ text: string,
+ opts?: { accountId?: string; mediaUrl?: string; replyToId?: string },
+) => Promise<{ messageId: string; channelId: string }>;
+
export type OutboundSendDeps = {
sendWhatsApp?: typeof sendMessageWhatsApp;
sendTelegram?: typeof sendMessageTelegram;
sendDiscord?: typeof sendMessageDiscord;
sendSlack?: typeof sendMessageSlack;
+ sendMattermost?: SendMattermostMessage;
sendSignal?: typeof sendMessageSignal;
sendIMessage?: typeof sendMessageIMessage;
sendMatrix?: SendMatrixMessage;
diff --git a/src/utils/message-channel.ts b/src/utils/message-channel.ts
index ecd1f713b..c09436ac8 100644
--- a/src/utils/message-channel.ts
+++ b/src/utils/message-channel.ts
@@ -22,6 +22,7 @@ const MARKDOWN_CAPABLE_CHANNELS = new Set([
"telegram",
"signal",
"discord",
+ "mattermost",
"tui",
INTERNAL_MESSAGE_CHANNEL,
]);
diff --git a/ui/src/ui/types.ts b/ui/src/ui/types.ts
index be278b8e5..8fb44c485 100644
--- a/ui/src/ui/types.ts
+++ b/ui/src/ui/types.ts
@@ -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";
diff --git a/ui/src/ui/views/channels.mattermost.ts b/ui/src/ui/views/channels.mattermost.ts
new file mode 100644
index 000000000..c2513ed44
--- /dev/null
+++ b/ui/src/ui/views/channels.mattermost.ts
@@ -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`
+
+ Mattermost
+ Bot token + WebSocket status and configuration.
+ ${accountCountLabel}
+
+
+
+ Configured
+ ${mattermost?.configured ? "Yes" : "No"}
+
+
+ Running
+ ${mattermost?.running ? "Yes" : "No"}
+
+
+ Connected
+ ${mattermost?.connected ? "Yes" : "No"}
+
+
+ Base URL
+ ${mattermost?.baseUrl || "n/a"}
+
+
+ Last start
+ ${mattermost?.lastStartAt ? formatAgo(mattermost.lastStartAt) : "n/a"}
+
+
+ Last probe
+ ${mattermost?.lastProbeAt ? formatAgo(mattermost.lastProbeAt) : "n/a"}
+
+
+
+ ${mattermost?.lastError
+ ? html`
+ ${mattermost.lastError}
+ `
+ : nothing}
+
+ ${mattermost?.probe
+ ? html`
+ Probe ${mattermost.probe.ok ? "ok" : "failed"} -
+ ${mattermost.probe.status ?? ""} ${mattermost.probe.error ?? ""}
+ `
+ : nothing}
+
+ ${renderChannelConfigSection({ channelId: "mattermost", props })}
+
+
+
+
+
+ `;
+}
diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts
index 232cf2c85..d9f148764 100644
--- a/ui/src/ui/views/channels.ts
+++ b/ui/src/ui/views/channels.ts
@@ -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,
@@ -135,6 +139,12 @@ function renderChannel(
slack: data.slack,
accountCountLabel,
});
+ case "mattermost":
+ return renderMattermostCard({
+ props,
+ mattermost: data.mattermost,
+ accountCountLabel,
+ });
case "signal":
return renderSignalCard({
props,
diff --git a/ui/src/ui/views/channels.types.ts b/ui/src/ui/views/channels.types.ts
index 43576d54a..d3a98d44e 100644
--- a/ui/src/ui/views/channels.types.ts
+++ b/ui/src/ui/views/channels.types.ts
@@ -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;