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 36e8fc62b..6a69d7860 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 b1102c47d..5786c696e 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..8ba462f45
--- /dev/null
+++ b/extensions/mattermost/package.json
@@ -0,0 +1,11 @@
+{
+ "name": "@clawdbot/mattermost",
+ "version": "2026.1.20-2",
+ "type": "module",
+ "description": "Clawdbot Mattermost channel plugin",
+ "clawdbot": {
+ "extensions": [
+ "./index.ts"
+ ]
+ }
+}
diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts
new file mode 100644
index 000000000..840772a17
--- /dev/null
+++ b/extensions/mattermost/src/channel.ts
@@ -0,0 +1,270 @@
+import {
+ applyAccountNameToChannelSection,
+ buildChannelConfigSchema,
+ DEFAULT_ACCOUNT_ID,
+ deleteAccountFromConfigSection,
+ getChatChannelMeta,
+ listMattermostAccountIds,
+ looksLikeMattermostTargetId,
+ migrateBaseNameToDefaultAccount,
+ normalizeAccountId,
+ normalizeMattermostBaseUrl,
+ normalizeMattermostMessagingTarget,
+ resolveDefaultMattermostAccountId,
+ resolveMattermostAccount,
+ resolveMattermostGroupRequireMention,
+ setAccountEnabledInConfigSection,
+ mattermostOnboardingAdapter,
+ MattermostConfigSchema,
+ type ChannelPlugin,
+ type ResolvedMattermostAccount,
+} from "clawdbot/plugin-sdk";
+
+import { getMattermostRuntime } from "./runtime.js";
+
+const meta = getChatChannelMeta("mattermost");
+
+export const mattermostPlugin: ChannelPlugin = {
+ 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 ?? getMattermostRuntime().channel.mattermost.sendMessageMattermost;
+ const result = await send(to, text, {
+ accountId: accountId ?? undefined,
+ replyToId: replyToId ?? undefined,
+ });
+ return { channel: "mattermost", ...result };
+ },
+ sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
+ const send =
+ deps?.sendMattermost ?? getMattermostRuntime().channel.mattermost.sendMessageMattermost;
+ const result = await send(to, text, {
+ accountId: accountId ?? undefined,
+ mediaUrl,
+ replyToId: replyToId ?? undefined,
+ });
+ return { channel: "mattermost", ...result };
+ },
+ },
+ status: {
+ defaultRuntime: {
+ accountId: DEFAULT_ACCOUNT_ID,
+ running: false,
+ connected: false,
+ lastConnectedAt: null,
+ lastDisconnect: null,
+ lastStartAt: null,
+ lastStopAt: null,
+ lastError: null,
+ },
+ buildChannelSummary: ({ snapshot }) => ({
+ configured: snapshot.configured ?? false,
+ botTokenSource: snapshot.botTokenSource ?? "none",
+ running: snapshot.running ?? false,
+ connected: snapshot.connected ?? false,
+ lastStartAt: snapshot.lastStartAt ?? null,
+ lastStopAt: snapshot.lastStopAt ?? null,
+ lastError: snapshot.lastError ?? null,
+ baseUrl: snapshot.baseUrl ?? null,
+ probe: snapshot.probe,
+ lastProbeAt: snapshot.lastProbeAt ?? null,
+ }),
+ probeAccount: async ({ account, timeoutMs }) => {
+ const token = account.botToken?.trim();
+ const baseUrl = account.baseUrl?.trim();
+ if (!token || !baseUrl) {
+ return { ok: false, error: "bot token or baseUrl missing" };
+ }
+ return await getMattermostRuntime().channel.mattermost.probeMattermost(
+ baseUrl,
+ token,
+ timeoutMs,
+ );
+ },
+ buildAccountSnapshot: ({ account, runtime, probe }) => ({
+ accountId: account.accountId,
+ name: account.name,
+ enabled: account.enabled,
+ configured: Boolean(account.botToken && account.baseUrl),
+ botTokenSource: account.botTokenSource,
+ baseUrl: account.baseUrl,
+ running: runtime?.running ?? false,
+ connected: runtime?.connected ?? false,
+ lastConnectedAt: runtime?.lastConnectedAt ?? null,
+ lastDisconnect: runtime?.lastDisconnect ?? null,
+ lastStartAt: runtime?.lastStartAt ?? null,
+ lastStopAt: runtime?.lastStopAt ?? null,
+ lastError: runtime?.lastError ?? null,
+ probe,
+ lastInboundAt: runtime?.lastInboundAt ?? null,
+ lastOutboundAt: runtime?.lastOutboundAt ?? null,
+ }),
+ },
+ setup: {
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
+ applyAccountName: ({ cfg, accountId, name }) =>
+ applyAccountNameToChannelSection({
+ cfg,
+ channelKey: "mattermost",
+ accountId,
+ name,
+ }),
+ validateInput: ({ accountId, input }) => {
+ if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
+ return "Mattermost env vars can only be used for the default account.";
+ }
+ const token = input.botToken ?? input.token;
+ const baseUrl = input.httpUrl;
+ if (!input.useEnv && (!token || !baseUrl)) {
+ return "Mattermost requires --bot-token and --http-url (or --use-env).";
+ }
+ if (baseUrl && !normalizeMattermostBaseUrl(baseUrl)) {
+ return "Mattermost --http-url must include a valid base URL.";
+ }
+ return null;
+ },
+ applyAccountConfig: ({ cfg, accountId, input }) => {
+ const token = input.botToken ?? input.token;
+ const baseUrl = input.httpUrl?.trim();
+ const namedConfig = applyAccountNameToChannelSection({
+ cfg,
+ channelKey: "mattermost",
+ accountId,
+ name: input.name,
+ });
+ const next =
+ accountId !== DEFAULT_ACCOUNT_ID
+ ? migrateBaseNameToDefaultAccount({
+ cfg: namedConfig,
+ channelKey: "mattermost",
+ })
+ : namedConfig;
+ if (accountId === DEFAULT_ACCOUNT_ID) {
+ return {
+ ...next,
+ channels: {
+ ...next.channels,
+ mattermost: {
+ ...next.channels?.mattermost,
+ enabled: true,
+ ...(input.useEnv
+ ? {}
+ : {
+ ...(token ? { botToken: token } : {}),
+ ...(baseUrl ? { baseUrl } : {}),
+ }),
+ },
+ },
+ };
+ }
+ return {
+ ...next,
+ channels: {
+ ...next.channels,
+ mattermost: {
+ ...next.channels?.mattermost,
+ enabled: true,
+ accounts: {
+ ...next.channels?.mattermost?.accounts,
+ [accountId]: {
+ ...next.channels?.mattermost?.accounts?.[accountId],
+ enabled: true,
+ ...(token ? { botToken: token } : {}),
+ ...(baseUrl ? { baseUrl } : {}),
+ },
+ },
+ },
+ },
+ };
+ },
+ },
+ gateway: {
+ startAccount: async (ctx) => {
+ const account = ctx.account;
+ ctx.setStatus({
+ accountId: account.accountId,
+ baseUrl: account.baseUrl,
+ botTokenSource: account.botTokenSource,
+ });
+ ctx.log?.info(`[${account.accountId}] starting channel`);
+ return getMattermostRuntime().channel.mattermost.monitorMattermostProvider({
+ botToken: account.botToken ?? undefined,
+ baseUrl: account.baseUrl ?? undefined,
+ accountId: account.accountId,
+ config: ctx.cfg,
+ runtime: ctx.runtime,
+ abortSignal: ctx.abortSignal,
+ statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
+ });
+ },
+ },
+};
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/pnpm-lock.yaml b/pnpm-lock.yaml
index c0830488b..7c2c788b2 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -324,6 +324,8 @@ importers:
specifier: ^11.10.6
version: 11.10.6
+ extensions/mattermost: {}
+
extensions/memory-core:
dependencies:
clawdbot:
diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts
index aa2281de6..5a5e1b4e1 100644
--- a/src/auto-reply/reply/get-reply-run.ts
+++ b/src/auto-reply/reply/get-reply-run.ts
@@ -157,7 +157,10 @@ export async function runPreparedReply(
const isFirstTurnInSession = isNewSession || !currentSystemSent;
const isGroupChat = sessionCtx.ChatType === "group";
- const wasMentioned = ctx.WasMentioned === true;
+ const originatingChannel =
+ (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider)?.toString().toLowerCase() ?? "";
+ const wasMentioned =
+ ctx.WasMentioned === true || (originatingChannel === "mattermost" && isGroupChat);
const isHeartbeat = opts?.isHeartbeat === true;
const typingMode = resolveTypingMode({
configured: sessionCfg?.typingMode ?? agentCfg?.typingMode,
diff --git a/src/channels/dock.ts b/src/channels/dock.ts
index 92199a0f2..e6fd3150a 100644
--- a/src/channels/dock.ts
+++ b/src/channels/dock.ts
@@ -11,6 +11,7 @@ import { requireActivePluginRegistry } from "../plugins/runtime.js";
import {
resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention,
+ resolveMattermostGroupRequireMention,
resolveSlackGroupRequireMention,
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
@@ -235,6 +236,30 @@ const DOCKS: Record = {
},
},
},
+ mattermost: {
+ id: "mattermost",
+ capabilities: {
+ chatTypes: ["direct", "channel", "group", "thread"],
+ media: true,
+ threads: true,
+ },
+ outbound: { textChunkLimit: 4000 },
+ streaming: {
+ blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
+ },
+ groups: {
+ resolveRequireMention: resolveMattermostGroupRequireMention,
+ },
+ threading: {
+ buildToolContext: ({ context, hasRepliedRef }) => ({
+ currentChannelId: context.To?.startsWith("channel:")
+ ? context.To.slice("channel:".length)
+ : undefined,
+ currentThreadTs: context.ReplyToId,
+ hasRepliedRef,
+ }),
+ },
+ },
signal: {
id: "signal",
capabilities: {
diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts
index 79dfa0320..bb7a111e8 100644
--- a/src/channels/plugins/group-mentions.ts
+++ b/src/channels/plugins/group-mentions.ts
@@ -1,6 +1,7 @@
import type { ClawdbotConfig } from "../../config/config.js";
import { resolveChannelGroupRequireMention } from "../../config/group-policy.js";
import type { DiscordConfig } from "../../config/types.js";
+import { resolveMattermostAccount } from "../../mattermost/accounts.js";
import { resolveSlackAccount } from "../../slack/accounts.js";
type GroupMentionParams = {
@@ -184,6 +185,15 @@ export function resolveSlackGroupRequireMention(params: GroupMentionParams): boo
return true;
}
+export function resolveMattermostGroupRequireMention(params: GroupMentionParams): boolean {
+ const account = resolveMattermostAccount({
+ cfg: params.cfg,
+ accountId: params.accountId,
+ });
+ if (typeof account.requireMention === "boolean") return account.requireMention;
+ return true;
+}
+
export function resolveBlueBubblesGroupRequireMention(params: GroupMentionParams): boolean {
return resolveChannelGroupRequireMention({
cfg: params.cfg,
diff --git a/src/channels/plugins/normalize/mattermost.ts b/src/channels/plugins/normalize/mattermost.ts
new file mode 100644
index 000000000..80366420f
--- /dev/null
+++ b/src/channels/plugins/normalize/mattermost.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/src/channels/plugins/onboarding/mattermost.ts b/src/channels/plugins/onboarding/mattermost.ts
new file mode 100644
index 000000000..3c7ffe2db
--- /dev/null
+++ b/src/channels/plugins/onboarding/mattermost.ts
@@ -0,0 +1,189 @@
+import type { ClawdbotConfig } from "../../../config/config.js";
+import {
+ listMattermostAccountIds,
+ resolveDefaultMattermostAccountId,
+ resolveMattermostAccount,
+} from "../../../mattermost/accounts.js";
+import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../routing/session-key.js";
+import { formatDocsLink } from "../../../terminal/links.js";
+import type { WizardPrompter } from "../../../wizard/prompts.js";
+import type { ChannelOnboardingAdapter } from "../onboarding-types.js";
+import { promptAccountId } from "./helpers.js";
+
+const channel = "mattermost" as const;
+
+async function noteMattermostSetup(prompter: WizardPrompter): Promise {
+ await prompter.note(
+ [
+ "1) Mattermost System Console -> Integrations -> Bot Accounts",
+ "2) Create a bot + copy its token",
+ "3) Use your server base URL (e.g., https://chat.example.com)",
+ "Tip: the bot must be a member of any channel you want it to monitor.",
+ `Docs: ${formatDocsLink("/channels/mattermost", "mattermost")}`,
+ ].join("\n"),
+ "Mattermost bot token",
+ );
+}
+
+export const mattermostOnboardingAdapter: ChannelOnboardingAdapter = {
+ channel,
+ getStatus: async ({ cfg }) => {
+ const configured = listMattermostAccountIds(cfg).some((accountId) => {
+ const account = resolveMattermostAccount({ cfg, accountId });
+ return Boolean(account.botToken && account.baseUrl);
+ });
+ return {
+ channel,
+ configured,
+ statusLines: [`Mattermost: ${configured ? "configured" : "needs token + url"}`],
+ selectionHint: configured ? "configured" : "needs setup",
+ quickstartScore: configured ? 2 : 1,
+ };
+ },
+ configure: async ({ cfg, prompter, accountOverrides, shouldPromptAccountIds }) => {
+ const override = accountOverrides.mattermost?.trim();
+ const defaultAccountId = resolveDefaultMattermostAccountId(cfg);
+ let accountId = override ? normalizeAccountId(override) : defaultAccountId;
+ if (shouldPromptAccountIds && !override) {
+ accountId = await promptAccountId({
+ cfg,
+ prompter,
+ label: "Mattermost",
+ currentId: accountId,
+ listAccountIds: listMattermostAccountIds,
+ defaultAccountId,
+ });
+ }
+
+ let next = cfg;
+ const resolvedAccount = resolveMattermostAccount({
+ cfg: next,
+ accountId,
+ });
+ const accountConfigured = Boolean(resolvedAccount.botToken && resolvedAccount.baseUrl);
+ const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
+ const canUseEnv =
+ allowEnv &&
+ Boolean(process.env.MATTERMOST_BOT_TOKEN?.trim()) &&
+ Boolean(process.env.MATTERMOST_URL?.trim());
+ const hasConfigValues =
+ Boolean(resolvedAccount.config.botToken) || Boolean(resolvedAccount.config.baseUrl);
+
+ let botToken: string | null = null;
+ let baseUrl: string | null = null;
+
+ if (!accountConfigured) {
+ await noteMattermostSetup(prompter);
+ }
+
+ if (canUseEnv && !hasConfigValues) {
+ const keepEnv = await prompter.confirm({
+ message: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?",
+ initialValue: true,
+ });
+ if (keepEnv) {
+ next = {
+ ...next,
+ channels: {
+ ...next.channels,
+ mattermost: {
+ ...next.channels?.mattermost,
+ enabled: true,
+ },
+ },
+ };
+ } else {
+ botToken = String(
+ await prompter.text({
+ message: "Enter Mattermost bot token",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ ).trim();
+ baseUrl = String(
+ await prompter.text({
+ message: "Enter Mattermost base URL",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ ).trim();
+ }
+ } else if (accountConfigured) {
+ const keep = await prompter.confirm({
+ message: "Mattermost credentials already configured. Keep them?",
+ initialValue: true,
+ });
+ if (!keep) {
+ botToken = String(
+ await prompter.text({
+ message: "Enter Mattermost bot token",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ ).trim();
+ baseUrl = String(
+ await prompter.text({
+ message: "Enter Mattermost base URL",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ ).trim();
+ }
+ } else {
+ botToken = String(
+ await prompter.text({
+ message: "Enter Mattermost bot token",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ ).trim();
+ baseUrl = String(
+ await prompter.text({
+ message: "Enter Mattermost base URL",
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ }),
+ ).trim();
+ }
+
+ if (botToken || baseUrl) {
+ if (accountId === DEFAULT_ACCOUNT_ID) {
+ next = {
+ ...next,
+ channels: {
+ ...next.channels,
+ mattermost: {
+ ...next.channels?.mattermost,
+ enabled: true,
+ ...(botToken ? { botToken } : {}),
+ ...(baseUrl ? { baseUrl } : {}),
+ },
+ },
+ };
+ } else {
+ next = {
+ ...next,
+ channels: {
+ ...next.channels,
+ mattermost: {
+ ...next.channels?.mattermost,
+ enabled: true,
+ accounts: {
+ ...next.channels?.mattermost?.accounts,
+ [accountId]: {
+ ...next.channels?.mattermost?.accounts?.[accountId],
+ enabled: next.channels?.mattermost?.accounts?.[accountId]?.enabled ?? true,
+ ...(botToken ? { botToken } : {}),
+ ...(baseUrl ? { baseUrl } : {}),
+ },
+ },
+ },
+ },
+ };
+ }
+ }
+
+ return { cfg: next, accountId };
+ },
+ disable: (cfg: ClawdbotConfig) => ({
+ ...cfg,
+ channels: {
+ ...cfg.channels,
+ mattermost: { ...cfg.channels?.mattermost, enabled: false },
+ },
+ }),
+};
diff --git a/src/channels/registry.ts b/src/channels/registry.ts
index 52e7a5f01..25fb13502 100644
--- a/src/channels/registry.ts
+++ b/src/channels/registry.ts
@@ -9,6 +9,7 @@ export const CHAT_CHANNEL_ORDER = [
"whatsapp",
"discord",
"slack",
+ "mattermost",
"signal",
"imessage",
] as const;
@@ -67,6 +68,16 @@ const CHAT_CHANNEL_META: Record = {
blurb: "supported (Socket Mode).",
systemImage: "number",
},
+ mattermost: {
+ id: "mattermost",
+ label: "Mattermost",
+ selectionLabel: "Mattermost (Bot Token)",
+ detailLabel: "Mattermost Bot",
+ docsPath: "/channels/mattermost",
+ docsLabel: "mattermost",
+ blurb: "self-hosted Slack-style chat (bot token + URL).",
+ systemImage: "bubble.left.and.bubble.right",
+ },
signal: {
id: "signal",
label: "Signal",
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 d275d3185..34b534285 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 d1d0a57e7..8658b0ece 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 1ba527439..21f461ec0 100644
--- a/src/config/schema.ts
+++ b/src/config/schema.ts
@@ -272,6 +272,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",
@@ -309,6 +310,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",
"plugins.enabled": "Enable Plugins",
@@ -415,6 +421,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":
@@ -532,6 +547,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":
@@ -606,6 +623,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",
};
const SENSITIVE_PATTERNS = [/token/i, /password/i, /secret/i, /api.?key/i];
diff --git a/src/config/types.agent-defaults.ts b/src/config/types.agent-defaults.ts
index 85eff97f2..10066bd75 100644
--- a/src/config/types.agent-defaults.ts
+++ b/src/config/types.agent-defaults.ts
@@ -162,13 +162,14 @@ export type AgentDefaultsConfig = {
every?: string;
/** Heartbeat model override (provider/model). */
model?: 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.messages.ts b/src/config/types.messages.ts
index 691ca617a..fc4146bc7 100644
--- a/src/config/types.messages.ts
+++ b/src/config/types.messages.ts
@@ -22,6 +22,7 @@ export type InboundDebounceByProvider = {
telegram?: number;
discord?: number;
slack?: number;
+ mattermost?: number;
signal?: number;
imessage?: number;
msteams?: number;
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 40dea6eb4..774645a14 100644
--- a/src/config/zod-schema.agent-runtime.ts
+++ b/src/config/zod-schema.agent-runtime.ts
@@ -20,6 +20,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 6e7b34b0d..1d6612aae 100644
--- a/src/config/zod-schema.core.ts
+++ b/src/config/zod-schema.core.ts
@@ -208,6 +208,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(),
@@ -222,6 +223,7 @@ export const DebounceMsBySurfaceSchema = z
telegram: z.number().int().nonnegative().optional(),
discord: z.number().int().nonnegative().optional(),
slack: z.number().int().nonnegative().optional(),
+ mattermost: z.number().int().nonnegative().optional(),
signal: z.number().int().nonnegative().optional(),
imessage: z.number().int().nonnegative().optional(),
msteams: z.number().int().nonnegative().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 906ef5433..200ff18c8 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..74caa18c6 100644
--- a/src/infra/outbound/deliver.ts
+++ b/src/infra/outbound/deliver.ts
@@ -6,6 +6,7 @@ import type { ChannelOutboundAdapter } from "../../channels/plugins/types.js";
import type { ClawdbotConfig } from "../../config/config.js";
import type { sendMessageDiscord } from "../../discord/send.js";
import type { sendMessageIMessage } from "../../imessage/send.js";
+import type { sendMessageMattermost } from "../../mattermost/send.js";
import { markdownToSignalTextChunks, type SignalTextStyleRange } from "../../signal/format.js";
import { sendMessageSignal } from "../../signal/send.js";
import type { sendMessageSlack } from "../../slack/send.js";
@@ -33,6 +34,7 @@ export type OutboundSendDeps = {
sendTelegram?: typeof sendMessageTelegram;
sendDiscord?: typeof sendMessageDiscord;
sendSlack?: typeof sendMessageSlack;
+ sendMattermost?: typeof sendMessageMattermost;
sendSignal?: typeof sendMessageSignal;
sendIMessage?: typeof sendMessageIMessage;
sendMatrix?: SendMatrixMessage;
diff --git a/src/mattermost/accounts.ts b/src/mattermost/accounts.ts
new file mode 100644
index 000000000..08ffa2f94
--- /dev/null
+++ b/src/mattermost/accounts.ts
@@ -0,0 +1,114 @@
+import type { ClawdbotConfig } from "../config/config.js";
+import type { MattermostAccountConfig, MattermostChatMode } from "../config/types.js";
+import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js";
+import { normalizeMattermostBaseUrl } from "./client.js";
+
+export type MattermostTokenSource = "env" | "config" | "none";
+export type MattermostBaseUrlSource = "env" | "config" | "none";
+
+export type ResolvedMattermostAccount = {
+ accountId: string;
+ enabled: boolean;
+ name?: string;
+ botToken?: string;
+ baseUrl?: string;
+ botTokenSource: MattermostTokenSource;
+ baseUrlSource: MattermostBaseUrlSource;
+ config: MattermostAccountConfig;
+ chatmode?: MattermostChatMode;
+ oncharPrefixes?: string[];
+ requireMention?: boolean;
+ textChunkLimit?: number;
+ blockStreaming?: boolean;
+ blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
+};
+
+function listConfiguredAccountIds(cfg: ClawdbotConfig): string[] {
+ const accounts = cfg.channels?.mattermost?.accounts;
+ if (!accounts || typeof accounts !== "object") return [];
+ return Object.keys(accounts).filter(Boolean);
+}
+
+export function listMattermostAccountIds(cfg: ClawdbotConfig): string[] {
+ const ids = listConfiguredAccountIds(cfg);
+ if (ids.length === 0) return [DEFAULT_ACCOUNT_ID];
+ return ids.sort((a, b) => a.localeCompare(b));
+}
+
+export function resolveDefaultMattermostAccountId(cfg: ClawdbotConfig): string {
+ const ids = listMattermostAccountIds(cfg);
+ if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID;
+ return ids[0] ?? DEFAULT_ACCOUNT_ID;
+}
+
+function resolveAccountConfig(
+ cfg: ClawdbotConfig,
+ accountId: string,
+): MattermostAccountConfig | undefined {
+ const accounts = cfg.channels?.mattermost?.accounts;
+ if (!accounts || typeof accounts !== "object") return undefined;
+ return accounts[accountId] as MattermostAccountConfig | undefined;
+}
+
+function mergeMattermostAccountConfig(
+ cfg: ClawdbotConfig,
+ accountId: string,
+): MattermostAccountConfig {
+ const { accounts: _ignored, ...base } = (cfg.channels?.mattermost ??
+ {}) as MattermostAccountConfig & { accounts?: unknown };
+ const account = resolveAccountConfig(cfg, accountId) ?? {};
+ return { ...base, ...account };
+}
+
+function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
+ if (config.chatmode === "oncall") return true;
+ if (config.chatmode === "onmessage") return false;
+ if (config.chatmode === "onchar") return true;
+ return config.requireMention;
+}
+
+export function resolveMattermostAccount(params: {
+ cfg: ClawdbotConfig;
+ accountId?: string | null;
+}): ResolvedMattermostAccount {
+ const accountId = normalizeAccountId(params.accountId);
+ const baseEnabled = params.cfg.channels?.mattermost?.enabled !== false;
+ const merged = mergeMattermostAccountConfig(params.cfg, accountId);
+ const accountEnabled = merged.enabled !== false;
+ const enabled = baseEnabled && accountEnabled;
+
+ const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
+ const envToken = allowEnv ? process.env.MATTERMOST_BOT_TOKEN?.trim() : undefined;
+ const envUrl = allowEnv ? process.env.MATTERMOST_URL?.trim() : undefined;
+ const configToken = merged.botToken?.trim();
+ const configUrl = merged.baseUrl?.trim();
+ const botToken = configToken || envToken;
+ const baseUrl = normalizeMattermostBaseUrl(configUrl || envUrl);
+ const requireMention = resolveMattermostRequireMention(merged);
+
+ const botTokenSource: MattermostTokenSource = configToken ? "config" : envToken ? "env" : "none";
+ const baseUrlSource: MattermostBaseUrlSource = configUrl ? "config" : envUrl ? "env" : "none";
+
+ return {
+ accountId,
+ enabled,
+ name: merged.name?.trim() || undefined,
+ botToken,
+ baseUrl,
+ botTokenSource,
+ baseUrlSource,
+ config: merged,
+ chatmode: merged.chatmode,
+ oncharPrefixes: merged.oncharPrefixes,
+ requireMention,
+ textChunkLimit: merged.textChunkLimit,
+ blockStreaming: merged.blockStreaming,
+ blockStreamingCoalesce: merged.blockStreamingCoalesce,
+ };
+}
+
+export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMattermostAccount[] {
+ return listMattermostAccountIds(cfg)
+ .map((accountId) => resolveMattermostAccount({ cfg, accountId }))
+ .filter((account) => account.enabled);
+}
diff --git a/src/mattermost/client.ts b/src/mattermost/client.ts
new file mode 100644
index 000000000..6b63f830f
--- /dev/null
+++ b/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/src/mattermost/index.ts b/src/mattermost/index.ts
new file mode 100644
index 000000000..9d09fc402
--- /dev/null
+++ b/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/src/mattermost/monitor.ts b/src/mattermost/monitor.ts
new file mode 100644
index 000000000..fb8bd00db
--- /dev/null
+++ b/src/mattermost/monitor.ts
@@ -0,0 +1,774 @@
+import WebSocket from "ws";
+
+import {
+ resolveEffectiveMessagesConfig,
+ resolveHumanDelayConfig,
+ resolveIdentityName,
+} from "../agents/identity.js";
+import { chunkMarkdownText, resolveTextChunkLimit } from "../auto-reply/chunk.js";
+import { hasControlCommand } from "../auto-reply/command-detection.js";
+import { shouldHandleTextCommands } from "../auto-reply/commands-registry.js";
+import { formatInboundEnvelope, formatInboundFromLabel } from "../auto-reply/envelope.js";
+import {
+ createInboundDebouncer,
+ resolveInboundDebounceMs,
+} from "../auto-reply/inbound-debounce.js";
+import { dispatchReplyFromConfig } from "../auto-reply/reply/dispatch-from-config.js";
+import { finalizeInboundContext } from "../auto-reply/reply/inbound-context.js";
+import {
+ buildPendingHistoryContextFromMap,
+ clearHistoryEntries,
+ DEFAULT_GROUP_HISTORY_LIMIT,
+ recordPendingHistoryEntry,
+ type HistoryEntry,
+} from "../auto-reply/reply/history.js";
+import { createReplyDispatcherWithTyping } from "../auto-reply/reply/reply-dispatcher.js";
+import {
+ extractShortModelName,
+ type ResponsePrefixContext,
+} from "../auto-reply/reply/response-prefix-template.js";
+import { buildMentionRegexes, matchesMentionPatterns } from "../auto-reply/reply/mentions.js";
+import type { ReplyPayload } from "../auto-reply/types.js";
+import type { ClawdbotConfig } from "../config/config.js";
+import { loadConfig } from "../config/config.js";
+import { resolveStorePath, updateLastRoute } from "../config/sessions.js";
+import { danger, logVerbose, shouldLogVerbose } from "../globals.js";
+import { createDedupeCache } from "../infra/dedupe.js";
+import { rawDataToString } from "../infra/ws.js";
+import { recordChannelActivity } from "../infra/channel-activity.js";
+import { enqueueSystemEvent } from "../infra/system-events.js";
+import { getChildLogger } from "../logging.js";
+import { mediaKindFromMime, type MediaKind } from "../media/constants.js";
+import { fetchRemoteMedia, type FetchLike } from "../media/fetch.js";
+import { saveMediaBuffer } from "../media/store.js";
+import { resolveAgentRoute } from "../routing/resolve-route.js";
+import { resolveThreadSessionKeys } from "../routing/session-key.js";
+import type { RuntimeEnv } from "../runtime.js";
+import type { ChannelAccountSnapshot } from "../channels/plugins/types.js";
+import { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js";
+import { resolveCommandAuthorizedFromAuthorizers } from "../channels/command-gating.js";
+import { resolveMattermostAccount } from "./accounts.js";
+import {
+ createMattermostClient,
+ fetchMattermostChannel,
+ fetchMattermostMe,
+ fetchMattermostUser,
+ normalizeMattermostBaseUrl,
+ sendMattermostTyping,
+ type MattermostChannel,
+ type MattermostPost,
+ type MattermostUser,
+} from "./client.js";
+import { sendMessageMattermost } from "./send.js";
+
+export type MonitorMattermostOpts = {
+ botToken?: string;
+ baseUrl?: string;
+ accountId?: string;
+ config?: ClawdbotConfig;
+ runtime?: RuntimeEnv;
+ abortSignal?: AbortSignal;
+ statusSink?: (patch: Partial) => void;
+};
+
+type MattermostEventPayload = {
+ event?: string;
+ data?: {
+ post?: string;
+ channel_id?: string;
+ channel_name?: string;
+ channel_display_name?: string;
+ channel_type?: string;
+ sender_name?: string;
+ team_id?: string;
+ };
+ broadcast?: {
+ channel_id?: string;
+ team_id?: string;
+ user_id?: string;
+ };
+};
+
+const RECENT_MATTERMOST_MESSAGE_TTL_MS = 5 * 60_000;
+const RECENT_MATTERMOST_MESSAGE_MAX = 2000;
+const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
+const USER_CACHE_TTL_MS = 10 * 60_000;
+const DEFAULT_ONCHAR_PREFIXES = [">", "!"];
+
+const recentInboundMessages = createDedupeCache({
+ ttlMs: RECENT_MATTERMOST_MESSAGE_TTL_MS,
+ maxSize: RECENT_MATTERMOST_MESSAGE_MAX,
+});
+
+function resolveRuntime(opts: MonitorMattermostOpts): RuntimeEnv {
+ return (
+ opts.runtime ?? {
+ log: console.log,
+ error: console.error,
+ exit: (code: number): never => {
+ throw new Error(`exit ${code}`);
+ },
+ }
+ );
+}
+
+function normalizeMention(text: string, mention: string | undefined): string {
+ if (!mention) return text.trim();
+ const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
+ const re = new RegExp(`@${escaped}\\b`, "gi");
+ return text.replace(re, " ").replace(/\s+/g, " ").trim();
+}
+
+function resolveOncharPrefixes(prefixes: string[] | undefined): string[] {
+ const cleaned = prefixes?.map((entry) => entry.trim()).filter(Boolean) ?? DEFAULT_ONCHAR_PREFIXES;
+ return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES;
+}
+
+function stripOncharPrefix(
+ text: string,
+ prefixes: string[],
+): { triggered: boolean; stripped: string } {
+ const trimmed = text.trimStart();
+ for (const prefix of prefixes) {
+ if (!prefix) continue;
+ if (trimmed.startsWith(prefix)) {
+ return {
+ triggered: true,
+ stripped: trimmed.slice(prefix.length).trimStart(),
+ };
+ }
+ }
+ return { triggered: false, stripped: text };
+}
+
+function isSystemPost(post: MattermostPost): boolean {
+ const type = post.type?.trim();
+ return Boolean(type);
+}
+
+function channelKind(channelType?: string | null): "dm" | "group" | "channel" {
+ if (!channelType) return "channel";
+ const normalized = channelType.trim().toUpperCase();
+ if (normalized === "D") return "dm";
+ if (normalized === "G") return "group";
+ return "channel";
+}
+
+function channelChatType(kind: "dm" | "group" | "channel"): "direct" | "group" | "channel" {
+ if (kind === "dm") return "direct";
+ if (kind === "group") return "group";
+ return "channel";
+}
+
+type MattermostMediaInfo = {
+ path: string;
+ contentType?: string;
+ kind: MediaKind;
+};
+
+function buildMattermostAttachmentPlaceholder(mediaList: MattermostMediaInfo[]): string {
+ if (mediaList.length === 0) return "";
+ if (mediaList.length === 1) {
+ const kind = mediaList[0].kind === "unknown" ? "document" : mediaList[0].kind;
+ return ``;
+ }
+ 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 runtime = resolveRuntime(opts);
+ const cfg = opts.config ?? loadConfig();
+ const account = resolveMattermostAccount({
+ cfg,
+ accountId: opts.accountId,
+ });
+ const botToken = opts.botToken?.trim() || account.botToken?.trim();
+ if (!botToken) {
+ throw new Error(
+ `Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
+ );
+ }
+ const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
+ if (!baseUrl) {
+ throw new Error(
+ `Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
+ );
+ }
+
+ const client = createMattermostClient({ baseUrl, botToken });
+ const botUser = await fetchMattermostMe(client);
+ const botUserId = botUser.id;
+ const botUsername = botUser.username?.trim() || undefined;
+ runtime.log?.(`mattermost connected as ${botUsername ? `@${botUsername}` : botUserId}`);
+
+ const channelCache = new Map();
+ const userCache = new Map();
+ const logger = getChildLogger({ module: "mattermost" });
+ const mediaMaxBytes =
+ resolveChannelMediaMaxBytes({
+ cfg,
+ resolveChannelLimitMb: () => undefined,
+ accountId: account.accountId,
+ }) ?? 8 * 1024 * 1024;
+ const historyLimit = Math.max(
+ 0,
+ cfg.messages?.groupChat?.historyLimit ?? DEFAULT_GROUP_HISTORY_LIMIT,
+ );
+ const channelHistories = new Map();
+
+ 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 fetchRemoteMedia({
+ url: `${client.apiBaseUrl}/files/${fileId}`,
+ fetchImpl: fetchWithAuth,
+ filePathHint: fileId,
+ maxBytes: mediaMaxBytes,
+ });
+ const saved = await saveMediaBuffer(
+ fetched.buffer,
+ fetched.contentType ?? undefined,
+ "inbound",
+ mediaMaxBytes,
+ );
+ const contentType = saved.contentType ?? fetched.contentType ?? undefined;
+ out.push({
+ path: saved.path,
+ contentType,
+ kind: mediaKindFromMime(contentType),
+ });
+ } catch (err) {
+ logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
+ }
+ }
+ return out;
+ };
+
+ const sendTypingIndicator = async (channelId: string, parentId?: string) => {
+ try {
+ await sendMattermostTyping(client, { channelId, parentId });
+ } catch (err) {
+ logger.debug?.(`mattermost typing cue failed for channel ${channelId}: ${String(err)}`);
+ }
+ };
+
+ const resolveChannelInfo = async (channelId: string): Promise => {
+ 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 = resolveAgentRoute({
+ cfg,
+ channel: "mattermost",
+ accountId: account.accountId,
+ teamId,
+ peer: {
+ kind,
+ id: kind === "dm" ? senderId : channelId,
+ },
+ });
+
+ const baseSessionKey = route.sessionKey;
+ const threadRootId = post.root_id?.trim() || undefined;
+ const threadKeys = resolveThreadSessionKeys({
+ baseSessionKey,
+ threadId: threadRootId,
+ parentSessionKey: threadRootId ? baseSessionKey : undefined,
+ });
+ const sessionKey = threadKeys.sessionKey;
+ const historyKey = kind === "dm" ? null : sessionKey;
+
+ const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
+ const rawText = post.message?.trim() || "";
+ const wasMentioned =
+ kind !== "dm" &&
+ ((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
+ matchesMentionPatterns(rawText, mentionRegexes));
+ const pendingBody =
+ rawText ||
+ (post.file_ids?.length
+ ? `[Mattermost ${post.file_ids.length === 1 ? "file" : "files"}]`
+ : "");
+ const pendingSender = payload.data?.sender_name?.trim() || senderId;
+ const recordPendingHistory = () => {
+ if (!historyKey || historyLimit <= 0) return;
+ const trimmed = pendingBody.trim();
+ if (!trimmed) return;
+ recordPendingHistoryEntry({
+ historyMap: channelHistories,
+ historyKey,
+ limit: historyLimit,
+ entry: {
+ sender: pendingSender,
+ body: trimmed,
+ timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
+ messageId: post.id ?? undefined,
+ },
+ });
+ };
+
+ const allowTextCommands = shouldHandleTextCommands({
+ cfg,
+ surface: "mattermost",
+ });
+ const isControlCommand = allowTextCommands && hasControlCommand(rawText, cfg);
+ const oncharEnabled = account.chatmode === "onchar" && kind !== "dm";
+ const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
+ const oncharResult = oncharEnabled
+ ? stripOncharPrefix(rawText, oncharPrefixes)
+ : { triggered: false, stripped: rawText };
+ const oncharTriggered = oncharResult.triggered;
+
+ const shouldRequireMention = kind === "channel" && (account.requireMention ?? true);
+ const shouldBypassMention = isControlCommand && shouldRequireMention && !wasMentioned;
+ const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
+ const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
+
+ if (oncharEnabled && !oncharTriggered && !wasMentioned && !isControlCommand) {
+ recordPendingHistory();
+ return;
+ }
+
+ if (kind === "channel" && shouldRequireMention && canDetectMention) {
+ if (!effectiveWasMentioned) {
+ recordPendingHistory();
+ return;
+ }
+ }
+
+ const senderName =
+ payload.data?.sender_name?.trim() ||
+ (await resolveUserInfo(senderId))?.username?.trim() ||
+ senderId;
+ const mediaList = await resolveMattermostMedia(post.file_ids);
+ const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList);
+ const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
+ const baseText = [bodySource, mediaPlaceholder].filter(Boolean).join("\n").trim();
+ const bodyText = normalizeMention(baseText, botUsername);
+ if (!bodyText) return;
+
+ recordChannelActivity({
+ channel: "mattermost",
+ accountId: account.accountId,
+ direction: "inbound",
+ });
+
+ const fromLabel = formatInboundFromLabel({
+ isGroup: kind !== "dm",
+ groupLabel: channelDisplay || roomLabel,
+ groupId: channelId,
+ groupFallback: roomLabel || "Channel",
+ directLabel: senderName,
+ directId: senderId,
+ });
+
+ const preview = bodyText.replace(/\s+/g, " ").slice(0, 160);
+ const inboundLabel =
+ kind === "dm"
+ ? `Mattermost DM from ${senderName}`
+ : `Mattermost message in ${roomLabel} from ${senderName}`;
+ enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
+ sessionKey,
+ contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`,
+ });
+
+ const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
+ const body = formatInboundEnvelope({
+ channel: "Mattermost",
+ from: fromLabel,
+ timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
+ body: textWithId,
+ chatType,
+ sender: { name: senderName, id: senderId },
+ });
+ let combinedBody = body;
+ if (historyKey && historyLimit > 0) {
+ combinedBody = buildPendingHistoryContextFromMap({
+ historyMap: channelHistories,
+ historyKey,
+ limit: historyLimit,
+ currentMessage: combinedBody,
+ formatEntry: (entry) =>
+ formatInboundEnvelope({
+ channel: "Mattermost",
+ from: fromLabel,
+ timestamp: entry.timestamp,
+ body: `${entry.body}${
+ entry.messageId ? ` [id:${entry.messageId} channel:${channelId}]` : ""
+ }`,
+ chatType,
+ senderLabel: entry.sender,
+ }),
+ });
+ }
+
+ const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`;
+ const mediaPayload = buildMattermostMediaPayload(mediaList);
+ const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
+ useAccessGroups: cfg.commands?.useAccessGroups ?? false,
+ authorizers: [],
+ });
+ const ctxPayload = finalizeInboundContext({
+ Body: combinedBody,
+ RawBody: bodyText,
+ CommandBody: bodyText,
+ From:
+ kind === "dm"
+ ? `mattermost:${senderId}`
+ : kind === "group"
+ ? `mattermost:group:${channelId}`
+ : `mattermost:channel:${channelId}`,
+ To: to,
+ SessionKey: sessionKey,
+ ParentSessionKey: threadKeys.parentSessionKey,
+ AccountId: route.accountId,
+ ChatType: chatType,
+ ConversationLabel: fromLabel,
+ GroupSubject: kind !== "dm" ? channelDisplay || roomLabel : undefined,
+ GroupChannel: channelName ? `#${channelName}` : undefined,
+ GroupSpace: teamId,
+ SenderName: senderName,
+ SenderId: senderId,
+ Provider: "mattermost" as const,
+ Surface: "mattermost" as const,
+ MessageSid: post.id ?? undefined,
+ MessageSids: allMessageIds.length > 1 ? allMessageIds : undefined,
+ MessageSidFirst: allMessageIds.length > 1 ? allMessageIds[0] : undefined,
+ MessageSidLast:
+ allMessageIds.length > 1 ? allMessageIds[allMessageIds.length - 1] : undefined,
+ ReplyToId: threadRootId,
+ MessageThreadId: threadRootId,
+ Timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
+ WasMentioned: kind !== "dm" ? effectiveWasMentioned : undefined,
+ CommandAuthorized: commandAuthorized,
+ OriginatingChannel: "mattermost" as const,
+ OriginatingTo: to,
+ ...mediaPayload,
+ });
+
+ if (kind === "dm") {
+ const sessionCfg = cfg.session;
+ const storePath = resolveStorePath(sessionCfg?.store, {
+ agentId: route.agentId,
+ });
+ await updateLastRoute({
+ storePath,
+ sessionKey: route.mainSessionKey,
+ deliveryContext: {
+ channel: "mattermost",
+ to,
+ accountId: route.accountId,
+ },
+ });
+ }
+
+ if (shouldLogVerbose()) {
+ const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
+ logVerbose(
+ `mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
+ );
+ }
+
+ const textLimit = resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
+ fallbackLimit: account.textChunkLimit ?? 4000,
+ });
+
+ let prefixContext: ResponsePrefixContext = {
+ identityName: resolveIdentityName(cfg, route.agentId),
+ };
+
+ const { dispatcher, replyOptions, markDispatchIdle } = createReplyDispatcherWithTyping({
+ responsePrefix: resolveEffectiveMessagesConfig(cfg, route.agentId).responsePrefix,
+ responsePrefixContextProvider: () => prefixContext,
+ humanDelay: resolveHumanDelayConfig(cfg, route.agentId),
+ deliver: async (payload: ReplyPayload) => {
+ const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
+ const text = payload.text ?? "";
+ if (mediaUrls.length === 0) {
+ const chunks = chunkMarkdownText(text, textLimit);
+ for (const chunk of chunks.length > 0 ? chunks : [text]) {
+ if (!chunk) continue;
+ await sendMessageMattermost(to, chunk, {
+ accountId: account.accountId,
+ replyToId: threadRootId,
+ });
+ }
+ } else {
+ let first = true;
+ for (const mediaUrl of mediaUrls) {
+ const caption = first ? text : "";
+ first = false;
+ await sendMessageMattermost(to, caption, {
+ accountId: account.accountId,
+ mediaUrl,
+ replyToId: threadRootId,
+ });
+ }
+ }
+ runtime.log?.(`delivered reply to ${to}`);
+ },
+ onError: (err, info) => {
+ runtime.error?.(danger(`mattermost ${info.kind} reply failed: ${String(err)}`));
+ },
+ onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
+ });
+
+ await dispatchReplyFromConfig({
+ ctx: ctxPayload,
+ cfg,
+ dispatcher,
+ replyOptions: {
+ ...replyOptions,
+ disableBlockStreaming:
+ typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
+ onModelSelected: (ctx) => {
+ prefixContext.provider = ctx.provider;
+ prefixContext.model = extractShortModelName(ctx.model);
+ prefixContext.modelFull = `${ctx.provider}/${ctx.model}`;
+ prefixContext.thinkingLevel = ctx.thinkLevel ?? "off";
+ },
+ },
+ });
+ markDispatchIdle();
+ if (historyKey && historyLimit > 0) {
+ clearHistoryEntries({ historyMap: channelHistories, historyKey });
+ }
+ };
+
+ const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "mattermost" });
+ const debouncer = createInboundDebouncer<{
+ post: MattermostPost;
+ payload: MattermostEventPayload;
+ }>({
+ debounceMs: inboundDebounceMs,
+ buildKey: (entry) => {
+ const channelId =
+ entry.post.channel_id ??
+ entry.payload.data?.channel_id ??
+ entry.payload.broadcast?.channel_id;
+ if (!channelId) return null;
+ const threadId = entry.post.root_id?.trim();
+ const threadKey = threadId ? `thread:${threadId}` : "channel";
+ return `mattermost:${account.accountId}:${channelId}:${threadKey}`;
+ },
+ shouldDebounce: (entry) => {
+ if (entry.post.file_ids && entry.post.file_ids.length > 0) return false;
+ const text = entry.post.message?.trim() ?? "";
+ if (!text) return false;
+ return !hasControlCommand(text, cfg);
+ },
+ onFlush: async (entries) => {
+ const last = entries.at(-1);
+ if (!last) return;
+ if (entries.length === 1) {
+ await handlePost(last.post, last.payload);
+ return;
+ }
+ const combinedText = entries
+ .map((entry) => entry.post.message?.trim() ?? "")
+ .filter(Boolean)
+ .join("\n");
+ const mergedPost: MattermostPost = {
+ ...last.post,
+ message: combinedText,
+ file_ids: [],
+ };
+ const ids = entries.map((entry) => entry.post.id).filter(Boolean) as string[];
+ await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
+ },
+ onError: (err) => {
+ runtime.error?.(danger(`mattermost debounce flush failed: ${String(err)}`));
+ },
+ });
+
+ const wsUrl = buildMattermostWsUrl(baseUrl);
+ let seq = 1;
+
+ const connectOnce = async (): Promise => {
+ const ws = new WebSocket(wsUrl);
+ const onAbort = () => ws.close();
+ opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
+
+ return await new Promise((resolve) => {
+ ws.on("open", () => {
+ opts.statusSink?.({
+ connected: true,
+ lastConnectedAt: Date.now(),
+ lastError: null,
+ });
+ ws.send(
+ JSON.stringify({
+ seq: seq++,
+ action: "authentication_challenge",
+ data: { token: botToken },
+ }),
+ );
+ });
+
+ ws.on("message", async (data) => {
+ const raw = rawDataToString(data);
+ let payload: MattermostEventPayload;
+ try {
+ payload = JSON.parse(raw) as MattermostEventPayload;
+ } catch {
+ return;
+ }
+ if (payload.event !== "posted") return;
+ const postData = payload.data?.post;
+ if (!postData) return;
+ let post: MattermostPost | null = null;
+ if (typeof postData === "string") {
+ try {
+ post = JSON.parse(postData) as MattermostPost;
+ } catch {
+ return;
+ }
+ } else if (typeof postData === "object") {
+ post = postData as MattermostPost;
+ }
+ if (!post) return;
+ try {
+ await debouncer.enqueue({ post, payload });
+ } catch (err) {
+ runtime.error?.(danger(`mattermost handler failed: ${String(err)}`));
+ }
+ });
+
+ ws.on("close", (code, reason) => {
+ const message = reason.length > 0 ? reason.toString("utf8") : "";
+ opts.statusSink?.({
+ connected: false,
+ lastDisconnect: {
+ at: Date.now(),
+ status: code,
+ error: message || undefined,
+ },
+ });
+ opts.abortSignal?.removeEventListener("abort", onAbort);
+ resolve();
+ });
+
+ ws.on("error", (err) => {
+ runtime.error?.(danger(`mattermost websocket error: ${String(err)}`));
+ opts.statusSink?.({
+ lastError: String(err),
+ });
+ });
+ });
+ };
+
+ while (!opts.abortSignal?.aborted) {
+ await connectOnce();
+ if (opts.abortSignal?.aborted) return;
+ await new Promise((resolve) => setTimeout(resolve, 2000));
+ }
+}
diff --git a/src/mattermost/probe.ts b/src/mattermost/probe.ts
new file mode 100644
index 000000000..c0fa8ae63
--- /dev/null
+++ b/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/src/mattermost/send.ts b/src/mattermost/send.ts
new file mode 100644
index 000000000..40f038cc0
--- /dev/null
+++ b/src/mattermost/send.ts
@@ -0,0 +1,207 @@
+import { loadConfig } from "../config/config.js";
+import { logVerbose, shouldLogVerbose } from "../globals.js";
+import { recordChannelActivity } from "../infra/channel-activity.js";
+import { loadWebMedia } from "../web/media.js";
+import { resolveMattermostAccount } from "./accounts.js";
+import {
+ createMattermostClient,
+ createMattermostDirectChannel,
+ createMattermostPost,
+ fetchMattermostMe,
+ fetchMattermostUserByUsername,
+ normalizeMattermostBaseUrl,
+ uploadMattermostFile,
+ type MattermostUser,
+} from "./client.js";
+
+export type MattermostSendOpts = {
+ botToken?: string;
+ baseUrl?: string;
+ accountId?: string;
+ mediaUrl?: string;
+ replyToId?: string;
+};
+
+export type MattermostSendResult = {
+ messageId: string;
+ channelId: string;
+};
+
+type MattermostTarget =
+ | { kind: "channel"; id: string }
+ | { kind: "user"; id?: string; username?: string };
+
+const botUserCache = new Map();
+const userByNameCache = new Map();
+
+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 cfg = loadConfig();
+ const account = resolveMattermostAccount({
+ cfg,
+ accountId: opts.accountId,
+ });
+ const token = opts.botToken?.trim() || account.botToken?.trim();
+ if (!token) {
+ throw new Error(
+ `Mattermost bot token missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.botToken or MATTERMOST_BOT_TOKEN for default).`,
+ );
+ }
+ const baseUrl = normalizeMattermostBaseUrl(opts.baseUrl ?? account.baseUrl);
+ if (!baseUrl) {
+ throw new Error(
+ `Mattermost baseUrl missing for account "${account.accountId}" (set channels.mattermost.accounts.${account.accountId}.baseUrl or MATTERMOST_URL for default).`,
+ );
+ }
+
+ const target = parseMattermostTarget(to);
+ const channelId = await resolveTargetChannelId({
+ target,
+ baseUrl,
+ token,
+ });
+
+ const client = createMattermostClient({ baseUrl, botToken: token });
+ let message = text?.trim() ?? "";
+ let fileIds: string[] | undefined;
+ let uploadError: Error | undefined;
+ const mediaUrl = opts.mediaUrl?.trim();
+ if (mediaUrl) {
+ try {
+ const media = await loadWebMedia(mediaUrl);
+ const fileInfo = await uploadMattermostFile(client, {
+ channelId,
+ buffer: media.buffer,
+ fileName: media.fileName ?? "upload",
+ contentType: media.contentType ?? undefined,
+ });
+ fileIds = [fileInfo.id];
+ } catch (err) {
+ uploadError = err instanceof Error ? err : new Error(String(err));
+ if (shouldLogVerbose()) {
+ logVerbose(
+ `mattermost send: media upload failed, falling back to URL text: ${String(err)}`,
+ );
+ }
+ message = normalizeMessage(message, isHttpUrl(mediaUrl) ? mediaUrl : "");
+ }
+ }
+
+ if (!message && (!fileIds || fileIds.length === 0)) {
+ if (uploadError) {
+ throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
+ }
+ throw new Error("Mattermost message is empty");
+ }
+
+ const post = await createMattermostPost(client, {
+ channelId,
+ message,
+ rootId: opts.replyToId,
+ fileIds,
+ });
+
+ recordChannelActivity({
+ channel: "mattermost",
+ accountId: account.accountId,
+ direction: "outbound",
+ });
+
+ return {
+ messageId: post.id ?? "unknown",
+ channelId,
+ };
+}
diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts
index 8bef1da37..62979bdd1 100644
--- a/src/plugin-sdk/index.ts
+++ b/src/plugin-sdk/index.ts
@@ -81,6 +81,7 @@ export type {
export {
DiscordConfigSchema,
IMessageConfigSchema,
+ MattermostConfigSchema,
MSTeamsConfigSchema,
SignalConfigSchema,
SlackConfigSchema,
@@ -120,6 +121,7 @@ export {
resolveBlueBubblesGroupRequireMention,
resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention,
+ resolveMattermostGroupRequireMention,
resolveSlackGroupRequireMention,
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
@@ -236,6 +238,21 @@ export {
normalizeSlackMessagingTarget,
} from "../channels/plugins/normalize/slack.js";
+// Channel: Mattermost
+export {
+ listEnabledMattermostAccounts,
+ listMattermostAccountIds,
+ resolveDefaultMattermostAccountId,
+ resolveMattermostAccount,
+ type ResolvedMattermostAccount,
+} from "../mattermost/accounts.js";
+export { normalizeMattermostBaseUrl } from "../mattermost/client.js";
+export { mattermostOnboardingAdapter } from "../channels/plugins/onboarding/mattermost.js";
+export {
+ looksLikeMattermostTargetId,
+ normalizeMattermostMessagingTarget,
+} from "../channels/plugins/normalize/mattermost.js";
+
// Channel: Telegram
export {
listTelegramAccountIds,
diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts
index 4765c71c7..e564ad2f8 100644
--- a/src/plugins/runtime/index.ts
+++ b/src/plugins/runtime/index.ts
@@ -57,6 +57,9 @@ import { enqueueSystemEvent } from "../../infra/system-events.js";
import { monitorIMessageProvider } from "../../imessage/monitor.js";
import { probeIMessage } from "../../imessage/probe.js";
import { sendMessageIMessage } from "../../imessage/send.js";
+import { monitorMattermostProvider } from "../../mattermost/monitor.js";
+import { probeMattermost } from "../../mattermost/probe.js";
+import { sendMessageMattermost } from "../../mattermost/send.js";
import { shouldLogVerbose } from "../../globals.js";
import { getChildLogger } from "../../logging.js";
import { normalizeLogLevel } from "../../logging/levels.js";
@@ -230,6 +233,11 @@ export function createPluginRuntime(): PluginRuntime {
monitorSlackProvider,
handleSlackAction,
},
+ mattermost: {
+ probeMattermost,
+ sendMessageMattermost,
+ monitorMattermostProvider,
+ },
telegram: {
auditGroupMembership: auditTelegramGroupMembership,
collectUnmentionedGroupIds: collectTelegramUnmentionedGroupIds,
diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts
index 089e20c37..31350693c 100644
--- a/src/plugins/runtime/types.ts
+++ b/src/plugins/runtime/types.ts
@@ -98,6 +98,10 @@ type ResolveSlackUserAllowlist =
type SendMessageSlack = typeof import("../../slack/send.js").sendMessageSlack;
type MonitorSlackProvider = typeof import("../../slack/index.js").monitorSlackProvider;
type HandleSlackAction = typeof import("../../agents/tools/slack-actions.js").handleSlackAction;
+type ProbeMattermost = typeof import("../../mattermost/probe.js").probeMattermost;
+type SendMessageMattermost = typeof import("../../mattermost/send.js").sendMessageMattermost;
+type MonitorMattermostProvider =
+ typeof import("../../mattermost/monitor.js").monitorMattermostProvider;
type AuditTelegramGroupMembership =
typeof import("../../telegram/audit.js").auditTelegramGroupMembership;
type CollectTelegramUnmentionedGroupIds =
@@ -242,6 +246,11 @@ export type PluginRuntime = {
monitorSlackProvider: MonitorSlackProvider;
handleSlackAction: HandleSlackAction;
};
+ mattermost: {
+ probeMattermost: ProbeMattermost;
+ sendMessageMattermost: SendMessageMattermost;
+ monitorMattermostProvider: MonitorMattermostProvider;
+ };
telegram: {
auditGroupMembership: AuditTelegramGroupMembership;
collectUnmentionedGroupIds: CollectTelegramUnmentionedGroupIds;
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 6cdbfb029..aaf89b9e9 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;
@@ -363,6 +396,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..96a6b8556 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,
@@ -97,7 +101,7 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe
if (snapshot?.channelOrder?.length) {
return snapshot.channelOrder;
}
- return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "nostr"];
+ return ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage", "nostr"];
}
function renderChannel(
@@ -135,6 +139,12 @@ function renderChannel(
slack: data.slack,
accountCountLabel,
});
+ case "mattermost":
+ return renderMattermostCard({
+ props,
+ mattermost: data.mattermost,
+ accountCountLabel,
+ });
case "signal":
return renderSignalCard({
props,
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;