feat: add Mattermost channel support

Add Mattermost as a supported messaging channel with bot API and WebSocket integration. Includes channel state tracking (tint, summary, details), multi-account support, and delivery target routing. Update documentation and tests to include Mattermost alongside existing channels.
This commit is contained in:
Dominic Damoah
2026-01-21 18:40:56 -05:00
parent fb164b321e
commit bf6df6d6b7
66 changed files with 2545 additions and 34 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -35,7 +35,7 @@ function detectAutoKind(input: string): ChannelResolveKind {
if (!trimmed) return "group";
if (trimmed.startsWith("@")) return "user";
if (/^<@!?/.test(trimmed)) return "user";
if (/^(user|discord|slack|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
if (/^(user|discord|slack|mattermost|matrix|msteams|teams|zalo|zalouser):/i.test(trimmed)) {
return "user";
}
return "group";

View File

@@ -52,6 +52,8 @@ const SHELL_ENV_EXPECTED_KEYS = [
"DISCORD_BOT_TOKEN",
"SLACK_BOT_TOKEN",
"SLACK_APP_TOKEN",
"MATTERMOST_BOT_TOKEN",
"MATTERMOST_URL",
"CLAWDBOT_GATEWAY_TOKEN",
"CLAWDBOT_GATEWAY_PASSWORD",
];

View File

@@ -124,6 +124,7 @@ export const LEGACY_CONFIG_MIGRATIONS_PART_1: LegacyConfigMigration[] = [
"telegram",
"discord",
"slack",
"mattermost",
"signal",
"imessage",
"msteams",

View File

@@ -17,6 +17,10 @@ export const LEGACY_CONFIG_RULES: LegacyConfigRule[] = [
path: ["slack"],
message: "slack config moved to channels.slack (auto-migrated on load).",
},
{
path: ["mattermost"],
message: "mattermost config moved to channels.mattermost (auto-migrated on load).",
},
{
path: ["signal"],
message: "signal config moved to channels.signal (auto-migrated on load).",

View File

@@ -272,6 +272,7 @@ const FIELD_LABELS: Record<string, string> = {
"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<string, string> = {
"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<string, string> = {
'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<string, string> = {
"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<string, string> = {
"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];

View File

@@ -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"

View File

@@ -1,5 +1,6 @@
import type { DiscordConfig } from "./types.discord.js";
import type { IMessageConfig } from "./types.imessage.js";
import type { MattermostConfig } from "./types.mattermost.js";
import type { MSTeamsConfig } from "./types.msteams.js";
import type { SignalConfig } from "./types.signal.js";
import type { SlackConfig } from "./types.slack.js";
@@ -17,6 +18,7 @@ export type ChannelsConfig = {
telegram?: TelegramConfig;
discord?: DiscordConfig;
slack?: SlackConfig;
mattermost?: MattermostConfig;
signal?: SignalConfig;
imessage?: IMessageConfig;
msteams?: MSTeamsConfig;

View File

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

View File

@@ -0,0 +1,40 @@
import type { BlockStreamingCoalesceConfig } from "./types.base.js";
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
export type MattermostAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this Mattermost account. Default: true. */
enabled?: boolean;
/** Bot token for Mattermost. */
botToken?: string;
/** Base URL for the Mattermost server (e.g., https://chat.example.com). */
baseUrl?: string;
/**
* Controls when channel messages trigger replies.
* - "oncall": only respond when mentioned
* - "onmessage": respond to every channel message
* - "onchar": respond when a trigger character prefixes the message
*/
chatmode?: MattermostChatMode;
/** Prefix characters that trigger onchar mode (default: [">", "!"]). */
oncharPrefixes?: string[];
/** Require @mention to respond in channels. Default: true. */
requireMention?: boolean;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
};
export type MattermostConfig = {
/** Optional per-account Mattermost configuration (multi-account). */
accounts?: Record<string, MattermostAccountConfig>;
} & MattermostAccountConfig;

View File

@@ -22,6 +22,7 @@ export type InboundDebounceByProvider = {
telegram?: number;
discord?: number;
slack?: number;
mattermost?: number;
signal?: number;
imessage?: number;
msteams?: number;

View File

@@ -13,6 +13,7 @@ export type QueueModeByProvider = {
telegram?: QueueMode;
discord?: QueueMode;
slack?: QueueMode;
mattermost?: QueueMode;
signal?: QueueMode;
imessage?: QueueMode;
msteams?: QueueMode;

View File

@@ -14,6 +14,7 @@ export * from "./types.hooks.js";
export * from "./types.imessage.js";
export * from "./types.messages.js";
export * from "./types.models.js";
export * from "./types.mattermost.js";
export * from "./types.msteams.js";
export * from "./types.plugins.js";
export * from "./types.queue.js";

View File

@@ -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"),

View File

@@ -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(),

View File

@@ -23,6 +23,7 @@ export const HookMappingSchema = z
z.literal("telegram"),
z.literal("discord"),
z.literal("slack"),
z.literal("mattermost"),
z.literal("signal"),
z.literal("imessage"),
z.literal("msteams"),

View File

@@ -367,6 +367,27 @@ export const SlackConfigSchema = SlackAccountSchema.extend({
}
});
export const MattermostAccountSchema = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
configWrites: z.boolean().optional(),
botToken: z.string().optional(),
baseUrl: z.string().optional(),
chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
oncharPrefixes: z.array(z.string()).optional(),
requireMention: z.boolean().optional(),
textChunkLimit: z.number().int().positive().optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
})
.strict();
export const MattermostConfigSchema = MattermostAccountSchema.extend({
accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
});
export const SignalAccountSchemaBase = z
.object({
name: z.string().optional(),

View File

@@ -4,6 +4,7 @@ import {
BlueBubblesConfigSchema,
DiscordConfigSchema,
IMessageConfigSchema,
MattermostConfigSchema,
MSTeamsConfigSchema,
SignalConfigSchema,
SlackConfigSchema,
@@ -27,6 +28,7 @@ export const ChannelsSchema = z
telegram: TelegramConfigSchema.optional(),
discord: DiscordConfigSchema.optional(),
slack: SlackConfigSchema.optional(),
mattermost: MattermostConfigSchema.optional(),
signal: SignalConfigSchema.optional(),
imessage: IMessageConfigSchema.optional(),
bluebubbles: BlueBubblesConfigSchema.optional(),

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -81,6 +81,7 @@ export type {
export {
DiscordConfigSchema,
IMessageConfigSchema,
MattermostConfigSchema,
MSTeamsConfigSchema,
SignalConfigSchema,
SlackConfigSchema,
@@ -120,6 +121,7 @@ export {
resolveBlueBubblesGroupRequireMention,
resolveDiscordGroupRequireMention,
resolveIMessageGroupRequireMention,
resolveMattermostGroupRequireMention,
resolveSlackGroupRequireMention,
resolveTelegramGroupRequireMention,
resolveWhatsAppGroupRequireMention,
@@ -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,

View File

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

View File

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

View File

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