Include full contents of all nested repositories

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 16:25:02 +01:00
parent 14ff8fd54c
commit 2401ed446f
7271 changed files with 1310112 additions and 6 deletions

View File

@@ -0,0 +1,17 @@
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
import { mattermostPlugin } from "./src/channel.js";
import { setMattermostRuntime } from "./src/runtime.js";
const plugin = {
id: "mattermost",
name: "Mattermost",
description: "Mattermost channel plugin",
configSchema: emptyPluginConfigSchema(),
register(api: OpenClawPluginApi) {
setMattermostRuntime(api.runtime);
api.registerChannel({ plugin: mattermostPlugin });
},
};
export default plugin;

View File

@@ -0,0 +1,9 @@
{
"id": "mattermost",
"channels": ["mattermost"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View File

@@ -0,0 +1,25 @@
{
"name": "@openclaw/mattermost",
"version": "2026.2.26",
"description": "OpenClaw Mattermost channel plugin",
"type": "module",
"openclaw": {
"extensions": [
"./index.ts"
],
"channel": {
"id": "mattermost",
"label": "Mattermost",
"selectionLabel": "Mattermost (plugin)",
"docsPath": "/channels/mattermost",
"docsLabel": "mattermost",
"blurb": "self-hosted Slack-style chat; install the plugin to enable.",
"order": 65
},
"install": {
"npmSpec": "@openclaw/mattermost",
"localPath": "extensions/mattermost",
"defaultChoice": "npm"
}
}
}

View File

@@ -0,0 +1,235 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { createReplyPrefixOptions } from "openclaw/plugin-sdk";
import { beforeEach, describe, expect, it } from "vitest";
import { mattermostPlugin } from "./channel.js";
import { resetMattermostReactionBotUserCacheForTests } from "./mattermost/reactions.js";
import {
createMattermostReactionFetchMock,
createMattermostTestConfig,
withMockedGlobalFetch,
} from "./mattermost/reactions.test-helpers.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("capabilities", () => {
it("declares reactions support", () => {
expect(mattermostPlugin.capabilities?.reactions).toBe(true);
});
});
describe("messageActions", () => {
beforeEach(() => {
resetMattermostReactionBotUserCacheForTests();
});
const runReactAction = async (params: Record<string, unknown>, fetchMode: "add" | "remove") => {
const cfg = createMattermostTestConfig();
const fetchImpl = createMattermostReactionFetchMock({
mode: fetchMode,
postId: "POST1",
emojiName: "thumbsup",
});
return await withMockedGlobalFetch(fetchImpl as unknown as typeof fetch, async () => {
return await mattermostPlugin.actions?.handleAction?.({
channel: "mattermost",
action: "react",
params,
cfg,
accountId: "default",
} as any);
});
};
it("exposes react when mattermost is configured", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
},
},
};
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
expect(actions).toContain("react");
expect(actions).not.toContain("send");
expect(mattermostPlugin.actions?.supportsAction?.({ action: "react" })).toBe(true);
});
it("hides react when mattermost is not configured", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
},
},
};
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
expect(actions).toEqual([]);
});
it("hides react when actions.reactions is false", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
actions: { reactions: false },
},
},
};
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
expect(actions).not.toContain("react");
expect(actions).not.toContain("send");
});
it("respects per-account actions.reactions in listActions", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
actions: { reactions: false },
accounts: {
default: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
actions: { reactions: true },
},
},
},
},
};
const actions = mattermostPlugin.actions?.listActions?.({ cfg }) ?? [];
expect(actions).toContain("react");
});
it("blocks react when default account disables reactions and accountId is omitted", async () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
actions: { reactions: true },
accounts: {
default: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
actions: { reactions: false },
},
},
},
},
};
await expect(
mattermostPlugin.actions?.handleAction?.({
channel: "mattermost",
action: "react",
params: { messageId: "POST1", emoji: "thumbsup" },
cfg,
} as any),
).rejects.toThrow("Mattermost reactions are disabled in config");
});
it("handles react by calling Mattermost reactions API", async () => {
const result = await runReactAction({ messageId: "POST1", emoji: "thumbsup" }, "add");
expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]);
expect(result?.details).toEqual({});
});
it("only treats boolean remove flag as removal", async () => {
const result = await runReactAction(
{ messageId: "POST1", emoji: "thumbsup", remove: "true" },
"add",
);
expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]);
});
it("removes reaction when remove flag is boolean true", async () => {
const result = await runReactAction(
{ messageId: "POST1", emoji: "thumbsup", remove: true },
"remove",
);
expect(result?.content).toEqual([
{ type: "text", text: "Removed reaction :thumbsup: from POST1" },
]);
expect(result?.details).toEqual({});
});
});
describe("config", () => {
it("formats allowFrom entries", () => {
const formatAllowFrom = mattermostPlugin.config.formatAllowFrom!;
const formatted = formatAllowFrom({
cfg: {} as OpenClawConfig,
allowFrom: ["@Alice", "user:USER123", "mattermost:BOT999"],
});
expect(formatted).toEqual(["@alice", "user123", "bot999"]);
});
it("uses account responsePrefix overrides", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
responsePrefix: "[Channel]",
accounts: {
default: { responsePrefix: "[Account]" },
},
},
},
};
const prefixContext = createReplyPrefixOptions({
cfg,
agentId: "main",
channel: "mattermost",
accountId: "default",
});
expect(prefixContext.responsePrefix).toBe("[Account]");
});
});
});

View File

