From bf6df6d6b72f2105443d7d5aa648f782e0793ee2 Mon Sep 17 00:00:00 2001
From: Dominic Damoah
- 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;
From 495a39b5a9893ced78aa9ea755ff749dcbf3425f Mon Sep 17 00:00:00 2001
From: Dominic Damoah
Date: Thu, 22 Jan 2026 12:02:30 -0500
Subject: [PATCH 2/3] refactor: extract mattermost channel plugin to extension
Move mattermost channel implementation from core to extensions/mattermost plugin. Extract config schema, group mentions, normalize utilities, and all mattermost-specific logic (accounts, client, monitor, probe, send) into the extension. Update imports to use plugin SDK and local modules. Add channel metadata directly in plugin definition instead of using getChatChannelMeta. Update package.json with channel and install configuration.
---
extensions/mattermost/package.json | 16 +-
extensions/mattermost/src/channel.ts | 54 +++--
extensions/mattermost/src/config-schema.ts | 24 +++
extensions/mattermost/src/group-mentions.ts | 14 ++
.../mattermost/src}/mattermost/accounts.ts | 7 +-
.../mattermost/src}/mattermost/client.ts | 0
.../mattermost/src}/mattermost/index.ts | 0
.../src/mattermost/monitor-helpers.ts | 150 +++++++++++++
.../mattermost/src}/mattermost/monitor.ts | 203 +++++++++---------
.../mattermost/src}/mattermost/probe.ts | 0
.../mattermost/src}/mattermost/send.ts | 19 +-
.../mattermost/src/normalize.ts | 0
.../mattermost/src/onboarding-helpers.ts | 42 ++++
.../mattermost/src/onboarding.ts | 14 +-
extensions/mattermost/src/types.ts | 40 ++++
src/auto-reply/reply/get-reply-run.ts | 3 +-
src/channels/dock.ts | 25 ---
src/channels/plugins/group-mentions.ts | 10 -
src/channels/registry.ts | 11 -
src/infra/outbound/deliver.ts | 9 +-
src/plugin-sdk/index.ts | 17 --
src/plugins/runtime/index.ts | 8 -
src/plugins/runtime/types.ts | 9 -
ui/src/ui/views/channels.ts | 2 +-
24 files changed, 442 insertions(+), 235 deletions(-)
create mode 100644 extensions/mattermost/src/config-schema.ts
create mode 100644 extensions/mattermost/src/group-mentions.ts
rename {src => extensions/mattermost/src}/mattermost/accounts.ts (96%)
rename {src => extensions/mattermost/src}/mattermost/client.ts (100%)
rename {src => extensions/mattermost/src}/mattermost/index.ts (100%)
create mode 100644 extensions/mattermost/src/mattermost/monitor-helpers.ts
rename {src => extensions/mattermost/src}/mattermost/monitor.ts (80%)
rename {src => extensions/mattermost/src}/mattermost/probe.ts (100%)
rename {src => extensions/mattermost/src}/mattermost/send.ts (93%)
rename src/channels/plugins/normalize/mattermost.ts => extensions/mattermost/src/normalize.ts (100%)
create mode 100644 extensions/mattermost/src/onboarding-helpers.ts
rename src/channels/plugins/onboarding/mattermost.ts => extensions/mattermost/src/onboarding.ts (91%)
create mode 100644 extensions/mattermost/src/types.ts
diff --git a/extensions/mattermost/package.json b/extensions/mattermost/package.json
index 8ba462f45..f98f3c446 100644
--- a/extensions/mattermost/package.json
+++ b/extensions/mattermost/package.json
@@ -6,6 +6,20 @@
"clawdbot": {
"extensions": [
"./index.ts"
- ]
+ ],
+ "channel": {
+ "id": "mattermost",
+ "label": "Mattermost",
+ "selectionLabel": "Mattermost (plugin)",
+ "docsPath": "/channels/mattermost",
+ "docsLabel": "mattermost",
+ "blurb": "self-hosted Slack-style chat; install the plugin to enable.",
+ "order": 65
+ },
+ "install": {
+ "npmSpec": "@clawdbot/mattermost",
+ "localPath": "extensions/mattermost",
+ "defaultChoice": "npm"
+ }
}
}
diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts
index 840772a17..b365fc61e 100644
--- a/extensions/mattermost/src/channel.ts
+++ b/extensions/mattermost/src/channel.ts
@@ -3,26 +3,42 @@ import {
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 { MattermostConfigSchema } from "./config-schema.js";
+import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
+import {
+ looksLikeMattermostTargetId,
+ normalizeMattermostMessagingTarget,
+} from "./normalize.js";
+import { mattermostOnboardingAdapter } from "./onboarding.js";
+import {
+ listMattermostAccountIds,
+ resolveDefaultMattermostAccountId,
+ resolveMattermostAccount,
+ type ResolvedMattermostAccount,
+} from "./mattermost/accounts.js";
+import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
+import { monitorMattermostProvider } from "./mattermost/monitor.js";
+import { probeMattermost } from "./mattermost/probe.js";
+import { sendMessageMattermost } from "./mattermost/send.js";
import { getMattermostRuntime } from "./runtime.js";
-const meta = getChatChannelMeta("mattermost");
+const meta = {
+ id: "mattermost",
+ label: "Mattermost",
+ selectionLabel: "Mattermost (plugin)",
+ detailLabel: "Mattermost Bot",
+ docsPath: "/channels/mattermost",
+ docsLabel: "mattermost",
+ blurb: "self-hosted Slack-style chat; install the plugin to enable.",
+ systemImage: "bubble.left.and.bubble.right",
+ order: 65,
+} as const;
export const mattermostPlugin: ChannelPlugin = {
id: "mattermost",
@@ -96,8 +112,7 @@ export const mattermostPlugin: ChannelPlugin = {
return { ok: true, to: trimmed };
},
sendText: async ({ to, text, accountId, deps, replyToId }) => {
- const send =
- deps?.sendMattermost ?? getMattermostRuntime().channel.mattermost.sendMessageMattermost;
+ const send = deps?.sendMattermost ?? sendMessageMattermost;
const result = await send(to, text, {
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
@@ -105,8 +120,7 @@ export const mattermostPlugin: ChannelPlugin = {
return { channel: "mattermost", ...result };
},
sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
- const send =
- deps?.sendMattermost ?? getMattermostRuntime().channel.mattermost.sendMessageMattermost;
+ const send = deps?.sendMattermost ?? sendMessageMattermost;
const result = await send(to, text, {
accountId: accountId ?? undefined,
mediaUrl,
@@ -144,11 +158,7 @@ export const mattermostPlugin: ChannelPlugin = {
if (!token || !baseUrl) {
return { ok: false, error: "bot token or baseUrl missing" };
}
- return await getMattermostRuntime().channel.mattermost.probeMattermost(
- baseUrl,
- token,
- timeoutMs,
- );
+ return await probeMattermost(baseUrl, token, timeoutMs);
},
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
@@ -256,7 +266,7 @@ export const mattermostPlugin: ChannelPlugin = {
botTokenSource: account.botTokenSource,
});
ctx.log?.info(`[${account.accountId}] starting channel`);
- return getMattermostRuntime().channel.mattermost.monitorMattermostProvider({
+ return monitorMattermostProvider({
botToken: account.botToken ?? undefined,
baseUrl: account.baseUrl ?? undefined,
accountId: account.accountId,
diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts
new file mode 100644
index 000000000..3cbecaf34
--- /dev/null
+++ b/extensions/mattermost/src/config-schema.ts
@@ -0,0 +1,24 @@
+import { z } from "zod";
+
+import { BlockStreamingCoalesceSchema } from "clawdbot/plugin-sdk";
+
+const MattermostAccountSchema = z
+ .object({
+ name: z.string().optional(),
+ capabilities: z.array(z.string()).optional(),
+ enabled: z.boolean().optional(),
+ configWrites: z.boolean().optional(),
+ botToken: z.string().optional(),
+ baseUrl: z.string().optional(),
+ chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
+ oncharPrefixes: z.array(z.string()).optional(),
+ requireMention: z.boolean().optional(),
+ textChunkLimit: z.number().int().positive().optional(),
+ blockStreaming: z.boolean().optional(),
+ blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
+ })
+ .strict();
+
+export const MattermostConfigSchema = MattermostAccountSchema.extend({
+ accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
+});
diff --git a/extensions/mattermost/src/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts
new file mode 100644
index 000000000..773e655ff
--- /dev/null
+++ b/extensions/mattermost/src/group-mentions.ts
@@ -0,0 +1,14 @@
+import type { ChannelGroupContext } from "clawdbot/plugin-sdk";
+
+import { resolveMattermostAccount } from "./mattermost/accounts.js";
+
+export function resolveMattermostGroupRequireMention(
+ params: ChannelGroupContext,
+): boolean | undefined {
+ const account = resolveMattermostAccount({
+ cfg: params.cfg,
+ accountId: params.accountId,
+ });
+ if (typeof account.requireMention === "boolean") return account.requireMention;
+ return true;
+}
diff --git a/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts
similarity index 96%
rename from src/mattermost/accounts.ts
rename to extensions/mattermost/src/mattermost/accounts.ts
index 08ffa2f94..e75f34593 100644
--- a/src/mattermost/accounts.ts
+++ b/extensions/mattermost/src/mattermost/accounts.ts
@@ -1,6 +1,7 @@
-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 type { ClawdbotConfig } from "clawdbot/plugin-sdk";
+import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
+
+import type { MattermostAccountConfig, MattermostChatMode } from "../types.js";
import { normalizeMattermostBaseUrl } from "./client.js";
export type MattermostTokenSource = "env" | "config" | "none";
diff --git a/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts
similarity index 100%
rename from src/mattermost/client.ts
rename to extensions/mattermost/src/mattermost/client.ts
diff --git a/src/mattermost/index.ts b/extensions/mattermost/src/mattermost/index.ts
similarity index 100%
rename from src/mattermost/index.ts
rename to extensions/mattermost/src/mattermost/index.ts
diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts
new file mode 100644
index 000000000..8c68a4f25
--- /dev/null
+++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts
@@ -0,0 +1,150 @@
+import { Buffer } from "node:buffer";
+
+import type WebSocket from "ws";
+
+import type { ClawdbotConfig } from "clawdbot/plugin-sdk";
+
+export type ResponsePrefixContext = {
+ model?: string;
+ modelFull?: string;
+ provider?: string;
+ thinkingLevel?: string;
+ identityName?: string;
+};
+
+export function extractShortModelName(fullModel: string): string {
+ const slash = fullModel.lastIndexOf("/");
+ const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel;
+ return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, "");
+}
+
+export function formatInboundFromLabel(params: {
+ isGroup: boolean;
+ groupLabel?: string;
+ groupId?: string;
+ directLabel: string;
+ directId?: string;
+ groupFallback?: string;
+}): string {
+ if (params.isGroup) {
+ const label = params.groupLabel?.trim() || params.groupFallback || "Group";
+ const id = params.groupId?.trim();
+ return id ? `${label} id:${id}` : label;
+ }
+
+ const directLabel = params.directLabel.trim();
+ const directId = params.directId?.trim();
+ if (!directId || directId === directLabel) return directLabel;
+ return `${directLabel} id:${directId}`;
+}
+
+type DedupeCache = {
+ check: (key: string | undefined | null, now?: number) => boolean;
+};
+
+export function createDedupeCache(options: { ttlMs: number; maxSize: number }): DedupeCache {
+ const ttlMs = Math.max(0, options.ttlMs);
+ const maxSize = Math.max(0, Math.floor(options.maxSize));
+ const cache = new Map();
+
+ const touch = (key: string, now: number) => {
+ cache.delete(key);
+ cache.set(key, now);
+ };
+
+ const prune = (now: number) => {
+ const cutoff = ttlMs > 0 ? now - ttlMs : undefined;
+ if (cutoff !== undefined) {
+ for (const [entryKey, entryTs] of cache) {
+ if (entryTs < cutoff) {
+ cache.delete(entryKey);
+ }
+ }
+ }
+ if (maxSize <= 0) {
+ cache.clear();
+ return;
+ }
+ while (cache.size > maxSize) {
+ const oldestKey = cache.keys().next().value as string | undefined;
+ if (!oldestKey) break;
+ cache.delete(oldestKey);
+ }
+ };
+
+ return {
+ check: (key, now = Date.now()) => {
+ if (!key) return false;
+ const existing = cache.get(key);
+ if (existing !== undefined && (ttlMs <= 0 || now - existing < ttlMs)) {
+ touch(key, now);
+ return true;
+ }
+ touch(key, now);
+ prune(now);
+ return false;
+ },
+ };
+}
+
+export function rawDataToString(
+ data: WebSocket.RawData,
+ encoding: BufferEncoding = "utf8",
+): string {
+ if (typeof data === "string") return data;
+ if (Buffer.isBuffer(data)) return data.toString(encoding);
+ if (Array.isArray(data)) return Buffer.concat(data).toString(encoding);
+ if (data instanceof ArrayBuffer) {
+ return Buffer.from(data).toString(encoding);
+ }
+ return Buffer.from(String(data)).toString(encoding);
+}
+
+function normalizeAgentId(value: string | undefined | null): string {
+ const trimmed = (value ?? "").trim();
+ if (!trimmed) return "main";
+ if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) return trimmed;
+ return (
+ trimmed
+ .toLowerCase()
+ .replace(/[^a-z0-9_-]+/g, "-")
+ .replace(/^-+/, "")
+ .replace(/-+$/, "")
+ .slice(0, 64) || "main"
+ );
+}
+
+type AgentEntry = NonNullable["list"]>[number];
+
+function listAgents(cfg: ClawdbotConfig): AgentEntry[] {
+ const list = cfg.agents?.list;
+ if (!Array.isArray(list)) return [];
+ return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
+}
+
+function resolveAgentEntry(cfg: ClawdbotConfig, agentId: string): AgentEntry | undefined {
+ const id = normalizeAgentId(agentId);
+ return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
+}
+
+export function resolveIdentityName(cfg: ClawdbotConfig, agentId: string): string | undefined {
+ const entry = resolveAgentEntry(cfg, agentId);
+ return entry?.identity?.name?.trim() || undefined;
+}
+
+export function resolveThreadSessionKeys(params: {
+ baseSessionKey: string;
+ threadId?: string | null;
+ parentSessionKey?: string;
+ useSuffix?: boolean;
+}): { sessionKey: string; parentSessionKey?: string } {
+ const threadId = (params.threadId ?? "").trim();
+ if (!threadId) {
+ return { sessionKey: params.baseSessionKey, parentSessionKey: undefined };
+ }
+ const useSuffix = params.useSuffix ?? true;
+ const sessionKey = useSuffix
+ ? `${params.baseSessionKey}:thread:${threadId}`
+ : params.baseSessionKey;
+ return { sessionKey, parentSessionKey: params.parentSessionKey };
+}
diff --git a/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts
similarity index 80%
rename from src/mattermost/monitor.ts
rename to extensions/mattermost/src/mattermost/monitor.ts
index fb8bd00db..7c0d98fca 100644
--- a/src/mattermost/monitor.ts
+++ b/extensions/mattermost/src/mattermost/monitor.ts
@@ -1,52 +1,21 @@
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 type {
+ ChannelAccountSnapshot,
+ ClawdbotConfig,
+ ReplyPayload,
+ RuntimeEnv,
+} from "clawdbot/plugin-sdk";
import {
buildPendingHistoryContextFromMap,
clearHistoryEntries,
DEFAULT_GROUP_HISTORY_LIMIT,
recordPendingHistoryEntry,
+ resolveChannelMediaMaxBytes,
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";
+} from "clawdbot/plugin-sdk";
+
+import { getMattermostRuntime } from "../runtime.js";
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
@@ -59,6 +28,15 @@ import {
type MattermostPost,
type MattermostUser,
} from "./client.js";
+import {
+ createDedupeCache,
+ extractShortModelName,
+ formatInboundFromLabel,
+ rawDataToString,
+ resolveIdentityName,
+ resolveThreadSessionKeys,
+ type ResponsePrefixContext,
+} from "./monitor-helpers.js";
import { sendMessageMattermost } from "./send.js";
export type MonitorMattermostOpts = {
@@ -71,6 +49,9 @@ export type MonitorMattermostOpts = {
statusSink?: (patch: Partial) => void;
};
+type FetchLike = typeof fetch;
+type MediaKind = "image" | "audio" | "video" | "document" | "unknown";
+
type MattermostEventPayload = {
event?: string;
data?: {
@@ -208,8 +189,9 @@ function buildMattermostWsUrl(baseUrl: string): string {
}
export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}): Promise {
+ const core = getMattermostRuntime();
const runtime = resolveRuntime(opts);
- const cfg = opts.config ?? loadConfig();
+ const cfg = opts.config ?? core.config.loadConfig();
const account = resolveMattermostAccount({
cfg,
accountId: opts.accountId,
@@ -235,7 +217,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const channelCache = new Map();
const userCache = new Map();
- const logger = getChildLogger({ module: "mattermost" });
+ const logger = core.logging.getChildLogger({ module: "mattermost" });
+ const logVerboseMessage = (message: string) => {
+ if (!core.logging.shouldLogVerbose()) return;
+ logger.debug?.(message);
+ };
const mediaMaxBytes =
resolveChannelMediaMaxBytes({
cfg,
@@ -262,13 +248,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const out: MattermostMediaInfo[] = [];
for (const fileId of ids) {
try {
- const fetched = await fetchRemoteMedia({
+ const fetched = await core.channel.media.fetchRemoteMedia({
url: `${client.apiBaseUrl}/files/${fileId}`,
fetchImpl: fetchWithAuth,
filePathHint: fileId,
maxBytes: mediaMaxBytes,
});
- const saved = await saveMediaBuffer(
+ const saved = await core.channel.media.saveMediaBuffer(
fetched.buffer,
fetched.contentType ?? undefined,
"inbound",
@@ -278,7 +264,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
out.push({
path: saved.path,
contentType,
- kind: mediaKindFromMime(contentType),
+ kind: core.media.mediaKindFromMime(contentType),
});
} catch (err) {
logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
@@ -366,7 +352,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
payload.data?.channel_display_name ?? channelInfo?.display_name ?? channelName;
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
- const route = resolveAgentRoute({
+ const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "mattermost",
accountId: account.accountId,
@@ -387,12 +373,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const sessionKey = threadKeys.sessionKey;
const historyKey = kind === "dm" ? null : sessionKey;
- const mentionRegexes = buildMentionRegexes(cfg, route.agentId);
+ const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
const rawText = post.message?.trim() || "";
const wasMentioned =
kind !== "dm" &&
((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
- matchesMentionPatterns(rawText, mentionRegexes));
+ core.channel.mentions.matchesMentionPatterns(rawText, mentionRegexes));
const pendingBody =
rawText ||
(post.file_ids?.length
@@ -416,11 +402,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
});
};
- const allowTextCommands = shouldHandleTextCommands({
+ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg,
surface: "mattermost",
});
- const isControlCommand = allowTextCommands && hasControlCommand(rawText, cfg);
+ const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
const oncharEnabled = account.chatmode === "onchar" && kind !== "dm";
const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
const oncharResult = oncharEnabled
@@ -456,7 +442,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const bodyText = normalizeMention(baseText, botUsername);
if (!bodyText) return;
- recordChannelActivity({
+ core.channel.activity.record({
channel: "mattermost",
accountId: account.accountId,
direction: "inbound",
@@ -476,13 +462,13 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
kind === "dm"
? `Mattermost DM from ${senderName}`
: `Mattermost message in ${roomLabel} from ${senderName}`;
- enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
+ core.system.enqueueSystemEvent(`${inboundLabel}: ${preview}`, {
sessionKey,
contextKey: `mattermost:message:${channelId}:${post.id ?? "unknown"}`,
});
const textWithId = `${bodyText}\n[mattermost message id: ${post.id ?? "unknown"} channel: ${channelId}]`;
- const body = formatInboundEnvelope({
+ const body = core.channel.reply.formatInboundEnvelope({
channel: "Mattermost",
from: fromLabel,
timestamp: typeof post.create_at === "number" ? post.create_at : undefined,
@@ -498,7 +484,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
limit: historyLimit,
currentMessage: combinedBody,
formatEntry: (entry) =>
- formatInboundEnvelope({
+ core.channel.reply.formatInboundEnvelope({
channel: "Mattermost",
from: fromLabel,
timestamp: entry.timestamp,
@@ -513,11 +499,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`;
const mediaPayload = buildMattermostMediaPayload(mediaList);
- const commandAuthorized = resolveCommandAuthorizedFromAuthorizers({
+ const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
useAccessGroups: cfg.commands?.useAccessGroups ?? false,
authorizers: [],
});
- const ctxPayload = finalizeInboundContext({
+ const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
RawBody: bodyText,
CommandBody: bodyText,
@@ -557,10 +543,10 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
if (kind === "dm") {
const sessionCfg = cfg.session;
- const storePath = resolveStorePath(sessionCfg?.store, {
+ const storePath = core.channel.session.resolveStorePath(sessionCfg?.store, {
agentId: route.agentId,
});
- await updateLastRoute({
+ await core.channel.session.updateLastRoute({
storePath,
sessionKey: route.mainSessionKey,
deliveryContext: {
@@ -571,14 +557,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
});
}
- if (shouldLogVerbose()) {
- const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
- logVerbose(
- `mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
- );
- }
+ const previewLine = bodyText.slice(0, 200).replace(/\n/g, "\\n");
+ logVerboseMessage(
+ `mattermost inbound: from=${ctxPayload.From} len=${bodyText.length} preview="${previewLine}"`,
+ );
- const textLimit = resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
+ const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
fallbackLimit: account.textChunkLimit ?? 4000,
});
@@ -586,43 +570,45 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
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,
- });
+ const { dispatcher, replyOptions, markDispatchIdle } =
+ core.channel.reply.createReplyDispatcherWithTyping({
+ responsePrefix: core.channel.reply.resolveEffectiveMessagesConfig(cfg, route.agentId)
+ .responsePrefix,
+ responsePrefixContextProvider: () => prefixContext,
+ humanDelay: core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId),
+ deliver: async (payload: ReplyPayload) => {
+ const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []);
+ const text = payload.text ?? "";
+ if (mediaUrls.length === 0) {
+ const chunks = core.channel.text.chunkMarkdownText(text, textLimit);
+ for (const chunk of chunks.length > 0 ? chunks : [text]) {
+ if (!chunk) continue;
+ await sendMessageMattermost(to, chunk, {
+ accountId: account.accountId,
+ replyToId: threadRootId,
+ });
+ }
+ } else {
+ let first = true;
+ for (const mediaUrl of mediaUrls) {
+ const caption = first ? text : "";
+ first = false;
+ await sendMessageMattermost(to, caption, {
+ accountId: account.accountId,
+ mediaUrl,
+ replyToId: threadRootId,
+ });
+ }
}
- } 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),
- });
+ runtime.log?.(`delivered reply to ${to}`);
+ },
+ onError: (err, info) => {
+ runtime.error?.(`mattermost ${info.kind} reply failed: ${String(err)}`);
+ },
+ onReplyStart: () => sendTypingIndicator(channelId, threadRootId),
+ });
- await dispatchReplyFromConfig({
+ await core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
@@ -644,8 +630,11 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
}
};
- const inboundDebounceMs = resolveInboundDebounceMs({ cfg, channel: "mattermost" });
- const debouncer = createInboundDebouncer<{
+ const inboundDebounceMs = core.channel.debounce.resolveInboundDebounceMs({
+ cfg,
+ channel: "mattermost",
+ });
+ const debouncer = core.channel.debounce.createInboundDebouncer<{
post: MattermostPost;
payload: MattermostEventPayload;
}>({
@@ -664,7 +653,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
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);
+ return !core.channel.text.hasControlCommand(text, cfg);
},
onFlush: async (entries) => {
const last = entries.at(-1);
@@ -686,7 +675,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
await handlePost(mergedPost, last.payload, ids.length > 0 ? ids : undefined);
},
onError: (err) => {
- runtime.error?.(danger(`mattermost debounce flush failed: ${String(err)}`));
+ runtime.error?.(`mattermost debounce flush failed: ${String(err)}`);
},
});
@@ -739,7 +728,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
try {
await debouncer.enqueue({ post, payload });
} catch (err) {
- runtime.error?.(danger(`mattermost handler failed: ${String(err)}`));
+ runtime.error?.(`mattermost handler failed: ${String(err)}`);
}
});
@@ -758,7 +747,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
});
ws.on("error", (err) => {
- runtime.error?.(danger(`mattermost websocket error: ${String(err)}`));
+ runtime.error?.(`mattermost websocket error: ${String(err)}`);
opts.statusSink?.({
lastError: String(err),
});
diff --git a/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts
similarity index 100%
rename from src/mattermost/probe.ts
rename to extensions/mattermost/src/mattermost/probe.ts
diff --git a/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts
similarity index 93%
rename from src/mattermost/send.ts
rename to extensions/mattermost/src/mattermost/send.ts
index 40f038cc0..f5b22c768 100644
--- a/src/mattermost/send.ts
+++ b/extensions/mattermost/src/mattermost/send.ts
@@ -1,7 +1,4 @@
-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 { getMattermostRuntime } from "../runtime.js";
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
@@ -34,6 +31,8 @@ type MattermostTarget =
const botUserCache = new Map();
const userByNameCache = new Map();
+const getCore = () => getMattermostRuntime();
+
function cacheKey(baseUrl: string, token: string): string {
return `${baseUrl}::${token}`;
}
@@ -129,7 +128,9 @@ export async function sendMessageMattermost(
text: string,
opts: MattermostSendOpts = {},
): Promise {
- const cfg = loadConfig();
+ const core = getCore();
+ const logger = core.logging.getChildLogger({ module: "mattermost" });
+ const cfg = core.config.loadConfig();
const account = resolveMattermostAccount({
cfg,
accountId: opts.accountId,
@@ -161,7 +162,7 @@ export async function sendMessageMattermost(
const mediaUrl = opts.mediaUrl?.trim();
if (mediaUrl) {
try {
- const media = await loadWebMedia(mediaUrl);
+ const media = await core.media.loadWebMedia(mediaUrl);
const fileInfo = await uploadMattermostFile(client, {
channelId,
buffer: media.buffer,
@@ -171,8 +172,8 @@ export async function sendMessageMattermost(
fileIds = [fileInfo.id];
} catch (err) {
uploadError = err instanceof Error ? err : new Error(String(err));
- if (shouldLogVerbose()) {
- logVerbose(
+ if (core.logging.shouldLogVerbose()) {
+ logger.debug?.(
`mattermost send: media upload failed, falling back to URL text: ${String(err)}`,
);
}
@@ -194,7 +195,7 @@ export async function sendMessageMattermost(
fileIds,
});
- recordChannelActivity({
+ core.channel.activity.record({
channel: "mattermost",
accountId: account.accountId,
direction: "outbound",
diff --git a/src/channels/plugins/normalize/mattermost.ts b/extensions/mattermost/src/normalize.ts
similarity index 100%
rename from src/channels/plugins/normalize/mattermost.ts
rename to extensions/mattermost/src/normalize.ts
diff --git a/extensions/mattermost/src/onboarding-helpers.ts b/extensions/mattermost/src/onboarding-helpers.ts
new file mode 100644
index 000000000..8a5d1f585
--- /dev/null
+++ b/extensions/mattermost/src/onboarding-helpers.ts
@@ -0,0 +1,42 @@
+import type { ClawdbotConfig, WizardPrompter } from "clawdbot/plugin-sdk";
+import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
+
+type PromptAccountIdParams = {
+ cfg: ClawdbotConfig;
+ prompter: WizardPrompter;
+ label: string;
+ currentId?: string;
+ listAccountIds: (cfg: ClawdbotConfig) => string[];
+ defaultAccountId: string;
+};
+
+export async function promptAccountId(params: PromptAccountIdParams): Promise {
+ const existingIds = params.listAccountIds(params.cfg);
+ const initial = params.currentId?.trim() || params.defaultAccountId || DEFAULT_ACCOUNT_ID;
+ const choice = (await params.prompter.select({
+ message: `${params.label} account`,
+ options: [
+ ...existingIds.map((id) => ({
+ value: id,
+ label: id === DEFAULT_ACCOUNT_ID ? "default (primary)" : id,
+ })),
+ { value: "__new__", label: "Add a new account" },
+ ],
+ initialValue: initial,
+ })) as string;
+
+ if (choice !== "__new__") return normalizeAccountId(choice);
+
+ const entered = await params.prompter.text({
+ message: `New ${params.label} account id`,
+ validate: (value) => (value?.trim() ? undefined : "Required"),
+ });
+ const normalized = normalizeAccountId(String(entered));
+ if (String(entered).trim() !== normalized) {
+ await params.prompter.note(
+ `Normalized account id to "${normalized}".`,
+ `${params.label} account`,
+ );
+ }
+ return normalized;
+}
diff --git a/src/channels/plugins/onboarding/mattermost.ts b/extensions/mattermost/src/onboarding.ts
similarity index 91%
rename from src/channels/plugins/onboarding/mattermost.ts
rename to extensions/mattermost/src/onboarding.ts
index 3c7ffe2db..431c648ae 100644
--- a/src/channels/plugins/onboarding/mattermost.ts
+++ b/extensions/mattermost/src/onboarding.ts
@@ -1,14 +1,12 @@
-import type { ClawdbotConfig } from "../../../config/config.js";
+import type { ChannelOnboardingAdapter, ClawdbotConfig, WizardPrompter } from "clawdbot/plugin-sdk";
+import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk";
+
import {
listMattermostAccountIds,
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
-} from "../../../mattermost/accounts.js";
-import { 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";
+} from "./mattermost/accounts.js";
+import { promptAccountId } from "./onboarding-helpers.js";
const channel = "mattermost" as const;
@@ -19,7 +17,7 @@ async function noteMattermostSetup(prompter: WizardPrompter): Promise {
"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")}`,
+ "Docs: https://docs.clawd.bot/channels/mattermost",
].join("\n"),
"Mattermost bot token",
);
diff --git a/extensions/mattermost/src/types.ts b/extensions/mattermost/src/types.ts
new file mode 100644
index 000000000..43e509763
--- /dev/null
+++ b/extensions/mattermost/src/types.ts
@@ -0,0 +1,40 @@
+import type { BlockStreamingCoalesceConfig } from "clawdbot/plugin-sdk";
+
+export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
+
+export type MattermostAccountConfig = {
+ /** Optional display name for this account (used in CLI/UI lists). */
+ name?: string;
+ /** Optional provider capability tags used for agent/runtime guidance. */
+ capabilities?: string[];
+ /** Allow channel-initiated config writes (default: true). */
+ configWrites?: boolean;
+ /** If false, do not start this Mattermost account. Default: true. */
+ enabled?: boolean;
+ /** Bot token for Mattermost. */
+ botToken?: string;
+ /** Base URL for the Mattermost server (e.g., https://chat.example.com). */
+ baseUrl?: string;
+ /**
+ * Controls when channel messages trigger replies.
+ * - "oncall": only respond when mentioned
+ * - "onmessage": respond to every channel message
+ * - "onchar": respond when a trigger character prefixes the message
+ */
+ chatmode?: MattermostChatMode;
+ /** Prefix characters that trigger onchar mode (default: [">", "!"]). */
+ oncharPrefixes?: string[];
+ /** Require @mention to respond in channels. Default: true. */
+ requireMention?: boolean;
+ /** Outbound text chunk size (chars). Default: 4000. */
+ textChunkLimit?: number;
+ /** Disable block streaming for this account. */
+ blockStreaming?: boolean;
+ /** Merge streamed block replies before sending. */
+ blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
+};
+
+export type MattermostConfig = {
+ /** Optional per-account Mattermost configuration (multi-account). */
+ accounts?: Record;
+} & MattermostAccountConfig;
diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts
index a2adbf312..524b6ddaf 100644
--- a/src/auto-reply/reply/get-reply-run.ts
+++ b/src/auto-reply/reply/get-reply-run.ts
@@ -159,8 +159,7 @@ export async function runPreparedReply(
const isGroupChat = sessionCtx.ChatType === "group";
const originatingChannel =
(ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider)?.toString().toLowerCase() ?? "";
- const wasMentioned =
- ctx.WasMentioned === true || (originatingChannel === "mattermost" && isGroupChat);
+ const wasMentioned = ctx.WasMentioned === true;
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 469880482..81b07c36a 100644
--- a/src/channels/dock.ts
+++ b/src/channels/dock.ts
@@ -12,7 +12,6 @@ import { requireActivePluginRegistry } from "../plugins/runtime.js";
import {
resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention,
- resolveMattermostGroupRequireMention,
resolveSlackGroupRequireMention,
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
@@ -231,30 +230,6 @@ const DOCKS: Record = {
buildToolContext: (params) => buildSlackThreadingToolContext(params),
},
},
- mattermost: {
- id: "mattermost",
- capabilities: {
- chatTypes: ["direct", "channel", "group", "thread"],
- media: true,
- threads: true,
- },
- outbound: { textChunkLimit: 4000 },
- streaming: {
- blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
- },
- groups: {
- resolveRequireMention: resolveMattermostGroupRequireMention,
- },
- threading: {
- buildToolContext: ({ context, hasRepliedRef }) => ({
- currentChannelId: context.To?.startsWith("channel:")
- ? context.To.slice("channel:".length)
- : undefined,
- currentThreadTs: context.ReplyToId,
- hasRepliedRef,
- }),
- },
- },
signal: {
id: "signal",
capabilities: {
diff --git a/src/channels/plugins/group-mentions.ts b/src/channels/plugins/group-mentions.ts
index bb7a111e8..79dfa0320 100644
--- a/src/channels/plugins/group-mentions.ts
+++ b/src/channels/plugins/group-mentions.ts
@@ -1,7 +1,6 @@
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 = {
@@ -185,15 +184,6 @@ 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/registry.ts b/src/channels/registry.ts
index 25fb13502..52e7a5f01 100644
--- a/src/channels/registry.ts
+++ b/src/channels/registry.ts
@@ -9,7 +9,6 @@ export const CHAT_CHANNEL_ORDER = [
"whatsapp",
"discord",
"slack",
- "mattermost",
"signal",
"imessage",
] as const;
@@ -68,16 +67,6 @@ 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/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts
index 74caa18c6..2d874d7e9 100644
--- a/src/infra/outbound/deliver.ts
+++ b/src/infra/outbound/deliver.ts
@@ -6,7 +6,6 @@ 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";
@@ -29,12 +28,18 @@ type SendMatrixMessage = (
opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number },
) => Promise<{ messageId: string; roomId: string }>;
+type SendMattermostMessage = (
+ to: string,
+ text: string,
+ opts?: { accountId?: string; mediaUrl?: string; replyToId?: string },
+) => Promise<{ messageId: string; channelId: string }>;
+
export type OutboundSendDeps = {
sendWhatsApp?: typeof sendMessageWhatsApp;
sendTelegram?: typeof sendMessageTelegram;
sendDiscord?: typeof sendMessageDiscord;
sendSlack?: typeof sendMessageSlack;
- sendMattermost?: typeof sendMessageMattermost;
+ sendMattermost?: SendMattermostMessage;
sendSignal?: typeof sendMessageSignal;
sendIMessage?: typeof sendMessageIMessage;
sendMatrix?: SendMatrixMessage;
diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts
index ef8f22d55..1da3650fe 100644
--- a/src/plugin-sdk/index.ts
+++ b/src/plugin-sdk/index.ts
@@ -81,7 +81,6 @@ export type {
export {
DiscordConfigSchema,
IMessageConfigSchema,
- MattermostConfigSchema,
MSTeamsConfigSchema,
SignalConfigSchema,
SlackConfigSchema,
@@ -121,7 +120,6 @@ export {
resolveBlueBubblesGroupRequireMention,
resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention,
- resolveMattermostGroupRequireMention,
resolveSlackGroupRequireMention,
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
@@ -241,21 +239,6 @@ export {
} from "../channels/plugins/normalize/slack.js";
export { buildSlackThreadingToolContext } from "../slack/threading-tool-context.js";
-// Channel: Mattermost
-export {
- listEnabledMattermostAccounts,
- listMattermostAccountIds,
- resolveDefaultMattermostAccountId,
- resolveMattermostAccount,
- type ResolvedMattermostAccount,
-} from "../mattermost/accounts.js";
-export { normalizeMattermostBaseUrl } from "../mattermost/client.js";
-export { mattermostOnboardingAdapter } from "../channels/plugins/onboarding/mattermost.js";
-export {
- looksLikeMattermostTargetId,
- normalizeMattermostMessagingTarget,
-} from "../channels/plugins/normalize/mattermost.js";
-
// Channel: Telegram
export {
listTelegramAccountIds,
diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts
index e564ad2f8..4765c71c7 100644
--- a/src/plugins/runtime/index.ts
+++ b/src/plugins/runtime/index.ts
@@ -57,9 +57,6 @@ 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";
@@ -233,11 +230,6 @@ 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 31350693c..089e20c37 100644
--- a/src/plugins/runtime/types.ts
+++ b/src/plugins/runtime/types.ts
@@ -98,10 +98,6 @@ 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 =
@@ -246,11 +242,6 @@ export type PluginRuntime = {
monitorSlackProvider: MonitorSlackProvider;
handleSlackAction: HandleSlackAction;
};
- mattermost: {
- probeMattermost: ProbeMattermost;
- sendMessageMattermost: SendMessageMattermost;
- monitorMattermostProvider: MonitorMattermostProvider;
- };
telegram: {
auditGroupMembership: AuditTelegramGroupMembership;
collectUnmentionedGroupIds: CollectTelegramUnmentionedGroupIds;
diff --git a/ui/src/ui/views/channels.ts b/ui/src/ui/views/channels.ts
index 96a6b8556..d9f148764 100644
--- a/ui/src/ui/views/channels.ts
+++ b/ui/src/ui/views/channels.ts
@@ -101,7 +101,7 @@ function resolveChannelOrder(snapshot: ChannelsStatusSnapshot | null): ChannelKe
if (snapshot?.channelOrder?.length) {
return snapshot.channelOrder;
}
- return ["whatsapp", "telegram", "discord", "slack", "mattermost", "signal", "imessage", "nostr"];
+ return ["whatsapp", "telegram", "discord", "slack", "signal", "imessage", "nostr"];
}
function renderChannel(
From 8b3cb373d40390927c11a00ff11d9b9c9e6da2ba Mon Sep 17 00:00:00 2001
From: Dominic Damoah
Date: Thu, 22 Jan 2026 12:11:05 -0500
Subject: [PATCH 3/3] fix: remove unused originatingChannel variable
Remove unused originatingChannel variable from runPreparedReply function that was assigned but never referenced.
---
src/auto-reply/reply/get-reply-run.ts | 2 --
1 file changed, 2 deletions(-)
diff --git a/src/auto-reply/reply/get-reply-run.ts b/src/auto-reply/reply/get-reply-run.ts
index 524b6ddaf..28c9e24cb 100644
--- a/src/auto-reply/reply/get-reply-run.ts
+++ b/src/auto-reply/reply/get-reply-run.ts
@@ -157,8 +157,6 @@ export async function runPreparedReply(
const isFirstTurnInSession = isNewSession || !currentSystemSent;
const isGroupChat = sessionCtx.ChatType === "group";
- const originatingChannel =
- (ctx.OriginatingChannel ?? ctx.Surface ?? ctx.Provider)?.toString().toLowerCase() ?? "";
const wasMentioned = ctx.WasMentioned === true;
const isHeartbeat = opts?.isHeartbeat === true;
const typingMode = resolveTypingMode({