From 279f7993886e7528d04c5e7ac74f6c3d503fbabe Mon Sep 17 00:00:00 2001
From: Peter Steinberger `
+- Public DMs: `channels.mattermost.dmPolicy="open"` plus `channels.mattermost.allowFrom=["*"]`.
+
+## Channels (groups)
+- Default: `channels.mattermost.groupPolicy = "allowlist"` (mention-gated).
+- Allowlist senders with `channels.mattermost.groupAllowFrom` (user IDs or `@username`).
+- Open channels: `channels.mattermost.groupPolicy="open"` (mention-gated).
+
## Targets for outbound delivery
Use these target formats with `clawdbot message send` or cron/webhooks:
diff --git a/docs/cli/channels.md b/docs/cli/channels.md
index fd74aabbd..48ed043a2 100644
--- a/docs/cli/channels.md
+++ b/docs/cli/channels.md
@@ -1,7 +1,7 @@
---
summary: "CLI reference for `clawdbot channels` (accounts, status, login/logout, logs)"
read_when:
- - You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost/Signal/iMessage)
+ - You want to add/remove channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage)
- You want to check channel status or tail channel logs
---
diff --git a/docs/cli/index.md b/docs/cli/index.md
index cc3078a5a..46f6d173e 100644
--- a/docs/cli/index.md
+++ b/docs/cli/index.md
@@ -352,7 +352,7 @@ Options:
## Channel helpers
### `channels`
-Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost/Signal/iMessage/MS Teams).
+Manage chat channel accounts (WhatsApp/Telegram/Discord/Slack/Mattermost (plugin)/Signal/iMessage/MS Teams).
Subcommands:
- `channels list`: show configured channels and auth profiles (Claude Code + Codex CLI OAuth sync included).
diff --git a/docs/cli/message.md b/docs/cli/message.md
index 43d820665..7cb9ae673 100644
--- a/docs/cli/message.md
+++ b/docs/cli/message.md
@@ -8,7 +8,7 @@ read_when:
# `clawdbot message`
Single outbound command for sending messages and channel actions
-(Discord/Slack/Mattermost/Telegram/WhatsApp/Signal/iMessage/MS Teams).
+(Discord/Slack/Mattermost (plugin)/Telegram/WhatsApp/Signal/iMessage/MS Teams).
## Usage
@@ -19,14 +19,14 @@ clawdbot message
- Any OS + WhatsApp/Telegram/Discord/Mattermost/iMessage gateway for AI agents (Pi).
+ Any OS + WhatsApp/Telegram/Discord/iMessage gateway for AI agents (Pi).
+ Plugins add Mattermost and more.
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 7f5a3dac4..ede005259 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/Discord/Slack/Mattermost status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
+- Channels: WhatsApp/Telegram/Discord/Slack + plugin channels (Mattermost, etc.) status + QR login + per-channel config (`channels.status`, `web.login.*`, `config.patch`)
- Instances: presence list + refresh (`system-presence`)
- Sessions: list + per-session thinking/verbose overrides (`sessions.list`, `sessions.patch`)
- Cron jobs: list/add/run/enable/disable + run history (`cron.*`)
diff --git a/extensions/mattermost/src/channel.test.ts b/extensions/mattermost/src/channel.test.ts
new file mode 100644
index 000000000..c31b603ca
--- /dev/null
+++ b/extensions/mattermost/src/channel.test.ts
@@ -0,0 +1,43 @@
+import { describe, expect, it } from "vitest";
+
+import { mattermostPlugin } from "./channel.js";
+
+describe("mattermostPlugin", () => {
+ describe("messaging", () => {
+ it("keeps @username targets", () => {
+ const normalize = mattermostPlugin.messaging?.normalizeTarget;
+ if (!normalize) return;
+
+ expect(normalize("@Alice")).toBe("@Alice");
+ expect(normalize("@alice")).toBe("@alice");
+ });
+
+ it("normalizes mattermost: prefix to user:", () => {
+ const normalize = mattermostPlugin.messaging?.normalizeTarget;
+ if (!normalize) return;
+
+ expect(normalize("mattermost:USER123")).toBe("user:USER123");
+ });
+ });
+
+ describe("pairing", () => {
+ it("normalizes allowlist entries", () => {
+ const normalize = mattermostPlugin.pairing?.normalizeAllowEntry;
+ if (!normalize) return;
+
+ expect(normalize("@Alice")).toBe("alice");
+ expect(normalize("user:USER123")).toBe("user123");
+ });
+ });
+
+ describe("config", () => {
+ it("formats allowFrom entries", () => {
+ const formatAllowFrom = mattermostPlugin.config.formatAllowFrom;
+
+ const formatted = formatAllowFrom({
+ allowFrom: ["@Alice", "user:USER123", "mattermost:BOT999"],
+ });
+ expect(formatted).toEqual(["@alice", "user123", "bot999"]);
+ });
+ });
+});
diff --git a/extensions/mattermost/src/channel.ts b/extensions/mattermost/src/channel.ts
index b365fc61e..5d0837423 100644
--- a/extensions/mattermost/src/channel.ts
+++ b/extensions/mattermost/src/channel.ts
@@ -3,6 +3,7 @@ import {
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
+ formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
setAccountEnabledInConfigSection,
@@ -38,14 +39,40 @@ const meta = {
blurb: "self-hosted Slack-style chat; install the plugin to enable.",
systemImage: "bubble.left.and.bubble.right",
order: 65,
+ quickstartAllowFrom: true,
} as const;
+function normalizeAllowEntry(entry: string): string {
+ return entry
+ .trim()
+ .replace(/^(mattermost|user):/i, "")
+ .replace(/^@/, "")
+ .toLowerCase();
+}
+
+function formatAllowEntry(entry: string): string {
+ const trimmed = entry.trim();
+ if (!trimmed) return "";
+ if (trimmed.startsWith("@")) {
+ const username = trimmed.slice(1).trim();
+ return username ? `@${username.toLowerCase()}` : "";
+ }
+ return trimmed.replace(/^(mattermost|user):/i, "").toLowerCase();
+}
+
export const mattermostPlugin: ChannelPlugin = {
id: "mattermost",
meta: {
...meta,
},
onboarding: mattermostOnboardingAdapter,
+ pairing: {
+ idLabel: "mattermostUserId",
+ normalizeAllowEntry: (entry) => normalizeAllowEntry(entry),
+ notifyApproval: async ({ id }) => {
+ console.log(`[mattermost] User ${id} approved for pairing`);
+ },
+ },
capabilities: {
chatTypes: ["direct", "channel", "group", "thread"],
threads: true,
@@ -84,6 +111,39 @@ export const mattermostPlugin: ChannelPlugin = {
botTokenSource: account.botTokenSource,
baseUrl: account.baseUrl,
}),
+ resolveAllowFrom: ({ cfg, accountId }) =>
+ (resolveMattermostAccount({ cfg, accountId }).config.allowFrom ?? []).map((entry) =>
+ String(entry),
+ ),
+ formatAllowFrom: ({ allowFrom }) =>
+ allowFrom
+ .map((entry) => formatAllowEntry(String(entry)))
+ .filter(Boolean),
+ },
+ security: {
+ resolveDmPolicy: ({ cfg, accountId, account }) => {
+ const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
+ const useAccountPath = Boolean(cfg.channels?.mattermost?.accounts?.[resolvedAccountId]);
+ const basePath = useAccountPath
+ ? `channels.mattermost.accounts.${resolvedAccountId}.`
+ : "channels.mattermost.";
+ return {
+ policy: account.config.dmPolicy ?? "pairing",
+ allowFrom: account.config.allowFrom ?? [],
+ policyPath: `${basePath}dmPolicy`,
+ allowFromPath: basePath,
+ approveHint: formatPairingApproveHint("mattermost"),
+ normalizeEntry: (raw) => normalizeAllowEntry(raw),
+ };
+ },
+ collectWarnings: ({ account, cfg }) => {
+ const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
+ const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
+ if (groupPolicy !== "open") return [];
+ return [
+ `- Mattermost channels: groupPolicy="open" allows any member to trigger (mention-gated). Set channels.mattermost.groupPolicy="allowlist" + channels.mattermost.groupAllowFrom to restrict senders.`,
+ ];
+ },
},
groups: {
resolveRequireMention: resolveMattermostGroupRequireMention,
@@ -105,23 +165,21 @@ export const mattermostPlugin: ChannelPlugin = {
return {
ok: false,
error: new Error(
- "Delivering to Mattermost requires --to ",
+ "Delivering to Mattermost requires --to ",
),
};
}
return { ok: true, to: trimmed };
},
- sendText: async ({ to, text, accountId, deps, replyToId }) => {
- const send = deps?.sendMattermost ?? sendMessageMattermost;
- const result = await send(to, text, {
+ sendText: async ({ to, text, accountId, replyToId }) => {
+ const result = await sendMessageMattermost(to, text, {
accountId: accountId ?? undefined,
replyToId: replyToId ?? undefined,
});
return { channel: "mattermost", ...result };
},
- sendMedia: async ({ to, text, mediaUrl, accountId, deps, replyToId }) => {
- const send = deps?.sendMattermost ?? sendMessageMattermost;
- const result = await send(to, text, {
+ sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
+ const result = await sendMessageMattermost(to, text, {
accountId: accountId ?? undefined,
mediaUrl,
replyToId: replyToId ?? undefined,
diff --git a/extensions/mattermost/src/config-schema.ts b/extensions/mattermost/src/config-schema.ts
index 3cbecaf34..618747995 100644
--- a/extensions/mattermost/src/config-schema.ts
+++ b/extensions/mattermost/src/config-schema.ts
@@ -1,8 +1,13 @@
import { z } from "zod";
-import { BlockStreamingCoalesceSchema } from "clawdbot/plugin-sdk";
+import {
+ BlockStreamingCoalesceSchema,
+ DmPolicySchema,
+ GroupPolicySchema,
+ requireOpenAllowFrom,
+} from "clawdbot/plugin-sdk";
-const MattermostAccountSchema = z
+const MattermostAccountSchemaBase = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
@@ -13,12 +18,36 @@ const MattermostAccountSchema = z
chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
oncharPrefixes: z.array(z.string()).optional(),
requireMention: z.boolean().optional(),
+ dmPolicy: DmPolicySchema.optional().default("pairing"),
+ allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
+ groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
+ groupPolicy: GroupPolicySchema.optional().default("allowlist"),
textChunkLimit: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
})
.strict();
-export const MattermostConfigSchema = MattermostAccountSchema.extend({
- accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
+const MattermostAccountSchema = MattermostAccountSchemaBase.superRefine((value, ctx) => {
+ requireOpenAllowFrom({
+ policy: value.dmPolicy,
+ allowFrom: value.allowFrom,
+ ctx,
+ path: ["allowFrom"],
+ message:
+ 'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
+ });
+});
+
+export const MattermostConfigSchema = MattermostAccountSchemaBase.extend({
+ accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
+}).superRefine((value, ctx) => {
+ requireOpenAllowFrom({
+ policy: value.dmPolicy,
+ allowFrom: value.allowFrom,
+ ctx,
+ path: ["allowFrom"],
+ message:
+ 'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
+ });
});
diff --git a/extensions/mattermost/src/group-mentions.ts b/extensions/mattermost/src/group-mentions.ts
index 773e655ff..b3fbc7e4f 100644
--- a/extensions/mattermost/src/group-mentions.ts
+++ b/extensions/mattermost/src/group-mentions.ts
@@ -11,4 +11,4 @@ export function resolveMattermostGroupRequireMention(
});
if (typeof account.requireMention === "boolean") return account.requireMention;
return true;
-}
+}
\ No newline at end of file
diff --git a/extensions/mattermost/src/mattermost/accounts.ts b/extensions/mattermost/src/mattermost/accounts.ts
index e75f34593..6af1b3e4c 100644
--- a/extensions/mattermost/src/mattermost/accounts.ts
+++ b/extensions/mattermost/src/mattermost/accounts.ts
@@ -112,4 +112,4 @@ export function listEnabledMattermostAccounts(cfg: ClawdbotConfig): ResolvedMatt
return listMattermostAccountIds(cfg)
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
.filter((account) => account.enabled);
-}
+}
\ No newline at end of file
diff --git a/extensions/mattermost/src/mattermost/client.ts b/extensions/mattermost/src/mattermost/client.ts
index 6b63f830f..277139d5d 100644
--- a/extensions/mattermost/src/mattermost/client.ts
+++ b/extensions/mattermost/src/mattermost/client.ts
@@ -205,4 +205,4 @@ export async function uploadMattermostFile(
throw new Error("Mattermost file upload failed");
}
return info;
-}
+}
\ No newline at end of file
diff --git a/extensions/mattermost/src/mattermost/monitor-helpers.ts b/extensions/mattermost/src/mattermost/monitor-helpers.ts
index 8c68a4f25..2aa00f158 100644
--- a/extensions/mattermost/src/mattermost/monitor-helpers.ts
+++ b/extensions/mattermost/src/mattermost/monitor-helpers.ts
@@ -147,4 +147,4 @@ export function resolveThreadSessionKeys(params: {
? `${params.baseSessionKey}:thread:${threadId}`
: params.baseSessionKey;
return { sessionKey, parentSessionKey: params.parentSessionKey };
-}
+}
\ No newline at end of file
diff --git a/extensions/mattermost/src/mattermost/monitor.ts b/extensions/mattermost/src/mattermost/monitor.ts
index 7c0d98fca..7e5079ecb 100644
--- a/extensions/mattermost/src/mattermost/monitor.ts
+++ b/extensions/mattermost/src/mattermost/monitor.ts
@@ -141,6 +141,39 @@ function channelChatType(kind: "dm" | "group" | "channel"): "direct" | "group" |
return "channel";
}
+function normalizeAllowEntry(entry: string): string {
+ const trimmed = entry.trim();
+ if (!trimmed) return "";
+ if (trimmed === "*") return "*";
+ return trimmed
+ .replace(/^(mattermost|user):/i, "")
+ .replace(/^@/, "")
+ .toLowerCase();
+}
+
+function normalizeAllowList(entries: Array): string[] {
+ const normalized = entries
+ .map((entry) => normalizeAllowEntry(String(entry)))
+ .filter(Boolean);
+ return Array.from(new Set(normalized));
+}
+
+function isSenderAllowed(params: {
+ senderId: string;
+ senderName?: string;
+ allowFrom: string[];
+}): boolean {
+ const allowFrom = params.allowFrom;
+ if (allowFrom.length === 0) return false;
+ if (allowFrom.includes("*")) return true;
+ const normalizedSenderId = normalizeAllowEntry(params.senderId);
+ const normalizedSenderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
+ return allowFrom.some(
+ (entry) =>
+ entry === normalizedSenderId || (normalizedSenderName && entry === normalizedSenderName),
+ );
+}
+
type MattermostMediaInfo = {
path: string;
contentType?: string;
@@ -346,6 +379,122 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const kind = channelKind(channelType);
const chatType = channelChatType(kind);
+ const senderName =
+ payload.data?.sender_name?.trim() ||
+ (await resolveUserInfo(senderId))?.username?.trim() ||
+ senderId;
+ const rawText = post.message?.trim() || "";
+ const dmPolicy = account.config.dmPolicy ?? "pairing";
+ const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
+ const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
+ const configAllowFrom = normalizeAllowList(account.config.allowFrom ?? []);
+ const configGroupAllowFrom = normalizeAllowList(account.config.groupAllowFrom ?? []);
+ const storeAllowFrom = normalizeAllowList(
+ await core.channel.pairing.readAllowFromStore("mattermost").catch(() => []),
+ );
+ const effectiveAllowFrom = Array.from(new Set([...configAllowFrom, ...storeAllowFrom]));
+ const effectiveGroupAllowFrom = Array.from(
+ new Set([
+ ...(configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom),
+ ...storeAllowFrom,
+ ]),
+ );
+ const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
+ cfg,
+ surface: "mattermost",
+ });
+ const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
+ const useAccessGroups = cfg.commands?.useAccessGroups !== false;
+ const senderAllowedForCommands = isSenderAllowed({
+ senderId,
+ senderName,
+ allowFrom: effectiveAllowFrom,
+ });
+ const groupAllowedForCommands = isSenderAllowed({
+ senderId,
+ senderName,
+ allowFrom: effectiveGroupAllowFrom,
+ });
+ const commandAuthorized =
+ kind === "dm"
+ ? dmPolicy === "open" || senderAllowedForCommands
+ : core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
+ useAccessGroups,
+ authorizers: [
+ { configured: effectiveAllowFrom.length > 0, allowed: senderAllowedForCommands },
+ {
+ configured: effectiveGroupAllowFrom.length > 0,
+ allowed: groupAllowedForCommands,
+ },
+ ],
+ });
+
+ if (kind === "dm") {
+ if (dmPolicy === "disabled") {
+ logVerboseMessage(`mattermost: drop dm (dmPolicy=disabled sender=${senderId})`);
+ return;
+ }
+ if (dmPolicy !== "open" && !senderAllowedForCommands) {
+ if (dmPolicy === "pairing") {
+ const { code, created } = await core.channel.pairing.upsertPairingRequest({
+ channel: "mattermost",
+ id: senderId,
+ meta: { name: senderName },
+ });
+ logVerboseMessage(
+ `mattermost: pairing request sender=${senderId} created=${created}`,
+ );
+ if (created) {
+ try {
+ await sendMessageMattermost(
+ `user:${senderId}`,
+ core.channel.pairing.buildPairingReply({
+ channel: "mattermost",
+ idLine: `Your Mattermost user id: ${senderId}`,
+ code,
+ }),
+ { accountId: account.accountId },
+ );
+ opts.statusSink?.({ lastOutboundAt: Date.now() });
+ } catch (err) {
+ logVerboseMessage(
+ `mattermost: pairing reply failed for ${senderId}: ${String(err)}`,
+ );
+ }
+ }
+ } else {
+ logVerboseMessage(
+ `mattermost: drop dm sender=${senderId} (dmPolicy=${dmPolicy})`,
+ );
+ }
+ return;
+ }
+ } else {
+ if (groupPolicy === "disabled") {
+ logVerboseMessage("mattermost: drop group message (groupPolicy=disabled)");
+ return;
+ }
+ if (groupPolicy === "allowlist") {
+ if (effectiveGroupAllowFrom.length === 0) {
+ logVerboseMessage("mattermost: drop group message (no group allowlist)");
+ return;
+ }
+ if (!groupAllowedForCommands) {
+ logVerboseMessage(
+ `mattermost: drop group sender=${senderId} (not in groupAllowFrom)`,
+ );
+ return;
+ }
+ }
+ }
+
+ if (kind !== "dm" && isControlCommand && !commandAuthorized) {
+ logVerboseMessage(
+ `mattermost: drop control command from unauthorized sender ${senderId}`,
+ );
+ return;
+ }
+
const teamId = payload.data?.team_id ?? channelInfo?.team_id ?? undefined;
const channelName = payload.data?.channel_name ?? channelInfo?.name ?? "";
const channelDisplay =
@@ -374,7 +523,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const historyKey = kind === "dm" ? null : sessionKey;
const mentionRegexes = core.channel.mentions.buildMentionRegexes(cfg, route.agentId);
- const rawText = post.message?.trim() || "";
const wasMentioned =
kind !== "dm" &&
((botUsername ? rawText.toLowerCase().includes(`@${botUsername.toLowerCase()}`) : false) ||
@@ -384,7 +532,7 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
(post.file_ids?.length
? `[Mattermost ${post.file_ids.length === 1 ? "file" : "files"}]`
: "");
- const pendingSender = payload.data?.sender_name?.trim() || senderId;
+ const pendingSender = senderName;
const recordPendingHistory = () => {
if (!historyKey || historyLimit <= 0) return;
const trimmed = pendingBody.trim();
@@ -402,11 +550,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
});
};
- const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
- cfg,
- surface: "mattermost",
- });
- const isControlCommand = allowTextCommands && core.channel.text.hasControlCommand(rawText, cfg);
const oncharEnabled = account.chatmode === "onchar" && kind !== "dm";
const oncharPrefixes = oncharEnabled ? resolveOncharPrefixes(account.oncharPrefixes) : [];
const oncharResult = oncharEnabled
@@ -414,8 +557,16 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
: { triggered: false, stripped: rawText };
const oncharTriggered = oncharResult.triggered;
- const shouldRequireMention = kind === "channel" && (account.requireMention ?? true);
- const shouldBypassMention = isControlCommand && shouldRequireMention && !wasMentioned;
+ const shouldRequireMention =
+ kind !== "dm" &&
+ core.channel.groups.resolveRequireMention({
+ cfg,
+ channel: "mattermost",
+ accountId: account.accountId,
+ groupId: channelId,
+ }) !== false;
+ const shouldBypassMention =
+ isControlCommand && shouldRequireMention && !wasMentioned && commandAuthorized;
const effectiveWasMentioned = wasMentioned || shouldBypassMention || oncharTriggered;
const canDetectMention = Boolean(botUsername) || mentionRegexes.length > 0;
@@ -424,17 +575,12 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
return;
}
- if (kind === "channel" && shouldRequireMention && canDetectMention) {
+ if (kind !== "dm" && shouldRequireMention && canDetectMention) {
if (!effectiveWasMentioned) {
recordPendingHistory();
return;
}
}
-
- const senderName =
- payload.data?.sender_name?.trim() ||
- (await resolveUserInfo(senderId))?.username?.trim() ||
- senderId;
const mediaList = await resolveMattermostMedia(post.file_ids);
const mediaPlaceholder = buildMattermostAttachmentPlaceholder(mediaList);
const bodySource = oncharTriggered ? oncharResult.stripped : rawText;
@@ -499,10 +645,6 @@ export async function monitorMattermostProvider(opts: MonitorMattermostOpts = {}
const to = kind === "dm" ? `user:${senderId}` : `channel:${channelId}`;
const mediaPayload = buildMattermostMediaPayload(mediaList);
- const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({
- useAccessGroups: cfg.commands?.useAccessGroups ?? false,
- authorizers: [],
- });
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: combinedBody,
RawBody: bodyText,
diff --git a/extensions/mattermost/src/mattermost/probe.ts b/extensions/mattermost/src/mattermost/probe.ts
index c0fa8ae63..0286979f6 100644
--- a/extensions/mattermost/src/mattermost/probe.ts
+++ b/extensions/mattermost/src/mattermost/probe.ts
@@ -67,4 +67,4 @@ export async function probeMattermost(
} finally {
if (timer) clearTimeout(timer);
}
-}
+}
\ No newline at end of file
diff --git a/extensions/mattermost/src/mattermost/send.ts b/extensions/mattermost/src/mattermost/send.ts
index f5b22c768..c2a2a251c 100644
--- a/extensions/mattermost/src/mattermost/send.ts
+++ b/extensions/mattermost/src/mattermost/send.ts
@@ -205,4 +205,4 @@ export async function sendMessageMattermost(
messageId: post.id ?? "unknown",
channelId,
};
-}
+}
\ No newline at end of file
diff --git a/extensions/mattermost/src/normalize.ts b/extensions/mattermost/src/normalize.ts
index 80366420f..b3318fe11 100644
--- a/extensions/mattermost/src/normalize.ts
+++ b/extensions/mattermost/src/normalize.ts
@@ -20,7 +20,7 @@ export function normalizeMattermostMessagingTarget(raw: string): string | undefi
}
if (trimmed.startsWith("@")) {
const id = trimmed.slice(1).trim();
- return id ? `user:${id}` : undefined;
+ return id ? `@${id}` : undefined;
}
if (trimmed.startsWith("#")) {
const id = trimmed.slice(1).trim();
diff --git a/extensions/mattermost/src/onboarding-helpers.ts b/extensions/mattermost/src/onboarding-helpers.ts
index 8a5d1f585..f44299222 100644
--- a/extensions/mattermost/src/onboarding-helpers.ts
+++ b/extensions/mattermost/src/onboarding-helpers.ts
@@ -39,4 +39,4 @@ export async function promptAccountId(params: PromptAccountIdParams): Promise;
+ /** Allowlist for group messages (user ids or @usernames). */
+ groupAllowFrom?: Array;
+ /** Group message policy (allowlist/open/disabled). */
+ groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Disable block streaming for this account. */
diff --git a/src/commands/channels/resolve.ts b/src/commands/channels/resolve.ts
index 820b53bf0..7394fa30f 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|mattermost|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
+ if (/^(user|discord|slack|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
return "user";
}
return "group";
diff --git a/src/config/io.ts b/src/config/io.ts
index 03b9583cf..6994e4485 100644
--- a/src/config/io.ts
+++ b/src/config/io.ts
@@ -52,8 +52,6 @@ const SHELL_ENV_EXPECTED_KEYS = [
"DISCORD_BOT_TOKEN",
"SLACK_BOT_TOKEN",
"SLACK_APP_TOKEN",
- "MATTERMOST_BOT_TOKEN",
- "MATTERMOST_URL",
"CLAWDBOT_GATEWAY_TOKEN",
"CLAWDBOT_GATEWAY_PASSWORD",
];
diff --git a/src/config/legacy.migrations.part-1.ts b/src/config/legacy.migrations.part-1.ts
index 4b38a4be5..f537c3ce8 100644
--- a/src/config/legacy.migrations.part-1.ts
+++ b/src/config/legacy.migrations.part-1.ts
@@ -124,7 +124,6 @@ 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 388083ae7..1ec76bc79 100644
--- a/src/config/legacy.rules.ts
+++ b/src/config/legacy.rules.ts
@@ -17,10 +17,6 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
path: ["slack"],
message: "slack config moved to channels.slack (auto-migrated on load).",
},
- {
- path: ["mattermost"],
- message: "mattermost config moved to channels.mattermost (auto-migrated on load).",
- },
{
path: ["signal"],
message: "signal config moved to channels.signal (auto-migrated on load).",
diff --git a/src/config/types.channels.ts b/src/config/types.channels.ts
index 19ac014dd..ac98e20de 100644
--- a/src/config/types.channels.ts
+++ b/src/config/types.channels.ts
@@ -1,6 +1,5 @@
import type { DiscordConfig } from "./types.discord.js";
import type { IMessageConfig } from "./types.imessage.js";
-import type { MattermostConfig } from "./types.mattermost.js";
import type { MSTeamsConfig } from "./types.msteams.js";
import type { SignalConfig } from "./types.signal.js";
import type { SlackConfig } from "./types.slack.js";
@@ -18,7 +17,6 @@ export type ChannelsConfig = {
telegram?: TelegramConfig;
discord?: DiscordConfig;
slack?: SlackConfig;
- mattermost?: MattermostConfig;
signal?: SignalConfig;
imessage?: IMessageConfig;
msteams?: MSTeamsConfig;
diff --git a/src/config/types.hooks.ts b/src/config/types.hooks.ts
index 2a5bf0f2f..03e9250b2 100644
--- a/src/config/types.hooks.ts
+++ b/src/config/types.hooks.ts
@@ -24,7 +24,6 @@ export type HookMappingConfig = {
| "telegram"
| "discord"
| "slack"
- | "mattermost"
| "signal"
| "imessage"
| "msteams";
diff --git a/src/config/types.mattermost.ts b/src/config/types.mattermost.ts
deleted file mode 100644
index b87bdfabe..000000000
--- a/src/config/types.mattermost.ts
+++ /dev/null
@@ -1,40 +0,0 @@
-import type { BlockStreamingCoalesceConfig } from "./types.base.js";
-
-export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
-
-export type MattermostAccountConfig = {
- /** Optional display name for this account (used in CLI/UI lists). */
- name?: string;
- /** Optional provider capability tags used for agent/runtime guidance. */
- capabilities?: string[];
- /** Allow channel-initiated config writes (default: true). */
- configWrites?: boolean;
- /** If false, do not start this Mattermost account. Default: true. */
- enabled?: boolean;
- /** Bot token for Mattermost. */
- botToken?: string;
- /** Base URL for the Mattermost server (e.g., https://chat.example.com). */
- baseUrl?: string;
- /**
- * Controls when channel messages trigger replies.
- * - "oncall": only respond when mentioned
- * - "onmessage": respond to every channel message
- * - "onchar": respond when a trigger character prefixes the message
- */
- chatmode?: MattermostChatMode;
- /** Prefix characters that trigger onchar mode (default: [">", "!"]). */
- oncharPrefixes?: string[];
- /** Require @mention to respond in channels. Default: true. */
- requireMention?: boolean;
- /** Outbound text chunk size (chars). Default: 4000. */
- textChunkLimit?: number;
- /** Disable block streaming for this account. */
- blockStreaming?: boolean;
- /** Merge streamed block replies before sending. */
- blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
-};
-
-export type MattermostConfig = {
- /** Optional per-account Mattermost configuration (multi-account). */
- accounts?: Record;
-} & MattermostAccountConfig;
diff --git a/src/config/types.queue.ts b/src/config/types.queue.ts
index 6289e7c56..0afeb5232 100644
--- a/src/config/types.queue.ts
+++ b/src/config/types.queue.ts
@@ -13,7 +13,6 @@ 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 46e79eaca..368618262 100644
--- a/src/config/types.ts
+++ b/src/config/types.ts
@@ -14,7 +14,6 @@ export * from "./types.hooks.js";
export * from "./types.imessage.js";
export * from "./types.messages.js";
export * from "./types.models.js";
-export * from "./types.mattermost.js";
export * from "./types.msteams.js";
export * from "./types.plugins.js";
export * from "./types.queue.js";
diff --git a/src/config/zod-schema.agent-runtime.ts b/src/config/zod-schema.agent-runtime.ts
index 66664a981..d34165907 100644
--- a/src/config/zod-schema.agent-runtime.ts
+++ b/src/config/zod-schema.agent-runtime.ts
@@ -29,7 +29,6 @@ export const HeartbeatSchema = z
z.literal("telegram"),
z.literal("discord"),
z.literal("slack"),
- z.literal("mattermost"),
z.literal("msteams"),
z.literal("signal"),
z.literal("imessage"),
diff --git a/src/config/zod-schema.hooks.ts b/src/config/zod-schema.hooks.ts
index 9153aa130..140e861dd 100644
--- a/src/config/zod-schema.hooks.ts
+++ b/src/config/zod-schema.hooks.ts
@@ -23,7 +23,6 @@ export const HookMappingSchema = z
z.literal("telegram"),
z.literal("discord"),
z.literal("slack"),
- z.literal("mattermost"),
z.literal("signal"),
z.literal("imessage"),
z.literal("msteams"),
diff --git a/src/config/zod-schema.providers-core.ts b/src/config/zod-schema.providers-core.ts
index 96bd04e10..68806c61f 100644
--- a/src/config/zod-schema.providers-core.ts
+++ b/src/config/zod-schema.providers-core.ts
@@ -367,27 +367,6 @@ export const SlackConfigSchema = SlackAccountSchema.extend({
}
});
-export const MattermostAccountSchema = z
- .object({
- name: z.string().optional(),
- capabilities: z.array(z.string()).optional(),
- enabled: z.boolean().optional(),
- configWrites: z.boolean().optional(),
- botToken: z.string().optional(),
- baseUrl: z.string().optional(),
- chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
- oncharPrefixes: z.array(z.string()).optional(),
- requireMention: z.boolean().optional(),
- textChunkLimit: z.number().int().positive().optional(),
- blockStreaming: z.boolean().optional(),
- blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
- })
- .strict();
-
-export const MattermostConfigSchema = MattermostAccountSchema.extend({
- accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
-});
-
export const SignalAccountSchemaBase = z
.object({
name: z.string().optional(),
diff --git a/src/config/zod-schema.providers.ts b/src/config/zod-schema.providers.ts
index aa5eb7737..a58119702 100644
--- a/src/config/zod-schema.providers.ts
+++ b/src/config/zod-schema.providers.ts
@@ -4,7 +4,6 @@ import {
BlueBubblesConfigSchema,
DiscordConfigSchema,
IMessageConfigSchema,
- MattermostConfigSchema,
MSTeamsConfigSchema,
SignalConfigSchema,
SlackConfigSchema,
@@ -28,7 +27,6 @@ export const ChannelsSchema = z
telegram: TelegramConfigSchema.optional(),
discord: DiscordConfigSchema.optional(),
slack: SlackConfigSchema.optional(),
- mattermost: MattermostConfigSchema.optional(),
signal: SignalConfigSchema.optional(),
imessage: IMessageConfigSchema.optional(),
bluebubbles: BlueBubblesConfigSchema.optional(),
diff --git a/src/infra/outbound/deliver.ts b/src/infra/outbound/deliver.ts
index 2d874d7e9..21fffe807 100644
--- a/src/infra/outbound/deliver.ts
+++ b/src/infra/outbound/deliver.ts
@@ -28,18 +28,11 @@ type SendMatrixMessage = (
opts?: { mediaUrl?: string; replyToId?: string; threadId?: string; timeoutMs?: number },
) => Promise<{ messageId: string; roomId: string }>;
-type SendMattermostMessage = (
- to: string,
- text: string,
- opts?: { accountId?: string; mediaUrl?: string; replyToId?: string },
-) => Promise<{ messageId: string; channelId: string }>;
-
export type OutboundSendDeps = {
sendWhatsApp?: typeof sendMessageWhatsApp;
sendTelegram?: typeof sendMessageTelegram;
sendDiscord?: typeof sendMessageDiscord;
sendSlack?: typeof sendMessageSlack;
- sendMattermost?: SendMattermostMessage;
sendSignal?: typeof sendMessageSignal;
sendIMessage?: typeof sendMessageIMessage;
sendMatrix?: SendMatrixMessage;
diff --git a/src/utils/message-channel.ts b/src/utils/message-channel.ts
index c09436ac8..ecd1f713b 100644
--- a/src/utils/message-channel.ts
+++ b/src/utils/message-channel.ts
@@ -22,7 +22,6 @@ 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 8fb44c485..be278b8e5 100644
--- a/ui/src/ui/types.ts
+++ b/ui/src/ui/types.ts
@@ -164,39 +164,6 @@ export type SlackStatus = {
lastProbeAt?: number | null;
};
-export type MattermostBot = {
- id?: string | null;
- username?: string | null;
-};
-
-export type MattermostProbe = {
- ok: boolean;
- status?: number | null;
- error?: string | null;
- elapsedMs?: number | null;
- bot?: MattermostBot | null;
-};
-
-export type MattermostStatus = {
- configured: boolean;
- botTokenSource?: string | null;
- running: boolean;
- connected?: boolean | null;
- lastConnectedAt?: number | null;
- lastDisconnect?: {
- at: number;
- status?: number | null;
- error?: string | null;
- loggedOut?: boolean | null;
- } | null;
- lastStartAt?: number | null;
- lastStopAt?: number | null;
- lastError?: string | null;
- baseUrl?: string | null;
- probe?: MattermostProbe | null;
- lastProbeAt?: number | null;
-};
-
export type SignalProbe = {
ok: boolean;
status?: number | null;
@@ -415,7 +382,6 @@ export type CronPayload =
| "telegram"
| "discord"
| "slack"
- | "mattermost"
| "signal"
| "imessage"
| "msteams";
diff --git a/ui/src/ui/views/channels.mattermost.ts b/ui/src/ui/views/channels.mattermost.ts
deleted file mode 100644
index c2513ed44..000000000
--- a/ui/src/ui/views/channels.mattermost.ts
+++ /dev/null
@@ -1,70 +0,0 @@
-import { html, nothing } from "lit";
-
-import { formatAgo } from "../format";
-import type { MattermostStatus } from "../types";
-import type { ChannelsProps } from "./channels.types";
-import { renderChannelConfigSection } from "./channels.config";
-
-export function renderMattermostCard(params: {
- props: ChannelsProps;
- mattermost?: MattermostStatus | null;
- accountCountLabel: unknown;
-}) {
- const { props, mattermost, accountCountLabel } = params;
-
- return html`
-
- 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 d9f148764..232cf2c85 100644
--- a/ui/src/ui/views/channels.ts
+++ b/ui/src/ui/views/channels.ts
@@ -7,7 +7,6 @@ import type {
ChannelsStatusSnapshot,
DiscordStatus,
IMessageStatus,
- MattermostStatus,
NostrProfile,
NostrStatus,
SignalStatus,
@@ -24,7 +23,6 @@ import { channelEnabled, renderChannelAccountCount } from "./channels.shared";
import { renderChannelConfigSection } from "./channels.config";
import { renderDiscordCard } from "./channels.discord";
import { renderIMessageCard } from "./channels.imessage";
-import { renderMattermostCard } from "./channels.mattermost";
import { renderNostrCard } from "./channels.nostr";
import { renderSignalCard } from "./channels.signal";
import { renderSlackCard } from "./channels.slack";
@@ -41,7 +39,6 @@ export function renderChannels(props: ChannelsProps) {
| undefined;
const discord = (channels?.discord ?? null) as DiscordStatus | null;
const slack = (channels?.slack ?? null) as SlackStatus | null;
- const mattermost = (channels?.mattermost ?? null) as MattermostStatus | null;
const signal = (channels?.signal ?? null) as SignalStatus | null;
const imessage = (channels?.imessage ?? null) as IMessageStatus | null;
const nostr = (channels?.nostr ?? null) as NostrStatus | null;
@@ -65,7 +62,6 @@ export function renderChannels(props: ChannelsProps) {
telegram,
discord,
slack,
- mattermost,
signal,
imessage,
nostr,
@@ -139,12 +135,6 @@ function renderChannel(
slack: data.slack,
accountCountLabel,
});
- case "mattermost":
- return renderMattermostCard({
- props,
- mattermost: data.mattermost,
- accountCountLabel,
- });
case "signal":
return renderSignalCard({
props,
diff --git a/ui/src/ui/views/channels.types.ts b/ui/src/ui/views/channels.types.ts
index d3a98d44e..43576d54a 100644
--- a/ui/src/ui/views/channels.types.ts
+++ b/ui/src/ui/views/channels.types.ts
@@ -4,7 +4,6 @@ import type {
ConfigUiHints,
DiscordStatus,
IMessageStatus,
- MattermostStatus,
NostrProfile,
NostrStatus,
SignalStatus,
@@ -54,7 +53,6 @@ export type ChannelsChannelData = {
telegram?: TelegramStatus;
discord?: DiscordStatus | null;
slack?: SlackStatus | null;
- mattermost?: MattermostStatus | null;
signal?: SignalStatus | null;
imessage?: IMessageStatus | null;
nostr?: NostrStatus | null;