@@ -0,0 +1,439 @@
import {
applyAccountNameToChannelSection,
buildChannelConfigSchema,
DEFAULT_ACCOUNT_ID,
deleteAccountFromConfigSection,
formatPairingApproveHint,
migrateBaseNameToDefaultAccount,
normalizeAccountId,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
setAccountEnabledInConfigSection,
type ChannelMessageActionAdapter,
type ChannelMessageActionName,
type ChannelPlugin,
} from "openclaw/plugin-sdk";
import { MattermostConfigSchema } from "./config-schema.js";
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
import {
listMattermostAccountIds,
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
type ResolvedMattermostAccount,
} from "./mattermost/accounts.js";
import { normalizeMattermostBaseUrl } from "./mattermost/client.js";
import { monitorMattermostProvider } from "./mattermost/monitor.js";
import { probeMattermost } from "./mattermost/probe.js";
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
import { sendMessageMattermost } from "./mattermost/send.js";
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
import { mattermostOnboardingAdapter } from "./onboarding.js";
import { getMattermostRuntime } from "./runtime.js";
const mattermostMessageActions: ChannelMessageActionAdapter = {
listActions: ({ cfg }) => {
const actionsConfig = cfg.channels?.mattermost?.actions as { reactions?: boolean } | undefined;
const baseReactions = actionsConfig?.reactions;
const hasReactionCapableAccount = listMattermostAccountIds(cfg)
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
.filter((account) => account.enabled)
.filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim()))
.some((account) => {
const accountActions = account.config.actions as { reactions?: boolean } | undefined;
return (accountActions?.reactions ?? baseReactions ?? true) !== false;
});
if (!hasReactionCapableAccount) {
return [];
}
return ["react"];
},
supportsAction: ({ action }) => {
return action === "react";
},
handleAction: async ({ action, params, cfg, accountId }) => {
if (action !== "react") {
throw new Error(`Mattermost action ${action} not supported`);
}
// Check reactions gate: per-account config takes precedence over base config
const mmBase = cfg?.channels?.mattermost as Record<string, unknown> | undefined;
const accounts = mmBase?.accounts as Record<string, Record<string, unknown>> | undefined;
const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg);
const acctConfig = accounts?.[resolvedAccountId];
const acctActions = acctConfig?.actions as { reactions?: boolean } | undefined;
const baseActions = mmBase?.actions as { reactions?: boolean } | undefined;
const reactionsEnabled = acctActions?.reactions ?? baseActions?.reactions ?? true;
if (!reactionsEnabled) {
throw new Error("Mattermost reactions are disabled in config");
}
const postIdRaw =
typeof (params as any)?.messageId === "string"
? (params as any).messageId
: typeof (params as any)?.postId === "string"
? (params as any).postId
: "";
const postId = postIdRaw.trim();
if (!postId) {
throw new Error("Mattermost react requires messageId (post id)");
}
const emojiRaw = typeof (params as any)?.emoji === "string" ? (params as any).emoji : "";
const emojiName = emojiRaw.trim().replace(/^:+|:+$/g, "");
if (!emojiName) {
throw new Error("Mattermost react requires emoji");
}
const remove = (params as any)?.remove === true;
if (remove) {
const result = await removeMattermostReaction({
cfg,
postId,
emojiName,
accountId: resolvedAccountId,
});
if (!result.ok) {
throw new Error(result.error);
}
return {
content: [
{ type: "text" as const, text: `Removed reaction :${emojiName}: from ${postId}` },
],
details: {},
};
}
const result = await addMattermostReaction({
cfg,
postId,
emojiName,
accountId: resolvedAccountId,
});
if (!result.ok) {
throw new Error(result.error);
}
return {
content: [{ type: "text" as const, text: `Reacted with :${emojiName}: on ${postId}` }],
details: {},
};
},
};
const meta = {
id: "mattermost",
label: "Mattermost",
selectionLabel: "Mattermost (plugin)",
detailLabel: "Mattermost Bot",
docsPath: "/channels/mattermost",
docsLabel: "mattermost",
blurb: "self-hosted Slack-style chat; install the plugin to enable.",
systemImage: "bubble.left.and.bubble.right",
order: 65,
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<ResolvedMattermostAccount> = {
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"],
reactions: true,
threads: true,
media: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.mattermost"] },
configSchema: buildChannelConfigSchema(MattermostConfigSchema),
config: {
listAccountIds: (cfg) => listMattermostAccountIds(cfg),
resolveAccount: (cfg, accountId) => resolveMattermostAccount({ cfg, accountId }),
defaultAccountId: (cfg) => resolveDefaultMattermostAccountId(cfg),
setAccountEnabled: ({ cfg, accountId, enabled }) =>
setAccountEnabledInConfigSection({
cfg,
sectionKey: "mattermost",
accountId,
enabled,
allowTopLevel: true,
}),
deleteAccount: ({ cfg, accountId }) =>
deleteAccountFromConfigSection({
cfg,
sectionKey: "mattermost",
accountId,
clearBaseFields: ["botToken", "baseUrl", "name"],
}),
isConfigured: (account) => Boolean(account.botToken && account.baseUrl),
describeAccount: (account) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.botToken && account.baseUrl),
botTokenSource: account.botTokenSource,
baseUrl: account.baseUrl,
}),
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 = resolveDefaultGroupPolicy(cfg);
const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent: cfg.channels?.mattermost !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
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,
},
actions: mattermostMessageActions,
messaging: {
normalizeTarget: normalizeMattermostMessagingTarget,
targetResolver: {
looksLikeId: looksLikeMattermostTargetId,
hint: "<channelId|user:ID|channel:ID>",
},
},
outbound: {
deliveryMode: "direct",
chunker: (text, limit) => getMattermostRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
resolveTarget: ({ to }) => {
const trimmed = to?.trim();
if (!trimmed) {
return {
ok: false,
error: new Error(
"Delivering to Mattermost requires --to <channelId|@username|user:ID|channel:ID>",
),
};
}
return { ok: true, to: trimmed };
},
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, replyToId }) => {
const result = await sendMessageMattermost(to, text, {
accountId: accountId ?? undefined,
mediaUrl,
replyToId: replyToId ?? undefined,
});
return { channel: "mattermost", ...result };
},
},
status: {
defaultRuntime: {
accountId: DEFAULT_ACCOUNT_ID,
running: false,
connected: false,
lastConnectedAt: null,
lastDisconnect: null,
lastStartAt: null,
lastStopAt: null,
lastError: null,
},
buildChannelSummary: ({ snapshot }) => ({
configured: snapshot.configured ?? false,
botTokenSource: snapshot.botTokenSource ?? "none",
running: snapshot.running ?? false,
connected: snapshot.connected ?? false,
lastStartAt: snapshot.lastStartAt ?? null,
lastStopAt: snapshot.lastStopAt ?? null,
lastError: snapshot.lastError ?? null,
baseUrl: snapshot.baseUrl ?? null,
probe: snapshot.probe,
lastProbeAt: snapshot.lastProbeAt ?? null,
}),
probeAccount: async ({ account, timeoutMs }) => {
const token = account.botToken?.trim();
const baseUrl = account.baseUrl?.trim();
if (!token || !baseUrl) {
return { ok: false, error: "bot token or baseUrl missing" };
}
return await probeMattermost(baseUrl, token, timeoutMs);
},
buildAccountSnapshot: ({ account, runtime, probe }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.botToken && account.baseUrl),
botTokenSource: account.botTokenSource,
baseUrl: account.baseUrl,
running: runtime?.running ?? false,
connected: runtime?.connected ?? false,
lastConnectedAt: runtime?.lastConnectedAt ?? null,
lastDisconnect: runtime?.lastDisconnect ?? null,
lastStartAt: runtime?.lastStartAt ?? null,
lastStopAt: runtime?.lastStopAt ?? null,
lastError: runtime?.lastError ?? null,
probe,
lastInboundAt: runtime?.lastInboundAt ?? null,
lastOutboundAt: runtime?.lastOutboundAt ?? null,
}),
},
setup: {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: "mattermost",
accountId,
name,
}),
validateInput: ({ accountId, input }) => {
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
return "Mattermost env vars can only be used for the default account.";
}
const token = input.botToken ?? input.token;
const baseUrl = input.httpUrl;
if (!input.useEnv && (!token || !baseUrl)) {
return "Mattermost requires --bot-token and --http-url (or --use-env).";
}
if (baseUrl && !normalizeMattermostBaseUrl(baseUrl)) {
return "Mattermost --http-url must include a valid base URL.";
}
return null;
},
applyAccountConfig: ({ cfg, accountId, input }) => {
const token = input.botToken ?? input.token;
const baseUrl = input.httpUrl?.trim();
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: "mattermost",
accountId,
name: input.name,
});
const next =
accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: "mattermost",
})
: namedConfig;
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...next,
channels: {
...next.channels,
mattermost: {
...next.channels?.mattermost,
enabled: true,
...(input.useEnv
? {}
: {
...(token ? { botToken: token } : {}),
...(baseUrl ? { baseUrl } : {}),
}),
},
},
};
}
return {
...next,
channels: {
...next.channels,
mattermost: {
...next.channels?.mattermost,
enabled: true,
accounts: {
...next.channels?.mattermost?.accounts,
[accountId]: {
...next.channels?.mattermost?.accounts?.[accountId],
enabled: true,
...(token ? { botToken: token } : {}),
...(baseUrl ? { baseUrl } : {}),
},
},
},
},
};
},
},
gateway: {
startAccount: async (ctx) => {
const account = ctx.account;
ctx.setStatus({
accountId: account.accountId,
baseUrl: account.baseUrl,
botTokenSource: account.botTokenSource,
});
ctx.log?.info(`[${account.accountId}] starting channel`);
return monitorMattermostProvider({
botToken: account.botToken ?? undefined,
baseUrl: account.baseUrl ?? undefined,
accountId: account.accountId,
config: ctx.cfg,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
});
},
},
};

View File

@@ -0,0 +1,62 @@
import {
BlockStreamingCoalesceSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom,
} from "openclaw/plugin-sdk";
import { z } from "zod";
const MattermostAccountSchemaBase = z
.object({
name: z.string().optional(),
capabilities: z.array(z.string()).optional(),
dangerouslyAllowNameMatching: z.boolean().optional(),
markdown: MarkdownConfigSchema,
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(),
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(),
chunkMode: z.enum(["length", "newline"]).optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
responsePrefix: z.string().optional(),
actions: z
.object({
reactions: z.boolean().optional(),
})
.optional(),
})
.strict();
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 "*"',
});
});

View File

@@ -0,0 +1,15 @@
import type { ChannelGroupContext } from "openclaw/plugin-sdk";
import { resolveMattermostAccount } from "./mattermost/accounts.js";
export function resolveMattermostGroupRequireMention(
params: ChannelGroupContext,
): boolean | undefined {
const account = resolveMattermostAccount({
cfg: params.cfg,
accountId: params.accountId,
});
if (typeof account.requireMention === "boolean") {
return account.requireMention;
}
return true;
}

View File

@@ -0,0 +1,128 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { MattermostAccountConfig, MattermostChatMode } from "../types.js";
import { normalizeMattermostBaseUrl } from "./client.js";
export type MattermostTokenSource = "env" | "config" | "none";
export type MattermostBaseUrlSource = "env" | "config" | "none";
export type ResolvedMattermostAccount = {
accountId: string;
enabled: boolean;
name?: string;
botToken?: string;
baseUrl?: string;
botTokenSource: MattermostTokenSource;
baseUrlSource: MattermostBaseUrlSource;
config: MattermostAccountConfig;
chatmode?: MattermostChatMode;
oncharPrefixes?: string[];
requireMention?: boolean;
textChunkLimit?: number;
blockStreaming?: boolean;
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
};
function listConfiguredAccountIds(cfg: OpenClawConfig): string[] {
const accounts = cfg.channels?.mattermost?.accounts;
if (!accounts || typeof accounts !== "object") {
return [];
}
return Object.keys(accounts).filter(Boolean);
}
export function listMattermostAccountIds(cfg: OpenClawConfig): string[] {
const ids = listConfiguredAccountIds(cfg);
if (ids.length === 0) {
return [DEFAULT_ACCOUNT_ID];
}
return ids.toSorted((a, b) => a.localeCompare(b));
}
export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string {
const ids = listMattermostAccountIds(cfg);
if (ids.includes(DEFAULT_ACCOUNT_ID)) {
return DEFAULT_ACCOUNT_ID;
}
return ids[0] ?? DEFAULT_ACCOUNT_ID;
}
function resolveAccountConfig(
cfg: OpenClawConfig,
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: OpenClawConfig,
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: OpenClawConfig;
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: OpenClawConfig): ResolvedMattermostAccount[] {
return listMattermostAccountIds(cfg)
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View File

@@ -0,0 +1,19 @@
import { describe, expect, it, vi } from "vitest";
import { createMattermostClient } from "./client.js";
describe("mattermost client", () => {
it("request returns undefined on 204 responses", async () => {
const fetchImpl = vi.fn(async () => {
return new Response(null, { status: 204 });
});
const client = createMattermostClient({
baseUrl: "https://chat.example.com",
botToken: "test-token",
fetchImpl: fetchImpl as any,
});
const result = await client.request<unknown>("/anything", { method: "DELETE" });
expect(result).toBeUndefined();
});
});

View File

@@ -0,0 +1,230 @@
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}`;
}
export 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"}`,
);
}
if (res.status === 204) {
return undefined as T;
}
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return (await res.json()) as T;
}
return (await res.text()) 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;
}

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

View File

@@ -0,0 +1,58 @@
import { resolveAllowlistMatchSimple, resolveEffectiveAllowFromLists } from "openclaw/plugin-sdk";
export function normalizeMattermostAllowEntry(entry: string): string {
const trimmed = entry.trim();
if (!trimmed) {
return "";
}
if (trimmed === "*") {
return "*";
}
return trimmed
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, "")
.toLowerCase();
}
export function normalizeMattermostAllowList(entries: Array<string | number>): string[] {
const normalized = entries
.map((entry) => normalizeMattermostAllowEntry(String(entry)))
.filter(Boolean);
return Array.from(new Set(normalized));
}
export function resolveMattermostEffectiveAllowFromLists(params: {
allowFrom?: Array<string | number> | null;
groupAllowFrom?: Array<string | number> | null;
storeAllowFrom?: Array<string | number> | null;
dmPolicy?: string | null;
}): {
effectiveAllowFrom: string[];
effectiveGroupAllowFrom: string[];
} {
return resolveEffectiveAllowFromLists({
allowFrom: normalizeMattermostAllowList(params.allowFrom ?? []),
groupAllowFrom: normalizeMattermostAllowList(params.groupAllowFrom ?? []),
storeAllowFrom: normalizeMattermostAllowList(params.storeAllowFrom ?? []),
dmPolicy: params.dmPolicy,
});
}
export function isMattermostSenderAllowed(params: {
senderId: string;
senderName?: string;
allowFrom: string[];
allowNameMatching?: boolean;
}): boolean {
const allowFrom = normalizeMattermostAllowList(params.allowFrom);
if (allowFrom.length === 0) {
return false;
}
const match = resolveAllowlistMatchSimple({
allowFrom,
senderId: normalizeMattermostAllowEntry(params.senderId),
senderName: params.senderName ? normalizeMattermostAllowEntry(params.senderName) : undefined,
allowNameMatching: params.allowNameMatching,
});
return match.allowed;
}

View File

@@ -0,0 +1,72 @@
import {
formatInboundFromLabel as formatInboundFromLabelShared,
resolveThreadSessionKeys as resolveThreadSessionKeysShared,
type OpenClawConfig,
} from "openclaw/plugin-sdk";
export { createDedupeCache, rawDataToString } from "openclaw/plugin-sdk";
export type ResponsePrefixContext = {
model?: string;
modelFull?: string;
provider?: string;
thinkingLevel?: string;
identityName?: string;
};
export function extractShortModelName(fullModel: string): string {
const slash = fullModel.lastIndexOf("/");
const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel;
return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, "");
}
export const formatInboundFromLabel = formatInboundFromLabelShared;
function normalizeAgentId(value: string | undefined | null): string {
const trimmed = (value ?? "").trim();
if (!trimmed) {
return "main";
}
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) {
return trimmed;
}
return (
trimmed
.toLowerCase()
.replace(/[^a-z0-9_-]+/g, "-")
.replace(/^-+/, "")
.replace(/-+$/, "")
.slice(0, 64) || "main"
);
}
type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
function listAgents(cfg: OpenClawConfig): AgentEntry[] {
const list = cfg.agents?.list;
if (!Array.isArray(list)) {
return [];
}
return list.filter((entry): entry is AgentEntry => Boolean(entry && typeof entry === "object"));
}
function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined {
const id = normalizeAgentId(agentId);
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
}
export function resolveIdentityName(cfg: OpenClawConfig, agentId: string): string | undefined {
const entry = resolveAgentEntry(cfg, agentId);
return entry?.identity?.name?.trim() || undefined;
}
export function resolveThreadSessionKeys(params: {
baseSessionKey: string;
threadId?: string | null;
parentSessionKey?: string;
useSuffix?: boolean;
}): { sessionKey: string; parentSessionKey?: string } {
return resolveThreadSessionKeysShared({
...params,
normalizeThreadId: (threadId) => threadId,
});
}

View File

@@ -0,0 +1,25 @@
const DEFAULT_ONCHAR_PREFIXES = [">", "!"];
export 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;
}
export 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 };
}

View File

@@ -0,0 +1,232 @@
import type { RuntimeEnv } from "openclaw/plugin-sdk";
import { describe, expect, it, vi } from "vitest";
import {
createMattermostConnectOnce,
type MattermostWebSocketLike,
WebSocketClosedBeforeOpenError,
} from "./monitor-websocket.js";
import { runWithReconnect } from "./reconnect.js";
class FakeWebSocket implements MattermostWebSocketLike {
public readonly sent: string[] = [];
public closeCalls = 0;
public terminateCalls = 0;
private openListeners: Array<() => void> = [];
private messageListeners: Array<(data: Buffer) => void | Promise<void>> = [];
private closeListeners: Array<(code: number, reason: Buffer) => void> = [];
private errorListeners: Array<(err: unknown) => void> = [];
on(event: "open", listener: () => void): void;
on(event: "message", listener: (data: Buffer) => void | Promise<void>): void;
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
on(event: "error", listener: (err: unknown) => void): void;
on(event: "open" | "message" | "close" | "error", listener: unknown): void {
if (event === "open") {
this.openListeners.push(listener as () => void);
return;
}
if (event === "message") {
this.messageListeners.push(listener as (data: Buffer) => void | Promise<void>);
return;
}
if (event === "close") {
this.closeListeners.push(listener as (code: number, reason: Buffer) => void);
return;
}
this.errorListeners.push(listener as (err: unknown) => void);
}
send(data: string): void {
this.sent.push(data);
}
close(): void {
this.closeCalls++;
}
terminate(): void {
this.terminateCalls++;
}
emitOpen(): void {
for (const listener of this.openListeners) {
listener();
}
}
emitMessage(data: Buffer): void {
for (const listener of this.messageListeners) {
void listener(data);
}
}
emitClose(code: number, reason = ""): void {
const buffer = Buffer.from(reason, "utf8");
for (const listener of this.closeListeners) {
listener(code, buffer);
}
}
emitError(err: unknown): void {
for (const listener of this.errorListeners) {
listener(err);
}
}
}
const testRuntime = (): RuntimeEnv =>
({
log: vi.fn(),
error: vi.fn(),
exit: ((code: number): never => {
throw new Error(`exit ${code}`);
}) as RuntimeEnv["exit"],
}) as RuntimeEnv;
describe("mattermost websocket monitor", () => {
it("rejects when websocket closes before open", async () => {
const socket = new FakeWebSocket();
const connectOnce = createMattermostConnectOnce({
wsUrl: "wss://example.invalid/api/v4/websocket",
botToken: "token",
runtime: testRuntime(),
nextSeq: () => 1,
onPosted: async () => {},
webSocketFactory: () => socket,
});
queueMicrotask(() => {
socket.emitClose(1006, "connection refused");
});
const failure = connectOnce();
await expect(failure).rejects.toBeInstanceOf(WebSocketClosedBeforeOpenError);
await expect(failure).rejects.toMatchObject({
message: "websocket closed before open (code 1006)",
});
});
it("retries when first attempt errors before open and next attempt succeeds", async () => {
const abort = new AbortController();
const reconnectDelays: number[] = [];
const onError = vi.fn();
const patches: Array<Record<string, unknown>> = [];
const sockets: FakeWebSocket[] = [];
let disconnects = 0;
const connectOnce = createMattermostConnectOnce({
wsUrl: "wss://example.invalid/api/v4/websocket",
botToken: "token",
runtime: testRuntime(),
nextSeq: (() => {
let seq = 1;
return () => seq++;
})(),
onPosted: async () => {},
abortSignal: abort.signal,
statusSink: (patch) => {
patches.push(patch as Record<string, unknown>);
if (patch.lastDisconnect) {
disconnects++;
if (disconnects >= 2) {
abort.abort();
}
}
},
webSocketFactory: () => {
const socket = new FakeWebSocket();
const attempt = sockets.length;
sockets.push(socket);
queueMicrotask(() => {
if (attempt === 0) {
socket.emitError(new Error("boom"));
socket.emitClose(1006, "connection refused");
return;
}
socket.emitOpen();
socket.emitClose(1000);
});
return socket;
},
});
await runWithReconnect(connectOnce, {
abortSignal: abort.signal,
initialDelayMs: 1,
onError,
onReconnect: (delay) => reconnectDelays.push(delay),
});
expect(sockets).toHaveLength(2);
expect(sockets[0].closeCalls).toBe(1);
expect(sockets[1].sent).toHaveLength(1);
expect(JSON.parse(sockets[1].sent[0])).toMatchObject({
action: "authentication_challenge",
data: { token: "token" },
seq: 1,
});
expect(onError).toHaveBeenCalledTimes(1);
expect(reconnectDelays).toEqual([1]);
expect(patches.some((patch) => patch.connected === true)).toBe(true);
expect(patches.filter((patch) => patch.connected === false)).toHaveLength(2);
});
it("dispatches reaction events to the reaction handler", async () => {
const socket = new FakeWebSocket();
const onPosted = vi.fn(async () => {});
const onReaction = vi.fn(async (payload) => payload);
const connectOnce = createMattermostConnectOnce({
wsUrl: "wss://example.invalid/api/v4/websocket",
botToken: "token",
runtime: testRuntime(),
nextSeq: () => 1,
onPosted,
onReaction,
webSocketFactory: () => socket,
});
const connected = connectOnce();
queueMicrotask(() => {
socket.emitOpen();
socket.emitMessage(
Buffer.from(
JSON.stringify({
event: "reaction_added",
data: {
reaction: JSON.stringify({
user_id: "user-1",
post_id: "post-1",
emoji_name: "thumbsup",
}),
},
}),
),
);
socket.emitClose(1000);
});
await connected;
expect(onReaction).toHaveBeenCalledTimes(1);
expect(onPosted).not.toHaveBeenCalled();
const payload = onReaction.mock.calls[0]?.[0];
expect(payload).toMatchObject({
event: "reaction_added",
data: {
reaction: JSON.stringify({
user_id: "user-1",
post_id: "post-1",
emoji_name: "thumbsup",
}),
},
});
expect(payload.data?.reaction).toBe(
JSON.stringify({
user_id: "user-1",
post_id: "post-1",
emoji_name: "thumbsup",
}),
);
expect(payload.data?.reaction).toBeDefined();
});
});

View File

@@ -0,0 +1,221 @@
import type { ChannelAccountSnapshot, RuntimeEnv } from "openclaw/plugin-sdk";
import WebSocket from "ws";
import type { MattermostPost } from "./client.js";
import { rawDataToString } from "./monitor-helpers.js";
export type MattermostEventPayload = {
event?: string;
data?: {
post?: string;
reaction?: 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;
};
};
export type MattermostWebSocketLike = {
on(event: "open", listener: () => void): void;
on(event: "message", listener: (data: WebSocket.RawData) => void | Promise<void>): void;
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
on(event: "error", listener: (err: unknown) => void): void;
send(data: string): void;
close(): void;
terminate(): void;
};
export type MattermostWebSocketFactory = (url: string) => MattermostWebSocketLike;
export class WebSocketClosedBeforeOpenError extends Error {
constructor(
public readonly code: number,
public readonly reason?: string,
) {
super(`websocket closed before open (code ${code})`);
this.name = "WebSocketClosedBeforeOpenError";
}
}
type CreateMattermostConnectOnceOpts = {
wsUrl: string;
botToken: string;
abortSignal?: AbortSignal;
statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
runtime: RuntimeEnv;
nextSeq: () => number;
onPosted: (post: MattermostPost, payload: MattermostEventPayload) => Promise<void>;
onReaction?: (payload: MattermostEventPayload) => Promise<void>;
webSocketFactory?: MattermostWebSocketFactory;
};
export const defaultMattermostWebSocketFactory: MattermostWebSocketFactory = (url) =>
new WebSocket(url) as MattermostWebSocketLike;
export function parsePostedPayload(
payload: MattermostEventPayload,
): { payload: MattermostEventPayload; post: MattermostPost } | null {
if (payload.event !== "posted") {
return null;
}
const postData = payload.data?.post;
if (!postData) {
return null;
}
let post: MattermostPost | null = null;
if (typeof postData === "string") {
try {
post = JSON.parse(postData) as MattermostPost;
} catch {
return null;
}
} else if (typeof postData === "object") {
post = postData as MattermostPost;
}
if (!post) {
return null;
}
return { payload, post };
}
export function parsePostedEvent(
data: WebSocket.RawData,
): { payload: MattermostEventPayload; post: MattermostPost } | null {
const raw = rawDataToString(data);
let payload: MattermostEventPayload;
try {
payload = JSON.parse(raw) as MattermostEventPayload;
} catch {
return null;
}
return parsePostedPayload(payload);
}
export function createMattermostConnectOnce(
opts: CreateMattermostConnectOnceOpts,
): () => Promise<void> {
const webSocketFactory = opts.webSocketFactory ?? defaultMattermostWebSocketFactory;
return async () => {
const ws = webSocketFactory(opts.wsUrl);
const onAbort = () => ws.terminate();
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
try {
return await new Promise<void>((resolve, reject) => {
let opened = false;
let settled = false;
const resolveOnce = () => {
if (settled) {
return;
}
settled = true;
resolve();
};
const rejectOnce = (error: Error) => {
if (settled) {
return;
}
settled = true;
reject(error);
};
ws.on("open", () => {
opened = true;
opts.statusSink?.({
connected: true,
lastConnectedAt: Date.now(),
lastError: null,
});
ws.send(
JSON.stringify({
seq: opts.nextSeq(),
action: "authentication_challenge",
data: { token: opts.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 === "reaction_added" || payload.event === "reaction_removed") {
if (!opts.onReaction) {
return;
}
try {
await opts.onReaction(payload);
} catch (err) {
opts.runtime.error?.(`mattermost reaction handler failed: ${String(err)}`);
}
return;
}
if (payload.event !== "posted") {
return;
}
const parsed = parsePostedPayload(payload);
if (!parsed) {
return;
}
try {
await opts.onPosted(parsed.post, parsed.payload);
} catch (err) {
opts.runtime.error?.(`mattermost handler failed: ${String(err)}`);
}
});
ws.on("close", (code, reason) => {
const message = reasonToString(reason);
opts.statusSink?.({
connected: false,
lastDisconnect: {
at: Date.now(),
status: code,
error: message || undefined,
},
});
if (opened) {
resolveOnce();
return;
}
rejectOnce(new WebSocketClosedBeforeOpenError(code, message || undefined));
});
ws.on("error", (err) => {
opts.runtime.error?.(`mattermost websocket error: ${String(err)}`);
opts.statusSink?.({
lastError: String(err),
});
try {
ws.close();
} catch {}
});
});
} finally {
opts.abortSignal?.removeEventListener("abort", onAbort);
}
};
}
function reasonToString(reason: Buffer | string | undefined): string {
if (!reason) {
return "";
}
if (typeof reason === "string") {
return reason;
}
return reason.length > 0 ? reason.toString("utf8") : "";
}

View File

@@ -0,0 +1,59 @@
import { resolveControlCommandGate } from "openclaw/plugin-sdk";
import { describe, expect, it } from "vitest";
import { resolveMattermostEffectiveAllowFromLists } from "./monitor-auth.js";
describe("mattermost monitor authz", () => {
it("keeps DM allowlist merged with pairing-store entries", () => {
const resolved = resolveMattermostEffectiveAllowFromLists({
dmPolicy: "pairing",
allowFrom: ["@trusted-user"],
groupAllowFrom: ["@group-owner"],
storeAllowFrom: ["user:attacker"],
});
expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
});
it("uses explicit groupAllowFrom without pairing-store inheritance", () => {
const resolved = resolveMattermostEffectiveAllowFromLists({
dmPolicy: "pairing",
allowFrom: ["@trusted-user"],
groupAllowFrom: ["@group-owner"],
storeAllowFrom: ["user:attacker"],
});
expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]);
});
it("does not inherit pairing-store entries into group allowlist", () => {
const resolved = resolveMattermostEffectiveAllowFromLists({
dmPolicy: "pairing",
allowFrom: ["@trusted-user"],
storeAllowFrom: ["user:attacker"],
});
expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
expect(resolved.effectiveGroupAllowFrom).toEqual(["trusted-user"]);
});
it("does not auto-authorize DM commands in open mode without allowlists", () => {
const resolved = resolveMattermostEffectiveAllowFromLists({
dmPolicy: "open",
allowFrom: [],
groupAllowFrom: [],
storeAllowFrom: [],
});
const commandGate = resolveControlCommandGate({
useAccessGroups: true,
authorizers: [
{ configured: resolved.effectiveAllowFrom.length > 0, allowed: false },
{ configured: resolved.effectiveGroupAllowFrom.length > 0, allowed: false },
],
allowTextCommands: true,
hasControlCommand: true,
});
expect(commandGate.commandAuthorized).toBe(false);
});
});

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,97 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { probeMattermost } from "./probe.js";
const mockFetch = vi.fn<typeof fetch>();
describe("probeMattermost", () => {
beforeEach(() => {
vi.stubGlobal("fetch", mockFetch);
mockFetch.mockReset();
});
afterEach(() => {
vi.unstubAllGlobals();
});
it("returns baseUrl missing for empty base URL", async () => {
await expect(probeMattermost(" ", "token")).resolves.toEqual({
ok: false,
error: "baseUrl missing",
});
expect(mockFetch).not.toHaveBeenCalled();
});
it("normalizes base URL and returns bot info", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ id: "bot-1", username: "clawbot" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
);
const result = await probeMattermost("https://mm.example.com/api/v4/", "bot-token");
expect(mockFetch).toHaveBeenCalledWith(
"https://mm.example.com/api/v4/users/me",
expect.objectContaining({
headers: { Authorization: "Bearer bot-token" },
}),
);
expect(result).toEqual(
expect.objectContaining({
ok: true,
status: 200,
bot: { id: "bot-1", username: "clawbot" },
}),
);
expect(result.elapsedMs).toBeGreaterThanOrEqual(0);
});
it("returns API error details from JSON response", async () => {
mockFetch.mockResolvedValueOnce(
new Response(JSON.stringify({ message: "invalid auth token" }), {
status: 401,
statusText: "Unauthorized",
headers: { "content-type": "application/json" },
}),
);
await expect(probeMattermost("https://mm.example.com", "bad-token")).resolves.toEqual(
expect.objectContaining({
ok: false,
status: 401,
error: "invalid auth token",
}),
);
});
it("falls back to statusText when error body is empty", async () => {
mockFetch.mockResolvedValueOnce(
new Response("", {
status: 403,
statusText: "Forbidden",
headers: { "content-type": "text/plain" },
}),
);
await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual(
expect.objectContaining({
ok: false,
status: 403,
error: "Forbidden",
}),
);
});
it("returns fetch error when request throws", async () => {
mockFetch.mockRejectedValueOnce(new Error("network down"));
await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual(
expect.objectContaining({
ok: false,
status: null,
error: "network down",
}),
);
});
});

View File

@@ -0,0 +1,61 @@
import type { BaseProbeResult } from "openclaw/plugin-sdk";
import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js";
export type MattermostProbe = BaseProbeResult & {
status?: number | null;
elapsedMs?: number | null;
bot?: MattermostUser;
};
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);
}
}
}

View File

@@ -0,0 +1,83 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { expect, vi } from "vitest";
export function createMattermostTestConfig(): OpenClawConfig {
return {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
},
},
};
}
export function createMattermostReactionFetchMock(params: {
postId: string;
emojiName: string;
mode: "add" | "remove" | "both";
userId?: string;
status?: number;
body?: unknown;
}) {
const userId = params.userId ?? "BOT123";
const mode = params.mode;
const allowAdd = mode === "add" || mode === "both";
const allowRemove = mode === "remove" || mode === "both";
const addStatus = params.status ?? 201;
const removeStatus = params.status ?? 204;
const removePath = `/api/v4/users/${userId}/posts/${params.postId}/reactions/${encodeURIComponent(params.emojiName)}`;
return vi.fn(async (url: any, init?: any) => {
if (String(url).endsWith("/api/v4/users/me")) {
return new Response(JSON.stringify({ id: userId }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (allowAdd && String(url).endsWith("/api/v4/reactions")) {
expect(init?.method).toBe("POST");
expect(JSON.parse(init?.body)).toEqual({
user_id: userId,
post_id: params.postId,
emoji_name: params.emojiName,
});
const responseBody = params.body === undefined ? { ok: true } : params.body;
return new Response(
responseBody === null ? null : JSON.stringify(responseBody),
responseBody === null
? { status: addStatus, headers: { "content-type": "text/plain" } }
: { status: addStatus, headers: { "content-type": "application/json" } },
);
}
if (allowRemove && String(url).endsWith(removePath)) {
expect(init?.method).toBe("DELETE");
const responseBody = params.body === undefined ? null : params.body;
return new Response(
responseBody === null ? null : JSON.stringify(responseBody),
responseBody === null
? { status: removeStatus, headers: { "content-type": "text/plain" } }
: { status: removeStatus, headers: { "content-type": "application/json" } },
);
}
throw new Error(`unexpected url: ${url}`);
});
}
export async function withMockedGlobalFetch<T>(
fetchImpl: typeof fetch,
run: () => Promise<T>,
): Promise<T> {
const prevFetch = globalThis.fetch;
(globalThis as any).fetch = fetchImpl;
try {
return await run();
} finally {
(globalThis as any).fetch = prevFetch;
}
}

View File

@@ -0,0 +1,103 @@
import { beforeEach, describe, expect, it } from "vitest";
import {
addMattermostReaction,
removeMattermostReaction,
resetMattermostReactionBotUserCacheForTests,
} from "./reactions.js";
import {
createMattermostReactionFetchMock,
createMattermostTestConfig,
} from "./reactions.test-helpers.js";
describe("mattermost reactions", () => {
beforeEach(() => {
resetMattermostReactionBotUserCacheForTests();
});
it("adds reactions by calling /users/me then POST /reactions", async () => {
const fetchMock = createMattermostReactionFetchMock({
mode: "add",
postId: "POST1",
emojiName: "thumbsup",
});
const result = await addMattermostReaction({
cfg: createMattermostTestConfig(),
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch,
});
expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalled();
});
it("returns a Result error when add reaction API call fails", async () => {
const fetchMock = createMattermostReactionFetchMock({
mode: "add",
postId: "POST1",
emojiName: "thumbsup",
status: 500,
body: { id: "err", message: "boom" },
});
const result = await addMattermostReaction({
cfg: createMattermostTestConfig(),
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch,
});
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.error).toContain("Mattermost add reaction failed");
}
});
it("removes reactions by calling /users/me then DELETE /users/:id/posts/:postId/reactions/:emoji", async () => {
const fetchMock = createMattermostReactionFetchMock({
mode: "remove",
postId: "POST1",
emojiName: "thumbsup",
});
const result = await removeMattermostReaction({
cfg: createMattermostTestConfig(),
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch,
});
expect(result).toEqual({ ok: true });
expect(fetchMock).toHaveBeenCalled();
});
it("caches the bot user id across reaction mutations", async () => {
const fetchMock = createMattermostReactionFetchMock({
mode: "both",
postId: "POST1",
emojiName: "thumbsup",
});
const cfg = createMattermostTestConfig();
const addResult = await addMattermostReaction({
cfg,
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch,
});
const removeResult = await removeMattermostReaction({
cfg,
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock as unknown as typeof fetch,
});
const usersMeCalls = fetchMock.mock.calls.filter((call) =>
String(call[0]).endsWith("/api/v4/users/me"),
);
expect(addResult).toEqual({ ok: true });
expect(removeResult).toEqual({ ok: true });
expect(usersMeCalls).toHaveLength(1);
});
});

View File

@@ -0,0 +1,124 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk";
import { resolveMattermostAccount } from "./accounts.js";
import { createMattermostClient, fetchMattermostMe, type MattermostClient } from "./client.js";
type Result = { ok: true } | { ok: false; error: string };
type ReactionParams = {
cfg: OpenClawConfig;
postId: string;
emojiName: string;
accountId?: string | null;
fetchImpl?: typeof fetch;
};
type ReactionMutation = (client: MattermostClient, params: MutationPayload) => Promise<void>;
type MutationPayload = { userId: string; postId: string; emojiName: string };
const BOT_USER_CACHE_TTL_MS = 10 * 60_000;
const botUserIdCache = new Map<string, { userId: string; expiresAt: number }>();
async function resolveBotUserId(
client: MattermostClient,
cacheKey: string,
): Promise<string | null> {
const cached = botUserIdCache.get(cacheKey);
if (cached && cached.expiresAt > Date.now()) {
return cached.userId;
}
const me = await fetchMattermostMe(client);
const userId = me?.id?.trim();
if (!userId) {
return null;
}
botUserIdCache.set(cacheKey, { userId, expiresAt: Date.now() + BOT_USER_CACHE_TTL_MS });
return userId;
}
export async function addMattermostReaction(params: {
cfg: OpenClawConfig;
postId: string;
emojiName: string;
accountId?: string | null;
fetchImpl?: typeof fetch;
}): Promise<Result> {
return runMattermostReaction(params, {
action: "add",
mutation: createReaction,
});
}
export async function removeMattermostReaction(params: {
cfg: OpenClawConfig;
postId: string;
emojiName: string;
accountId?: string | null;
fetchImpl?: typeof fetch;
}): Promise<Result> {
return runMattermostReaction(params, {
action: "remove",
mutation: deleteReaction,
});
}
export function resetMattermostReactionBotUserCacheForTests(): void {
botUserIdCache.clear();
}
async function runMattermostReaction(
params: ReactionParams,
options: {
action: "add" | "remove";
mutation: ReactionMutation;
},
): Promise<Result> {
const resolved = resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId });
const baseUrl = resolved.baseUrl?.trim();
const botToken = resolved.botToken?.trim();
if (!baseUrl || !botToken) {
return { ok: false, error: "Mattermost botToken/baseUrl missing." };
}
const client = createMattermostClient({
baseUrl,
botToken,
fetchImpl: params.fetchImpl,
});
const cacheKey = `${baseUrl}:${botToken}`;
const userId = await resolveBotUserId(client, cacheKey);
if (!userId) {
return { ok: false, error: "Mattermost reactions failed: could not resolve bot user id." };
}
try {
await options.mutation(client, {
userId,
postId: params.postId,
emojiName: params.emojiName,
});
} catch (err) {
return { ok: false, error: `Mattermost ${options.action} reaction failed: ${String(err)}` };
}
return { ok: true };
}
async function createReaction(client: MattermostClient, params: MutationPayload): Promise<void> {
await client.request<Record<string, unknown>>("/reactions", {
method: "POST",
body: JSON.stringify({
user_id: params.userId,
post_id: params.postId,
emoji_name: params.emojiName,
}),
});
}
async function deleteReaction(client: MattermostClient, params: MutationPayload): Promise<void> {
const emoji = encodeURIComponent(params.emojiName);
await client.request<unknown>(
`/users/${params.userId}/posts/${params.postId}/reactions/${emoji}`,
{
method: "DELETE",
},
);
}

View File

@@ -0,0 +1,192 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { runWithReconnect } from "./reconnect.js";
beforeEach(() => {
vi.clearAllMocks();
});
describe("runWithReconnect", () => {
it("retries after connectFn resolves (normal close)", async () => {
let callCount = 0;
const abort = new AbortController();
const connectFn = vi.fn(async () => {
callCount++;
if (callCount >= 3) {
abort.abort();
}
});
await runWithReconnect(connectFn, {
abortSignal: abort.signal,
initialDelayMs: 1,
});
expect(connectFn).toHaveBeenCalledTimes(3);
});
it("retries after connectFn throws (connection error)", async () => {
let callCount = 0;
const abort = new AbortController();
const onError = vi.fn();
const connectFn = vi.fn(async () => {
callCount++;
if (callCount < 3) {
throw new Error("fetch failed");
}
abort.abort();
});
await runWithReconnect(connectFn, {
abortSignal: abort.signal,
onError,
initialDelayMs: 1,
});
expect(connectFn).toHaveBeenCalledTimes(3);
expect(onError).toHaveBeenCalledTimes(2);
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: "fetch failed" }));
});
it("uses exponential backoff on consecutive errors, capped at maxDelayMs", async () => {
const abort = new AbortController();
const delays: number[] = [];
let callCount = 0;
const connectFn = vi.fn(async () => {
callCount++;
if (callCount >= 6) {
abort.abort();
return;
}
throw new Error("connection refused");
});
await runWithReconnect(connectFn, {
abortSignal: abort.signal,
onReconnect: (delayMs) => delays.push(delayMs),
// Keep this test fast: validate the exponential pattern, not real-time waiting.
initialDelayMs: 1,
maxDelayMs: 10,
});
expect(connectFn).toHaveBeenCalledTimes(6);
// 5 errors produce delays: 1, 2, 4, 8, 10(cap)
// 6th succeeds -> delay resets to 100
// But 6th also aborts → onReconnect NOT called (abort check fires first)
expect(delays).toEqual([1, 2, 4, 8, 10]);
});
it("resets backoff after successful connection", async () => {
const abort = new AbortController();
const delays: number[] = [];
let callCount = 0;
const connectFn = vi.fn(async () => {
callCount++;
if (callCount === 1) {
throw new Error("first failure");
}
if (callCount === 2) {
return; // success
}
if (callCount === 3) {
throw new Error("second failure");
}
abort.abort();
});
await runWithReconnect(connectFn, {
abortSignal: abort.signal,
onReconnect: (delayMs) => delays.push(delayMs),
initialDelayMs: 1,
maxDelayMs: 60_000,
});
expect(connectFn).toHaveBeenCalledTimes(4);
// call 1: fail -> delay 1
// call 2: success → delay resets to 1
// call 3: fail -> delay 1 (reset held)
// call 4: success + abort → no onReconnect
expect(delays).toEqual([1, 1, 1]);
});
it("stops immediately when abort signal is pre-fired", async () => {
const abort = new AbortController();
abort.abort();
const connectFn = vi.fn(async () => {});
await runWithReconnect(connectFn, { abortSignal: abort.signal });
expect(connectFn).not.toHaveBeenCalled();
});
it("stops after current connection when abort fires mid-connection", async () => {
const abort = new AbortController();
const connectFn = vi.fn(async () => {
abort.abort();
});
await runWithReconnect(connectFn, {
abortSignal: abort.signal,
initialDelayMs: 1,
});
expect(connectFn).toHaveBeenCalledTimes(1);
});
it("abort signal interrupts backoff sleep immediately", async () => {
const abort = new AbortController();
const connectFn = vi.fn(async () => {
// Schedule abort to fire 10ms into the 60s sleep
setTimeout(() => abort.abort(), 10);
});
const start = Date.now();
await runWithReconnect(connectFn, {
abortSignal: abort.signal,
initialDelayMs: 60_000,
});
const elapsed = Date.now() - start;
expect(connectFn).toHaveBeenCalledTimes(1);
expect(elapsed).toBeLessThan(5000);
});
it("applies jitter to reconnect delay when configured", async () => {
const abort = new AbortController();
const delays: number[] = [];
let callCount = 0;
const connectFn = vi.fn(async () => {
callCount++;
if (callCount === 1) {
throw new Error("connection refused");
}
abort.abort();
});
await runWithReconnect(connectFn, {
abortSignal: abort.signal,
onReconnect: (delayMs) => delays.push(delayMs),
initialDelayMs: 10,
jitterRatio: 0.5,
random: () => 1,
});
expect(connectFn).toHaveBeenCalledTimes(2);
expect(delays).toEqual([15]);
});
it("supports strategy hook to stop reconnecting after failure", async () => {
const onReconnect = vi.fn();
const connectFn = vi.fn(async () => {
throw new Error("fatal");
});
await runWithReconnect(connectFn, {
initialDelayMs: 1,
onReconnect,
shouldReconnect: (params) => params.outcome !== "rejected",
});
expect(connectFn).toHaveBeenCalledTimes(1);
expect(onReconnect).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,103 @@
export type ReconnectOutcome = "resolved" | "rejected";
export type ShouldReconnectParams = {
attempt: number;
delayMs: number;
outcome: ReconnectOutcome;
error?: unknown;
};
export type RunWithReconnectOpts = {
abortSignal?: AbortSignal;
onError?: (err: unknown) => void;
onReconnect?: (delayMs: number) => void;
initialDelayMs?: number;
maxDelayMs?: number;
jitterRatio?: number;
random?: () => number;
shouldReconnect?: (params: ShouldReconnectParams) => boolean;
};
/**
* Reconnection loop with exponential backoff.
*
* Calls `connectFn` in a while loop. On normal resolve (connection closed),
* the backoff resets. On thrown error (connection failed), the current delay is
* used, then doubled for the next retry.
* The loop exits when `abortSignal` fires.
*/
export async function runWithReconnect(
connectFn: () => Promise<void>,
opts: RunWithReconnectOpts = {},
): Promise<void> {
const { initialDelayMs = 2000, maxDelayMs = 60_000 } = opts;
const jitterRatio = Math.max(0, opts.jitterRatio ?? 0);
const random = opts.random ?? Math.random;
let retryDelay = initialDelayMs;
let attempt = 0;
while (!opts.abortSignal?.aborted) {
let shouldIncreaseDelay = false;
let outcome: ReconnectOutcome = "resolved";
let error: unknown;
try {
await connectFn();
retryDelay = initialDelayMs;
} catch (err) {
if (opts.abortSignal?.aborted) {
return;
}
outcome = "rejected";
error = err;
opts.onError?.(err);
shouldIncreaseDelay = true;
}
if (opts.abortSignal?.aborted) {
return;
}
const delayMs = withJitter(retryDelay, jitterRatio, random);
const shouldReconnect =
opts.shouldReconnect?.({
attempt,
delayMs,
outcome,
error,
}) ?? true;
if (!shouldReconnect) {
return;
}
opts.onReconnect?.(delayMs);
await sleepAbortable(delayMs, opts.abortSignal);
if (shouldIncreaseDelay) {
retryDelay = Math.min(retryDelay * 2, maxDelayMs);
}
attempt++;
}
}
function withJitter(baseMs: number, jitterRatio: number, random: () => number): number {
if (jitterRatio <= 0) {
return baseMs;
}
const normalized = Math.max(0, Math.min(1, random()));
const spread = baseMs * jitterRatio;
return Math.max(1, Math.round(baseMs - spread + normalized * spread * 2));
}
function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal?.aborted) {
resolve();
return;
}
const onAbort = () => {
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}

View File

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

View File

@@ -0,0 +1,46 @@
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 ? `@${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 @@
export { promptAccountId } from "openclaw/plugin-sdk";

View File

@@ -0,0 +1,178 @@
import type { ChannelOnboardingAdapter, OpenClawConfig, WizardPrompter } from "openclaw/plugin-sdk";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import {
listMattermostAccountIds,
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
} from "./mattermost/accounts.js";
import { promptAccountId } from "./onboarding-helpers.js";
const channel = "mattermost" as const;
async function noteMattermostSetup(prompter: WizardPrompter): Promise<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: https://docs.openclaw.ai/channels/mattermost",
].join("\n"),
"Mattermost bot token",
);
}
async function promptMattermostCredentials(prompter: WizardPrompter): Promise<{
botToken: string;
baseUrl: string;
}> {
const botToken = String(
await prompter.text({
message: "Enter Mattermost bot token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
const baseUrl = String(
await prompter.text({
message: "Enter Mattermost base URL",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
return { botToken, baseUrl };
}
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 {
const entered = await promptMattermostCredentials(prompter);
botToken = entered.botToken;
baseUrl = entered.baseUrl;
}
} else if (accountConfigured) {
const keep = await prompter.confirm({
message: "Mattermost credentials already configured. Keep them?",
initialValue: true,
});
if (!keep) {
const entered = await promptMattermostCredentials(prompter);
botToken = entered.botToken;
baseUrl = entered.baseUrl;
}
} else {
const entered = await promptMattermostCredentials(prompter);
botToken = entered.botToken;
baseUrl = entered.baseUrl;
}
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: OpenClawConfig) => ({
...cfg,
channels: {
...cfg.channels,
mattermost: { ...cfg.channels?.mattermost, enabled: false },
},
}),
};

View File

@@ -0,0 +1,14 @@
import type { PluginRuntime } from "openclaw/plugin-sdk";
let runtime: PluginRuntime | null = null;
export function setMattermostRuntime(next: PluginRuntime) {
runtime = next;
}
export function getMattermostRuntime(): PluginRuntime {
if (!runtime) {
throw new Error("Mattermost runtime not initialized");
}
return runtime;
}

View File

@@ -0,0 +1,62 @@
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "openclaw/plugin-sdk";
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
export type MattermostAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/**
* Break-glass override: allow mutable identity matching (@username/display name) in allowlists.
* Default behavior is ID-only matching.
*/
dangerouslyAllowNameMatching?: boolean;
/** 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;
/** Direct message policy (pairing/allowlist/open/disabled). */
dmPolicy?: DmPolicy;
/** Allowlist for direct messages (user ids or @usernames). */
allowFrom?: Array<string | number>;
/** Allowlist for group messages (user ids or @usernames). */
groupAllowFrom?: Array<string | number>;
/** Group message policy (allowlist/open/disabled). */
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
/** Action toggles for this account. */
actions?: {
/** Enable message reaction actions. Default: true. */
reactions?: boolean;
};
};
export type MattermostConfig = {
/** Optional per-account Mattermost configuration (multi-account). */
accounts?: Record<string, MattermostAccountConfig>;
} & MattermostAccountConfig;