Initial commit: OpenClaw Mattermost Extension

This commit is contained in:
2026-04-10 21:57:29 -07:00
commit 2072259fb6
126 changed files with 26403 additions and 0 deletions
+3
View File
@@ -0,0 +1,3 @@
// Keep this barrel helper-only so plugin-sdk facades do not pull the full
// channel plugin (and its runtime state) into tests or other shared surfaces.
export { isMattermostSenderAllowed } from "./src/mattermost/monitor-auth.js";
+6
View File
@@ -0,0 +1,6 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export { collectMattermostSecurityAuditFindings } from "./src/security-audit.js";
@@ -0,0 +1,6 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";
export const defaultMarkdownTableMode = "off";
+20
View File
@@ -0,0 +1,20 @@
import { defineChannelPluginEntry } from "openclaw/plugin-sdk/core";
import { mattermostPlugin } from "./src/channel.js";
import { registerSlashCommandRoute } from "./src/mattermost/slash-state.js";
import { setMattermostRuntime } from "./src/runtime.js";
export { mattermostPlugin } from "./src/channel.js";
export { setMattermostRuntime } from "./src/runtime.js";
export default defineChannelPluginEntry({
id: "mattermost",
name: "Mattermost",
description: "Mattermost channel plugin",
plugin: mattermostPlugin,
setRuntime: setMattermostRuntime,
registerFull(api) {
// Actual slash-command registration happens after the monitor connects and
// knows the team id; the route itself can be wired here.
registerSlashCommandRoute(api);
},
});
@@ -0,0 +1,9 @@
{
"id": "mattermost",
"channels": ["mattermost"],
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}
+41
View File
@@ -0,0 +1,41 @@
{
"name": "@openclaw/mattermost",
"version": "2026.4.4",
"description": "OpenClaw Mattermost channel plugin",
"type": "module",
"dependencies": {
"@sinclair/typebox": "0.34.49",
"ws": "^8.20.0"
},
"devDependencies": {
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.4"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.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",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.4"
}
}
}
+88
View File
@@ -0,0 +1,88 @@
// Private runtime barrel for the bundled Mattermost extension.
// Keep this barrel thin and generic-only.
export type {
BaseProbeResult,
ChannelAccountSnapshot,
ChannelDirectoryEntry,
ChannelGroupContext,
ChannelMessageActionName,
ChannelPlugin,
ChatType,
HistoryEntry,
OpenClawConfig,
OpenClawPluginApi,
PluginRuntime,
} from "openclaw/plugin-sdk/core";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
export type { ModelsProviderData } from "openclaw/plugin-sdk/command-auth";
export type {
BlockStreamingCoalesceConfig,
DmPolicy,
GroupPolicy,
} from "openclaw/plugin-sdk/config-runtime";
export {
DEFAULT_ACCOUNT_ID,
buildChannelConfigSchema,
createDedupeCache,
parseStrictPositiveInteger,
resolveClientIp,
isTrustedProxyAddress,
} from "openclaw/plugin-sdk/core";
export { buildComputedAccountStatusSnapshot } from "openclaw/plugin-sdk/channel-status";
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
export {
buildModelsProviderData,
listSkillCommandsForAgents,
resolveControlCommandGate,
resolveStoredModelOverride,
} from "openclaw/plugin-sdk/command-auth";
export {
GROUP_POLICY_BLOCKED_LABEL,
isDangerousNameMatchingEnabled,
loadSessionStore,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveStorePath,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/config-runtime";
export { formatInboundFromLabel } from "openclaw/plugin-sdk/channel-inbound";
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export {
DM_GROUP_ACCESS_REASON,
readStoreAllowFromForDmPolicy,
resolveDmGroupAccessWithLists,
resolveEffectiveAllowFromLists,
} from "openclaw/plugin-sdk/channel-policy";
export { evaluateSenderGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
export { createChannelReplyPipeline } from "openclaw/plugin-sdk/channel-reply-pipeline";
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
export { rawDataToString } from "openclaw/plugin-sdk/browser-support";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
export {
DEFAULT_GROUP_HISTORY_LIMIT,
buildPendingHistoryContextFromMap,
clearHistoryEntriesIfEnabled,
recordPendingHistoryEntryIfEnabled,
} from "openclaw/plugin-sdk/reply-history";
export { normalizeAccountId, resolveThreadSessionKeys } from "openclaw/plugin-sdk/routing";
export { resolveAllowlistMatchSimple } from "openclaw/plugin-sdk/allow-from";
export { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-targets";
export {
isRequestBodyLimitError,
readRequestBodyWithLimit,
} from "openclaw/plugin-sdk/webhook-ingress";
export {
applyAccountNameToChannelSection,
applySetupAccountConfigPatch,
migrateBaseNameToDefaultAccount,
} from "openclaw/plugin-sdk/setup";
export {
getAgentScopedMediaLocalRoots,
resolveChannelMediaMaxBytes,
} from "openclaw/plugin-sdk/media-runtime";
export { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
+4
View File
@@ -0,0 +1,4 @@
import { defineSetupPluginEntry } from "openclaw/plugin-sdk/core";
import { mattermostPlugin } from "./src/channel.js";
export default defineSetupPluginEntry(mattermostPlugin);
@@ -0,0 +1,28 @@
import { describe, expect, it } from "vitest";
import { mattermostApprovalAuth } from "./approval-auth.js";
describe("mattermostApprovalAuth", () => {
it("authorizes stable Mattermost user ids and ignores usernames", () => {
expect(
mattermostApprovalAuth.authorizeActorAction({
cfg: {
channels: { mattermost: { allowFrom: ["user:abcdefghijklmnopqrstuvwxyz"] } },
},
senderId: "abcdefghijklmnopqrstuvwxyz",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
expect(
mattermostApprovalAuth.authorizeActorAction({
cfg: {
channels: { mattermost: { allowFrom: ["@owner"] } },
},
senderId: "attacker-user-id",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
});
});
@@ -0,0 +1,29 @@
import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import { resolveMattermostAccount } from "./mattermost/accounts.js";
const MATTERMOST_USER_ID_RE = /^[a-z0-9]{26}$/;
function normalizeMattermostApproverId(value: string | number): string | undefined {
const normalized = String(value)
.trim()
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, "")
.trim()
.toLowerCase();
return MATTERMOST_USER_ID_RE.test(normalized) ? normalized : undefined;
}
export const mattermostApprovalAuth = createResolvedApproverActionAuthAdapter({
channelLabel: "Mattermost",
resolveApprovers: ({ cfg, accountId }) => {
const account = resolveMattermostAccount({ cfg, accountId }).config;
return resolveApprovalApprovers({
allowFrom: account.allowFrom,
normalizeApprover: normalizeMattermostApproverId,
});
},
normalizeSenderId: (value) => normalizeMattermostApproverId(value),
});
+8
View File
@@ -0,0 +1,8 @@
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
export type { ChannelPlugin } from "openclaw/plugin-sdk/core";
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/core";
export {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
} from "openclaw/plugin-sdk/config-runtime";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
+618
View File
@@ -0,0 +1,618 @@
import { Type } from "@sinclair/typebox";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { createChannelReplyPipeline } from "../runtime-api.js";
vi.mock("../../../src/config/bundled-channel-config-runtime.js", () => ({
getBundledChannelRuntimeMap: () => new Map(),
getBundledChannelConfigSchemaMap: () => new Map(),
}));
const { sendMessageMattermostMock, mockFetchGuard } = vi.hoisted(() => ({
sendMessageMattermostMock: vi.fn(),
mockFetchGuard: vi.fn(async (p: { url: string; init?: RequestInit }) => {
const response = await globalThis.fetch(p.url, p.init);
return { response, release: async () => {}, finalUrl: p.url };
}),
}));
vi.mock("./mattermost/send.js", () => ({
sendMessageMattermost: sendMessageMattermostMock,
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
const original = (await vi.importActual("openclaw/plugin-sdk/ssrf-runtime")) as Record<
string,
unknown
>;
return { ...original, fetchWithSsrFGuard: mockFetchGuard };
});
import {
createMattermostReactionFetchMock,
createMattermostTestConfig,
withMockedGlobalFetch,
} from "./mattermost/reactions.test-helpers.js";
let mattermostPlugin: typeof import("./channel.js").mattermostPlugin;
let resetMattermostReactionBotUserCacheForTests: typeof import("./mattermost/reactions.js").resetMattermostReactionBotUserCacheForTests;
type MattermostHandleAction = NonNullable<
NonNullable<typeof mattermostPlugin.actions>["handleAction"]
>;
type MattermostActionContext = Parameters<MattermostHandleAction>[0];
type MattermostSendText = NonNullable<NonNullable<typeof mattermostPlugin.outbound>["sendText"]>;
type MattermostSendTextParams = Parameters<MattermostSendText>[0];
type MattermostSendMedia = NonNullable<NonNullable<typeof mattermostPlugin.outbound>["sendMedia"]>;
type MattermostSendMediaParams = Parameters<MattermostSendMedia>[0];
function getDescribedActions(cfg: OpenClawConfig, accountId?: string): string[] {
return [...(mattermostPlugin.actions?.describeMessageTool?.({ cfg, accountId })?.actions ?? [])];
}
function requireMattermostNormalizeTarget() {
const normalize = mattermostPlugin.messaging?.normalizeTarget;
if (!normalize) {
throw new Error("mattermost messaging.normalizeTarget missing");
}
return normalize;
}
function requireMattermostPairingNormalizer() {
const normalize = mattermostPlugin.pairing?.normalizeAllowEntry;
if (!normalize) {
throw new Error("mattermost pairing.normalizeAllowEntry missing");
}
return normalize;
}
function requireMattermostReplyToModeResolver() {
const resolveReplyToMode = mattermostPlugin.threading?.resolveReplyToMode;
if (!resolveReplyToMode) {
throw new Error("mattermost threading.resolveReplyToMode missing");
}
return resolveReplyToMode;
}
function requireMattermostSendText() {
const sendText = mattermostPlugin.outbound?.sendText;
if (!sendText) {
throw new Error("mattermost outbound.sendText missing");
}
return sendText;
}
function requireMattermostSendMedia() {
const sendMedia = mattermostPlugin.outbound?.sendMedia;
if (!sendMedia) {
throw new Error("mattermost outbound.sendMedia missing");
}
return sendMedia;
}
function requireMattermostChunker() {
const chunker = mattermostPlugin.outbound?.chunker;
if (!chunker) {
throw new Error("mattermost outbound.chunker missing");
}
return chunker;
}
function createMattermostActionContext(
overrides: Partial<MattermostActionContext>,
): MattermostActionContext {
return {
channel: "mattermost",
action: "send",
params: {},
cfg: createMattermostTestConfig(),
...overrides,
};
}
describe("mattermostPlugin", () => {
beforeAll(async () => {
({ mattermostPlugin } = await import("./channel.js"));
({ resetMattermostReactionBotUserCacheForTests } = await import("./mattermost/reactions.js"));
});
beforeEach(() => {
sendMessageMattermostMock.mockReset();
sendMessageMattermostMock.mockResolvedValue({
messageId: "post-1",
channelId: "channel-1",
});
});
describe("messaging", () => {
it("keeps @username targets", () => {
const normalize = requireMattermostNormalizeTarget();
expect(normalize("@Alice")).toBe("@Alice");
expect(normalize("@alice")).toBe("@alice");
});
it("normalizes spaced mattermost prefixes to user targets", () => {
const normalize = requireMattermostNormalizeTarget();
expect(normalize("mattermost:USER123")).toBe("user:USER123");
expect(normalize(" mattermost:USER123 ")).toBe("user:USER123");
});
});
describe("pairing", () => {
it("normalizes allowlist entries", () => {
const normalize = requireMattermostPairingNormalizer();
expect(normalize("@Alice")).toBe("alice");
expect(normalize("user:USER123")).toBe("user123");
expect(normalize(" @Alice ")).toBe("alice");
expect(normalize(" mattermost:USER123 ")).toBe("user123");
});
});
describe("threading", () => {
it("uses replyToMode for channel messages and keeps direct messages off", () => {
const resolveReplyToMode = requireMattermostReplyToModeResolver();
const cfg: OpenClawConfig = {
channels: {
mattermost: {
replyToMode: "all",
},
},
};
expect(
resolveReplyToMode({
cfg,
accountId: "default",
chatType: "channel",
}),
).toBe("all");
expect(
resolveReplyToMode({
cfg,
accountId: "default",
chatType: "direct",
}),
).toBe("off");
});
it("uses configured defaultAccount when accountId is omitted", () => {
const resolveReplyToMode = requireMattermostReplyToModeResolver();
const cfg: OpenClawConfig = {
channels: {
mattermost: {
defaultAccount: "alerts",
replyToMode: "off",
accounts: {
alerts: {
replyToMode: "all",
botToken: "alerts-token",
baseUrl: "https://alerts.example.com",
},
},
},
},
};
expect(
resolveReplyToMode({
cfg,
chatType: "channel",
}),
).toBe("all");
});
});
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, async () => {
return await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "react",
params,
cfg,
accountId: "default",
}),
);
});
};
it("exposes react when mattermost is configured", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
// pragma: allowlist secret
botToken: "test-token",
baseUrl: "https://chat.example.com",
},
},
};
const actions = getDescribedActions(cfg);
expect(actions).toContain("react");
expect(actions).toContain("send");
expect(mattermostPlugin.actions?.supportsAction?.({ action: "react" })).toBe(true);
expect(mattermostPlugin.actions?.supportsAction?.({ action: "send" })).toBe(true);
});
it("hides react when mattermost is not configured", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
},
},
};
const actions = getDescribedActions(cfg);
expect(actions).toEqual([]);
});
it("keeps buttons optional in message tool schema", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
// pragma: allowlist secret
botToken: "test-token",
baseUrl: "https://chat.example.com",
},
},
};
const discovery = mattermostPlugin.actions?.describeMessageTool?.({ cfg });
const schema = discovery?.schema;
if (!schema || Array.isArray(schema)) {
throw new Error("expected mattermost message-tool schema");
}
expect(Type.Object(schema.properties).required).toBeUndefined();
});
it("hides react when actions.reactions is false", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
// pragma: allowlist secret
botToken: "test-token",
baseUrl: "https://chat.example.com",
actions: { reactions: false },
},
},
};
const actions = getDescribedActions(cfg);
expect(actions).not.toContain("react");
expect(actions).toContain("send");
});
it("respects per-account actions.reactions in message discovery", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
actions: { reactions: false },
accounts: {
default: {
enabled: true,
// pragma: allowlist secret
botToken: "test-token",
baseUrl: "https://chat.example.com",
actions: { reactions: true },
},
},
},
},
};
const actions = getDescribedActions(cfg);
expect(actions).toContain("react");
});
it("honors the selected Mattermost account during discovery", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
enabled: true,
actions: { reactions: false },
accounts: {
default: {
enabled: true,
// pragma: allowlist secret
botToken: "test-token",
baseUrl: "https://chat.example.com",
actions: { reactions: false },
},
work: {
enabled: true,
botToken: "work-token",
baseUrl: "https://chat.example.com",
actions: { reactions: true },
},
},
},
},
};
expect(getDescribedActions(cfg, "default")).toEqual(["send"]);
expect(getDescribedActions(cfg, "work")).toEqual(["send", "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,
// pragma: allowlist secret
botToken: "test-token",
baseUrl: "https://chat.example.com",
actions: { reactions: false },
},
},
},
},
};
await expect(
mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "react",
params: { messageId: "POST1", emoji: "thumbsup" },
cfg,
}),
),
).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({});
});
it("maps replyTo to replyToId for send actions", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "hello",
replyTo: "post-root",
},
cfg,
accountId: "default",
}),
);
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
"channel:CHAN1",
"hello",
expect.objectContaining({
accountId: "default",
replyToId: "post-root",
}),
);
});
it("falls back to trimmed replyTo when replyToId is blank", async () => {
const cfg = createMattermostTestConfig();
await mattermostPlugin.actions?.handleAction?.(
createMattermostActionContext({
action: "send",
params: {
to: "channel:CHAN1",
message: "hello",
replyToId: " ",
replyTo: " post-root ",
},
cfg,
accountId: "default",
}),
);
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
"channel:CHAN1",
"hello",
expect.objectContaining({
accountId: "default",
replyToId: "post-root",
}),
);
});
});
describe("outbound", () => {
it("chunks outbound text without requiring Mattermost runtime initialization", () => {
const chunker = requireMattermostChunker();
expect(() => chunker("hello world", 5)).not.toThrow();
expect(chunker("hello world", 5)).toEqual(["hello", "world"]);
});
it("forwards mediaLocalRoots on sendMedia", async () => {
const sendMedia = requireMattermostSendMedia();
const cfg = createMattermostTestConfig();
const params: MattermostSendMediaParams = {
cfg,
to: "channel:CHAN1",
text: "hello",
mediaUrl: "/tmp/workspace/image.png",
mediaLocalRoots: ["/tmp/workspace"],
accountId: "default",
replyToId: "post-root",
};
await sendMedia(params);
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
"channel:CHAN1",
"hello",
expect.objectContaining({
mediaUrl: "/tmp/workspace/image.png",
mediaLocalRoots: ["/tmp/workspace"],
}),
);
});
it("threads resolved cfg on sendText", async () => {
const sendText = requireMattermostSendText();
const cfg = {
channels: {
mattermost: {
// pragma: allowlist secret
botToken: "resolved-bot-token",
baseUrl: "https://chat.example.com",
},
},
} as OpenClawConfig;
const params: MattermostSendTextParams = {
cfg,
to: "channel:CHAN1",
text: "hello",
accountId: "default",
};
await sendText(params);
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
"channel:CHAN1",
"hello",
expect.objectContaining({
cfg,
accountId: "default",
}),
);
});
it("uses threadId as fallback when replyToId is absent (sendText)", async () => {
const sendText = requireMattermostSendText();
const cfg = createMattermostTestConfig();
const params: MattermostSendTextParams = {
cfg,
to: "channel:CHAN1",
text: "hello",
accountId: "default",
threadId: "post-root",
};
await sendText(params);
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
"channel:CHAN1",
"hello",
expect.objectContaining({
accountId: "default",
replyToId: "post-root",
}),
);
});
it("uses threadId as fallback when replyToId is absent (sendMedia)", async () => {
const sendMedia = requireMattermostSendMedia();
const cfg = createMattermostTestConfig();
const params: MattermostSendMediaParams = {
cfg,
to: "channel:CHAN1",
text: "caption",
mediaUrl: "https://example.com/image.png",
accountId: "default",
threadId: "post-root",
};
await sendMedia(params);
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
"channel:CHAN1",
"caption",
expect.objectContaining({
accountId: "default",
replyToId: "post-root",
}),
);
});
});
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 = createChannelReplyPipeline({
cfg,
agentId: "main",
channel: "mattermost",
accountId: "default",
});
expect(prefixContext.responsePrefix).toBe("[Account]");
});
});
});
+730
View File
@@ -0,0 +1,730 @@
import { Type } from "@sinclair/typebox";
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-actions";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
ChannelMessageToolDiscovery,
} from "openclaw/plugin-sdk/channel-contract";
import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel-pairing";
import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/core";
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { mattermostApprovalAuth } from "./approval-auth.js";
import {
compileMattermostInteractiveReplies,
isMattermostInteractiveRepliesEnabled,
} from "./interactive-replies.js";
import {
chunkTextForOutbound,
createAccountStatusSink,
DEFAULT_ACCOUNT_ID,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
type ChannelPlugin,
} from "./channel-api.js";
import { MattermostChannelConfigSchema } from "./config-surface.js";
import { collectMattermostMutableAllowlistWarnings } from "./doctor.js";
import { collectMattermostSecurityAuditFindings } from "./security-audit.js";
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
import {
listMattermostAccountIds,
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
resolveMattermostReplyToMode,
type ResolvedMattermostAccount,
} from "./mattermost/accounts.js";
import {
listMattermostDirectoryGroups,
listMattermostDirectoryPeers,
} from "./mattermost/directory.js";
import { monitorMattermostProvider } from "./mattermost/monitor.js";
import { probeMattermost } from "./mattermost/probe.js";
import { deleteMessage, editMessage } from "./mattermost/actions.js";
import { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
import { sendMessageMattermost } from "./mattermost/send.js";
import { collectMattermostSlashCallbackPaths } from "./mattermost/slash-commands.js";
import { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js";
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
import { getMattermostRuntime } from "./runtime.js";
import { resolveMattermostOutboundSessionRoute } from "./session-route.js";
import { mattermostSetupAdapter } from "./setup-core.js";
import { mattermostSetupWizard } from "./setup-surface.js";
import type { MattermostConfig } from "./types.js";
import type { OpenClawConfig } from "./mattermost/runtime-api.js";
const mattermostSecurityAdapter = createRestrictSendersChannelSecurity<ResolvedMattermostAccount>({
channelKey: "mattermost",
resolveDmPolicy: (account) => account.config.dmPolicy,
resolveDmAllowFrom: (account) => account.config.allowFrom,
resolveGroupPolicy: (account) => account.config.groupPolicy,
surface: "Mattermost channels",
openScope: "any member",
groupPolicyPath: "channels.mattermost.groupPolicy",
groupAllowFromPath: "channels.mattermost.groupAllowFrom",
policyPathSuffix: "dmPolicy",
normalizeDmEntry: (raw) => normalizeAllowEntry(raw),
});
function describeMattermostMessageTool({
cfg,
accountId,
}: Parameters<
NonNullable<ChannelMessageActionAdapter["describeMessageTool"]>
>[0]): ChannelMessageToolDiscovery {
const enabledAccounts = (
accountId
? [resolveMattermostAccount({ cfg, accountId })]
: listMattermostAccountIds(cfg).map((listedAccountId) =>
resolveMattermostAccount({ cfg, accountId: listedAccountId }),
)
)
.filter((account) => account.enabled)
.filter((account) => Boolean(account.botToken?.trim() && account.baseUrl?.trim()));
const actions: ChannelMessageActionName[] = [];
if (enabledAccounts.length > 0) {
actions.push("send");
}
const actionsConfig = cfg.channels?.mattermost?.actions as
| { reactions?: boolean; delete?: boolean; edit?: boolean }
| undefined;
const baseReactions = actionsConfig?.reactions;
const hasReactionCapableAccount = enabledAccounts.some((account) => {
const accountActions = account.config.actions as { reactions?: boolean } | undefined;
return (accountActions?.reactions ?? baseReactions ?? true) !== false;
});
if (hasReactionCapableAccount) {
actions.push("react");
}
const baseDelete = actionsConfig?.delete;
const hasDeleteCapableAccount = enabledAccounts.some((account) => {
const accountActions = account.config.actions as { delete?: boolean } | undefined;
return (accountActions?.delete ?? baseDelete ?? true) !== false;
});
if (hasDeleteCapableAccount) {
actions.push("delete");
}
const baseEdit = actionsConfig?.edit;
const hasEditCapableAccount = enabledAccounts.some((account) => {
const accountActions = account.config.actions as { edit?: boolean } | undefined;
return (accountActions?.edit ?? baseEdit ?? true) !== false;
});
if (hasEditCapableAccount) {
actions.push("edit");
}
return {
actions,
capabilities: enabledAccounts.length > 0 ? ["buttons", "interactive"] : [],
schema:
enabledAccounts.length > 0
? {
properties: {
buttons: Type.Optional(createMessageToolButtonsSchema()),
},
}
: null,
};
}
const mattermostMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: describeMattermostMessageTool,
supportsAction: ({ action }) => {
return action === "send" || action === "react" || action === "delete" || action === "edit";
},
handleAction: async ({ action, params, cfg, accountId }) => {
if (action === "react") {
const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg);
const mattermostConfig = cfg.channels?.mattermost as MattermostConfig | undefined;
const account = resolveMattermostAccount({ cfg, accountId: resolvedAccountId });
const reactionsEnabled =
account.config.actions?.reactions ?? mattermostConfig?.actions?.reactions ?? true;
if (!reactionsEnabled) {
throw new Error("Mattermost reactions are disabled in config");
}
const { postId, emojiName, remove } = parseMattermostReactActionParams(params);
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: {},
};
}
if (action === "edit") {
const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg);
const mattermostConfig = cfg.channels?.mattermost as MattermostConfig | undefined;
const account = resolveMattermostAccount({ cfg, accountId: resolvedAccountId });
const editEnabled =
account.config.actions?.edit ?? mattermostConfig?.actions?.edit ?? true;
if (!editEnabled) {
throw new Error("Mattermost edit is disabled in config");
}
const postId = readTrimmedString(params.postId) ?? readTrimmedString(params.messageId);
if (!postId) {
throw new Error("Mattermost edit requires postId (messageId)");
}
const channelId = readTrimmedString(params.channelId);
if (!channelId) {
throw new Error("Mattermost edit requires channelId");
}
const message = readTrimmedString(params.message) ?? "";
const props =
typeof params.props === "object" && params.props !== null
? (params.props as Record<string, unknown>)
: undefined;
if (!message && !props) {
throw new Error("Mattermost edit requires message or props");
}
const result = await editMessage({
cfg,
postId,
channelId,
message,
props,
accountId: resolvedAccountId,
});
if (!result.ok) {
throw new Error(result.error);
}
return {
content: [{ type: "text" as const, text: `Edited message ${postId}` }],
details: { postId: result.postId, channelId: result.channelId },
};
}
if (action === "delete") {
const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg);
const mattermostConfig = cfg.channels?.mattermost as MattermostConfig | undefined;
const account = resolveMattermostAccount({ cfg, accountId: resolvedAccountId });
const deleteEnabled =
account.config.actions?.delete ?? mattermostConfig?.actions?.delete ?? true;
if (!deleteEnabled) {
throw new Error("Mattermost delete is disabled in config");
}
const postId = readTrimmedString(params.postId) ?? readTrimmedString(params.messageId);
if (!postId) {
throw new Error("Mattermost delete requires postId (messageId)");
}
const result = await deleteMessage({
cfg,
postId,
accountId: resolvedAccountId,
});
if (!result.ok) {
throw new Error(result.error);
}
return {
content: [{ type: "text" as const, text: `Deleted message ${postId}` }],
details: {},
};
}
if (action !== "send") {
throw new Error(`Unsupported Mattermost action: ${action}`);
}
// Send action with optional interactive buttons
const to =
typeof params.to === "string"
? params.to.trim()
: typeof params.target === "string"
? params.target.trim()
: "";
if (!to) {
throw new Error("Mattermost send requires a target (to).");
}
const message = typeof params.message === "string" ? params.message : "";
// Match the shared runner semantics: trim empty reply IDs away before
// falling back from replyToId to replyTo on direct plugin calls.
const replyToId = readMattermostReplyToId(params);
const resolvedAccountId = accountId || undefined;
const mediaUrl =
typeof params.media === "string" ? params.media.trim() || undefined : undefined;
// Parse interactive directives from the message if interactiveReplies capability is enabled
const interactiveButtons = isMattermostInteractiveRepliesEnabled({
cfg,
accountId: resolvedAccountId,
})
? parseInteractiveDirectivesToButtons(message)
: undefined;
// Merge parsed directives with any explicitly provided buttons
const mergedButtons = mergeButtons(interactiveButtons, params.buttons);
const result = await sendMessageMattermost(to, message, {
accountId: resolvedAccountId,
replyToId,
buttons: mergedButtons,
attachmentText: typeof params.attachmentText === "string" ? params.attachmentText : undefined,
mediaUrl,
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
ok: true,
channel: "mattermost",
messageId: result.messageId,
channelId: result.channelId,
}),
},
],
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 readTrimmedString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function parseMattermostReactActionParams(params: Record<string, unknown>): {
postId: string;
emojiName: string;
remove: boolean;
} {
const postId = readTrimmedString(params.messageId) ?? readTrimmedString(params.postId);
if (!postId) {
throw new Error("Mattermost react requires messageId (post id)");
}
const emojiName = readTrimmedString(params.emoji)?.replace(/^:+|:+$/g, "");
if (!emojiName) {
throw new Error("Mattermost react requires emoji");
}
return {
postId,
emojiName,
remove: params.remove === true,
};
}
function readMattermostReplyToId(params: Record<string, unknown>): string | undefined {
return readTrimmedString(params.replyToId) ?? readTrimmedString(params.replyTo);
}
function normalizeAllowEntry(entry: string): string {
return entry
.trim()
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, "")
.toLowerCase();
}
/**
* Parse interactive directives from message text and convert to buttons array.
* Supports [[mattermost_buttons: ...]] and [[mattermost_select: ...]] directives.
*/
function parseInteractiveDirectivesToButtons(message: string): Array<Record<string, unknown>> | undefined {
const result = compileMattermostInteractiveReplies({ text: message });
const blocks = result.interactive?.blocks;
if (!blocks || blocks.length === 0) {
return undefined;
}
const buttons: Array<Record<string, unknown>> = [];
for (const block of blocks) {
if (block.type === "buttons" && Array.isArray(block.buttons)) {
for (const btn of block.buttons) {
if (typeof btn === "object" && btn !== null) {
buttons.push({
id: String((btn as { value?: string }).value ?? "").replace(/\s+/g, "_"),
name: String((btn as { label?: string }).label ?? ""),
text: String((btn as { label?: string }).label ?? ""),
callback_data: String((btn as { value?: string }).value ?? ""),
style: (btn as { style?: string }).style ?? "default",
});
}
}
}
}
return buttons.length > 0 ? buttons : undefined;
}
/**
* Merge parsed directive buttons with explicitly provided buttons.
* Explicit buttons take precedence over parsed directives.
*/
function mergeButtons(
parsedButtons: Array<Record<string, unknown>> | undefined,
explicitButtons: unknown,
): Array<Record<string, unknown>> | undefined {
const explicit = Array.isArray(explicitButtons) ? explicitButtons : [];
const parsed = parsedButtons ?? [];
if (explicit.length === 0 && parsed.length === 0) {
return undefined;
}
// Deduplicate by callback_data/value
const seenValues = new Set<string>();
const merged: Array<Record<string, unknown>> = [];
for (const btn of [...explicit, ...parsed]) {
if (typeof btn !== "object" || btn === null) {
continue;
}
const value = String(
(btn as { callback_data?: string; value?: string }).callback_data ??
(btn as { callback_data?: string; value?: string }).value ??
"",
);
if (value && !seenValues.has(value)) {
seenValues.add(value);
merged.push(btn as Record<string, unknown>);
}
}
return merged.length > 0 ? merged : undefined;
}
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();
}
const mattermostConfigAdapter = createScopedChannelConfigAdapter<ResolvedMattermostAccount>({
sectionKey: "mattermost",
listAccountIds: listMattermostAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveMattermostAccount),
defaultAccountId: resolveDefaultMattermostAccountId,
clearBaseFields: ["botToken", "baseUrl", "name"],
resolveAllowFrom: (account: ResolvedMattermostAccount) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: formatAllowEntry,
}),
});
export const mattermostPlugin: ChannelPlugin<ResolvedMattermostAccount> = createChatChannelPlugin({
base: {
id: "mattermost",
meta: {
...meta,
},
setup: mattermostSetupAdapter,
setupWizard: mattermostSetupWizard,
capabilities: {
chatTypes: ["direct", "channel", "group", "thread"],
reactions: true,
threads: true,
media: true,
nativeCommands: true,
},
streaming: {
blockStreamingCoalesceDefaults: { minChars: 1500, idleMs: 1000 },
},
reload: { configPrefixes: ["channels.mattermost"] },
configSchema: MattermostChannelConfigSchema,
config: {
...mattermostConfigAdapter,
isConfigured: (account) => Boolean(account.botToken && account.baseUrl),
describeAccount: (account) =>
describeAccountSnapshot({
account,
configured: Boolean(account.botToken && account.baseUrl),
extra: {
botTokenSource: account.botTokenSource,
baseUrl: account.baseUrl,
},
}),
},
auth: mattermostApprovalAuth as any,
doctor: {
collectMutableAllowlistWarnings: collectMattermostMutableAllowlistWarnings,
},
groups: {
resolveRequireMention: resolveMattermostGroupRequireMention,
},
actions: mattermostMessageActions,
directory: createChannelDirectoryAdapter({
listGroups: async (params) => listMattermostDirectoryGroups(params),
listGroupsLive: async (params) => listMattermostDirectoryGroups(params),
listPeers: async (params) => listMattermostDirectoryPeers(params),
listPeersLive: async (params) => listMattermostDirectoryPeers(params),
}),
messaging: {
normalizeTarget: normalizeMattermostMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveMattermostOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeMattermostTargetId,
hint: "<channelId|user:ID|channel:ID>",
resolveTarget: async ({ cfg, accountId, input }) => {
const resolved = await resolveMattermostOpaqueTarget({
input,
cfg,
accountId,
});
if (!resolved) {
return null;
}
return {
to: resolved.to,
kind: resolved.kind,
source: "directory",
};
},
},
},
status: createComputedAccountStatusAdapter<ResolvedMattermostAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID, {
connected: false,
lastConnectedAt: null,
lastDisconnect: null,
}),
buildChannelSummary: ({ snapshot }) =>
buildPassiveProbedChannelStatusSummary(snapshot, {
botTokenSource: snapshot.botTokenSource ?? "none",
connected: snapshot.connected ?? false,
baseUrl: snapshot.baseUrl ?? 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,
account.config.allowPrivateNetwork === true,
);
},
resolveAccountSnapshot: ({ account, runtime }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.botToken && account.baseUrl),
extra: {
botTokenSource: account.botTokenSource,
baseUrl: account.baseUrl,
connected: runtime?.connected ?? false,
lastConnectedAt: runtime?.lastConnectedAt ?? null,
lastDisconnect: runtime?.lastDisconnect ?? null,
},
}),
}),
gateway: {
resolveGatewayAuthBypassPaths: ({ cfg }) => {
const base = cfg.channels?.mattermost;
const callbackPaths = new Set(
collectMattermostSlashCallbackPaths(base?.commands).filter(
(path) =>
path === "/api/channels/mattermost/command" ||
path.startsWith("/api/channels/mattermost/"),
),
);
const accounts = base?.accounts ?? {};
for (const account of Object.values(accounts)) {
const accountConfig =
account && typeof account === "object" && !Array.isArray(account)
? (account as {
commands?: Parameters<typeof collectMattermostSlashCallbackPaths>[0];
})
: undefined;
for (const path of collectMattermostSlashCallbackPaths(accountConfig?.commands)) {
if (
path === "/api/channels/mattermost/command" ||
path.startsWith("/api/channels/mattermost/")
) {
callbackPaths.add(path);
}
}
}
return [...callbackPaths];
},
startAccount: async (ctx) => {
const account = ctx.account;
const statusSink = createAccountStatusSink({
accountId: ctx.accountId,
setStatus: ctx.setStatus,
});
statusSink({
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,
});
},
},
},
pairing: {
text: {
idLabel: "mattermostUserId",
message: "OpenClaw: your access has been approved.",
normalizeAllowEntry: (entry) => normalizeAllowEntry(entry),
notify: createLoggedPairingApprovalNotifier(
({ id }) => `[mattermost] User ${id} approved for pairing`,
),
},
},
threading: {
scopedAccountReplyToMode: {
resolveAccount: (cfg, accountId) =>
resolveMattermostAccount({
cfg,
accountId: accountId ?? resolveDefaultMattermostAccountId(cfg),
}),
resolveReplyToMode: (account, chatType) =>
resolveMattermostReplyToMode(
account,
chatType === "direct" || chatType === "group" || chatType === "channel"
? chatType
: "channel",
),
},
},
security: {
...mattermostSecurityAdapter,
collectAuditFindings: async (params) => {
const account = params.account;
return await collectMattermostSecurityAuditFindings({
cfg: params.sourceConfig,
accountId: account?.accountId,
account,
});
},
},
outbound: {
base: {
deliveryMode: "direct",
chunker: chunkTextForOutbound,
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 };
},
},
attachedResults: {
channel: "mattermost",
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) =>
await sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
}),
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaLocalRoots,
accountId,
replyToId,
threadId,
}) =>
await sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
mediaUrl,
mediaLocalRoots,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
}),
},
},
});
@@ -0,0 +1,7 @@
export {
BlockStreamingCoalesceSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom,
} from "openclaw/plugin-sdk/channel-config-schema";
@@ -0,0 +1,134 @@
import {
BlockStreamingCoalesceSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom,
} from "openclaw/plugin-sdk/channel-config-primitives";
import { z } from "openclaw/plugin-sdk/zod";
import { buildSecretInputSchema } from "./secret-input.js";
const MattermostGroupSchema = z
.object({
/** Whether mentions are required to trigger the bot in this group. */
requireMention: z.boolean().optional(),
})
.strict();
function requireMattermostOpenAllowFrom(params: {
policy?: string;
allowFrom?: Array<string | number>;
ctx: z.RefinementCtx;
}) {
requireOpenAllowFrom({
policy: params.policy,
allowFrom: params.allowFrom,
ctx: params.ctx,
path: ["allowFrom"],
message:
'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
});
}
const DmChannelRetrySchema = z
.object({
/** Maximum number of retry attempts for DM channel creation (default: 3) */
maxRetries: z.number().int().min(0).max(10).optional(),
/** Initial delay in milliseconds before first retry (default: 1000) */
initialDelayMs: z.number().int().min(100).max(60000).optional(),
/** Maximum delay in milliseconds between retries (default: 10000) */
maxDelayMs: z.number().int().min(1000).max(60000).optional(),
/** Timeout for each individual DM channel creation request in milliseconds (default: 30000) */
timeoutMs: z.number().int().min(5000).max(120000).optional(),
})
.strict()
.refine(
(data) => {
if (data.initialDelayMs !== undefined && data.maxDelayMs !== undefined) {
return data.initialDelayMs <= data.maxDelayMs;
}
return true;
},
{
message: "initialDelayMs must be less than or equal to maxDelayMs",
path: ["initialDelayMs"],
},
)
.optional();
const MattermostSlashCommandsSchema = z
.object({
/** Enable native slash commands. "auto" resolves to false (opt-in). */
native: z.union([z.boolean(), z.literal("auto")]).optional(),
/** Also register skill-based commands. */
nativeSkills: z.union([z.boolean(), z.literal("auto")]).optional(),
/** Path for the callback endpoint on the gateway HTTP server. */
callbackPath: z.string().optional(),
/** Explicit callback URL (e.g. behind reverse proxy). */
callbackUrl: z.string().optional(),
})
.strict()
.optional();
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: buildSecretInputSchema().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(),
replyToMode: z.enum(["off", "first", "all"]).optional(),
responsePrefix: z.string().optional(),
actions: z
.object({
reactions: z.boolean().optional(),
})
.optional(),
commands: MattermostSlashCommandsSchema,
interactions: z
.object({
callbackBaseUrl: z.string().optional(),
allowedSourceIps: z.array(z.string()).optional(),
})
.optional(),
/** Per-group configuration (keyed by Mattermost channel ID or "*" for default). */
groups: z.record(z.string(), MattermostGroupSchema.optional()).optional(),
/** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Mattermost on LAN/VPN. */
allowPrivateNetwork: z.boolean().optional(),
/** Retry configuration for DM channel creation */
dmChannelRetry: DmChannelRetrySchema,
})
.strict();
const MattermostAccountSchema = MattermostAccountSchemaBase.superRefine((value, ctx) => {
requireMattermostOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
});
});
export const MattermostConfigSchema = MattermostAccountSchemaBase.extend({
accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
requireMattermostOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
});
});
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { MattermostConfigSchema } from "./config-schema.js";
describe("MattermostConfigSchema", () => {
it("accepts SecretRef botToken at top-level", () => {
const result = MattermostConfigSchema.safeParse({
botToken: { source: "env", provider: "default", id: "MATTERMOST_BOT_TOKEN" },
baseUrl: "https://chat.example.com",
});
expect(result.success).toBe(true);
});
it("accepts SecretRef botToken on account", () => {
const result = MattermostConfigSchema.safeParse({
accounts: {
main: {
botToken: { source: "env", provider: "default", id: "MATTERMOST_BOT_TOKEN_MAIN" },
baseUrl: "https://chat.example.com",
},
},
});
expect(result.success).toBe(true);
});
it("accepts replyToMode", () => {
const result = MattermostConfigSchema.safeParse({
replyToMode: "all",
});
expect(result.success).toBe(true);
});
it("accepts groups with requireMention", () => {
const result = MattermostConfigSchema.safeParse({
groups: {
"*": { requireMention: true },
"channel-123": { requireMention: false },
},
});
expect(result.success).toBe(true);
});
it("accepts groups on account", () => {
const result = MattermostConfigSchema.safeParse({
accounts: {
main: {
baseUrl: "https://chat.example.com",
groups: {
"*": { requireMention: true },
},
},
},
});
expect(result.success).toBe(true);
});
it("rejects unknown properties inside groups entry", () => {
const result = MattermostConfigSchema.safeParse({
groups: {
"*": { requireMention: true, unknownProp: "bad" },
},
});
expect(result.success).toBe(false);
});
it("rejects unsupported direct-message reply threading config", () => {
const result = MattermostConfigSchema.safeParse({
dm: {
replyToMode: "all",
},
});
expect(result.success).toBe(false);
});
it("rejects unsupported per-chat-type reply threading config", () => {
const result = MattermostConfigSchema.safeParse({
replyToModeByChatType: {
direct: "all",
},
});
expect(result.success).toBe(false);
});
});
+135
View File
@@ -0,0 +1,135 @@
import { z } from "openclaw/plugin-sdk/zod";
import {
BlockStreamingCoalesceSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom,
} from "./config-runtime.js";
import { buildSecretInputSchema } from "./secret-input.js";
const MattermostGroupSchema = z
.object({
/** Whether mentions are required to trigger the bot in this group. */
requireMention: z.boolean().optional(),
})
.strict();
function requireMattermostOpenAllowFrom(params: {
policy?: string;
allowFrom?: Array<string | number>;
ctx: z.RefinementCtx;
}) {
requireOpenAllowFrom({
policy: params.policy,
allowFrom: params.allowFrom,
ctx: params.ctx,
path: ["allowFrom"],
message:
'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
});
}
const DmChannelRetrySchema = z
.object({
/** Maximum number of retry attempts for DM channel creation (default: 3) */
maxRetries: z.number().int().min(0).max(10).optional(),
/** Initial delay in milliseconds before first retry (default: 1000) */
initialDelayMs: z.number().int().min(100).max(60000).optional(),
/** Maximum delay in milliseconds between retries (default: 10000) */
maxDelayMs: z.number().int().min(1000).max(60000).optional(),
/** Timeout for each individual DM channel creation request in milliseconds (default: 30000) */
timeoutMs: z.number().int().min(5000).max(120000).optional(),
})
.strict()
.refine(
(data) => {
if (data.initialDelayMs !== undefined && data.maxDelayMs !== undefined) {
return data.initialDelayMs <= data.maxDelayMs;
}
return true;
},
{
message: "initialDelayMs must be less than or equal to maxDelayMs",
path: ["initialDelayMs"],
},
)
.optional();
const MattermostSlashCommandsSchema = z
.object({
/** Enable native slash commands. "auto" resolves to false (opt-in). */
native: z.union([z.boolean(), z.literal("auto")]).optional(),
/** Also register skill-based commands. */
nativeSkills: z.union([z.boolean(), z.literal("auto")]).optional(),
/** Path for the callback endpoint on the gateway HTTP server. */
callbackPath: z.string().optional(),
/** Explicit callback URL (e.g. behind reverse proxy). */
callbackUrl: z.string().optional(),
})
.strict()
.optional();
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: buildSecretInputSchema().optional(),
pat: buildSecretInputSchema().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(),
replyToMode: z.enum(["off", "first", "all"]).optional(),
responsePrefix: z.string().optional(),
actions: z
.object({
reactions: z.boolean().optional(),
})
.optional(),
commands: MattermostSlashCommandsSchema,
interactions: z
.object({
callbackBaseUrl: z.string().optional(),
allowedSourceIps: z.array(z.string()).optional(),
})
.optional(),
/** Per-group configuration (keyed by Mattermost channel ID or "*" for default). */
groups: z.record(z.string(), MattermostGroupSchema.optional()).optional(),
/** Allow fetching from private/internal IP addresses (e.g. localhost). Required for self-hosted Mattermost on LAN/VPN. */
allowPrivateNetwork: z.boolean().optional(),
/** Retry configuration for DM channel creation */
dmChannelRetry: DmChannelRetrySchema,
})
.strict();
const MattermostAccountSchema = MattermostAccountSchemaBase.superRefine((value, ctx) => {
requireMattermostOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
});
});
export const MattermostConfigSchema = MattermostAccountSchemaBase.extend({
accounts: z.record(z.string(), MattermostAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
requireMattermostOpenAllowFrom({
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
});
});
@@ -0,0 +1,5 @@
import type { ChannelConfigSchema } from "openclaw/plugin-sdk";
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
import { MattermostConfigSchema } from "./config-schema-core.js";
export const MattermostChannelConfigSchema: ChannelConfigSchema = buildChannelConfigSchema(MattermostConfigSchema);
+322
View File
@@ -0,0 +1,322 @@
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
type ChannelDoctorAdapter,
type ChannelDoctorConfigMutation,
type ChannelDoctorLegacyConfigRule,
} from "openclaw/plugin-sdk/channel-contract";
import { type OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { collectProviderDangerousNameMatchingScopes } from "openclaw/plugin-sdk/runtime";
import { isMattermostMutableAllowEntry } from "./security-audit.js";
/**
* Check if an allowlist entry is mutable (uses name/username instead of stable ID).
* Mattermost IDs are 26-character alphanumeric strings.
*/
function isMattermostMutableAllowEntryLocal(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const normalized = text
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, "")
.trim()
.toLowerCase();
// Mattermost IDs are exactly 26 alphanumeric characters
if (/^[a-z0-9]{26}$/.test(normalized)) {
return false;
}
return true;
}
/**
* Collect warnings for mutable allowlist entries that should be migrated to stable IDs.
*/
export const collectMattermostMutableAllowlistWarnings =
createDangerousNameMatchingMutableAllowlistWarningCollector({
channel: "mattermost",
detector: isMattermostMutableAllowEntryLocal,
collectLists: (scope) => [
{
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
},
{
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
},
],
});
/**
* Type guard for object records.
*/
function asObjectRecord(value: unknown): Record<string, unknown> | null {
return value && typeof value === "object" && !Array.isArray(value)
? (value as Record<string, unknown>)
: null;
}
/**
* Check if two allowFrom lists are equal.
*/
function allowFromEqual(a: unknown, b: unknown): boolean {
if (!Array.isArray(a) || !Array.isArray(b)) {
return false;
}
const na = a.map((v) => String(v).trim()).filter(Boolean);
const nb = b.map((v) => String(v).trim()).filter(Boolean);
if (na.length !== nb.length) {
return false;
}
return na.every((v, i) => v === nb[i]);
}
/**
* Normalize Mattermost DM aliases for legacy configuration migration.
* Handles migration of dm.policy -> dmPolicy and dm.allowFrom -> allowFrom.
*/
function normalizeMattermostDmAliases(params: {
entry: Record<string, unknown>;
pathPrefix: string;
changes: string[];
}): { entry: Record<string, unknown>; changed: boolean } {
let changed = false;
let updated: Record<string, unknown> = params.entry;
const rawDm = updated.dm;
const dm = asObjectRecord(rawDm) ? (structuredClone(rawDm) as Record<string, unknown>) : null;
let dmChanged = false;
const topDmPolicy = updated.dmPolicy;
const legacyDmPolicy = dm?.policy;
if (topDmPolicy === undefined && legacyDmPolicy !== undefined) {
updated = { ...updated, dmPolicy: legacyDmPolicy };
changed = true;
if (dm) {
delete dm.policy;
dmChanged = true;
}
params.changes.push(`Moved ${params.pathPrefix}.dm.policy → ${params.pathPrefix}.dmPolicy.`);
} else if (
topDmPolicy !== undefined &&
legacyDmPolicy !== undefined &&
topDmPolicy === legacyDmPolicy
) {
if (dm) {
delete dm.policy;
dmChanged = true;
params.changes.push(`Removed ${params.pathPrefix}.dm.policy (dmPolicy already set).`);
}
}
const topAllowFrom = updated.allowFrom;
const legacyAllowFrom = dm?.allowFrom;
if (topAllowFrom === undefined && legacyAllowFrom !== undefined) {
updated = { ...updated, allowFrom: legacyAllowFrom };
changed = true;
if (dm) {
delete dm.allowFrom;
dmChanged = true;
}
params.changes.push(
`Moved ${params.pathPrefix}.dm.allowFrom → ${params.pathPrefix}.allowFrom.`,
);
} else if (
topAllowFrom !== undefined &&
legacyAllowFrom !== undefined &&
allowFromEqual(topAllowFrom, legacyAllowFrom)
) {
if (dm) {
delete dm.allowFrom;
dmChanged = true;
params.changes.push(`Removed ${params.pathPrefix}.dm.allowFrom (allowFrom already set).`);
}
}
if (dm && asObjectRecord(rawDm) && dmChanged) {
const keys = Object.keys(dm);
if (keys.length === 0) {
if (updated.dm !== undefined) {
const { dm: _ignored, ...rest } = updated;
updated = rest;
changed = true;
params.changes.push(`Removed empty ${params.pathPrefix}.dm after migration.`);
}
} else {
updated = { ...updated, dm };
changed = true;
}
}
return { entry: updated, changed };
}
/**
* Check if a Mattermost config entry has legacy streaming aliases.
*/
function hasLegacyMattermostStreamingAliases(value: unknown): boolean {
const entry = asObjectRecord(value);
if (!entry) {
return false;
}
return false;
}
function hasLegacyMattermostAccountStreamingAliases(value: unknown): boolean {
const accounts = asObjectRecord(value);
if (!accounts) {
return false;
}
return Object.values(accounts).some((account) => hasLegacyMattermostStreamingAliases(account));
}
/**
* Legacy configuration rules for Mattermost.
*/
const MATTERMOST_LEGACY_CONFIG_RULES: ChannelDoctorLegacyConfigRule[] = [
{
path: ["channels", "mattermost"],
message:
"channels.mattermost.dm.policy and channels.mattermost.dm.allowFrom are legacy; use channels.mattermost.dmPolicy and channels.mattermost.allowForm.",
match: (value) => {
const entry = asObjectRecord(value);
if (!entry) return false;
const dm = asObjectRecord(entry.dm);
return dm?.policy !== undefined || dm?.allowFrom !== undefined;
},
},
{
path: ["channels", "mattermost", "accounts"],
message:
"channels.mattermost.accounts.<id>.dm.policy and .dm.allowFrom are legacy; use channels.mattermost.accounts.<id>.dmPolicy and .allowFrom.",
match: hasLegacyMattermostAccountStreamingAliases,
},
];
/**
* Normalize Mattermost compatibility configuration.
* Handles migrations from legacy dm.* structure to flat structure.
*/
function normalizeMattermostCompatibilityConfig(cfg: OpenClawConfig): ChannelDoctorConfigMutation {
const rawEntry = asObjectRecord((cfg.channels as Record<string, unknown> | undefined)?.mattermost);
if (!rawEntry) {
return { config: cfg, changes: [] };
}
const changes: string[] = [];
let updated = rawEntry;
let changed = false;
const base = normalizeMattermostDmAliases({
entry: rawEntry,
pathPrefix: "channels.mattermost",
changes,
});
updated = base.entry;
changed = base.changed;
const rawAccounts = asObjectRecord(updated.accounts);
if (rawAccounts) {
let accountsChanged = false;
const accounts = { ...rawAccounts };
for (const [accountId, rawAccount] of Object.entries(rawAccounts)) {
const account = asObjectRecord(rawAccount);
if (!account) {
continue;
}
const dm = normalizeMattermostDmAliases({
entry: account,
pathPrefix: `channels.mattermost.accounts.${accountId}`,
changes,
});
if (dm.changed) {
accounts[accountId] = dm.entry;
accountsChanged = true;
}
}
if (accountsChanged) {
updated = { ...updated, accounts };
changed = true;
}
}
if (!changed) {
return { config: cfg, changes: [] };
}
return {
config: {
...cfg,
channels: {
...cfg.channels,
mattermost: updated as unknown as NonNullable<OpenClawConfig["channels"]>["mattermost"],
} as OpenClawConfig["channels"],
},
changes,
};
}
/**
* Collect warnings for deprecated or insecure configurations.
*/
function collectMattermostDeprecatedConfigWarnings(cfg: OpenClawConfig): string[] {
const warnings: string[] = [];
const mmCfg = (cfg.channels as Record<string, unknown> | undefined)?.mattermost;
const entry = asObjectRecord(mmCfg);
if (!entry) return warnings;
const baseUrl = entry.baseUrl;
if (typeof baseUrl === "string" && baseUrl.startsWith("http://")) {
warnings.push(
`- Insecure HTTP URL detected: channels.mattermost.baseUrl uses http://. Use https:// for secure communication.`,
);
}
const accounts = asObjectRecord(entry.accounts);
if (accounts) {
for (const [accountId, rawAccount] of Object.entries(accounts)) {
const account = asObjectRecord(rawAccount);
if (account && typeof account.baseUrl === "string" && account.baseUrl.startsWith("http://")) {
warnings.push(
`- Insecure HTTP URL detected: channels.mattermost.accounts.${accountId}.baseUrl uses http://. Use https:// for secure communication.`,
);
}
}
}
const dmPolicy = entry.dmPolicy;
const allowFrom = entry.allowFrom;
if (dmPolicy === "open" && (!allowFrom || (Array.isArray(allowFrom) && allowFrom.length === 0))) {
warnings.push(
`- Open DM policy configured without an allowlist. Any Mattermost user can send DMs to the bot. Consider using dmPolicy='pairing' or 'allowlist' for better security.`,
);
}
return warnings;
}
/**
* Mattermost doctor adapter providing:
* - Legacy config migration detection
* - Mutable allowlist warnings
* - Security configuration checks
* - Compatibility normalization
*/
export const mattermostDoctor: ChannelDoctorAdapter = {
dmAllowFromMode: "topOrNested",
groupModel: "route",
groupAllowFromFallbackToAllowFrom: false,
warnOnEmptyGroupSenderAllowlist: false,
legacyConfigRules: MATTERMOST_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: ({ cfg }) => normalizeMattermostCompatibilityConfig(cfg),
collectMutableAllowlistWarnings: ({ cfg }) => collectMattermostMutableAllowlistWarnings({ cfg }),
collectPreviewWarnings: ({ cfg }) => collectMattermostDeprecatedConfigWarnings(cfg),
};
/**
* @deprecated Use mattermostDoctor instead. Kept for backward compatibility.
*/
export { isMattermostMutableAllowEntry };
+448
View File
@@ -0,0 +1,448 @@
import { describe, expect, it } from "vitest";
import {
ErrorCodes,
MattermostError,
MattermostAPIError,
AuthenticationError,
ValidationError,
PermissionError,
RateLimitError,
ResourceNotFoundError,
NetworkError,
TimeoutError,
ConfigurationError,
MissingBotTokenError,
MissingBaseUrlError,
isRetryableError,
getRetryAfterMs,
withRetry,
ErrorBoundary,
getUserFriendlyMessage,
createErrorFromResponse,
} from "./errors.js";
describe("Error Handling", () => {
describe("ErrorCodes", () => {
it("has all expected error codes", () => {
expect(ErrorCodes.MATTERMOST_API_ERROR).toBe("MATTERMOST_API_ERROR");
expect(ErrorCodes.AUTHENTICATION_FAILED).toBe("AUTHENTICATION_FAILED");
expect(ErrorCodes.VALIDATION_ERROR).toBe("VALIDATION_ERROR");
expect(ErrorCodes.PERMISSION_DENIED).toBe("PERMISSION_DENIED");
expect(ErrorCodes.RATE_LIMIT_EXCEEDED).toBe("RATE_LIMIT_EXCEEDED");
expect(ErrorCodes.NETWORK_ERROR).toBe("NETWORK_ERROR");
expect(ErrorCodes.TIMEOUT_ERROR).toBe("TIMEOUT_ERROR");
expect(ErrorCodes.RESOURCE_NOT_FOUND).toBe("RESOURCE_NOT_FOUND");
expect(ErrorCodes.CONFIGURATION_ERROR).toBe("CONFIGURATION_ERROR");
expect(ErrorCodes.MISSING_BOT_TOKEN).toBe("MISSING_BOT_TOKEN");
expect(ErrorCodes.MISSING_BASE_URL).toBe("MISSING_BASE_URL");
expect(ErrorCodes.UNKNOWN_ERROR).toBe("UNKNOWN_ERROR");
});
});
describe("MattermostError", () => {
it("creates a basic error with context", () => {
const error = new MattermostError({
code: ErrorCodes.UNKNOWN_ERROR,
message: "Test error",
context: {
operation: "test-operation",
timestamp: new Date().toISOString(),
},
});
expect(error.code).toBe(ErrorCodes.UNKNOWN_ERROR);
expect(error.message).toBe("Test error");
expect(error.context.operation).toBe("test-operation");
expect(error.retryable).toBe(false);
});
it("serializes to JSON safely", () => {
const error = new MattermostError({
code: ErrorCodes.MATTERMOST_API_ERROR,
message: "API error",
context: {
operation: "api-call",
accountId: "test-account",
statusCode: 500,
timestamp: new Date().toISOString(),
},
retryable: true,
});
const json = error.toJSON();
expect(json.code).toBe(ErrorCodes.MATTERMOST_API_ERROR);
expect(json.retryable).toBe(true);
expect(json.context.accountId).toBe("test-account");
expect(json.context.statusCode).toBe(500);
});
it("formats log string correctly", () => {
const timestamp = new Date().toISOString();
const error = new MattermostError({
code: ErrorCodes.NETWORK_ERROR,
message: "Connection failed",
context: {
operation: "network-request",
timestamp,
},
});
const logString = error.toLogString();
expect(logString).toContain(ErrorCodes.NETWORK_ERROR);
expect(logString).toContain("Connection failed");
expect(logString).toContain("network-request");
});
});
describe("MattermostAPIError", () => {
it("marks 5xx errors as retryable", () => {
const error = new MattermostAPIError(
"Server error",
{ operation: "test", timestamp: new Date().toISOString() },
503
);
expect(error.retryable).toBe(true);
expect(error.code).toBe(ErrorCodes.MATTERMOST_API_SERVER_ERROR);
});
it("marks 429 errors as retryable", () => {
const error = new MattermostAPIError(
"Rate limited",
{ operation: "test", timestamp: new Date().toISOString() },
429
);
expect(error.retryable).toBe(true);
expect(error.code).toBe(ErrorCodes.MATTERMOST_API_RATE_LIMIT);
});
it("marks 4xx errors (except 429) as not retryable", () => {
const error = new MattermostAPIError(
"Bad request",
{ operation: "test", timestamp: new Date().toISOString() },
400
);
expect(error.retryable).toBe(false);
});
it("provides user-friendly messages for common status codes", () => {
const error401 = new MattermostAPIError(
"Auth failed",
{ operation: "test", timestamp: new Date().toISOString() },
401
);
expect(error401.userMessage).toContain("bot token");
const error429 = new MattermostAPIError(
"Rate limited",
{ operation: "test", timestamp: new Date().toISOString() },
429
);
expect(error429.userMessage).toContain("Rate limit");
});
});
describe("AuthenticationError", () => {
it("is not retryable", () => {
const error = new AuthenticationError(
"Auth failed",
{ operation: "test", timestamp: new Date().toISOString() }
);
expect(error.retryable).toBe(false);
expect(error.code).toBe(ErrorCodes.AUTHENTICATION_FAILED);
});
it("has user-friendly message", () => {
const error = new AuthenticationError(
"Auth failed",
{ operation: "test", timestamp: new Date().toISOString() }
);
expect(error.userMessage).toContain("Authentication failed");
expect(error.userMessage).toContain("bot token");
});
});
describe("ConfigurationError", () => {
it("MissingBaseUrlError has correct message", () => {
const error = new MissingBaseUrlError({
operation: "create-client",
timestamp: new Date().toISOString(),
});
expect(error.message).toContain("baseUrl is required");
expect(error.code).toBe(ErrorCodes.MISSING_BASE_URL);
});
it("MissingBotTokenError has correct message", () => {
const error = new MissingBotTokenError({
operation: "create-client",
timestamp: new Date().toISOString(),
});
expect(error.message).toContain("bot token is required");
expect(error.code).toBe(ErrorCodes.MISSING_BOT_TOKEN);
});
});
describe("isRetryableError", () => {
it("returns true for MattermostError with retryable=true", () => {
const error = new MattermostAPIError(
"Server error",
{ operation: "test", timestamp: new Date().toISOString() },
503
);
expect(isRetryableError(error)).toBe(true);
});
it("returns false for MattermostError with retryable=false", () => {
const error = new AuthenticationError(
"Auth failed",
{ operation: "test", timestamp: new Date().toISOString() }
);
expect(isRetryableError(error)).toBe(false);
});
it("returns true for network error codes", () => {
const error = { code: "ECONNRESET" };
expect(isRetryableError(error)).toBe(true);
});
it("returns true for timeout errors", () => {
const error = { message: "Request timeout" };
expect(isRetryableError(error)).toBe(true);
});
});
describe("getRetryAfterMs", () => {
it("extracts retry-after from RateLimitError", () => {
const error = new RateLimitError(
"Rate limited",
{ operation: "test", timestamp: new Date().toISOString() },
5000
);
expect(getRetryAfterMs(error)).toBe(5000);
});
it("extracts retry-after from headers", () => {
const error = {
headers: {
get: (name: string) => (name === "retry-after" ? "10" : null),
},
};
expect(getRetryAfterMs(error)).toBe(10000);
});
it("returns undefined when no retry-after available", () => {
expect(getRetryAfterMs(new Error("Some error"))).toBeUndefined();
});
});
describe("withRetry", () => {
it("succeeds on first attempt", async () => {
const fn = () => Promise.resolve("success");
const result = await withRetry("test-op", fn);
expect(result).toBe("success");
});
it("retries on retryable errors and succeeds", async () => {
let attempts = 0;
const fn = () => {
attempts++;
if (attempts < 3) {
const error = new MattermostAPIError(
"Server error",
{ operation: "test", timestamp: new Date().toISOString() },
503
);
return Promise.reject(error);
}
return Promise.resolve("success");
};
const result = await withRetry("test-op", fn, { maxRetries: 3, initialDelayMs: 10 });
expect(result).toBe("success");
expect(attempts).toBe(3);
});
it("does not retry on non-retryable errors", async () => {
let attempts = 0;
const fn = () => {
attempts++;
const error = new AuthenticationError(
"Auth failed",
{ operation: "test", timestamp: new Date().toISOString() }
);
return Promise.reject(error);
};
await expect(
withRetry("test-op", fn, { maxRetries: 3, initialDelayMs: 10 })
).rejects.toBeInstanceOf(AuthenticationError);
expect(attempts).toBe(1);
});
it("calls onRetry callback", async () => {
const onRetry = jest.fn();
let attempts = 0;
const fn = () => {
attempts++;
if (attempts < 2) {
const error = new NetworkError(
"Network error",
{ operation: "test", timestamp: new Date().toISOString() }
);
return Promise.reject(error);
}
return Promise.resolve("success");
};
await withRetry("test-op", fn, {
maxRetries: 3,
initialDelayMs: 10,
onRetry,
});
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledWith(
expect.objectContaining({
attempt: 1,
maxRetries: 3,
})
);
});
});
describe("ErrorBoundary", () => {
it("registers and calls error handlers", () => {
const boundary = new ErrorBoundary();
const handler = jest.fn();
boundary.onError(handler);
const error = new Error("Test error");
boundary.handle(error);
expect(handler).toHaveBeenCalledWith(expect.any(Error));
});
it("wraps functions and handles errors", async () => {
const boundary = new ErrorBoundary();
const handler = jest.fn();
boundary.onError(handler);
const fn = boundary.wrap(
async () => {
throw new Error("Wrapped error");
},
"test-operation"
);
await expect(fn()).rejects.toThrow();
expect(handler).toHaveBeenCalled();
});
it("does not call handlers after shutdown", () => {
const boundary = new ErrorBoundary();
const handler = jest.fn();
boundary.onError(handler);
boundary.shutdown();
boundary.handle(new Error("Test"));
expect(handler).not.toHaveBeenCalled();
});
});
describe("getUserFriendlyMessage", () => {
it("returns userMessage for MattermostError", () => {
const error = new AuthenticationError(
"Auth failed",
{ operation: "test", timestamp: new Date().toISOString() }
);
expect(getUserFriendlyMessage(error)).toBe(error.userMessage);
});
it("returns friendly message for connection errors", () => {
const error = new Error("ECONNREFUSED");
expect(getUserFriendlyMessage(error)).toContain("Unable to connect");
});
it("returns friendly message for timeout errors", () => {
const error = new Error("Request timeout");
expect(getUserFriendlyMessage(error)).toContain("timed out");
});
it("returns default message for unknown errors", () => {
expect(getUserFriendlyMessage("unknown")).toContain("unexpected error");
});
});
describe("createErrorFromResponse", () => {
it("creates ValidationError for 400", async () => {
const response = new Response(JSON.stringify({ message: "Bad request" }), {
status: 400,
headers: { "content-type": "application/json" },
});
const error = await createErrorFromResponse(response, "test-op");
expect(error).toBeInstanceOf(ValidationError);
expect(error.code).toBe(ErrorCodes.VALIDATION_ERROR);
});
it("creates AuthenticationError for 401", async () => {
const response = new Response(JSON.stringify({ message: "Unauthorized" }), {
status: 401,
headers: { "content-type": "application/json" },
});
const error = await createErrorFromResponse(response, "test-op");
expect(error).toBeInstanceOf(AuthenticationError);
expect(error.code).toBe(ErrorCodes.AUTHENTICATION_FAILED);
});
it("creates PermissionError for 403", async () => {
const response = new Response(JSON.stringify({ message: "Forbidden" }), {
status: 403,
headers: { "content-type": "application/json" },
});
const error = await createErrorFromResponse(response, "test-op");
expect(error).toBeInstanceOf(PermissionError);
expect(error.code).toBe(ErrorCodes.PERMISSION_DENIED);
});
it("creates ResourceNotFoundError for 404", async () => {
const response = new Response(JSON.stringify({ message: "Not found" }), {
status: 404,
headers: { "content-type": "application/json" },
});
const error = await createErrorFromResponse(response, "test-op");
expect(error).toBeInstanceOf(ResourceNotFoundError);
expect(error.code).toBe(ErrorCodes.RESOURCE_NOT_FOUND);
});
it("creates RateLimitError for 429 with retry-after", async () => {
const response = new Response(JSON.stringify({ message: "Rate limited" }), {
status: 429,
headers: {
"content-type": "application/json",
"retry-after": "10",
},
});
const error = await createErrorFromResponse(response, "test-op");
expect(error).toBeInstanceOf(RateLimitError);
expect(error.code).toBe(ErrorCodes.RATE_LIMIT_EXCEEDED);
expect((error as RateLimitError).retryAfterMs).toBe(10000);
});
it("creates MattermostAPIError for 5xx", async () => {
const response = new Response(JSON.stringify({ message: "Server error" }), {
status: 503,
headers: { "content-type": "application/json" },
});
const error = await createErrorFromResponse(response, "test-op");
expect(error).toBeInstanceOf(MattermostAPIError);
expect(error.code).toBe(ErrorCodes.MATTERMOST_API_SERVER_ERROR);
expect(error.retryable).toBe(true);
});
});
});
+855
View File
@@ -0,0 +1,855 @@
import { extractErrorCode, formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { retryAsync, type RetryOptions, type RetryInfo } from "openclaw/plugin-sdk/retry-runtime";
export const ErrorCodes = {
MATTERMOST_API_ERROR: "MATTERMOST_API_ERROR",
MATTERMOST_API_TIMEOUT: "MATTERMOST_API_TIMEOUT",
MATTERMOST_API_RATE_LIMIT: "MATTERMOST_API_RATE_LIMIT",
MATTERMOST_API_SERVER_ERROR: "MATTERMOST_API_SERVER_ERROR",
AUTHENTICATION_FAILED: "AUTHENTICATION_FAILED",
TOKEN_INVALID: "TOKEN_INVALID",
TOKEN_EXPIRED: "TOKEN_EXPIRED",
INSUFFICIENT_PERMISSIONS: "INSUFFICIENT_PERMISSIONS",
VALIDATION_ERROR: "VALIDATION_ERROR",
INVALID_BASE_URL: "INVALID_BASE_URL",
INVALID_CHANNEL_ID: "INVALID_CHANNEL_ID",
INVALID_USER_ID: "INVALID_USER_ID",
INVALID_MESSAGE_FORMAT: "INVALID_MESSAGE_FORMAT",
PERMISSION_DENIED: "PERMISSION_DENIED",
CHANNEL_ACCESS_DENIED: "CHANNEL_ACCESS_DENIED",
POST_EDIT_DENIED: "POST_EDIT_DENIED",
FILE_UPLOAD_DENIED: "FILE_UPLOAD_DENIED",
RATE_LIMIT_EXCEEDED: "RATE_LIMIT_EXCEEDED",
RATE_LIMIT_RETRY_AFTER: "RATE_LIMIT_RETRY_AFTER",
NETWORK_ERROR: "NETWORK_ERROR",
CONNECTION_ERROR: "CONNECTION_ERROR",
TIMEOUT_ERROR: "TIMEOUT_ERROR",
DNS_ERROR: "DNS_ERROR",
RESOURCE_NOT_FOUND: "RESOURCE_NOT_FOUND",
USER_NOT_FOUND: "USER_NOT_FOUND",
CHANNEL_NOT_FOUND: "CHANNEL_NOT_FOUND",
POST_NOT_FOUND: "POST_NOT_FOUND",
TEAM_NOT_FOUND: "TEAM_NOT_FOUND",
CONFIGURATION_ERROR: "CONFIGURATION_ERROR",
MISSING_BOT_TOKEN: "MISSING_BOT_TOKEN",
MISSING_BASE_URL: "MISSING_BASE_URL",
INVALID_CONFIGURATION: "INVALID_CONFIGURATION",
FILE_UPLOAD_FAILED: "FILE_UPLOAD_FAILED",
FILE_TOO_LARGE: "FILE_TOO_LARGE",
INVALID_FILE_TYPE: "INVALID_FILE_TYPE",
WEBSOCKET_ERROR: "WEBSOCKET_ERROR",
WEBSOCKET_CONNECTION_FAILED: "WEBSOCKET_CONNECTION_FAILED",
WEBSOCKET_MESSAGE_ERROR: "WEBSOCKET_MESSAGE_ERROR",
UNKNOWN_ERROR: "UNKNOWN_ERROR",
UNEXPECTED_ERROR: "UNEXPECTED_ERROR",
} as const;
export type ErrorCode = typeof ErrorCodes[keyof typeof ErrorCodes];
export interface ErrorContext {
operation: string;
accountId?: string;
userId?: string;
channelId?: string;
postId?: string;
timestamp?: string;
statusCode?: number;
requestPath?: string;
metadata?: Record<string, unknown>;
}
export function createErrorContext(
operation: string,
partial?: Partial<Omit<ErrorContext, "operation" | "timestamp">>
): ErrorContext {
return {
operation,
timestamp: new Date().toISOString(),
...partial,
};
}
export interface MattermostErrorOptions {
code: ErrorCode;
message: string;
context: ErrorContext;
cause?: Error | unknown;
retryable?: boolean;
userMessage?: string;
retryAfterMs?: number;
}
export class MattermostError extends Error {
readonly code: ErrorCode;
readonly context: ErrorContext;
readonly cause?: Error | unknown;
readonly retryable: boolean;
readonly userMessage: string;
readonly retryAfterMs?: number;
constructor(options: MattermostErrorOptions) {
super(options.message);
this.name = "MattermostError";
this.code = options.code;
this.context = options.context;
this.cause = options.cause;
this.retryable = options.retryable ?? false;
this.userMessage = options.userMessage ?? options.message;
this.retryAfterMs = options.retryAfterMs;
if (Error.captureStackTrace) {
Error.captureStackTrace(this, MattermostError);
}
}
toJSON(): Record<string, unknown> {
const context = this.context as ErrorContext;
return {
name: this.name,
code: this.code,
message: this.userMessage,
context: {
operation: context.operation,
accountId: context.accountId,
userId: context.userId,
channelId: context.channelId,
postId: context.postId,
timestamp: context.timestamp,
statusCode: context.statusCode,
requestPath: context.requestPath,
},
retryable: this.retryable,
retryAfterMs: this.retryAfterMs,
causeCode: this.cause instanceof MattermostError
? this.cause.code
: extractErrorCode(this.cause),
};
}
toLogString(): string {
return `[${this.code}] ${this.userMessage} | Operation: ${this.context.operation} | Time: ${this.context.timestamp}`;
}
}
export class MattermostAPIError extends MattermostError {
constructor(
message: string,
context: ErrorContext,
statusCode?: number,
cause?: Error | unknown
) {
const retryable = statusCode ? isRetryableStatusCode(statusCode) : false;
let code: ErrorCode = ErrorCodes.MATTERMOST_API_ERROR;
if (statusCode === 429) {
code = ErrorCodes.MATTERMOST_API_RATE_LIMIT;
} else if (statusCode && statusCode >= 500) {
code = ErrorCodes.MATTERMOST_API_SERVER_ERROR;
} else if (statusCode && statusCode === 408) {
code = ErrorCodes.MATTERMOST_API_TIMEOUT;
}
super({
code,
message,
context: { ...context, statusCode },
cause,
retryable,
userMessage: getUserMessageForStatusCode(statusCode, message),
});
this.name = "MattermostAPIError";
}
}
export class AuthenticationError extends MattermostError {
constructor(
message: string,
context: ErrorContext,
cause?: Error | unknown,
specificCode: ErrorCode = ErrorCodes.AUTHENTICATION_FAILED
) {
super({
code: specificCode,
message,
context: { ...context, statusCode: 401 },
cause,
retryable: false,
userMessage: "Authentication failed. Please check your bot token and try again.",
});
this.name = "AuthenticationError";
}
}
export class TokenInvalidError extends AuthenticationError {
constructor(context: ErrorContext, cause?: Error | unknown) {
super(
"The bot token is invalid or has been revoked",
context,
cause,
ErrorCodes.TOKEN_INVALID
);
this.name = "TokenInvalidError";
}
}
export class TokenExpiredError extends AuthenticationError {
constructor(context: ErrorContext, cause?: Error | unknown) {
super(
"The bot token has expired",
context,
cause,
ErrorCodes.TOKEN_EXPIRED
);
this.name = "TokenExpiredError";
}
}
export class ValidationError extends MattermostError {
constructor(
message: string,
context: ErrorContext,
cause?: Error | unknown,
field?: string
) {
super({
code: ErrorCodes.VALIDATION_ERROR,
message,
context: {
...context,
metadata: field ? { field } : undefined
},
cause,
retryable: false,
userMessage: `Validation error: ${message}`,
});
this.name = "ValidationError";
}
}
export class InvalidBaseUrlError extends ValidationError {
constructor(context: ErrorContext, cause?: Error | unknown) {
super(
"Invalid or missing Mattermost base URL",
context,
cause,
"baseUrl"
);
this.name = "InvalidBaseUrlError";
}
}
export class PermissionError extends MattermostError {
constructor(
message: string,
context: ErrorContext,
cause?: Error | unknown,
specificCode: ErrorCode = ErrorCodes.PERMISSION_DENIED
) {
super({
code: specificCode,
message,
context: { ...context, statusCode: 403 },
cause,
retryable: false,
userMessage: "Permission denied. You don't have access to perform this action.",
});
this.name = "PermissionError";
}
}
export class ChannelAccessDeniedError extends PermissionError {
constructor(channelId: string, context: ErrorContext, cause?: Error | unknown) {
super(
`Access denied to channel ${channelId}`,
{ ...context, channelId },
cause,
ErrorCodes.CHANNEL_ACCESS_DENIED
);
this.name = "ChannelAccessDeniedError";
}
}
export class PostEditDeniedError extends PermissionError {
constructor(postId: string, context: ErrorContext, cause?: Error | unknown) {
super(
`Cannot edit post ${postId}: insufficient permissions`,
{ ...context, postId },
cause,
ErrorCodes.POST_EDIT_DENIED
);
this.name = "PostEditDeniedError";
}
}
export class FileUploadDeniedError extends PermissionError {
constructor(context: ErrorContext, cause?: Error | unknown) {
super(
"File upload not permitted",
context,
cause,
ErrorCodes.FILE_UPLOAD_DENIED
);
this.name = "FileUploadDeniedError";
}
}
export class RateLimitError extends MattermostError {
constructor(
message: string,
context: ErrorContext,
retryAfterMs: number,
cause?: Error | unknown
) {
super({
code: ErrorCodes.RATE_LIMIT_EXCEEDED,
message,
context: { ...context, statusCode: 429 },
cause,
retryable: true,
userMessage: "Rate limit exceeded. Please try again later.",
retryAfterMs,
});
this.name = "RateLimitError";
}
}
export class ResourceNotFoundError extends MattermostError {
constructor(
resourceType: string,
resourceId: string,
context: ErrorContext,
cause?: Error | unknown
) {
const codeMap: Record<string, ErrorCode> = {
user: ErrorCodes.USER_NOT_FOUND,
channel: ErrorCodes.CHANNEL_NOT_FOUND,
post: ErrorCodes.POST_NOT_FOUND,
team: ErrorCodes.TEAM_NOT_FOUND,
};
super({
code: codeMap[resourceType] ?? ErrorCodes.RESOURCE_NOT_FOUND,
message: `${resourceType} not found: ${resourceId}`,
context: { ...context, statusCode: 404 },
cause,
retryable: false,
userMessage: `The requested ${resourceType} could not be found.`,
});
this.name = "ResourceNotFoundError";
}
}
export class ConfigurationError extends MattermostError {
constructor(
message: string,
context: ErrorContext,
cause?: Error | unknown,
specificCode: ErrorCode = ErrorCodes.CONFIGURATION_ERROR
) {
super({
code: specificCode,
message,
context,
cause,
retryable: false,
userMessage: `Configuration error: ${message}. Please check your settings.`,
});
this.name = "ConfigurationError";
}
}
export class MissingBotTokenError extends ConfigurationError {
constructor(context: ErrorContext, cause?: Error | unknown) {
super(
"Mattermost bot token is required",
context,
cause,
ErrorCodes.MISSING_BOT_TOKEN
);
this.name = "MissingBotTokenError";
}
}
export class MissingBaseUrlError extends ConfigurationError {
constructor(context: ErrorContext, cause?: Error | unknown) {
super(
"Mattermost baseUrl is required",
context,
cause,
ErrorCodes.MISSING_BASE_URL
);
this.name = "MissingBaseUrlError";
}
}
export class NetworkError extends MattermostError {
constructor(
message: string,
context: ErrorContext,
cause?: Error | unknown,
specificCode: ErrorCode = ErrorCodes.NETWORK_ERROR
) {
super({
code: specificCode,
message,
context,
cause,
retryable: true,
userMessage: "Network error. Please check your connection and try again.",
});
this.name = "NetworkError";
}
}
export class ConnectionError extends NetworkError {
constructor(context: ErrorContext, cause?: Error | unknown) {
super(
"Failed to connect to Mattermost server",
context,
cause,
ErrorCodes.CONNECTION_ERROR
);
this.name = "ConnectionError";
}
}
export class TimeoutError extends NetworkError {
constructor(operation: string, context: ErrorContext, cause?: Error | unknown) {
super(
`Operation timed out: ${operation}`,
context,
cause,
ErrorCodes.TIMEOUT_ERROR
);
this.name = "TimeoutError";
}
}
function isRetryableStatusCode(statusCode: number): boolean {
if (statusCode === 429) return true;
if (statusCode >= 500 && statusCode < 600) return true;
if (statusCode === 408) return true;
return false;
}
function getUserMessageForStatusCode(statusCode: number | undefined, detail: string): string {
if (!statusCode) {
return `Mattermost API error: ${detail}`;
}
const messages: Record<number, string> = {
400: "Bad request. Please check your input and try again.",
401: "Authentication failed. Please check your bot token.",
403: "Permission denied. The bot doesn't have access to this resource.",
404: "The requested resource was not found.",
408: "Request timed out. Please try again.",
429: "Rate limit exceeded. Please wait a moment and try again.",
500: "Mattermost server error. Please try again later.",
502: "Mattermost server is temporarily unavailable. Please try again later.",
503: "Mattermost service is temporarily unavailable. Please try again later.",
504: "Mattermost server timeout. Please try again later.",
};
return messages[statusCode] ?? `Mattermost API error (${statusCode}): ${detail}`;
}
export function isRetryableError(error: unknown): boolean {
if (error instanceof MattermostError) {
return error.retryable;
}
if (typeof error === "object" && error !== null) {
const code = (error as { code?: string }).code;
if (code && RETRYABLE_ERROR_CODES.has(code)) {
return true;
}
const errno = (error as { errno?: string | number }).errno;
if (typeof errno === "string" && RETRYABLE_ERROR_CODES.has(errno)) {
return true;
}
const message = String((error as { message?: string }).message || "").toLowerCase();
if (RETRYABLE_MESSAGE_PATTERNS.some(pattern => message.includes(pattern))) {
return true;
}
}
return false;
}
const RETRYABLE_ERROR_CODES = new Set([
"ECONNRESET",
"ECONNREFUSED",
"ETIMEDOUT",
"ESOCKETTIMEDOUT",
"ECONNABORTED",
"ENOTFOUND",
"EAI_AGAIN",
"EHOSTUNREACH",
"ENETUNREACH",
"EPIPE",
"UND_ERR_CONNECT_TIMEOUT",
"UND_ERR_DNS_RESOLVE_FAILED",
"UND_ERR_CONNECT",
"UND_ERR_SOCKET",
"UND_ERR_HEADERS_TIMEOUT",
"UND_ERR_BODY_TIMEOUT",
]);
const RETRYABLE_MESSAGE_PATTERNS = [
"rate limit",
"too many requests",
"timeout",
"timed out",
"network error",
"connection refused",
"econnreset",
"econnrefused",
"etimedout",
"enotfound",
"socket hang up",
"getaddrinfo",
"temporary",
"unavailable",
"5",
];
export function getRetryAfterMs(error: unknown): number | undefined {
if (error instanceof RateLimitError) {
return error.retryAfterMs;
}
if (typeof error === "object" && error !== null) {
const retryAfter = (error as { retryAfter?: number; retry_after?: number }).retryAfter
?? (error as { retry_after?: number }).retry_after;
if (typeof retryAfter === "number" && retryAfter > 0) {
return retryAfter * 1000;
}
const headers = (error as { headers?: { get?: (name: string) => string | null } }).headers;
if (headers?.get) {
const headerValue = headers.get("retry-after");
if (headerValue) {
const seconds = parseInt(headerValue, 10);
if (!isNaN(seconds) && seconds > 0) {
return seconds * 1000;
}
}
}
}
return undefined;
}
export interface RetryConfig {
maxRetries?: number;
initialDelayMs?: number;
maxDelayMs?: number;
timeoutMs?: number;
onRetry?: (info: RetryAttemptInfo) => void;
}
export interface RetryAttemptInfo {
attempt: number;
maxRetries: number;
delayMs: number;
error: MattermostError | Error;
}
const DEFAULT_RETRY_CONFIG: Required<RetryConfig> = {
maxRetries: 3,
initialDelayMs: 1000,
maxDelayMs: 30000,
timeoutMs: 30000,
onRetry: () => {},
};
export async function withRetry<T>(
operation: string,
fn: () => Promise<T>,
config: RetryConfig = {},
context?: Partial<ErrorContext>
): Promise<T> {
const cfg = { ...DEFAULT_RETRY_CONFIG, ...config };
let lastError: MattermostError | Error | undefined;
for (let attempt = 1; attempt <= cfg.maxRetries + 1; attempt++) {
try {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), cfg.timeoutMs);
try {
return await fn();
} finally {
clearTimeout(timeoutId);
}
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
if (attempt > cfg.maxRetries) {
break;
}
if (!isRetryableError(lastError)) {
throw lastError;
}
const baseDelay = cfg.initialDelayMs * Math.pow(2, attempt - 1);
const jitter = Math.random() * 0.3 * baseDelay;
let delayMs = Math.min(baseDelay + jitter, cfg.maxDelayMs);
const retryAfter = getRetryAfterMs(lastError);
if (retryAfter !== undefined) {
delayMs = Math.max(delayMs, retryAfter);
}
delayMs = Math.round(delayMs);
cfg.onRetry({
attempt,
maxRetries: cfg.maxRetries,
delayMs,
error: lastError,
});
await sleep(delayMs);
}
}
throw lastError ?? new MattermostError({
code: ErrorCodes.UNKNOWN_ERROR,
message: `Operation failed after ${cfg.maxRetries} retries: ${operation}`,
context: createErrorContext(operation, context),
});
}
function sleep(ms: number): Promise<void> {
return new Promise(resolve => setTimeout(resolve, ms));
}
export type ErrorHandler = (error: MattermostError | Error) => void;
export class ErrorBoundary {
private handlers: Set<ErrorHandler> = new Set();
private isShutdown = false;
onError(handler: ErrorHandler): () => void {
this.handlers.add(handler);
return () => this.handlers.delete(handler);
}
handle(error: unknown): void {
if (this.isShutdown) {
return;
}
const normalizedError = normalizeError(error);
for (const handler of this.handlers) {
try {
handler(normalizedError);
} catch {
// Silently ignore handler failures to avoid console noise
}
}
}
wrap<T extends (...args: unknown[]) => Promise<unknown>>(
fn: T,
operation: string,
context?: Partial<ErrorContext>
): (...args: Parameters<T>) => Promise<ReturnType<T>> {
return async (...args: Parameters<T>): Promise<ReturnType<T>> => {
try {
return await fn(...args) as ReturnType<T>;
} catch (error) {
const normalized = normalizeError(error, operation, context);
this.handle(normalized);
throw normalized;
}
};
}
shutdown(): void {
this.isShutdown = true;
this.handlers.clear();
}
}
export const globalErrorBoundary = new ErrorBoundary();
function normalizeError(
error: unknown,
operation = "unknown",
context?: Partial<ErrorContext>
): MattermostError | Error {
if (error instanceof MattermostError) {
return error;
}
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes("unauthorized") || message.includes("401")) {
return new AuthenticationError(
error.message,
createErrorContext(operation, context),
error
);
}
if (message.includes("forbidden") || message.includes("403")) {
return new PermissionError(
error.message,
createErrorContext(operation, context),
error
);
}
if (message.includes("not found") || message.includes("404")) {
return new ResourceNotFoundError(
"resource",
"unknown",
createErrorContext(operation, context),
error
);
}
return error;
}
return new MattermostError({
code: ErrorCodes.UNKNOWN_ERROR,
message: String(error),
context: createErrorContext(operation, context),
});
}
export async function createErrorFromResponse(
response: Response,
operation: string,
context?: Partial<ErrorContext>
): Promise<MattermostError> {
const statusCode = response.status;
const errorContext = createErrorContext(operation, {
...context,
statusCode,
requestPath: response.url,
});
let detail = "Unknown error";
try {
const contentType = response.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
const data = await response.json() as { message?: string; error?: string };
detail = data.message ?? data.error ?? JSON.stringify(data);
} else {
detail = await response.text();
}
} catch {
detail = response.statusText;
}
switch (statusCode) {
case 400:
return new ValidationError(
`Bad request: ${detail}`,
errorContext
);
case 401:
return new AuthenticationError(
`Authentication failed: ${detail}`,
errorContext
);
case 403:
return new PermissionError(
`Permission denied: ${detail}`,
errorContext
);
case 404:
return new ResourceNotFoundError(
"resource",
"unknown",
errorContext
);
case 408:
return new TimeoutError(
operation,
errorContext
);
case 429: {
const retryAfterHeader = response.headers.get("retry-after");
const retryAfterMs = retryAfterHeader
? parseInt(retryAfterHeader, 10) * 1000
: 5000;
return new RateLimitError(
`Rate limit exceeded: ${detail}`,
errorContext,
retryAfterMs
);
}
case 500:
case 502:
case 503:
case 504:
return new MattermostAPIError(
`Server error (${statusCode}): ${detail}`,
errorContext,
statusCode
);
default:
return new MattermostAPIError(
`API error (${statusCode}): ${detail}`,
errorContext,
statusCode
);
}
}
export function getUserFriendlyMessage(error: unknown): string {
if (error instanceof MattermostError) {
return error.userMessage;
}
if (error instanceof Error) {
const message = error.message.toLowerCase();
if (message.includes("econnrefused") || message.includes("enotfound")) {
return "Unable to connect to Mattermost server. Please check that the server URL is correct and the server is running.";
}
if (message.includes("timeout")) {
return "The request timed out. The Mattermost server may be slow or unavailable. Please try again.";
}
if (message.includes("certificate") || message.includes("ssl")) {
return "SSL/TLS certificate error. Please check the server configuration or use a valid certificate.";
}
return formatErrorMessage(error);
}
return "An unexpected error occurred. Please try again or contact support if the problem persists.";
}
export { retryAsync, type RetryOptions, type RetryInfo };
export { extractErrorCode, formatErrorMessage };
@@ -0,0 +1,46 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../runtime-api.js";
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
describe("resolveMattermostGroupRequireMention", () => {
it("defaults to requiring mention when no override is configured", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {},
},
};
const requireMention = resolveMattermostGroupRequireMention({ cfg, accountId: "default" });
expect(requireMention).toBe(true);
});
it("respects chatmode-derived account override", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
chatmode: "onmessage",
},
},
};
const requireMention = resolveMattermostGroupRequireMention({ cfg, accountId: "default" });
expect(requireMention).toBe(false);
});
it("prefers an explicit runtime override when provided", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
chatmode: "oncall",
},
},
};
const requireMention = resolveMattermostGroupRequireMention({
cfg,
accountId: "default",
requireMentionOverride: false,
});
expect(requireMention).toBe(false);
});
});
@@ -0,0 +1,23 @@
import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
import { resolveMattermostAccount } from "./mattermost/accounts.js";
import type { ChannelGroupContext } from "./runtime-api.js";
export function resolveMattermostGroupRequireMention(
params: ChannelGroupContext & { requireMentionOverride?: boolean },
): boolean | undefined {
const account = resolveMattermostAccount({
cfg: params.cfg,
accountId: params.accountId,
});
const requireMentionOverride =
typeof params.requireMentionOverride === "boolean"
? params.requireMentionOverride
: account.requireMention;
return resolveChannelGroupRequireMention({
cfg: params.cfg,
channel: "mattermost",
groupId: params.groupId,
accountId: params.accountId,
requireMentionOverride,
});
}
@@ -0,0 +1,271 @@
import { describe, it, expect } from "vitest";
import {
compileMattermostInteractiveReplies,
isMattermostInteractiveRepliesEnabled,
sanitizeButtonValue,
validateInteractiveChoices,
} from "../interactive-replies.js";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
describe("compileMattermostInteractiveReplies", () => {
it("should parse button directives correctly", () => {
const payload: ReplyPayload = {
text: "Please select an action: [[mattermost_buttons: Approve:approve:primary, Reject:reject:danger]]",
};
const result = compileMattermostInteractiveReplies(payload);
expect(result.interactive).toBeDefined();
expect(result.interactive?.blocks).toHaveLength(2);
expect(result.interactive?.blocks[0]).toEqual({ type: "text", text: "Please select an action:" });
expect(result.interactive?.blocks[1]).toEqual({
type: "buttons",
buttons: [
{ label: "Approve", value: "approve", style: "primary" },
{ label: "Reject", value: "reject", style: "danger" },
],
});
expect(result.text).toBe("Please select an action:");
});
it("should parse select directives correctly", () => {
const payload: ReplyPayload = {
text: "Choose a priority: [[mattermost_select: Select Priority | Low:low, Medium:medium, High:high]]",
};
const result = compileMattermostInteractiveReplies(payload);
expect(result.interactive).toBeDefined();
expect(result.interactive?.blocks).toHaveLength(2);
expect(result.interactive?.blocks[1]).toEqual({
type: "select",
placeholder: "Select Priority",
options: [
{ label: "Low", value: "low" },
{ label: "Medium", value: "medium" },
{ label: "High", value: "high" },
],
});
});
it("should handle mixed buttons and selects", () => {
const payload: ReplyPayload = {
text: "First: [[mattermost_buttons: Yes:yes, No:no]] Second: [[mattermost_select: Pick | A:a, B:b]]",
};
const result = compileMattermostInteractiveReplies(payload);
expect(result.interactive?.blocks).toHaveLength(4);
expect(result.interactive?.blocks[0]).toEqual({ type: "text", text: "First:" });
expect(result.interactive?.blocks[1]?.type).toBe("buttons");
expect(result.interactive?.blocks[2]).toEqual({ type: "text", text: " Second:" });
expect(result.interactive?.blocks[3]?.type).toBe("select");
});
it("should return original payload when no directives present", () => {
const payload: ReplyPayload = {
text: "Just a normal message without directives",
};
const result = compileMattermostInteractiveReplies(payload);
expect(result).toEqual(payload);
});
it("should handle empty text", () => {
const payload: ReplyPayload = {
text: "",
};
const result = compileMattermostInteractiveReplies(payload);
expect(result).toEqual(payload);
});
it("should preserve existing interactive blocks", () => {
const payload: ReplyPayload = {
text: "[[mattermost_buttons: OK:ok]]",
interactive: {
blocks: [{ type: "text", text: "Existing text" }],
},
};
const result = compileMattermostInteractiveReplies(payload);
expect(result.interactive?.blocks).toHaveLength(2);
expect(result.interactive?.blocks[0]).toEqual({ type: "text", text: "Existing text" });
expect(result.interactive?.blocks[1]?.type).toBe("buttons");
});
it("should handle buttons without explicit style (defaults to undefined)", () => {
const payload: ReplyPayload = {
text: "[[mattermost_buttons: Button1:val1, Button2:val2]]",
};
const result = compileMattermostInteractiveReplies(payload);
const buttons = result.interactive?.blocks[0] as { type: "buttons"; buttons: Array<{ style?: string }> };
expect(buttons.buttons[0].style).toBeUndefined();
expect(buttons.buttons[1].style).toBeUndefined();
});
it("should handle select without placeholder", () => {
const payload: ReplyPayload = {
text: "[[mattermost_select: Option1:opt1, Option2:opt2]]",
};
const result = compileMattermostInteractiveReplies(payload);
const select = result.interactive?.blocks[0] as { type: "select"; placeholder: string };
expect(select.placeholder).toBe("Choose an option");
});
it("should limit buttons to maximum of 5", () => {
const payload: ReplyPayload = {
text: "[[mattermost_buttons: A:a, B:b, C:c, D:d, E:e, F:f]]",
};
const result = compileMattermostInteractiveReplies(payload);
const buttons = result.interactive?.blocks[0] as { type: "buttons"; buttons: Array<unknown> };
expect(buttons.buttons).toHaveLength(5);
});
it("should ignore invalid directives gracefully", () => {
const payload: ReplyPayload = {
text: "Valid: [[mattermost_buttons: OK:ok]] Invalid: [[mattermost_buttons:]] End",
};
const result = compileMattermostInteractiveReplies(payload);
// Should still process the valid directive
expect(result.interactive?.blocks).toHaveLength(3);
expect(result.text).toContain("Valid:");
expect(result.text).toContain("Invalid:");
expect(result.text).toContain("End");
});
it("should sanitize potentially dangerous values", () => {
const payload: ReplyPayload = {
text: "[[mattermost_buttons: Test:<script>alert('xss')</script>]]",
};
const result = compileMattermostInteractiveReplies(payload);
const buttons = result.interactive?.blocks[0] as { type: "buttons"; buttons: Array<{ value: string }> };
expect(buttons.buttons[0].value).not.toContain("<script>");
});
});
describe("isMattermostInteractiveRepliesEnabled", () => {
it("should return true when interactiveReplies in array capabilities", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
capabilities: ["interactiveReplies"],
},
},
};
const result = isMattermostInteractiveRepliesEnabled({ cfg });
expect(result).toBe(true);
});
it("should return true when interactiveReplies in object capabilities", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
capabilities: {
interactiveReplies: true,
},
},
},
};
const result = isMattermostInteractiveRepliesEnabled({ cfg });
expect(result).toBe(true);
});
it("should return false when capabilities is empty", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {},
},
};
const result = isMattermostInteractiveRepliesEnabled({ cfg });
expect(result).toBe(false);
});
it("should return false when interactiveReplies is false", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
capabilities: {
interactiveReplies: false,
},
},
},
};
const result = isMattermostInteractiveRepliesEnabled({ cfg });
expect(result).toBe(false);
});
});
describe("sanitizeButtonValue", () => {
it("should remove dangerous characters", () => {
const value = "<script>alert('xss')</script>";
const sanitized = sanitizeButtonValue(value);
expect(sanitized).not.toContain("<");
expect(sanitized).not.toContain(">");
});
it("should limit value length to 100 characters", () => {
const value = "a".repeat(200);
const sanitized = sanitizeButtonValue(value);
expect(sanitized.length).toBe(100);
});
it("should preserve safe alphanumeric characters", () => {
const value = "safe_value-123";
const sanitized = sanitizeButtonValue(value);
expect(sanitized).toBe("safe_value-123");
});
});
describe("validateInteractiveChoices", () => {
it("should return sanitized choices for valid input", () => {
const choices = [
{ label: "OK", value: "ok", style: "primary" as const },
{ label: "Cancel", value: "cancel", style: "danger" as const },
];
const result = validateInteractiveChoices(choices);
expect(result).toHaveLength(2);
expect(result?.[0].label).toBe("OK");
expect(result?.[0].style).toBe("primary");
});
it("should return null for empty choices", () => {
const result = validateInteractiveChoices([]);
expect(result).toBeNull();
});
it("should deduplicate by value", () => {
const choices = [
{ label: "A", value: "dup" },
{ label: "B", value: "DUP" }, // Same value, different case
];
const result = validateInteractiveChoices(choices);
expect(result).toHaveLength(1);
});
it("should truncate labels to 30 characters", () => {
const choices = [{ label: "a".repeat(50), value: "val" }];
const result = validateInteractiveChoices(choices);
expect(result?.[0].label.length).toBe(30);
});
});
@@ -0,0 +1,317 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import {
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
type ResolvedMattermostAccount,
} from "./mattermost/accounts.js";
// Maximum items for Mattermost interactive components
const MATTERMOST_BUTTON_MAX_ITEMS = 5;
const MATTERMOST_SELECT_MAX_ITEMS = 100;
// Directive regex pattern for [[mattermost_buttons: ...]] and [[mattermost_select: ...]]
const MATTERMOST_DIRECTIVE_RE = /\[\[mattermost_(buttons|select):\s*([^\]]+)\]\]/gi;
// Button styles supported by Mattermost
const VALID_BUTTON_STYLES = new Set(["default", "primary", "danger"]);
type MattermostChoice = {
label: string;
value: string;
style?: "default" | "primary" | "danger";
};
/**
* Parse a single choice from a raw string.
* Format: "Label:value" or "Label:value:style"
* Style is optional and defaults to "default"
*/
function parseChoice(raw: string, options?: { allowStyle?: boolean }): MattermostChoice | null {
const trimmed = raw.trim();
if (!trimmed) {
return null;
}
// Split by first colon to get label and value+style
const delimiter = trimmed.indexOf(":");
if (delimiter === -1) {
// No colon: use trimmed as both label and value
return {
label: trimmed,
value: trimmed,
};
}
const label = trimmed.slice(0, delimiter).trim();
let valueAndStyle = trimmed.slice(delimiter + 1).trim();
if (!label || !valueAndStyle) {
return null;
}
// Check for style suffix if enabled
let style: MattermostChoice["style"];
if (options?.allowStyle) {
const lastColon = valueAndStyle.lastIndexOf(":");
if (lastColon !== -1) {
const maybeStyle = valueAndStyle.slice(lastColon + 1).trim().toLowerCase();
if (VALID_BUTTON_STYLES.has(maybeStyle)) {
const valueOnly = valueAndStyle.slice(0, lastColon).trim();
if (valueOnly) {
valueAndStyle = valueOnly;
style = maybeStyle as MattermostChoice["style"];
}
}
}
}
return { label, value: valueAndStyle, style };
}
/**
* Parse multiple choices from a comma-separated string.
*/
function parseChoices(
raw: string,
maxItems: number,
options?: { allowStyle?: boolean },
): MattermostChoice[] {
return raw
.split(",")
.map((entry) => parseChoice(entry, options))
.filter((entry): entry is MattermostChoice => Boolean(entry))
.slice(0, maxItems);
}
/**
* Build a text block for interactive payload.
*/
function buildTextBlock(
text: string,
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | null {
const trimmed = text.trim();
if (!trimmed) {
return null;
}
return { type: "text", text: trimmed };
}
/**
* Build a buttons block from a raw directive body.
* Format: "Label1:value1:primary, Label2:value2:danger"
*/
function buildButtonsBlock(
raw: string,
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | null {
const choices = parseChoices(raw, MATTERMOST_BUTTON_MAX_ITEMS, { allowStyle: true });
if (choices.length === 0) {
return null;
}
return {
type: "buttons",
buttons: choices.map((choice) => ({
label: choice.label,
value: choice.value,
...(choice.style && choice.style !== "default" ? { style: choice.style } : {}),
})),
};
}
/**
* Build a select (dropdown) block from a raw directive body.
* Format: "Placeholder | Label1:value1, Label2:value2"
* Or without placeholder: "Label1:value1, Label2:value2"
*/
function buildSelectBlock(
raw: string,
): NonNullable<ReplyPayload["interactive"]>["blocks"][number] | null {
// Split by pipe to separate placeholder from options
const parts = raw
.split("|")
.map((entry) => entry.trim())
.filter(Boolean);
if (parts.length === 0) {
return null;
}
const [first, second] = parts;
const placeholder = parts.length >= 2 ? first : "Choose an option";
const optionsRaw = parts.length >= 2 ? second : first;
const choices = parseChoices(optionsRaw, MATTERMOST_SELECT_MAX_ITEMS);
if (choices.length === 0) {
return null;
}
return {
type: "select",
placeholder,
options: choices,
};
}
/**
* Check if interactive replies capability is enabled for an account.
* Supports both array format ("interactiveReplies") and object format ({ interactiveReplies: true })
*/
function resolveInteractiveRepliesFromCapabilities(capabilities: unknown): boolean {
if (!capabilities) {
return false;
}
if (Array.isArray(capabilities)) {
return capabilities.some(
(entry) => String(entry).trim().toLowerCase() === "interactivereplies",
);
}
if (typeof capabilities === "object") {
return (capabilities as { interactiveReplies?: unknown }).interactiveReplies === true;
}
return false;
}
/**
* Check if interactive replies are enabled for a specific Mattermost account.
*/
export function isMattermostInteractiveRepliesEnabled(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): boolean {
const account = resolveMattermostAccount({
cfg: params.cfg,
accountId: params.accountId ?? resolveDefaultMattermostAccountId(params.cfg),
});
return resolveInteractiveRepliesFromCapabilities(account.config.capabilities);
}
/**
* Compile Mattermost interactive directives from a reply payload.
* Parses [[mattermost_buttons: ...]] and [[mattermost_select: ...]] directives
* and converts them to interactive blocks.
*
* @param payload - The reply payload potentially containing directives
* @returns Modified payload with parsed interactive blocks
*/
export function compileMattermostInteractiveReplies(payload: ReplyPayload): ReplyPayload {
const text = payload.text;
if (!text) {
return payload;
}
const generatedBlocks: NonNullable<ReplyPayload["interactive"]>["blocks"] = [];
const visibleTextParts: string[] = [];
let cursor = 0;
let matchedDirective = false;
let generatedInteractiveBlock = false;
// Reset regex state
MATTERMOST_DIRECTIVE_RE.lastIndex = 0;
// Find all directives in the text
for (const match of text.matchAll(MATTERMOST_DIRECTIVE_RE)) {
matchedDirective = true;
const matchText = match[0];
const directiveType = match[1];
const body = match[2];
const index = match.index ?? 0;
// Capture text before this directive
const precedingText = text.slice(cursor, index);
visibleTextParts.push(precedingText);
// Add text block if there's content
const textBlock = buildTextBlock(precedingText);
if (textBlock) {
generatedBlocks.push(textBlock);
}
// Build the appropriate interactive block
const block =
directiveType.toLowerCase() === "buttons"
? buildButtonsBlock(body)
: buildSelectBlock(body);
if (block) {
generatedInteractiveBlock = true;
generatedBlocks.push(block);
}
cursor = index + matchText.length;
}
// Capture any trailing text after the last directive
const trailingText = text.slice(cursor);
visibleTextParts.push(trailingText);
const trailingBlock = buildTextBlock(trailingText);
if (trailingBlock) {
generatedBlocks.push(trailingBlock);
}
// Clean the visible text by removing the directive syntax
const cleanedText = visibleTextParts.join("");
// If no directives were found or no blocks were generated, return original payload
if (!matchedDirective || !generatedInteractiveBlock) {
return payload;
}
// Merge generated blocks with any existing interactive blocks
return {
...payload,
text: cleanedText.trim() || undefined,
interactive: {
blocks: [...(payload.interactive?.blocks ?? []), ...generatedBlocks],
},
};
}
/**
* Sanitize button value for use in Mattermost callbacks.
* Removes potentially dangerous characters that could cause issues.
*/
export function sanitizeButtonValue(value: string): string {
// Remove any potentially dangerous characters
// Only allow alphanumeric, spaces, and common safe punctuation
return value
.replace(/[<>\{\}\[\]\\]/g, "")
.slice(0, 100); // Limit length
}
/**
* Validate that a button/select configuration is safe to render.
* Returns sanitized choices or null if validation fails.
*/
export function validateInteractiveChoices(choices: MattermostChoice[]): MattermostChoice[] | null {
if (!choices.length || choices.length > MATTERMOST_BUTTON_MAX_ITEMS) {
return null;
}
const seenValues = new Set<string>();
const sanitized: MattermostChoice[] = [];
for (const choice of choices) {
const sanitizedValue = sanitizeButtonValue(choice.value);
const sanitizedLabel = choice.label.slice(0, 30); // Mattermost UI limit
// Skip duplicates
if (seenValues.has(sanitizedValue.toLowerCase())) {
continue;
}
seenValues.add(sanitizedValue.toLowerCase());
sanitized.push({
label: sanitizedLabel,
value: sanitizedValue,
style: choice.style && VALID_BUTTON_STYLES.has(choice.style) ? choice.style : undefined,
});
}
return sanitized.length > 0 ? sanitized : null;
}
@@ -0,0 +1,138 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../runtime-api.js";
import {
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
resolveMattermostReplyToMode,
} from "./accounts.js";
describe("resolveDefaultMattermostAccountId", () => {
it("prefers channels.mattermost.defaultAccount when it matches a configured account", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
defaultAccount: "alerts",
accounts: {
default: { botToken: "tok-default", baseUrl: "https://chat.example.com" },
alerts: { botToken: "tok-alerts", baseUrl: "https://alerts.example.com" },
},
},
},
};
expect(resolveDefaultMattermostAccountId(cfg)).toBe("alerts");
});
it("normalizes channels.mattermost.defaultAccount before lookup", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
defaultAccount: "Ops Team",
accounts: {
"ops-team": { botToken: "tok-ops", baseUrl: "https://chat.example.com" },
},
},
},
};
expect(resolveDefaultMattermostAccountId(cfg)).toBe("ops-team");
});
it("falls back when channels.mattermost.defaultAccount is missing", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
defaultAccount: "missing",
accounts: {
default: { botToken: "tok-default", baseUrl: "https://chat.example.com" },
alerts: { botToken: "tok-alerts", baseUrl: "https://alerts.example.com" },
},
},
},
};
expect(resolveDefaultMattermostAccountId(cfg)).toBe("default");
});
});
describe("resolveMattermostReplyToMode", () => {
it("uses configured defaultAccount when accountId is omitted", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
defaultAccount: "alerts",
accounts: {
alerts: {
botToken: "tok-alerts",
baseUrl: "https://alerts.example.com",
replyToMode: "all",
},
},
},
},
};
const account = resolveMattermostAccount({ cfg });
expect(account.accountId).toBe("alerts");
expect(resolveMattermostReplyToMode(account, "channel")).toBe("all");
});
it("uses the configured mode for channel and group messages", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
replyToMode: "all",
},
},
};
const account = resolveMattermostAccount({ cfg, accountId: "default" });
expect(resolveMattermostReplyToMode(account, "channel")).toBe("all");
expect(resolveMattermostReplyToMode(account, "group")).toBe("all");
});
it("keeps direct messages off even when replyToMode is enabled", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
replyToMode: "all",
},
},
};
const account = resolveMattermostAccount({ cfg, accountId: "default" });
expect(resolveMattermostReplyToMode(account, "direct")).toBe("off");
});
it("defaults to off when replyToMode is unset", () => {
const account = resolveMattermostAccount({ cfg: {}, accountId: "default" });
expect(resolveMattermostReplyToMode(account, "channel")).toBe("off");
});
it("preserves shared commands config when an account overrides one commands field", () => {
const account = resolveMattermostAccount({
cfg: {
channels: {
mattermost: {
commands: {
native: true,
},
accounts: {
work: {
commands: {
callbackPath: "/hooks/work",
},
},
},
},
},
},
accountId: "work",
});
expect(account.config.commands).toEqual({
native: true,
callbackPath: "/hooks/work",
});
});
});
@@ -0,0 +1,159 @@
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
import { normalizeResolvedSecretInputString, normalizeSecretInputString } from "../secret-input.js";
import type {
MattermostAccountConfig,
MattermostChatMode,
MattermostChatTypeKey,
MattermostReplyToMode,
} from "../types.js";
import { normalizeMattermostBaseUrl } from "./client.js";
import type { OpenClawConfig } from "./runtime-api.js";
export type MattermostTokenSource = "env" | "config" | "none";
export type MattermostBaseUrlSource = "env" | "config" | "none";
export type ResolvedMattermostAccount = {
accountId: string;
enabled: boolean;
name?: string;
botToken?: string;
botTokenSource?: MattermostTokenSource;
pat?: string;
baseUrl?: string;
baseUrlSource?: MattermostBaseUrlSource;
config: MattermostAccountConfig;
chatmode?: MattermostChatMode;
oncharPrefixes?: string[];
requireMention?: boolean;
textChunkLimit?: number;
blockStreaming?: boolean;
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
};
const mattermostAccountHelpers = createAccountListHelpers("mattermost");
export function listMattermostAccountIds(cfg: OpenClawConfig): string[] {
return mattermostAccountHelpers.listAccountIds(cfg);
}
export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string {
return mattermostAccountHelpers.resolveDefaultAccountId(cfg);
}
function mergeMattermostAccountConfig(
cfg: OpenClawConfig,
accountId: string,
): MattermostAccountConfig {
return resolveMergedAccountConfig<MattermostAccountConfig>({
channelConfig: cfg.channels?.mattermost as MattermostAccountConfig | undefined,
accounts: cfg.channels?.mattermost?.accounts as
| Record<string, Partial<MattermostAccountConfig>>
| undefined,
accountId,
omitKeys: ["defaultAccount"],
nestedObjectKeys: ["commands"],
});
}
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;
allowUnresolvedSecretRef?: boolean;
}): ResolvedMattermostAccount {
const accountId = normalizeAccountId(
params.accountId ?? resolveDefaultMattermostAccountId(params.cfg),
);
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 envPat = allowEnv ? process.env.MATTERMOST_PAT?.trim() : undefined;
const envUrl = allowEnv ? process.env.MATTERMOST_URL?.trim() : undefined;
const configToken = params.allowUnresolvedSecretRef
? normalizeSecretInputString(merged.botToken)
: normalizeResolvedSecretInputString({
value: merged.botToken,
path: `channels.mattermost.accounts.${accountId}.botToken`,
});
const configPat = params.allowUnresolvedSecretRef
? normalizeSecretInputString(merged.pat)
: normalizeResolvedSecretInputString({
value: merged.pat,
path: `channels.mattermost.accounts.${accountId}.pat`,
});
const configUrl = merged.baseUrl?.trim();
const botToken = configToken || envToken;
const pat = configPat || envPat;
const baseUrl = normalizeMattermostBaseUrl(configUrl || envUrl);
const requireMention = resolveMattermostRequireMention(merged);
let botTokenSource: MattermostTokenSource = "none";
if (configToken) {
botTokenSource = "config";
} else if (envToken) {
botTokenSource = "env";
}
let baseUrlSource: MattermostBaseUrlSource = "none";
if (configUrl) {
baseUrlSource = "config";
} else if (envUrl) {
baseUrlSource = "env";
}
return {
accountId,
enabled,
name: merged.name?.trim() || undefined,
botToken,
botTokenSource,
pat,
baseUrl,
baseUrlSource,
config: merged,
chatmode: merged.chatmode,
oncharPrefixes: merged.oncharPrefixes,
requireMention,
textChunkLimit: merged.textChunkLimit,
blockStreaming: merged.blockStreaming,
blockStreamingCoalesce: merged.blockStreamingCoalesce,
};
}
/**
* Resolve the effective replyToMode for a given chat type.
* Mattermost auto-threading only applies to channel and group messages.
*/
export function resolveMattermostReplyToMode(
account: ResolvedMattermostAccount,
kind: MattermostChatTypeKey,
): MattermostReplyToMode {
if (kind === "direct") {
return "off";
}
return account.config.replyToMode ?? "off";
}
export function listEnabledMattermostAccounts(cfg: OpenClawConfig): ResolvedMattermostAccount[] {
return listMattermostAccountIds(cfg)
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}
@@ -0,0 +1,551 @@
import { createWriteStream } from "node:fs";
import { mkdir, unlink } from "node:fs/promises";
import { dirname, extname } from "node:path";
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
fetchMattermostMe,
type MattermostClient,
type MattermostFetch,
} from "./client.js";
import type { OpenClawConfig } from "./runtime-api.js";
/** Default maximum file size (100MB in bytes) */
export const DEFAULT_MAX_FILE_SIZE = 100 * 1024 * 1024;
/** Blocked executable file extensions */
export const BLOCKED_EXTENSIONS = new Set([
".exe",
".sh",
".bat",
".cmd",
".com",
".msi",
".dll",
".so",
".dylib",
".app",
".dmg",
".pkg",
".deb",
".rpm",
".apk",
".ipa",
".jar",
".war",
".ear",
".py",
".rb",
".pl",
".php",
".js",
".ts",
".vbs",
".ps1",
".psm1",
".scr",
".hta",
".bin",
".run",
".out",
".elf",
]);
/** Allowed MIME type prefixes (configurable allowlist) */
export const ALLOWED_MIME_PREFIXES = [
"image/",
"video/",
"audio/",
"text/",
"application/pdf",
"application/json",
"application/xml",
"application/csv",
"application/zip",
"application/x-zip",
"application/x-zip-compressed",
"application/gzip",
"application/x-gzip",
"application/tar",
"application/x-tar",
];
export type EditMessageResult =
| { ok: true; postId: string; channelId: string }
| { ok: false; error: string; errorCode?: string };
export type EditMessageParams = {
cfg: OpenClawConfig;
postId: string;
channelId: string;
message: string;
props?: Record<string, unknown>;
accountId?: string | null;
fetchImpl?: MattermostFetch;
};
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 editMessage(params: EditMessageParams): Promise<EditMessageResult> {
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,
allowPrivateNetwork: resolved.config?.allowPrivateNetwork === true,
});
const cacheKey = `${baseUrl}:${botToken}`;
const botUserId = await resolveBotUserId(client, cacheKey);
if (!botUserId) {
return { ok: false, error: "Mattermost edit failed: could not resolve bot user id." };
}
if (!params.postId?.trim()) {
return { ok: false, error: "Mattermost edit requires postId" };
}
if (!params.channelId?.trim()) {
return { ok: false, error: "Mattermost edit requires channelId" };
}
if (!params.message?.trim() && !params.props) {
return { ok: false, error: "Mattermost edit requires message or props" };
}
try {
const existingPost = await client.request<{
id: string;
user_id: string;
channel_id: string;
type?: string;
}>(`/posts/${params.postId}`);
if (existingPost.user_id !== botUserId) {
return {
ok: false,
error: "Permission denied: cannot edit messages from other users",
errorCode: "PERMISSION_DENIED",
};
}
if (existingPost.type && existingPost.type !== "") {
return {
ok: false,
error: "Cannot edit system messages",
errorCode: "SYSTEM_MESSAGE",
};
}
const updatePayload: Record<string, unknown> = {
id: params.postId,
channel_id: params.channelId,
};
if (params.message?.trim()) {
updatePayload.message = params.message.trim();
}
if (params.props) {
updatePayload.props = params.props;
}
const updatedPost = await client.request<{
id: string;
channel_id: string;
}>(`/posts/${params.postId}`, {
method: "PUT",
body: JSON.stringify(updatePayload),
});
return {
ok: true,
postId: updatedPost.id,
channelId: updatedPost.channel_id,
};
} catch (err) {
const errorMessage = String(err);
if (errorMessage.includes("403") || errorMessage.includes("permission")) {
return {
ok: false,
error: "Permission denied: insufficient permissions to edit this message",
errorCode: "PERMISSION_DENIED",
};
}
if (errorMessage.includes("404") || errorMessage.includes("not found")) {
return {
ok: false,
error: "Post not found: the message may have been deleted",
errorCode: "POST_NOT_FOUND",
};
}
if (errorMessage.includes("429") || errorMessage.includes("rate limit")) {
return {
ok: false,
error: "Rate limited: too many edit requests, please try again later",
errorCode: "RATE_LIMITED",
};
}
if (errorMessage.includes("401") || errorMessage.includes("unauthorized")) {
return {
ok: false,
error: "Authentication failed: invalid or expired token",
errorCode: "AUTH_FAILED",
};
}
return {
ok: false,
error: `Mattermost edit failed: ${errorMessage}`,
errorCode: "UNKNOWN_ERROR",
};
}
}
export function resetMattermostEditBotUserCacheForTests(): void {
botUserIdCache.clear();
}
type DeleteMessageResult = { ok: true } | { ok: false; error: string };
export type DeleteMessageParams = {
cfg: OpenClawConfig;
postId: string;
channelId?: string;
accountId?: string | null;
fetchImpl?: MattermostFetch;
};
export async function deleteMessage(params: DeleteMessageParams): Promise<DeleteMessageResult> {
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,
allowPrivateNetwork: resolved.config?.allowPrivateNetwork === true,
});
return deleteMattermostPost(client, params.postId);
}
async function deleteMattermostPost(
client: MattermostClient,
postId: string,
): Promise<DeleteMessageResult> {
try {
await client.request<unknown>(`/posts/${encodeURIComponent(postId)}`, {
method: "DELETE",
});
return { ok: true };
} catch (err) {
const errorMessage = String(err).toLowerCase();
const isNotFound =
errorMessage.includes("404") ||
errorMessage.includes("not found") ||
errorMessage.includes("message not found") ||
errorMessage.includes("post not found") ||
errorMessage.includes("no such post");
if (isNotFound) {
return { ok: true };
}
const isPermissionDenied =
errorMessage.includes("403") ||
errorMessage.includes("permission denied") ||
errorMessage.includes("unauthorized") ||
errorMessage.includes("access denied") ||
errorMessage.includes("forbidden");
if (isPermissionDenied) {
return { ok: false, error: `Permission denied: unable to delete message ${postId}` };
}
const isSystemMessage =
errorMessage.includes("system message") ||
errorMessage.includes("cannot delete system message");
if (isSystemMessage) {
return { ok: false, error: `Cannot delete system message ${postId}` };
}
return { ok: false, error: `Failed to delete message ${postId}: ${String(err)}` };
}
}
export type DownloadFileResult =
| { ok: true; filePath: string; fileId: string; metadata: FileMetadata }
| { ok: false; error: string; errorCode?: string };
export type FileMetadata = {
id: string;
name: string;
mimeType: string;
size: number;
extension: string;
};
export type DownloadFileParams = {
cfg: OpenClawConfig;
fileId: string;
destinationPath: string;
maxSize?: number;
accountId?: string | null;
fetchImpl?: MattermostFetch;
/** Optional custom allowed MIME type prefixes */
allowedMimePrefixes?: string[];
};
/**
* Downloads a file from Mattermost with security validations.
* Uses PAT for authentication.
* Security: Validates file type against allowlist, blocks executables, checks size before download.
* Streams download to avoid memory issues.
*
* Mattermost API: GET /api/v4/files/{file_id}
*/
export async function downloadFile(params: DownloadFileParams): Promise<DownloadFileResult> {
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.", errorCode: "CONFIG_ERROR" };
}
if (!params.fileId?.trim()) {
return { ok: false, error: "File ID is required", errorCode: "INVALID_PARAMS" };
}
if (!params.destinationPath?.trim()) {
return { ok: false, error: "Destination path is required", errorCode: "INVALID_PARAMS" };
}
const maxSize = params.maxSize ?? DEFAULT_MAX_FILE_SIZE;
const fileId = params.fileId.trim();
const destinationPath = params.destinationPath.trim();
const client = createMattermostClient({
baseUrl,
botToken,
fetchImpl: params.fetchImpl,
allowPrivateNetwork: resolved.config?.allowPrivateNetwork === true,
});
try {
const fileInfo = await client.request<{
id: string;
name?: string | null;
mime_type?: string | null;
size?: number | null;
}>(`/files/${fileId}/info`);
const fileName = fileInfo.name?.trim() || "unknown";
const mimeType = fileInfo.mime_type?.trim() || "application/octet-stream";
const fileSize = fileInfo.size ?? 0;
const extension = extname(fileName).toLowerCase();
if (fileSize > maxSize) {
return {
ok: false,
error: `File size (${fileSize} bytes) exceeds maximum allowed (${maxSize} bytes)`,
errorCode: "FILE_TOO_LARGE",
};
}
if (fileSize <= 0) {
return {
ok: false,
error: "Invalid file size: file is empty",
errorCode: "EMPTY_FILE",
};
}
if (BLOCKED_EXTENSIONS.has(extension)) {
return {
ok: false,
error: `Executable file type not allowed: ${extension}`,
errorCode: "BLOCKED_FILE_TYPE",
};
}
const allowedPrefixes = params.allowedMimePrefixes ?? ALLOWED_MIME_PREFIXES;
const isMimeTypeAllowed = allowedPrefixes.some((prefix) => mimeType.toLowerCase().startsWith(prefix.toLowerCase()));
if (!isMimeTypeAllowed) {
return {
ok: false,
error: `File MIME type not allowed: ${mimeType}`,
errorCode: "BLOCKED_MIME_TYPE",
};
}
const dir = dirname(destinationPath);
await mkdir(dir, { recursive: true });
const downloadUrl = `${client.apiBaseUrl}/files/${fileId}`;
const res = await client.fetchImpl(downloadUrl, {
method: "GET",
headers: {
Authorization: `Bearer ${botToken}`,
},
});
if (!res.ok) {
const detail = await readMattermostError(res);
return {
ok: false,
error: `Download failed: ${res.status} ${res.statusText} - ${detail}`,
errorCode: "DOWNLOAD_FAILED",
};
}
const contentLength = res.headers.get("content-length");
if (contentLength) {
const actualSize = parseInt(contentLength, 10);
if (actualSize > maxSize) {
return {
ok: false,
error: `File size (${actualSize} bytes) exceeds maximum allowed (${maxSize} bytes)`,
errorCode: "FILE_TOO_LARGE",
};
}
}
const fileStream = createWriteStream(destinationPath);
const body = res.body;
if (!body) {
return {
ok: false,
error: "Download failed: no response body",
errorCode: "DOWNLOAD_FAILED",
};
}
const reader = body.getReader();
let downloadedSize = 0;
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
downloadedSize += value.length;
if (downloadedSize > maxSize) {
fileStream.destroy();
await unlink(destinationPath).catch(() => undefined);
return {
ok: false,
error: `Downloaded file size exceeds maximum allowed (${maxSize} bytes)`,
errorCode: "FILE_TOO_LARGE",
};
}
fileStream.write(Buffer.from(value));
}
fileStream.end();
await new Promise<void>((resolve, reject) => {
fileStream.on("finish", resolve);
fileStream.on("error", reject);
});
} catch (streamErr) {
fileStream.destroy();
await unlink(destinationPath).catch(() => undefined);
throw streamErr;
}
const metadata: FileMetadata = {
id: fileId,
name: fileName,
mimeType,
size: downloadedSize,
extension,
};
return {
ok: true,
filePath: destinationPath,
fileId,
metadata,
};
} catch (err) {
const errorMessage = String(err);
if (errorMessage.includes("404") || errorMessage.includes("not found")) {
return {
ok: false,
error: "File not found",
errorCode: "FILE_NOT_FOUND",
};
}
if (errorMessage.includes("403") || errorMessage.includes("permission")) {
return {
ok: false,
error: "Permission denied: insufficient permissions to download this file",
errorCode: "PERMISSION_DENIED",
};
}
if (errorMessage.includes("401") || errorMessage.includes("unauthorized")) {
return {
ok: false,
error: "Authentication failed: invalid or expired token",
errorCode: "AUTH_FAILED",
};
}
return {
ok: false,
error: `Download failed: ${errorMessage}`,
errorCode: "DOWNLOAD_FAILED",
};
}
}
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();
}
@@ -0,0 +1,512 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { createMattermostClient, createMattermostDirectChannelWithRetry } from "./client.js";
describe("createMattermostDirectChannelWithRetry", () => {
const mockFetch = vi.fn<typeof fetch>();
beforeEach(() => {
vi.restoreAllMocks();
vi.clearAllMocks();
vi.useFakeTimers();
});
afterEach(async () => {
await vi.runOnlyPendingTimersAsync();
vi.useRealTimers();
vi.restoreAllMocks();
});
function createMockClient() {
return createMattermostClient({
baseUrl: "https://mattermost.example.com",
// pragma: allowlist secret
botToken: "test-token",
fetchImpl: mockFetch,
});
}
function createFetchFailedError(params: { message: string; code?: string }): TypeError {
const cause = Object.assign(new Error(params.message), {
code: params.code,
});
return Object.assign(new TypeError("fetch failed"), { cause });
}
async function resolveRetryRun<T>(run: Promise<T>): Promise<T> {
await vi.runAllTimersAsync();
return await run;
}
function suppressUnhandled<T>(run: Promise<T>): Promise<T> {
run.catch(() => {});
return run;
}
it("succeeds on first attempt without retries", async () => {
mockFetch.mockResolvedValueOnce({
ok: true,
status: 201,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ id: "dm-channel-123" }),
} as Response);
const client = createMockClient();
const onRetry = vi.fn();
const result = await resolveRetryRun(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
onRetry,
}),
);
expect(result.id).toBe("dm-channel-123");
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(onRetry).not.toHaveBeenCalled();
});
it("retries on 429 rate limit error and succeeds", async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 429,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ message: "Too many requests" }),
text: async () => "Too many requests",
} as Response)
.mockResolvedValueOnce({
ok: true,
status: 201,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ id: "dm-channel-456" }),
} as Response);
const client = createMockClient();
const onRetry = vi.fn();
const result = await resolveRetryRun(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 3,
initialDelayMs: 10,
onRetry,
}),
);
expect(result.id).toBe("dm-channel-456");
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(onRetry).toHaveBeenCalledTimes(1);
expect(onRetry).toHaveBeenCalledWith(
1,
expect.any(Number),
expect.objectContaining({ message: expect.stringContaining("429") }),
);
});
it("retries on port 443 connection errors (not misclassified as 4xx)", async () => {
// This tests that port numbers like :443 don't trigger false 4xx classification
mockFetch
.mockRejectedValueOnce(new Error("connect ECONNRESET 104.18.32.10:443"))
.mockResolvedValueOnce({
ok: true,
status: 201,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ id: "dm-channel-port" }),
} as Response);
const client = createMockClient();
const result = await resolveRetryRun(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 3,
initialDelayMs: 10,
}),
);
// Should retry and succeed on second attempt (port 443 should NOT be treated as 4xx)
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(result.id).toBe("dm-channel-port");
});
it("does not retry on 400 even if error message contains '429' text", async () => {
// This tests that "429" in error detail doesn't trigger false rate-limit retry
// e.g., "Invalid user ID: 4294967295" should NOT be retried
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ message: "Invalid user ID: 4294967295" }),
text: async () => "Invalid user ID: 4294967295",
} as Response);
const client = createMockClient();
const run = suppressUnhandled(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 3,
initialDelayMs: 10,
}),
);
await expect(resolveRetryRun(run)).rejects.toThrow();
// Should not retry - only called once (400 is a client error, even though message contains "429")
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("retries on 5xx server errors", async () => {
mockFetch
.mockResolvedValueOnce({
ok: false,
status: 503,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ message: "Service unavailable" }),
text: async () => "Service unavailable",
} as Response)
.mockResolvedValueOnce({
ok: false,
status: 502,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ message: "Bad gateway" }),
text: async () => "Bad gateway",
} as Response)
.mockResolvedValueOnce({
ok: true,
status: 201,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ id: "dm-channel-789" }),
} as Response);
const client = createMockClient();
const result = await resolveRetryRun(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 3,
initialDelayMs: 10,
}),
);
expect(result.id).toBe("dm-channel-789");
expect(mockFetch).toHaveBeenCalledTimes(3);
});
it("retries on network errors", async () => {
mockFetch
.mockRejectedValueOnce(new Error("Network error: connection refused"))
.mockRejectedValueOnce(new Error("ECONNRESET"))
.mockResolvedValueOnce({
ok: true,
status: 201,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ id: "dm-channel-abc" }),
} as Response);
const client = createMockClient();
const result = await resolveRetryRun(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 3,
initialDelayMs: 10,
}),
);
expect(result.id).toBe("dm-channel-abc");
expect(mockFetch).toHaveBeenCalledTimes(3);
});
it("retries on fetch failed errors when the cause carries a transient code", async () => {
mockFetch
.mockRejectedValueOnce(
createFetchFailedError({
message: "connect ECONNREFUSED 127.0.0.1:81",
code: "ECONNREFUSED",
}),
)
.mockResolvedValueOnce({
ok: true,
status: 201,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ id: "dm-channel-fetch-failed" }),
} as Response);
const client = createMockClient();
const result = await resolveRetryRun(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 3,
initialDelayMs: 10,
}),
);
expect(result.id).toBe("dm-channel-fetch-failed");
expect(mockFetch).toHaveBeenCalledTimes(2);
});
it("does not retry on 4xx client errors (except 429)", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ message: "Bad request" }),
text: async () => "Bad request",
} as Response);
const client = createMockClient();
const run = suppressUnhandled(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 3,
initialDelayMs: 10,
}),
);
await expect(resolveRetryRun(run)).rejects.toThrow("400");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("does not retry on 404 not found", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 404,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ message: "User not found" }),
text: async () => "User not found",
} as Response);
const client = createMockClient();
const run = suppressUnhandled(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 3,
initialDelayMs: 10,
}),
);
await expect(resolveRetryRun(run)).rejects.toThrow("404");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("throws after exhausting all retries", async () => {
mockFetch.mockResolvedValue({
ok: false,
status: 503,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ message: "Service unavailable" }),
text: async () => "Service unavailable",
} as Response);
const client = createMockClient();
const run = suppressUnhandled(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 2,
initialDelayMs: 10,
}),
);
await expect(resolveRetryRun(run)).rejects.toThrow();
expect(mockFetch).toHaveBeenCalledTimes(3); // initial + 2 retries
});
it("respects custom timeout option and aborts fetch", async () => {
let abortSignal: AbortSignal | undefined;
let abortListenerCalled = false;
mockFetch.mockImplementationOnce((url, init) => {
abortSignal = init?.signal ?? undefined;
if (abortSignal) {
abortSignal.addEventListener("abort", () => {
abortListenerCalled = true;
});
}
// Return a promise that rejects when aborted, otherwise never resolves
return new Promise((_, reject) => {
if (abortSignal) {
const checkAbort = () => {
if (abortSignal?.aborted) {
reject(new Error("AbortError"));
} else {
setTimeout(checkAbort, 10);
}
};
setTimeout(checkAbort, 10);
}
});
});
const client = createMockClient();
const run = suppressUnhandled(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
timeoutMs: 50,
maxRetries: 0,
initialDelayMs: 10,
}),
);
await expect(resolveRetryRun(run)).rejects.toThrow();
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(abortSignal).toBeDefined();
expect(abortListenerCalled).toBe(true);
});
it("uses exponential backoff with jitter between retries", async () => {
const delays: number[] = [];
mockFetch
.mockRejectedValueOnce(new Error("Mattermost API 503 Service Unavailable"))
.mockRejectedValueOnce(new Error("Mattermost API 503 Service Unavailable"))
.mockResolvedValueOnce({
ok: true,
status: 201,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ id: "dm-channel-delay" }),
} as Response);
const client = createMockClient();
await resolveRetryRun(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 3,
initialDelayMs: 100,
maxDelayMs: 1000,
onRetry: (attempt, delayMs) => {
delays.push(delayMs);
},
}),
);
expect(delays).toHaveLength(2);
// First retry: exponentialDelay = 100ms, jitter = 0-100ms, total = 100-200ms
expect(delays[0]).toBeGreaterThanOrEqual(100);
expect(delays[0]).toBeLessThanOrEqual(200);
// Second retry: exponentialDelay = 200ms, jitter = 0-200ms, total = 200-400ms
expect(delays[1]).toBeGreaterThanOrEqual(200);
expect(delays[1]).toBeLessThanOrEqual(400);
});
it("respects maxDelayMs cap", async () => {
const delays: number[] = [];
mockFetch
.mockRejectedValueOnce(new Error("Mattermost API 503"))
.mockRejectedValueOnce(new Error("Mattermost API 503"))
.mockRejectedValueOnce(new Error("Mattermost API 503"))
.mockRejectedValueOnce(new Error("Mattermost API 503"))
.mockResolvedValueOnce({
ok: true,
status: 201,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ id: "dm-channel-max" }),
} as Response);
const client = createMockClient();
await resolveRetryRun(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 4,
initialDelayMs: 1000,
maxDelayMs: 2500,
onRetry: (attempt, delayMs) => {
delays.push(delayMs);
},
}),
);
expect(delays).toHaveLength(4);
// All delays should be capped at maxDelayMs
delays.forEach((delay) => {
expect(delay).toBeLessThanOrEqual(2500);
});
});
it("does not retry on 4xx errors even if message contains retryable keywords", async () => {
// This tests the fix for false positives where a 400 error with "timeout" in the message
// would incorrectly be retried
mockFetch.mockResolvedValueOnce({
ok: false,
status: 400,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ message: "Request timeout: connection timed out" }),
text: async () => "Request timeout: connection timed out",
} as Response);
const client = createMockClient();
const run = suppressUnhandled(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 3,
initialDelayMs: 10,
}),
);
await expect(resolveRetryRun(run)).rejects.toThrow("400");
// Should not retry - only called once
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("does not retry on 403 Forbidden even with 'abort' in message", async () => {
mockFetch.mockResolvedValueOnce({
ok: false,
status: 403,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ message: "Request aborted: forbidden" }),
text: async () => "Request aborted: forbidden",
} as Response);
const client = createMockClient();
const run = suppressUnhandled(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 3,
initialDelayMs: 10,
}),
);
await expect(resolveRetryRun(run)).rejects.toThrow("403");
expect(mockFetch).toHaveBeenCalledTimes(1);
});
it("passes AbortSignal to fetch for timeout support", async () => {
let capturedSignal: AbortSignal | undefined;
mockFetch.mockImplementationOnce((url, init) => {
capturedSignal = init?.signal ?? undefined;
return Promise.resolve({
ok: true,
status: 201,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ id: "dm-channel-signal" }),
} as Response);
});
const client = createMockClient();
await resolveRetryRun(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
timeoutMs: 5000,
}),
);
expect(capturedSignal).toBeDefined();
expect(capturedSignal).toBeInstanceOf(AbortSignal);
});
it("retries on 5xx even if error message contains 4xx substring", async () => {
// This tests the fix for the ordering bug: 503 with "upstream 404" should be retried
mockFetch
.mockRejectedValueOnce(new Error("Mattermost API 503: upstream returned 404 Not Found"))
.mockResolvedValueOnce({
ok: true,
status: 201,
headers: new Headers({ "content-type": "application/json" }),
json: async () => ({ id: "dm-channel-5xx-with-404" }),
} as Response);
const client = createMockClient();
const result = await resolveRetryRun(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 3,
initialDelayMs: 10,
}),
);
// Should retry and succeed on second attempt
expect(mockFetch).toHaveBeenCalledTimes(2);
expect(result.id).toBe("dm-channel-5xx-with-404");
});
});
@@ -0,0 +1,291 @@
import { describe, expect, it, vi } from "vitest";
import {
createMattermostClient,
createMattermostPost,
normalizeMattermostBaseUrl,
updateMattermostPost,
} from "./client.js";
// ── Helper: mock fetch that captures requests ────────────────────────
function createMockFetch(response?: { status?: number; body?: unknown; contentType?: string }) {
const status = response?.status ?? 200;
const body = response?.body ?? {};
const contentType = response?.contentType ?? "application/json";
const calls: Array<{ url: string; init?: RequestInit }> = [];
const mockFetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
const urlStr = typeof url === "string" ? url : url.toString();
calls.push({ url: urlStr, init });
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": contentType },
});
});
return { mockFetch: mockFetch as typeof fetch, calls };
}
function createTestClient(response?: { status?: number; body?: unknown; contentType?: string }) {
const { mockFetch, calls } = createMockFetch(response);
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
return { client, calls };
}
async function updatePostAndCapture(
update: Parameters<typeof updateMattermostPost>[2],
response?: { status?: number; body?: unknown; contentType?: string },
) {
const { client, calls } = createTestClient(response ?? { body: { id: "post1" } });
await updateMattermostPost(client, "post1", update);
return {
calls,
body: JSON.parse(calls[0].init?.body as string) as Record<string, unknown>,
};
}
// ── normalizeMattermostBaseUrl ────────────────────────────────────────
describe("normalizeMattermostBaseUrl", () => {
it("strips trailing slashes", () => {
expect(normalizeMattermostBaseUrl("http://localhost:8065/")).toBe("http://localhost:8065");
});
it("strips /api/v4 suffix", () => {
expect(normalizeMattermostBaseUrl("http://localhost:8065/api/v4")).toBe(
"http://localhost:8065",
);
});
it("returns undefined for empty input", () => {
expect(normalizeMattermostBaseUrl("")).toBeUndefined();
expect(normalizeMattermostBaseUrl(null)).toBeUndefined();
expect(normalizeMattermostBaseUrl(undefined)).toBeUndefined();
});
it("preserves valid base URL", () => {
expect(normalizeMattermostBaseUrl("http://mm.example.com")).toBe("http://mm.example.com");
});
});
// ── createMattermostClient ───────────────────────────────────────────
describe("createMattermostClient", () => {
it("creates a client with normalized baseUrl", () => {
const { mockFetch } = createMockFetch();
const client = createMattermostClient({
baseUrl: "http://localhost:8065/",
botToken: "tok",
fetchImpl: mockFetch,
});
expect(client.baseUrl).toBe("http://localhost:8065");
expect(client.apiBaseUrl).toBe("http://localhost:8065/api/v4");
});
it("throws on empty baseUrl", () => {
expect(() => createMattermostClient({ baseUrl: "", botToken: "tok" })).toThrow(
"baseUrl is required",
);
});
it("sends Authorization header with Bearer token", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "u1" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "my-secret-token",
fetchImpl: mockFetch,
});
await client.request("/users/me");
const headers = new Headers(calls[0].init?.headers);
expect(headers.get("Authorization")).toBe("Bearer my-secret-token");
});
it("sets Content-Type for string bodies", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "p1" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await client.request("/posts", { method: "POST", body: JSON.stringify({ message: "hi" }) });
const headers = new Headers(calls[0].init?.headers);
expect(headers.get("Content-Type")).toBe("application/json");
});
it("throws on non-ok responses", async () => {
const { mockFetch } = createMockFetch({
status: 404,
body: { message: "Not Found" },
});
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await expect(client.request("/missing")).rejects.toThrow("Mattermost API 404");
});
it("returns undefined on 204 responses", async () => {
const fetchImpl = vi.fn<typeof fetch>(async () => {
return new Response(null, { status: 204 });
});
const client = createMattermostClient({
baseUrl: "https://chat.example.com",
// pragma: allowlist secret
botToken: "test-token",
fetchImpl,
});
const result = await client.request<unknown>("/anything", { method: "DELETE" });
expect(result).toBeUndefined();
});
});
// ── createMattermostPost ─────────────────────────────────────────────
describe("createMattermostPost", () => {
it("sends channel_id and message", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post1" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await createMattermostPost(client, {
channelId: "ch123",
message: "Hello world",
});
const body = JSON.parse(calls[0].init?.body as string);
expect(body.channel_id).toBe("ch123");
expect(body.message).toBe("Hello world");
});
it("includes rootId when provided", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post2" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await createMattermostPost(client, {
channelId: "ch123",
message: "Reply",
rootId: "root456",
});
const body = JSON.parse(calls[0].init?.body as string);
expect(body.root_id).toBe("root456");
});
it("includes fileIds when provided", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post3" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await createMattermostPost(client, {
channelId: "ch123",
message: "With file",
fileIds: ["file1", "file2"],
});
const body = JSON.parse(calls[0].init?.body as string);
expect(body.file_ids).toEqual(["file1", "file2"]);
});
it("includes props when provided (for interactive buttons)", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post4" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
const props = {
attachments: [
{
text: "Choose:",
actions: [{ id: "btn1", type: "button", name: "Click" }],
},
],
};
await createMattermostPost(client, {
channelId: "ch123",
message: "Pick an option",
props,
});
const body = JSON.parse(calls[0].init?.body as string);
expect(body.props).toEqual(props);
expect(body.props.attachments[0].actions[0].type).toBe("button");
});
it("omits props when not provided", async () => {
const { mockFetch, calls } = createMockFetch({ body: { id: "post5" } });
const client = createMattermostClient({
baseUrl: "http://localhost:8065",
botToken: "tok",
fetchImpl: mockFetch,
});
await createMattermostPost(client, {
channelId: "ch123",
message: "No props",
});
const body = JSON.parse(calls[0].init?.body as string);
expect(body.props).toBeUndefined();
});
});
// ── updateMattermostPost ─────────────────────────────────────────────
describe("updateMattermostPost", () => {
it("sends PUT to /posts/{id}", async () => {
const { calls } = await updatePostAndCapture({ message: "Updated" });
expect(calls[0].url).toContain("/posts/post1");
expect(calls[0].init?.method).toBe("PUT");
});
it("includes post id in the body", async () => {
const { body } = await updatePostAndCapture({ message: "Updated" });
expect(body.id).toBe("post1");
expect(body.message).toBe("Updated");
});
it("includes props for button completion updates", async () => {
const { body } = await updatePostAndCapture({
message: "Original message",
props: {
attachments: [{ text: "✓ **do_now** selected by @tony" }],
},
});
expect(body.message).toBe("Original message");
expect(body.props).toMatchObject({
attachments: [{ text: expect.stringContaining("✓") }],
});
expect(body.props).toMatchObject({
attachments: [{ text: expect.stringContaining("do_now") }],
});
});
it("omits message when not provided", async () => {
const { body } = await updatePostAndCapture({
props: { attachments: [] },
});
expect(body.id).toBe("post1");
expect(body.message).toBeUndefined();
expect(body.props).toEqual({ attachments: [] });
});
});
@@ -0,0 +1,496 @@
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { z } from "openclaw/plugin-sdk/zod";
import {
MattermostError,
MattermostAPIError,
AuthenticationError,
ValidationError,
PermissionError,
RateLimitError,
ResourceNotFoundError,
NetworkError,
TimeoutError,
ConfigurationError,
MissingBotTokenError,
MissingBaseUrlError,
createErrorFromResponse,
createErrorContext,
withRetry,
isRetryableError,
getRetryAfterMs,
globalErrorBoundary,
getUserFriendlyMessage,
ErrorCodes,
type ErrorContext,
type RetryConfig,
} from "../errors.js";
export type MattermostFetch = (input: RequestInfo | URL, init?: RequestInit) => Promise<Response>;
export type MattermostClient = {
baseUrl: string;
apiBaseUrl: string;
token: string;
request: <T>(path: string, init?: RequestInit, operation?: string) => Promise<T>;
/** Guarded fetch implementation; use in place of raw fetch for outbound requests. */
fetchImpl: MattermostFetch;
/**
* Get the appropriate token for an operation.
* - 'read' operations use botToken
* - 'write' operations use PAT if available, otherwise botToken
*/
getTokenForOperation: (operation: "read" | "write") => string;
};
export type MattermostUser = {
id: string;
username?: string | null;
nickname?: string | null;
first_name?: string | null;
last_name?: string | null;
update_at?: number;
};
export type MattermostChannel = {
id: string;
name?: string | null;
display_name?: string | null;
type?: string | null;
team_id?: string | null;
};
export const MattermostPostSchema = z
.object({
id: z.string(),
user_id: z.string().nullable().optional(),
channel_id: z.string().nullable().optional(),
message: z.string().nullable().optional(),
file_ids: z.array(z.string()).nullable().optional(),
type: z.string().nullable().optional(),
root_id: z.string().nullable().optional(),
create_at: z.number().nullable().optional(),
props: z.record(z.string(), z.unknown()).nullable().optional(),
})
.passthrough();
export type MattermostPost = z.infer<typeof MattermostPostSchema>;
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,
operation = "build-url",
context?: Partial<ErrorContext>
): string {
const normalized = normalizeMattermostBaseUrl(baseUrl);
if (!normalized) {
throw new MissingBaseUrlError(
{ operation, ...context },
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;
pat?: string;
fetchImpl?: MattermostFetch;
allowPrivateNetwork?: boolean;
/** Optional account ID for error context */
accountId?: string;
},
operationContext?: Partial<ErrorContext>
): MattermostClient {
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl);
const botToken = params.botToken.trim();
const pat = params.pat?.trim();
if (!baseUrl) {
throw new MissingBaseUrlError(
{ operation: "create-client", accountId: params.accountId, ...operationContext },
new Error("Mattermost baseUrl is required")
);
}
if (!botToken) {
throw new MissingBotTokenError(
{ operation: "create-client", accountId: params.accountId, ...operationContext },
new Error("Mattermost bot token is required")
);
}
const apiBaseUrl = `${baseUrl}/api/v4`;
const getTokenForOperation = (operation: "read" | "write"): string => {
if (operation === "read") {
return botToken;
}
return pat || botToken;
};
const token = botToken;
const errorContext: Partial<ErrorContext> = {
accountId: params.accountId,
...operationContext
};
// When no custom fetchImpl is provided (production path), use an SSRF-guarded wrapper
// that validates the target URL before making the request (DNS rebinding protection etc.).
// A custom fetchImpl is accepted for testing and special cases.
const externalFetchImpl = params.fetchImpl;
// Guarded fetch adapter: calls fetchWithSsrFGuard and returns a plain Response.
// Body is buffered before releasing the dispatcher so callers get a complete Response.
// Null-body status codes per Fetch spec — Response constructor rejects a body for these.
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
const guardedFetchImpl: MattermostFetch = async (input, init) => {
const url =
typeof input === "string"
? input
: input instanceof URL
? input.toString()
: (input as Request).url;
const { response, release } = await fetchWithSsrFGuard({
url,
init,
auditContext: "mattermost-api",
policy: params.allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
});
try {
const bodyBytes = NULL_BODY_STATUSES.has(response.status)
? null
: await response.arrayBuffer();
return new Response(bodyBytes, { status: response.status, headers: response.headers });
} finally {
await release();
}
};
const fetchImpl = externalFetchImpl ?? guardedFetchImpl;
const request = async <T>(
path: string,
init?: RequestInit,
operation = "api-request"
): Promise<T> => {
const url = buildMattermostApiUrl(baseUrl, path, operation, errorContext);
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 operationName = `${operation}: ${path}`;
const requestContext: Partial<ErrorContext> = {
...errorContext,
requestPath: path,
};
try {
const res = await fetchImpl(url, { ...init, headers });
if (!res.ok) {
const error = await createErrorFromResponse(res, operationName, requestContext);
throw 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;
} catch (err) {
if (err instanceof MattermostError) {
throw err;
}
const isNetworkError = err instanceof TypeError ||
(err instanceof Error && (
err.message.includes("fetch") ||
err.message.includes("network") ||
err.message.includes("ECONN")
));
if (isNetworkError) {
throw new NetworkError(
`Network error during ${operationName}`,
createErrorContext(operationName, requestContext),
err
);
}
throw err;
}
};
return { baseUrl, apiBaseUrl, token, request, fetchImpl, getTokenForOperation };
}
export async function fetchMattermostMe(
client: MattermostClient,
context?: Partial<ErrorContext>
): Promise<MattermostUser> {
return await client.request<MattermostUser>("/users/me", undefined, "fetch-me");
}
export async function fetchMattermostUser(
client: MattermostClient,
userId: string,
context?: Partial<ErrorContext>
): Promise<MattermostUser> {
return await client.request<MattermostUser>(
`/users/${userId}`,
undefined,
"fetch-user"
);
}
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 fetchMattermostChannelByName(
client: MattermostClient,
teamId: string,
channelName: string,
): Promise<MattermostChannel> {
return await client.request<MattermostChannel>(
`/teams/${teamId}/channels/name/${encodeURIComponent(channelName)}`,
);
}
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[],
signal?: AbortSignal,
): Promise<MattermostChannel> {
return await client.request<MattermostChannel>("/channels/direct", {
method: "POST",
body: JSON.stringify(userIds),
signal,
});
}
export type CreateDmChannelRetryOptions = {
/** Maximum number of retry attempts (default: 3) */
maxRetries?: number;
/** Initial delay in milliseconds (default: 1000) */
initialDelayMs?: number;
/** Maximum delay in milliseconds (default: 10000) */
maxDelayMs?: number;
/** Timeout for each individual request in milliseconds (default: 30000) */
timeoutMs?: number;
/** Optional logger for retry events */
onRetry?: (attempt: number, delayMs: number, error: Error) => void;
};
export async function createMattermostDirectChannelWithRetry(
client: MattermostClient,
userIds: string[],
options: CreateDmChannelRetryOptions = {},
context?: Partial<ErrorContext>
): Promise<MattermostChannel> {
const {
maxRetries = 3,
initialDelayMs = 1000,
maxDelayMs = 10000,
timeoutMs = 30000,
onRetry,
} = options;
const operationContext: Partial<ErrorContext> = {
operation: "create-dm-channel",
...context,
};
const retryConfig: RetryConfig = {
maxRetries,
initialDelayMs,
maxDelayMs,
timeoutMs,
onRetry: onRetry
? (info) => onRetry(info.attempt, info.delayMs, info.error)
: undefined,
};
return await withRetry(
"create-dm-channel",
async () => {
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
return await createMattermostDirectChannel(client, userIds, controller.signal);
} finally {
clearTimeout(timeoutId);
}
},
retryConfig,
operationContext
);
}
export async function createMattermostPost(
client: MattermostClient,
params: {
channelId: string;
message: string;
rootId?: string;
fileIds?: string[];
props?: Record<string, unknown>;
},
): Promise<MattermostPost> {
const payload: Record<string, unknown> = {
channel_id: params.channelId,
message: params.message,
};
if (params.rootId) {
payload.root_id = params.rootId;
}
if (params.fileIds?.length) {
payload.file_ids = params.fileIds;
}
if (params.props) {
payload.props = params.props;
}
return await client.request<MattermostPost>("/posts", {
method: "POST",
body: JSON.stringify(payload),
});
}
export type MattermostTeam = {
id: string;
name?: string | null;
display_name?: string | null;
};
export async function fetchMattermostUserTeams(
client: MattermostClient,
userId: string,
): Promise<MattermostTeam[]> {
return await client.request<MattermostTeam[]>(`/users/${userId}/teams`);
}
export async function updateMattermostPost(
client: MattermostClient,
postId: string,
params: {
message?: string;
props?: Record<string, unknown>;
},
): Promise<MattermostPost> {
const payload: Record<string, unknown> = { id: postId };
if (params.message !== undefined) {
payload.message = params.message;
}
if (params.props !== undefined) {
payload.props = params.props;
}
return await client.request<MattermostPost>(`/posts/${postId}`, {
method: "PUT",
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 client.fetchImpl(`${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;
}
@@ -0,0 +1,172 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const {
listMattermostAccountIdsMock,
resolveMattermostAccountMock,
createMattermostClientMock,
fetchMattermostMeMock,
} = vi.hoisted(() => {
return {
listMattermostAccountIdsMock: vi.fn(),
resolveMattermostAccountMock: vi.fn(),
createMattermostClientMock: vi.fn(),
fetchMattermostMeMock: vi.fn(),
};
});
vi.mock("./accounts.js", () => {
return {
listMattermostAccountIds: listMattermostAccountIdsMock,
resolveMattermostAccount: resolveMattermostAccountMock,
};
});
vi.mock("./client.js", () => {
return {
createMattermostClient: createMattermostClientMock,
fetchMattermostMe: fetchMattermostMeMock,
};
});
let listMattermostDirectoryGroups: typeof import("./directory.js").listMattermostDirectoryGroups;
let listMattermostDirectoryPeers: typeof import("./directory.js").listMattermostDirectoryPeers;
describe("mattermost directory", () => {
beforeAll(async () => {
({ listMattermostDirectoryGroups, listMattermostDirectoryPeers } =
await import("./directory.js"));
});
beforeEach(() => {
vi.clearAllMocks();
});
it("deduplicates channels across enabled accounts and skips failing accounts", async () => {
const clientA = {
token: "token-a",
request: vi.fn().mockResolvedValueOnce([
{ id: "chan-1", type: "O", name: "alerts", display_name: "Alerts" },
{ id: "chan-2", type: "P", name: "ops", display_name: "Ops" },
{ id: "chan-3", type: "D", name: "dm", display_name: "Direct" },
]),
};
const clientB = {
token: "token-b",
request: vi.fn().mockRejectedValue(new Error("expired token")),
};
const clientC = {
token: "token-c",
request: vi.fn().mockResolvedValueOnce([
{ id: "chan-2", type: "P", name: "ops", display_name: "Ops" },
{ id: "chan-4", type: "O", name: "infra", display_name: "Infra" },
]),
};
listMattermostAccountIdsMock.mockReturnValue(["default", "alerts", "infra"]);
resolveMattermostAccountMock.mockImplementation(({ accountId }) => {
if (accountId === "disabled") {
return { enabled: false };
}
return { enabled: true, botToken: `token-${accountId}`, baseUrl: "https://chat.example.com" };
});
createMattermostClientMock
.mockReturnValueOnce(clientA)
.mockReturnValueOnce(clientB)
.mockReturnValueOnce(clientC);
fetchMattermostMeMock.mockResolvedValue({ id: "me-1" });
await expect(
listMattermostDirectoryGroups({
cfg: {} as never,
runtime: {} as never,
query: " op ",
}),
).resolves.toEqual([{ kind: "group", id: "channel:chan-2", name: "ops", handle: "Ops" }]);
});
it("uses the first healthy client for peers and filters self and blanks", async () => {
const client = {
token: "token-default",
request: vi
.fn()
.mockResolvedValueOnce([{ id: "team-1" }])
.mockResolvedValueOnce([{ user_id: "me-1" }, { user_id: "user-1" }, { user_id: "user-2" }])
.mockResolvedValueOnce([
{
id: "user-1",
username: "alice",
first_name: "Alice",
last_name: "Ng",
},
{
id: "user-2",
username: "bob",
nickname: "Bobby",
},
{
id: "me-1",
username: "self",
},
]),
};
listMattermostAccountIdsMock.mockReturnValue(["default"]);
resolveMattermostAccountMock.mockReturnValue({
enabled: true,
botToken: "token-default",
baseUrl: "https://chat.example.com",
});
createMattermostClientMock.mockReturnValue(client);
fetchMattermostMeMock.mockResolvedValue({ id: "me-1" });
await expect(
listMattermostDirectoryPeers({
cfg: {} as never,
runtime: {} as never,
}),
).resolves.toEqual([
{ kind: "user", id: "user:user-1", name: "alice", handle: "Alice Ng" },
{ kind: "user", id: "user:user-2", name: "bob", handle: "Bobby" },
]);
});
it("uses user search when a query is present and applies limits", async () => {
const client = {
token: "token-default",
request: vi
.fn()
.mockResolvedValueOnce([{ id: "team-1" }])
.mockResolvedValueOnce([
{ id: "user-1", username: "alice", first_name: "Alice", last_name: "Ng" },
{ id: "user-2", username: "alex", nickname: "Lex" },
]),
};
listMattermostAccountIdsMock.mockReturnValue(["default"]);
resolveMattermostAccountMock.mockReturnValue({
enabled: true,
botToken: "token-default",
baseUrl: "https://chat.example.com",
});
createMattermostClientMock.mockReturnValue(client);
fetchMattermostMeMock.mockResolvedValue({ id: "me-1" });
await expect(
listMattermostDirectoryPeers({
cfg: {} as never,
runtime: {} as never,
query: " ali ",
limit: 1,
}),
).resolves.toEqual([{ kind: "user", id: "user:user-1", name: "alice", handle: "Alice Ng" }]);
expect(client.request).toHaveBeenNthCalledWith(
2,
"/users/search",
expect.objectContaining({
method: "POST",
body: JSON.stringify({ term: "ali", team_id: "team-1" }),
}),
);
});
});
@@ -0,0 +1,172 @@
import { listMattermostAccountIds, resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
fetchMattermostMe,
type MattermostChannel,
type MattermostClient,
type MattermostUser,
} from "./client.js";
import type { ChannelDirectoryEntry, OpenClawConfig, RuntimeEnv } from "./runtime-api.js";
export type MattermostDirectoryParams = {
cfg: OpenClawConfig;
accountId?: string | null;
query?: string | null;
limit?: number | null;
runtime: RuntimeEnv;
};
function buildClient(params: {
cfg: OpenClawConfig;
accountId?: string | null;
}): MattermostClient | null {
const account = resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId });
if (!account.enabled || !account.botToken || !account.baseUrl) {
return null;
}
return createMattermostClient({
baseUrl: account.baseUrl,
botToken: account.botToken,
allowPrivateNetwork: account.config?.allowPrivateNetwork === true,
});
}
/**
* Build clients from ALL enabled accounts (deduplicated by token).
*
* We always scan every account because:
* - Private channels are only visible to bots that are members
* - The requesting agent's account may have an expired/invalid token
*
* This means a single healthy bot token is enough for directory discovery.
*/
function buildClients(params: MattermostDirectoryParams): MattermostClient[] {
const accountIds = listMattermostAccountIds(params.cfg);
const seen = new Set<string>();
const clients: MattermostClient[] = [];
for (const id of accountIds) {
const client = buildClient({ cfg: params.cfg, accountId: id });
if (client && !seen.has(client.token)) {
seen.add(client.token);
clients.push(client);
}
}
return clients;
}
/**
* List channels (public + private) visible to any configured bot account.
*
* NOTE: Uses per_page=200 which covers most instances. Mattermost does not
* return a "has more" indicator, so very large instances (200+ channels per bot)
* may see incomplete results. Pagination can be added if needed.
*/
export async function listMattermostDirectoryGroups(
params: MattermostDirectoryParams,
): Promise<ChannelDirectoryEntry[]> {
const clients = buildClients(params);
if (!clients.length) {
return [];
}
const q = params.query?.trim().toLowerCase() || "";
const seenIds = new Set<string>();
const entries: ChannelDirectoryEntry[] = [];
for (const client of clients) {
try {
const me = await fetchMattermostMe(client);
const channels = await client.request<MattermostChannel[]>(
`/users/${me.id}/channels?per_page=200`,
);
for (const ch of channels) {
if (ch.type !== "O" && ch.type !== "P") continue;
if (seenIds.has(ch.id)) continue;
if (q) {
const name = (ch.name ?? "").toLowerCase();
const display = (ch.display_name ?? "").toLowerCase();
if (!name.includes(q) && !display.includes(q)) continue;
}
seenIds.add(ch.id);
entries.push({
kind: "group" as const,
id: `channel:${ch.id}`,
name: ch.name ?? undefined,
handle: ch.display_name ?? undefined,
});
}
} catch (err) {
// Token may be expired/revoked — skip this account and try others
console.debug?.(
"[mattermost-directory] listGroups: skipping account:",
(err as Error)?.message,
);
continue;
}
}
return params.limit && params.limit > 0 ? entries.slice(0, params.limit) : entries;
}
/**
* List team members as peer directory entries.
*
* Uses only the first available client since all bots in a team see the same
* user list (unlike channels where membership varies). Uses the first team
* returned — multi-team setups will only see members from that team.
*
* NOTE: per_page=200 for member listing; same pagination caveat as groups.
*/
export async function listMattermostDirectoryPeers(
params: MattermostDirectoryParams,
): Promise<ChannelDirectoryEntry[]> {
const clients = buildClients(params);
if (!clients.length) {
return [];
}
// All bots see the same user list, so one client suffices (unlike channels
// where private channel membership varies per bot).
const client = clients[0];
try {
const me = await fetchMattermostMe(client);
const teams = await client.request<{ id: string }[]>("/users/me/teams");
if (!teams.length) {
return [];
}
// Uses first team — multi-team setups may need iteration in the future
const teamId = teams[0].id;
const q = params.query?.trim().toLowerCase() || "";
let users: MattermostUser[];
if (q) {
users = await client.request<MattermostUser[]>("/users/search", {
method: "POST",
body: JSON.stringify({ term: q, team_id: teamId }),
});
} else {
const members = await client.request<{ user_id: string }[]>(
`/teams/${teamId}/members?per_page=200`,
);
const userIds = members.map((m) => m.user_id).filter((id) => id !== me.id);
if (!userIds.length) {
return [];
}
users = await client.request<MattermostUser[]>("/users/ids", {
method: "POST",
body: JSON.stringify(userIds),
});
}
const entries = users
.filter((u) => u.id !== me.id)
.map((u) => ({
kind: "user" as const,
id: `user:${u.id}`,
name: u.username ?? undefined,
handle:
[u.first_name, u.last_name].filter(Boolean).join(" ").trim() || u.nickname || undefined,
}));
return params.limit && params.limit > 0 ? entries.slice(0, params.limit) : entries;
} catch (err) {
console.debug?.("[mattermost-directory] listPeers failed:", (err as Error)?.message);
return [];
}
}
@@ -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";
@@ -0,0 +1,887 @@
import { type IncomingMessage, type ServerResponse } from "node:http";
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
import type { PluginRuntime } from "../../runtime-api.js";
import { setMattermostRuntime } from "../runtime.js";
import { resolveMattermostAccount } from "./accounts.js";
import type { MattermostClient, MattermostPost } from "./client.js";
import {
buildButtonAttachments,
computeInteractionCallbackUrl,
createMattermostInteractionHandler,
generateInteractionToken,
getInteractionCallbackUrl,
getInteractionSecret,
resolveInteractionCallbackPath,
resolveInteractionCallbackUrl,
setInteractionCallbackUrl,
setInteractionSecret,
verifyInteractionToken,
} from "./interactions.js";
// ── HMAC token management ────────────────────────────────────────────
describe("setInteractionSecret / getInteractionSecret", () => {
beforeEach(() => {
// pragma: allowlist secret
setInteractionSecret("test-bot-token");
});
it("derives a deterministic secret from the bot token", () => {
setInteractionSecret("token-a");
const secretA = getInteractionSecret();
setInteractionSecret("token-a");
const secretA2 = getInteractionSecret();
expect(secretA).toBe(secretA2);
});
it("produces different secrets for different tokens", () => {
setInteractionSecret("token-a");
const secretA = getInteractionSecret();
setInteractionSecret("token-b");
const secretB = getInteractionSecret();
expect(secretA).not.toBe(secretB);
});
it("returns a hex string", () => {
expect(getInteractionSecret()).toMatch(/^[0-9a-f]+$/);
});
});
// ── Token generation / verification ──────────────────────────────────
describe("generateInteractionToken / verifyInteractionToken", () => {
beforeEach(() => {
// pragma: allowlist secret
setInteractionSecret("test-bot-token");
});
it("generates a hex token", () => {
const token = generateInteractionToken({ action_id: "click" });
expect(token).toMatch(/^[0-9a-f]{64}$/);
});
it("verifies a valid token", () => {
const context = { action_id: "do_now", item_id: "123" };
const token = generateInteractionToken(context);
expect(verifyInteractionToken(context, token)).toBe(true);
});
it("rejects a tampered token", () => {
const context = { action_id: "do_now" };
const token = generateInteractionToken(context);
const tampered = token.replace(/.$/, token.endsWith("0") ? "1" : "0");
expect(verifyInteractionToken(context, tampered)).toBe(false);
});
it("rejects a token generated with different context", () => {
const token = generateInteractionToken({ action_id: "a" });
expect(verifyInteractionToken({ action_id: "b" }, token)).toBe(false);
});
it("rejects tokens with wrong length", () => {
const context = { action_id: "test" };
expect(verifyInteractionToken(context, "short")).toBe(false);
});
it("is deterministic for the same context", () => {
const context = { action_id: "test", x: 1 };
const t1 = generateInteractionToken(context);
const t2 = generateInteractionToken(context);
expect(t1).toBe(t2);
});
it("produces the same token regardless of key order", () => {
const contextA = { action_id: "do_now", tweet_id: "123", action: "do" };
const contextB = { action: "do", action_id: "do_now", tweet_id: "123" };
const contextC = { tweet_id: "123", action: "do", action_id: "do_now" };
const tokenA = generateInteractionToken(contextA);
const tokenB = generateInteractionToken(contextB);
const tokenC = generateInteractionToken(contextC);
expect(tokenA).toBe(tokenB);
expect(tokenB).toBe(tokenC);
});
it("verifies a token when Mattermost reorders context keys", () => {
// Simulate: token generated with keys in one order, verified with keys in another
// (Mattermost reorders context keys when storing/returning interactive message payloads)
const originalContext = { action_id: "bm_do", tweet_id: "999", action: "do" };
const token = generateInteractionToken(originalContext);
// Mattermost returns keys in alphabetical order (or any arbitrary order)
const reorderedContext = { action: "do", action_id: "bm_do", tweet_id: "999" };
expect(verifyInteractionToken(reorderedContext, token)).toBe(true);
});
it("verifies nested context regardless of nested key order", () => {
const originalContext = {
action_id: "nested",
payload: {
model: "gpt-5",
meta: {
provider: "openai",
page: 2,
},
},
};
const token = generateInteractionToken(originalContext);
const reorderedContext = {
payload: {
meta: {
page: 2,
provider: "openai",
},
model: "gpt-5",
},
action_id: "nested",
};
expect(verifyInteractionToken(reorderedContext, token)).toBe(true);
});
it("rejects nested context tampering", () => {
const originalContext = {
action_id: "nested",
payload: {
provider: "openai",
model: "gpt-5",
},
};
const token = generateInteractionToken(originalContext);
const tamperedContext = {
action_id: "nested",
payload: {
provider: "anthropic",
model: "gpt-5",
},
};
expect(verifyInteractionToken(tamperedContext, token)).toBe(false);
});
it("scopes tokens per account when account secrets differ", () => {
// pragma: allowlist secret
setInteractionSecret("acct-a", "bot-token-a");
// pragma: allowlist secret
setInteractionSecret("acct-b", "bot-token-b");
const context = { action_id: "do_now", item_id: "123" };
const tokenA = generateInteractionToken(context, "acct-a");
expect(verifyInteractionToken(context, tokenA, "acct-a")).toBe(true);
expect(verifyInteractionToken(context, tokenA, "acct-b")).toBe(false);
});
});
// ── Callback URL registry ────────────────────────────────────────────
describe("callback URL registry", () => {
it("stores and retrieves callback URLs", () => {
setInteractionCallbackUrl("acct1", "http://localhost:18789/mattermost/interactions/acct1");
expect(getInteractionCallbackUrl("acct1")).toBe(
"http://localhost:18789/mattermost/interactions/acct1",
);
});
it("returns undefined for unknown account", () => {
expect(getInteractionCallbackUrl("nonexistent-account-id")).toBeUndefined();
});
});
describe("resolveInteractionCallbackUrl", () => {
afterEach(() => {
for (const accountId of ["cached", "default", "acct", "myaccount"]) {
setInteractionCallbackUrl(accountId, "");
}
});
it("prefers cached URL from registry", () => {
setInteractionCallbackUrl("cached", "http://cached:1234/path");
expect(resolveInteractionCallbackUrl("cached")).toBe("http://cached:1234/path");
});
it("recomputes from config when bypassing the cache explicitly", () => {
setInteractionCallbackUrl("acct", "http://cached:1234/path");
const url = computeInteractionCallbackUrl("acct", {
gateway: { port: 9999, customBindHost: "gateway.internal" },
});
expect(url).toBe("http://gateway.internal:9999/mattermost/interactions/acct");
});
it("uses interactions.callbackBaseUrl when configured", () => {
const url = resolveInteractionCallbackUrl("default", {
channels: {
mattermost: {
interactions: {
callbackBaseUrl: "https://gateway.example.com/openclaw",
},
},
},
});
expect(url).toBe("https://gateway.example.com/openclaw/mattermost/interactions/default");
});
it("trims trailing slashes from callbackBaseUrl", () => {
const url = resolveInteractionCallbackUrl("acct", {
channels: {
mattermost: {
interactions: {
callbackBaseUrl: "https://gateway.example.com/root///",
},
},
},
});
expect(url).toBe("https://gateway.example.com/root/mattermost/interactions/acct");
});
it("uses merged per-account interactions.callbackBaseUrl", () => {
const cfg = {
gateway: { port: 9999 },
channels: {
mattermost: {
accounts: {
acct: {
// pragma: allowlist secret
botToken: "bot-token",
baseUrl: "https://chat.example.com",
interactions: {
callbackBaseUrl: "https://gateway.example.com/root",
},
},
},
},
},
};
const account = resolveMattermostAccount({
cfg,
accountId: "acct",
allowUnresolvedSecretRef: true,
});
const url = resolveInteractionCallbackUrl(account.accountId, {
gateway: cfg.gateway,
interactions: account.config.interactions,
});
expect(url).toBe("https://gateway.example.com/root/mattermost/interactions/acct");
});
it("falls back to gateway.customBindHost when configured", () => {
const url = resolveInteractionCallbackUrl("default", {
gateway: { port: 9999, customBindHost: "gateway.internal" },
});
expect(url).toBe("http://gateway.internal:9999/mattermost/interactions/default");
});
it("falls back to localhost when customBindHost is a wildcard bind address", () => {
const url = resolveInteractionCallbackUrl("default", {
gateway: { port: 9999, customBindHost: "0.0.0.0" },
});
expect(url).toBe("http://localhost:9999/mattermost/interactions/default");
});
it("brackets IPv6 custom bind hosts", () => {
const url = resolveInteractionCallbackUrl("acct", {
gateway: { port: 9999, customBindHost: "::1" },
});
expect(url).toBe("http://[::1]:9999/mattermost/interactions/acct");
});
it("uses default port 18789 when no config provided", () => {
const url = resolveInteractionCallbackUrl("myaccount");
expect(url).toBe("http://localhost:18789/mattermost/interactions/myaccount");
});
});
describe("resolveInteractionCallbackPath", () => {
it("builds the per-account callback path", () => {
expect(resolveInteractionCallbackPath("acct")).toBe("/mattermost/interactions/acct");
});
});
// ── buildButtonAttachments ───────────────────────────────────────────
describe("buildButtonAttachments", () => {
beforeEach(() => {
setInteractionSecret("test-bot-token");
});
it("returns an array with one attachment containing all buttons", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost:18789/mattermost/interactions/default",
buttons: [
{ id: "btn1", name: "Click Me" },
{ id: "btn2", name: "Skip", style: "danger" },
],
});
expect(result).toHaveLength(1);
expect(result[0].actions).toHaveLength(2);
});
it("sets type to 'button' on every action", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost:18789/cb",
buttons: [{ id: "a", name: "A" }],
});
expect(result[0].actions![0].type).toBe("button");
});
it("includes HMAC _token in integration context", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost:18789/cb",
buttons: [{ id: "test", name: "Test" }],
});
const action = result[0].actions![0];
expect(action.integration.context._token).toMatch(/^[0-9a-f]{64}$/);
});
it("includes sanitized action_id in integration context", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost:18789/cb",
buttons: [{ id: "my_action", name: "Do It" }],
});
const action = result[0].actions![0];
// sanitizeActionId strips hyphens and underscores (Mattermost routing bug #25747)
expect(action.integration.context.action_id).toBe("myaction");
expect(action.id).toBe("myaction");
});
it("merges custom context into integration context", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost:18789/cb",
buttons: [{ id: "btn", name: "Go", context: { tweet_id: "123", batch: true } }],
});
const ctx = result[0].actions![0].integration.context;
expect(ctx.tweet_id).toBe("123");
expect(ctx.batch).toBe(true);
expect(ctx.action_id).toBe("btn");
expect(ctx._token).toBeDefined();
});
it("passes callback URL to each button integration", () => {
const url = "http://localhost:18789/mattermost/interactions/default";
const result = buildButtonAttachments({
callbackUrl: url,
buttons: [
{ id: "a", name: "A" },
{ id: "b", name: "B" },
],
});
for (const action of result[0].actions!) {
expect(action.integration.url).toBe(url);
}
});
it("preserves button style", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost/cb",
buttons: [
{ id: "ok", name: "OK", style: "primary" },
{ id: "no", name: "No", style: "danger" },
],
});
expect(result[0].actions![0].style).toBe("primary");
expect(result[0].actions![1].style).toBe("danger");
});
it("uses provided text for the attachment", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost/cb",
buttons: [{ id: "x", name: "X" }],
text: "Choose an action:",
});
expect(result[0].text).toBe("Choose an action:");
});
it("defaults to empty string text when not provided", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost/cb",
buttons: [{ id: "x", name: "X" }],
});
expect(result[0].text).toBe("");
});
it("generates verifiable tokens", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost/cb",
buttons: [{ id: "verify_me", name: "V", context: { extra: "data" } }],
});
const ctx = result[0].actions![0].integration.context;
const token = ctx._token as string;
const { _token, ...contextWithoutToken } = ctx;
expect(verifyInteractionToken(contextWithoutToken, token)).toBe(true);
});
it("generates tokens that verify even when Mattermost reorders context keys", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost/cb",
buttons: [{ id: "do_action", name: "Do", context: { tweet_id: "42", category: "ai" } }],
});
const ctx = result[0].actions![0].integration.context;
const token = ctx._token as string;
// Simulate Mattermost returning context with keys in a different order
const reordered: Record<string, unknown> = {};
const keys = Object.keys(ctx).filter((k) => k !== "_token");
// Reverse the key order to simulate reordering
for (const key of keys.reverse()) {
reordered[key] = ctx[key];
}
expect(verifyInteractionToken(reordered, token)).toBe(true);
});
});
describe("createMattermostInteractionHandler", () => {
function setInteractionRuntime(
enqueueSystemEvent: (
text: string,
options: { sessionKey?: string | null; sessionId?: string | null; userId?: string | null },
) => boolean = () => true,
) {
setMattermostRuntime({
system: {
enqueueSystemEvent,
},
} as unknown as PluginRuntime);
}
function createMattermostClientMock(
requestImpl: (path: string, init?: { method?: string }) => Promise<unknown>,
): MattermostClient {
return {
baseUrl: "https://chat.example.com",
apiBaseUrl: "https://chat.example.com/api/v4",
// pragma: allowlist secret
token: "bot-token",
request: async <T>(path: string, init?: RequestInit) => (await requestImpl(path, init)) as T,
fetchImpl: vi.fn<typeof fetch>(),
};
}
beforeEach(() => {
setInteractionRuntime();
// pragma: allowlist secret
setInteractionSecret("acct", "bot-token");
});
function createReq(params: {
method?: string;
body?: unknown;
remoteAddress?: string;
headers?: Record<string, string>;
}): IncomingMessage {
const body = params.body === undefined ? "" : JSON.stringify(params.body);
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
const req = {
method: params.method ?? "POST",
headers: params.headers ?? {},
socket: { remoteAddress: params.remoteAddress ?? "203.0.113.10" },
on(event: string, handler: (...args: unknown[]) => void) {
const existing = listeners.get(event) ?? [];
existing.push(handler);
listeners.set(event, existing);
return this;
},
} as IncomingMessage & { emitTest: (event: string, ...args: unknown[]) => void };
req.emitTest = (event: string, ...args: unknown[]) => {
const handlers = listeners.get(event) ?? [];
for (const handler of handlers) {
handler(...args);
}
};
queueMicrotask(() => {
if (body) {
req.emitTest("data", Buffer.from(body));
}
req.emitTest("end");
});
return req;
}
function createRes(): ServerResponse & { headers: Record<string, string>; body: string } {
const res = {
statusCode: 200,
headers: {},
body: "",
setHeader(name: string, value: string | number | readonly string[]) {
res.headers[name] = Array.isArray(value) ? value.join(",") : String(value);
return res;
},
end(
chunk?: string | Buffer | Uint8Array,
_encoding?: BufferEncoding | (() => void),
cb?: () => void,
) {
res.body = chunk ? String(chunk) : "";
cb?.();
return res;
},
} as ServerResponse & { headers: Record<string, string>; body: string };
return res;
}
function createActionContext(actionId = "approve", channelId = "chan-1") {
const context = { action_id: actionId, __openclaw_channel_id: channelId };
return { context, token: generateInteractionToken(context, "acct") };
}
function createInteractionBody(params: {
context: Record<string, unknown>;
token: string;
channelId?: string;
postId?: string;
userId?: string;
userName?: string;
}) {
return {
user_id: params.userId ?? "user-1",
...(params.userName ? { user_name: params.userName } : {}),
channel_id: params.channelId ?? "chan-1",
post_id: params.postId ?? "post-1",
context: { ...params.context, _token: params.token },
};
}
async function runHandler(
handler: ReturnType<typeof createMattermostInteractionHandler>,
params: {
body: unknown;
remoteAddress?: string;
headers?: Record<string, string>;
},
) {
const req = createReq({
remoteAddress: params.remoteAddress,
headers: params.headers,
body: params.body,
});
const res = createRes();
await handler(req, res);
return res;
}
function expectForbiddenResponse(
res: ServerResponse & { body: string },
expectedMessage: string,
) {
expect(res.statusCode).toBe(403);
expect(res.body).toContain(expectedMessage);
}
function expectSuccessfulApprovalUpdate(
res: ServerResponse & { body: string },
requestLog?: Array<{ path: string; method?: string }>,
) {
expect(res.statusCode).toBe(200);
expect(res.body).toBe("{}");
if (requestLog) {
expect(requestLog).toEqual([
{ path: "/posts/post-1", method: undefined },
{ path: "/posts/post-1", method: "PUT" },
]);
}
}
function createActionPost(params?: {
actionId?: string;
actionName?: string;
channelId?: string;
rootId?: string;
}): MattermostPost {
return {
id: "post-1",
channel_id: params?.channelId ?? "chan-1",
...(params?.rootId ? { root_id: params.rootId } : {}),
message: "Choose",
props: {
attachments: [
{
actions: [
{
id: params?.actionId ?? "approve",
name: params?.actionName ?? "Approve",
},
],
},
],
},
};
}
function createUnusedInteractionHandler() {
return createMattermostInteractionHandler({
client: createMattermostClientMock(async () => ({ message: "unused" })),
botUserId: "bot",
accountId: "acct",
});
}
async function runApproveInteraction(params?: {
actionName?: string;
allowedSourceIps?: string[];
trustedProxies?: string[];
remoteAddress?: string;
headers?: Record<string, string>;
}) {
const { context, token } = createActionContext();
const requestLog: Array<{ path: string; method?: string }> = [];
const handler = createMattermostInteractionHandler({
client: createMattermostClientMock(async (path: string, init?: { method?: string }) => {
requestLog.push({ path, method: init?.method });
if (init?.method === "PUT") {
return { id: "post-1" };
}
return createActionPost({ actionName: params?.actionName });
}),
botUserId: "bot",
accountId: "acct",
allowedSourceIps: params?.allowedSourceIps,
trustedProxies: params?.trustedProxies,
});
const res = await runHandler(handler, {
remoteAddress: params?.remoteAddress,
headers: params?.headers,
body: createInteractionBody({ context, token, userName: "alice" }),
});
return { res, requestLog };
}
async function runInvalidActionRequest(actionId: string) {
const { context, token } = createActionContext();
const handler = createMattermostInteractionHandler({
client: createMattermostClientMock(async () =>
createActionPost({ actionId, actionName: actionId }),
),
botUserId: "bot",
accountId: "acct",
});
return await runHandler(handler, {
body: createInteractionBody({ context, token }),
});
}
it("accepts callback requests from an allowlisted source IP", async () => {
const { res, requestLog } = await runApproveInteraction({
allowedSourceIps: ["198.51.100.8"],
remoteAddress: "198.51.100.8",
});
expectSuccessfulApprovalUpdate(res, requestLog);
});
it("accepts forwarded Mattermost source IPs from a trusted proxy", async () => {
const { res } = await runApproveInteraction({
allowedSourceIps: ["198.51.100.8"],
trustedProxies: ["127.0.0.1"],
remoteAddress: "127.0.0.1",
headers: { "x-forwarded-for": "198.51.100.8" },
});
expect(res.statusCode).toBe(200);
expect(res.body).toBe("{}");
});
it("rejects callback requests from non-allowlisted source IPs", async () => {
const { context, token } = createActionContext();
const handler = createMattermostInteractionHandler({
client: createMattermostClientMock(async () => {
throw new Error("should not fetch post for rejected origins");
}),
botUserId: "bot",
accountId: "acct",
allowedSourceIps: ["127.0.0.1"],
});
const res = await runHandler(handler, {
remoteAddress: "198.51.100.8",
body: createInteractionBody({ context, token }),
});
expectForbiddenResponse(res, "Forbidden origin");
});
it("rejects requests with an invalid interaction token", async () => {
const handler = createUnusedInteractionHandler();
const res = await runHandler(handler, {
body: {
user_id: "user-1",
channel_id: "chan-1",
post_id: "post-1",
context: { action_id: "approve", _token: "deadbeef" },
},
});
expectForbiddenResponse(res, "Invalid token");
});
it("rejects requests when the signed channel does not match the callback payload", async () => {
const { context, token } = createActionContext();
const handler = createUnusedInteractionHandler();
const res = await runHandler(handler, {
body: createInteractionBody({ context, token, channelId: "chan-2" }),
});
expectForbiddenResponse(res, "Channel mismatch");
});
it("rejects requests when the fetched post does not belong to the callback channel", async () => {
const { context, token } = createActionContext();
const handler = createMattermostInteractionHandler({
client: createMattermostClientMock(async () => createActionPost({ channelId: "chan-9" })),
botUserId: "bot",
accountId: "acct",
});
const res = await runHandler(handler, {
body: createInteractionBody({ context, token }),
});
expectForbiddenResponse(res, "Post/channel mismatch");
});
it("rejects requests when the action is not present on the fetched post", async () => {
const res = await runInvalidActionRequest("reject");
expect(res.statusCode).toBe(403);
expect(res.body).toContain("Unknown action");
});
it("accepts actions when the button name matches the action id", async () => {
const { res, requestLog } = await runApproveInteraction({
actionName: "approve",
});
expectSuccessfulApprovalUpdate(res, requestLog);
});
it("blocks button dispatch when the sender is not allowed for the action", async () => {
const { context, token } = createActionContext();
const dispatchButtonClick = vi.fn();
const handleInteraction = vi.fn();
const handler = createMattermostInteractionHandler({
client: createMattermostClientMock(async (_path: string, init?: { method?: string }) =>
init?.method === "PUT" ? { id: "post-1" } : createActionPost(),
),
botUserId: "bot",
accountId: "acct",
authorizeButtonClick: async () => ({
ok: false,
response: {
ephemeral_text: "blocked",
},
}),
handleInteraction,
dispatchButtonClick,
});
const res = await runHandler(handler, {
body: createInteractionBody({ context, token }),
});
expect(res.statusCode).toBe(200);
expect(res.body).toContain("blocked");
expect(handleInteraction).not.toHaveBeenCalled();
expect(dispatchButtonClick).not.toHaveBeenCalled();
});
it("forwards fetched post threading metadata to session and button callbacks", async () => {
const enqueueSystemEvent = vi.fn();
setInteractionRuntime(enqueueSystemEvent);
const { context, token } = createActionContext();
const resolveSessionKey = vi.fn().mockResolvedValue("session:thread:root-9");
const dispatchButtonClick = vi.fn();
const fetchedPost = createActionPost({ rootId: "root-9" });
const handler = createMattermostInteractionHandler({
client: createMattermostClientMock(async (_path: string, init?: { method?: string }) =>
init?.method === "PUT" ? { id: "post-1" } : fetchedPost,
),
botUserId: "bot",
accountId: "acct",
resolveSessionKey,
dispatchButtonClick,
});
const res = await runHandler(handler, {
body: createInteractionBody({ context, token, userName: "alice" }),
});
expect(res.statusCode).toBe(200);
expect(resolveSessionKey).toHaveBeenCalledWith({
channelId: "chan-1",
userId: "user-1",
post: fetchedPost,
});
expect(enqueueSystemEvent).toHaveBeenCalledWith(
expect.stringContaining('Mattermost button click: action="approve"'),
expect.objectContaining({ sessionKey: "session:thread:root-9" }),
);
expect(dispatchButtonClick).toHaveBeenCalledWith(
expect.objectContaining({
channelId: "chan-1",
userId: "user-1",
postId: "post-1",
post: fetchedPost,
}),
);
});
it("lets a custom interaction handler short-circuit generic completion updates", async () => {
const { context, token } = createActionContext("mdlprov");
const requestLog: Array<{ path: string; method?: string }> = [];
const handleInteraction = vi.fn().mockResolvedValue({
ephemeral_text: "Only the original requester can use this picker.",
});
const dispatchButtonClick = vi.fn();
const handler = createMattermostInteractionHandler({
client: createMattermostClientMock(async (path: string, init?: { method?: string }) => {
requestLog.push({ path, method: init?.method });
return createActionPost({
actionId: "mdlprov",
actionName: "Browse providers",
});
}),
botUserId: "bot",
accountId: "acct",
handleInteraction,
dispatchButtonClick,
});
const res = await runHandler(handler, {
body: createInteractionBody({
context,
token,
userId: "user-2",
userName: "alice",
}),
});
expect(res.statusCode).toBe(200);
expect(res.body).toBe(
JSON.stringify({
ephemeral_text: "Only the original requester can use this picker.",
}),
);
expect(requestLog).toEqual([{ path: "/posts/post-1", method: undefined }]);
expect(handleInteraction).toHaveBeenCalledWith(
expect.objectContaining({
actionId: "mdlprov",
actionName: "Browse providers",
originalMessage: "Choose",
post: expect.objectContaining({ id: "post-1" }),
userName: "alice",
}),
);
expect(dispatchButtonClick).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,687 @@
import { createHmac } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support";
import { getMattermostRuntime } from "../runtime.js";
import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js";
import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig } from "./runtime-api.js";
const INTERACTION_MAX_BODY_BYTES = 64 * 1024;
const INTERACTION_BODY_TIMEOUT_MS = 10_000;
const SIGNED_CHANNEL_ID_CONTEXT_KEY = "__openclaw_channel_id";
/**
* Mattermost interactive message callback payload.
* Sent by Mattermost when a user clicks an action button.
* See: https://developers.mattermost.com/integrate/plugins/interactive-messages/
*/
export type MattermostInteractionPayload = {
user_id: string;
user_name?: string;
channel_id: string;
team_id?: string;
post_id: string;
trigger_id?: string;
type?: string;
data_source?: string;
context?: Record<string, unknown>;
};
export type MattermostInteractionResponse = {
update?: {
message: string;
props?: Record<string, unknown>;
};
ephemeral_text?: string;
};
export type MattermostInteractionAuthorizationResult =
| { ok: true }
| { ok: false; statusCode?: number; response?: MattermostInteractionResponse };
export type MattermostInteractiveButtonInput = {
id?: string;
callback_data?: string;
text?: string;
name?: string;
label?: string;
style?: "default" | "primary" | "danger";
context?: Record<string, unknown>;
};
// ── Callback URL registry ──────────────────────────────────────────────
const callbackUrls = new Map<string, string>();
export function setInteractionCallbackUrl(accountId: string, url: string): void {
callbackUrls.set(accountId, url);
}
export function getInteractionCallbackUrl(accountId: string): string | undefined {
return callbackUrls.get(accountId);
}
type InteractionCallbackConfig = Pick<OpenClawConfig, "gateway" | "channels"> & {
interactions?: {
callbackBaseUrl?: string;
};
};
export function resolveInteractionCallbackPath(accountId: string): string {
return `/mattermost/interactions/${accountId}`;
}
function isWildcardBindHost(rawHost: string): boolean {
const trimmed = rawHost.trim();
if (!trimmed) return false;
const host = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
return host === "0.0.0.0" || host === "::" || host === "0:0:0:0:0:0:0:0" || host === "::0";
}
function normalizeCallbackBaseUrl(baseUrl: string): string {
return baseUrl.trim().replace(/\/+$/, "");
}
function headerValue(value: string | string[] | undefined): string | undefined {
if (Array.isArray(value)) {
return value[0]?.trim() || undefined;
}
return value?.trim() || undefined;
}
function isAllowedInteractionSource(params: {
req: IncomingMessage;
allowedSourceIps?: string[];
trustedProxies?: string[];
allowRealIpFallback?: boolean;
}): boolean {
const { allowedSourceIps } = params;
if (!allowedSourceIps?.length) {
return true;
}
const clientIp = resolveClientIp({
remoteAddr: params.req.socket?.remoteAddress,
forwardedFor: headerValue(params.req.headers["x-forwarded-for"]),
realIp: headerValue(params.req.headers["x-real-ip"]),
trustedProxies: params.trustedProxies,
allowRealIpFallback: params.allowRealIpFallback,
});
return isTrustedProxyAddress(clientIp, allowedSourceIps);
}
/**
* Resolve the interaction callback URL for an account.
* Falls back to computing it from interactions.callbackBaseUrl or gateway host config.
*/
export function computeInteractionCallbackUrl(
accountId: string,
cfg?: InteractionCallbackConfig,
): string {
const path = resolveInteractionCallbackPath(accountId);
// Prefer merged per-account config when available, but keep the top-level path for
// callers/tests that still pass the root Mattermost config shape directly.
const callbackBaseUrl =
cfg?.interactions?.callbackBaseUrl?.trim() ??
cfg?.channels?.mattermost?.interactions?.callbackBaseUrl?.trim();
if (callbackBaseUrl) {
return `${normalizeCallbackBaseUrl(callbackBaseUrl)}${path}`;
}
const port = typeof cfg?.gateway?.port === "number" ? cfg.gateway.port : 18789;
let host =
cfg?.gateway?.customBindHost && !isWildcardBindHost(cfg.gateway.customBindHost)
? cfg.gateway.customBindHost.trim()
: "localhost";
// Bracket IPv6 literals so the URL is valid: http://[::1]:18789/...
if (host.includes(":") && !(host.startsWith("[") && host.endsWith("]"))) {
host = `[${host}]`;
}
return `http://${host}:${port}${path}`;
}
/**
* Resolve the interaction callback URL for an account.
* Prefers the in-memory registered URL (set by the gateway monitor) so callers outside the
* monitor lifecycle can reuse the runtime-validated callback destination.
*/
export function resolveInteractionCallbackUrl(
accountId: string,
cfg?: InteractionCallbackConfig,
): string {
const cached = callbackUrls.get(accountId);
if (cached) {
return cached;
}
return computeInteractionCallbackUrl(accountId, cfg);
}
// ── HMAC token management ──────────────────────────────────────────────
// Secret is derived from the bot token so it's stable across CLI and gateway processes.
const interactionSecrets = new Map<string, string>();
let defaultInteractionSecret: string | undefined;
function deriveInteractionSecret(botToken: string): string {
return createHmac("sha256", "openclaw-mattermost-interactions").update(botToken).digest("hex");
}
export function setInteractionSecret(accountIdOrBotToken: string, botToken?: string): void {
if (typeof botToken === "string") {
interactionSecrets.set(accountIdOrBotToken, deriveInteractionSecret(botToken));
return;
}
// Backward-compatible fallback for call sites/tests that only pass botToken.
defaultInteractionSecret = deriveInteractionSecret(accountIdOrBotToken);
}
export function getInteractionSecret(accountId?: string): string {
const scoped = accountId ? interactionSecrets.get(accountId) : undefined;
if (scoped) {
return scoped;
}
if (defaultInteractionSecret) {
return defaultInteractionSecret;
}
// Fallback for single-account runtimes that only registered scoped secrets.
if (interactionSecrets.size === 1) {
const first = interactionSecrets.values().next().value;
if (typeof first === "string") {
return first;
}
}
throw new Error(
"Interaction secret not initialized — call setInteractionSecret(accountId, botToken) first",
);
}
function canonicalizeInteractionContext(value: unknown): unknown {
if (Array.isArray(value)) {
return value.map((item) => canonicalizeInteractionContext(item));
}
if (value && typeof value === "object") {
const entries = Object.entries(value as Record<string, unknown>)
.filter(([, entryValue]) => entryValue !== undefined)
.sort(([left], [right]) => left.localeCompare(right))
.map(([key, entryValue]) => [key, canonicalizeInteractionContext(entryValue)]);
return Object.fromEntries(entries);
}
return value;
}
export function generateInteractionToken(
context: Record<string, unknown>,
accountId?: string,
): string {
const secret = getInteractionSecret(accountId);
const payload = JSON.stringify(canonicalizeInteractionContext(context));
return createHmac("sha256", secret).update(payload).digest("hex");
}
export function verifyInteractionToken(
context: Record<string, unknown>,
token: string,
accountId?: string,
): boolean {
const expected = generateInteractionToken(context, accountId);
return safeEqualSecret(expected, token);
}
// ── Button builder helpers ─────────────────────────────────────────────
export type MattermostButton = {
id: string;
type: "button" | "select";
name: string;
style?: "default" | "primary" | "danger";
integration: {
url: string;
context: Record<string, unknown>;
};
};
export type MattermostAttachment = {
text?: string;
actions?: MattermostButton[];
[key: string]: unknown;
};
/**
* Build Mattermost `props.attachments` with interactive buttons.
*
* Each button includes an HMAC token in its integration context so the
* callback handler can verify the request originated from a legitimate
* button click (Mattermost's recommended security pattern).
*/
/**
* Sanitize a button ID so Mattermost's action router can match it.
* Mattermost uses the action ID in the URL path `/api/v4/posts/{id}/actions/{actionId}`
* and IDs containing hyphens or underscores break the server-side routing.
* See: https://github.com/mattermost/mattermost/issues/25747
*/
function sanitizeActionId(id: string): string {
return id.replace(/[-_]/g, "");
}
export function buildButtonAttachments(params: {
callbackUrl: string;
accountId?: string;
buttons: Array<{
id: string;
name: string;
style?: "default" | "primary" | "danger";
context?: Record<string, unknown>;
}>;
text?: string;
}): MattermostAttachment[] {
const actions: MattermostButton[] = params.buttons.map((btn) => {
const safeId = sanitizeActionId(btn.id);
const context: Record<string, unknown> = {
action_id: safeId,
...btn.context,
};
const token = generateInteractionToken(context, params.accountId);
return {
id: safeId,
type: "button" as const,
name: btn.name,
style: btn.style,
integration: {
url: params.callbackUrl,
context: {
...context,
_token: token,
},
},
};
});
return [
{
text: params.text ?? "",
actions,
},
];
}
export function buildButtonProps(params: {
callbackUrl: string;
accountId?: string;
channelId: string;
buttons: Array<unknown>;
text?: string;
}): Record<string, unknown> | undefined {
const rawButtons = params.buttons.flatMap((item) =>
Array.isArray(item) ? item : [item],
) as MattermostInteractiveButtonInput[];
const buttons = rawButtons
.map((btn) => ({
id: String(btn.id ?? btn.callback_data ?? "").trim(),
name: String(btn.text ?? btn.name ?? btn.label ?? "").trim(),
style: btn.style ?? "default",
context:
typeof btn.context === "object" && btn.context !== null
? {
...btn.context,
[SIGNED_CHANNEL_ID_CONTEXT_KEY]: params.channelId,
}
: { [SIGNED_CHANNEL_ID_CONTEXT_KEY]: params.channelId },
}))
.filter((btn) => btn.id && btn.name);
if (buttons.length === 0) {
return undefined;
}
return {
attachments: buildButtonAttachments({
callbackUrl: params.callbackUrl,
accountId: params.accountId,
buttons,
text: params.text,
}),
};
}
// ── Request body reader ────────────────────────────────────────────────
function readInteractionBody(req: IncomingMessage): Promise<string> {
return new Promise((resolve, reject) => {
const chunks: Buffer[] = [];
let totalBytes = 0;
const timer = setTimeout(() => {
req.destroy();
reject(new Error("Request body read timeout"));
}, INTERACTION_BODY_TIMEOUT_MS);
req.on("data", (chunk: Buffer) => {
totalBytes += chunk.length;
if (totalBytes > INTERACTION_MAX_BODY_BYTES) {
req.destroy();
clearTimeout(timer);
reject(new Error("Request body too large"));
return;
}
chunks.push(chunk);
});
req.on("end", () => {
clearTimeout(timer);
resolve(Buffer.concat(chunks).toString("utf8"));
});
req.on("error", (err) => {
clearTimeout(timer);
reject(err);
});
});
}
// ── HTTP handler ───────────────────────────────────────────────────────
export function createMattermostInteractionHandler(params: {
client: MattermostClient;
botUserId: string;
accountId: string;
allowedSourceIps?: string[];
trustedProxies?: string[];
allowRealIpFallback?: boolean;
resolveSessionKey?: (params: {
channelId: string;
userId: string;
post: MattermostPost;
}) => Promise<string>;
handleInteraction?: (opts: {
payload: MattermostInteractionPayload;
userName: string;
actionId: string;
actionName: string;
originalMessage: string;
context: Record<string, unknown>;
post: MattermostPost;
}) => Promise<MattermostInteractionResponse | null>;
authorizeButtonClick?: (opts: {
payload: MattermostInteractionPayload;
post: MattermostPost;
}) => Promise<MattermostInteractionAuthorizationResult>;
dispatchButtonClick?: (opts: {
channelId: string;
userId: string;
userName: string;
actionId: string;
actionName: string;
postId: string;
post: MattermostPost;
}) => Promise<void>;
log?: (message: string) => void;
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
const { client, accountId, log } = params;
const core = getMattermostRuntime();
return async (req: IncomingMessage, res: ServerResponse) => {
// Only accept POST
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Method Not Allowed" }));
return;
}
if (
!isAllowedInteractionSource({
req,
allowedSourceIps: params.allowedSourceIps,
trustedProxies: params.trustedProxies,
allowRealIpFallback: params.allowRealIpFallback,
})
) {
log?.(
`mattermost interaction: rejected callback source remote=${req.socket?.remoteAddress ?? "?"}`,
);
res.statusCode = 403;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Forbidden origin" }));
return;
}
let payload: MattermostInteractionPayload;
try {
const raw = await readInteractionBody(req);
payload = JSON.parse(raw) as MattermostInteractionPayload;
} catch (err) {
log?.(`mattermost interaction: failed to parse body: ${String(err)}`);
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Invalid request body" }));
return;
}
const context = payload.context;
if (!context) {
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Missing context" }));
return;
}
// Verify HMAC token
const token = context._token;
if (typeof token !== "string") {
log?.("mattermost interaction: missing _token in context");
res.statusCode = 403;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Missing token" }));
return;
}
// Strip _token before verification (it wasn't in the original context)
const { _token, ...contextWithoutToken } = context;
if (!verifyInteractionToken(contextWithoutToken, token, accountId)) {
log?.("mattermost interaction: invalid _token");
res.statusCode = 403;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Invalid token" }));
return;
}
const actionId = context.action_id;
if (typeof actionId !== "string") {
res.statusCode = 400;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Missing action_id in context" }));
return;
}
const signedChannelId =
typeof contextWithoutToken[SIGNED_CHANNEL_ID_CONTEXT_KEY] === "string"
? contextWithoutToken[SIGNED_CHANNEL_ID_CONTEXT_KEY].trim()
: "";
if (signedChannelId && signedChannelId !== payload.channel_id) {
log?.(
`mattermost interaction: signed channel mismatch payload=${payload.channel_id} signed=${signedChannelId}`,
);
res.statusCode = 403;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Channel mismatch" }));
return;
}
const userName = payload.user_name ?? payload.user_id;
let originalMessage = "";
let originalPost: MattermostPost | null = null;
let clickedButtonName: string | null = null;
try {
originalPost = await client.request<MattermostPost>(`/posts/${payload.post_id}`);
const postChannelId = originalPost.channel_id?.trim();
if (!postChannelId || postChannelId !== payload.channel_id) {
log?.(
`mattermost interaction: post channel mismatch payload=${payload.channel_id} post=${postChannelId ?? "<missing>"}`,
);
res.statusCode = 403;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Post/channel mismatch" }));
return;
}
originalMessage = originalPost.message ?? "";
// Ensure the callback can only target an action that exists on the original post.
const postAttachments = Array.isArray(originalPost?.props?.attachments)
? (originalPost.props.attachments as Array<{
actions?: Array<{ id?: string; name?: string }>;
}>)
: [];
for (const att of postAttachments) {
const match = att.actions?.find((a) => a.id === actionId);
if (match?.name) {
clickedButtonName = match.name;
break;
}
}
if (clickedButtonName === null) {
log?.(`mattermost interaction: action ${actionId} not found in post ${payload.post_id}`);
res.statusCode = 403;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Unknown action" }));
return;
}
} catch (err) {
log?.(`mattermost interaction: failed to validate post ${payload.post_id}: ${String(err)}`);
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Failed to validate interaction" }));
return;
}
if (!originalPost) {
log?.(`mattermost interaction: missing fetched post ${payload.post_id}`);
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Failed to load interaction post" }));
return;
}
log?.(
`mattermost interaction: action=${actionId} user=${payload.user_name ?? payload.user_id} ` +
`post=${payload.post_id} channel=${payload.channel_id}`,
);
if (params.authorizeButtonClick) {
try {
const authorization = await params.authorizeButtonClick({
payload,
post: originalPost,
});
if (!authorization.ok) {
res.statusCode = authorization.statusCode ?? 200;
res.setHeader("Content-Type", "application/json");
res.end(
JSON.stringify(
authorization.response ?? {
ephemeral_text: "You are not allowed to use this action here.",
},
),
);
return;
}
} catch (err) {
log?.(`mattermost interaction: authorization failed: ${String(err)}`);
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Interaction authorization failed" }));
return;
}
}
if (params.handleInteraction) {
try {
const response = await params.handleInteraction({
payload,
userName,
actionId,
actionName: clickedButtonName,
originalMessage,
context: contextWithoutToken,
post: originalPost,
});
if (response !== null) {
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify(response));
return;
}
} catch (err) {
log?.(`mattermost interaction: custom handler failed: ${String(err)}`);
res.statusCode = 500;
res.setHeader("Content-Type", "application/json");
res.end(JSON.stringify({ error: "Interaction handler failed" }));
return;
}
}
// Dispatch as system event so the agent can handle it.
// Wrapped in try/catch — the post update below must still run even if
// system event dispatch fails (e.g. missing sessionKey or channel lookup).
try {
const eventLabel =
`Mattermost button click: action="${actionId}" ` +
`by ${payload.user_name ?? payload.user_id} ` +
`in channel ${payload.channel_id}`;
const sessionKey = params.resolveSessionKey
? await params.resolveSessionKey({
channelId: payload.channel_id,
userId: payload.user_id,
post: originalPost,
})
: `agent:main:mattermost:${accountId}:${payload.channel_id}`;
core.system.enqueueSystemEvent(eventLabel, {
sessionKey,
contextKey: `mattermost:interaction:${payload.post_id}:${actionId}`,
});
} catch (err) {
log?.(`mattermost interaction: system event dispatch failed: ${String(err)}`);
}
// Update the post via API to replace buttons with a completion indicator.
try {
await updateMattermostPost(client, payload.post_id, {
message: originalMessage,
props: {
attachments: [
{
text: `✓ **${clickedButtonName}** selected by @${userName}`,
},
],
},
});
} catch (err) {
log?.(`mattermost interaction: failed to update post ${payload.post_id}: ${String(err)}`);
}
// Respond with empty JSON — the post update is handled above
res.statusCode = 200;
res.setHeader("Content-Type", "application/json");
res.end("{}");
// Dispatch a synthetic inbound message so the agent responds to the button click.
if (params.dispatchButtonClick) {
try {
await params.dispatchButtonClick({
channelId: payload.channel_id,
userId: payload.user_id,
userName,
actionId,
actionName: clickedButtonName,
postId: payload.post_id,
post: originalPost,
});
} catch (err) {
log?.(`mattermost interaction: dispatchButtonClick failed: ${String(err)}`);
}
}
};
}
@@ -0,0 +1,175 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../runtime-api.js";
import {
buildMattermostAllowedModelRefs,
parseMattermostModelPickerContext,
renderMattermostModelSummaryView,
renderMattermostModelsPickerView,
renderMattermostProviderPickerView,
resolveMattermostModelPickerCurrentModel,
resolveMattermostModelPickerEntry,
} from "./model-picker.js";
const data = {
byProvider: new Map<string, Set<string>>([
["anthropic", new Set(["claude-opus-4-5", "claude-sonnet-4-5"])],
["openai", new Set(["gpt-4.1", "gpt-5"])],
]),
providers: ["anthropic", "openai"],
resolvedDefault: {
provider: "anthropic",
model: "claude-opus-4-5",
},
modelNames: new Map<string, string>(),
};
describe("Mattermost model picker", () => {
it("resolves bare /model and /models entry points", () => {
expect(resolveMattermostModelPickerEntry("/model")).toEqual({ kind: "summary" });
expect(resolveMattermostModelPickerEntry("/models")).toEqual({ kind: "providers" });
expect(resolveMattermostModelPickerEntry("/models OpenAI")).toEqual({
kind: "models",
provider: "openai",
});
expect(resolveMattermostModelPickerEntry("/model openai/gpt-5")).toBeNull();
});
it("builds the allowed model refs set", () => {
expect(buildMattermostAllowedModelRefs(data)).toEqual(
new Set([
"anthropic/claude-opus-4-5",
"anthropic/claude-sonnet-4-5",
"openai/gpt-4.1",
"openai/gpt-5",
]),
);
});
it("renders the summary view with a browse button", () => {
const view = renderMattermostModelSummaryView({
ownerUserId: "user-1",
currentModel: "openai/gpt-5",
});
expect(view.text).toContain("Current: openai/gpt-5");
expect(view.text).toContain("Tap below to browse models");
expect(view.text).toContain("/oc_model <provider/model> to switch");
expect(view.buttons[0]?.[0]?.text).toBe("Browse providers");
});
it("trims accidental model spacing in Mattermost current-model text", () => {
const view = renderMattermostModelSummaryView({
ownerUserId: "user-1",
currentModel: " OpenAI/ gpt-5 ",
});
expect(view.text).toContain("Current: openai/gpt-5");
});
it("renders providers and models with Telegram-style navigation", () => {
const providersView = renderMattermostProviderPickerView({
ownerUserId: "user-1",
data,
currentModel: "openai/gpt-5",
});
const providerTexts = providersView.buttons.flat().map((button) => button.text);
expect(providerTexts).toContain("anthropic (2)");
expect(providerTexts).toContain("openai (2)");
const modelsView = renderMattermostModelsPickerView({
ownerUserId: "user-1",
data,
provider: "openai",
page: 1,
currentModel: "openai/gpt-5",
});
const modelTexts = modelsView.buttons.flat().map((button) => button.text);
expect(modelsView.text).toContain("Models (openai) - 2 available");
expect(modelTexts).toContain("gpt-5 [current]");
expect(modelTexts).toContain("Back to providers");
});
it("renders unique alphanumeric action ids per button", () => {
const modelsView = renderMattermostModelsPickerView({
ownerUserId: "user-1",
data,
provider: "openai",
page: 1,
currentModel: "openai/gpt-5",
});
const ids = modelsView.buttons.flat().map((button) => button.id);
expect(ids.every((id) => typeof id === "string" && /^[a-z0-9]+$/.test(id))).toBe(true);
expect(new Set(ids).size).toBe(ids.length);
});
it("parses signed picker contexts", () => {
expect(
parseMattermostModelPickerContext({
oc_model_picker: true,
action: "select",
ownerUserId: "user-1",
provider: "openai",
page: 2,
model: "gpt-5",
}),
).toEqual({
action: "select",
ownerUserId: "user-1",
provider: "openai",
page: 2,
model: "gpt-5",
});
expect(parseMattermostModelPickerContext({ action: "select" })).toBeNull();
});
it("falls back to the routed agent default model when no override is stored", async () => {
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-model-picker-"));
try {
const cfg: OpenClawConfig = {
session: {
store: path.join(testDir, "{agentId}.json"),
},
agents: {
defaults: {
model: "anthropic/claude-opus-4-5",
},
list: [
{
id: "support",
model: "openai/gpt-5",
},
],
},
};
const providerData = {
byProvider: new Map<string, Set<string>>([
["anthropic", new Set(["claude-opus-4-5"])],
["openai", new Set(["gpt-5"])],
]),
providers: ["anthropic", "openai"],
resolvedDefault: {
provider: "openai",
model: "gpt-5",
},
modelNames: new Map<string, string>(),
};
expect(
resolveMattermostModelPickerCurrentModel({
cfg,
route: {
agentId: "support",
sessionKey: "agent:support:main",
},
data: providerData,
}),
).toBe("openai/gpt-5");
} finally {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,382 @@
import { createHash } from "node:crypto";
import type { MattermostInteractiveButtonInput } from "./interactions.js";
import {
loadSessionStore,
normalizeProviderId,
resolveStorePath,
resolveStoredModelOverride,
type ModelsProviderData,
type OpenClawConfig,
} from "./runtime-api.js";
const MATTERMOST_MODEL_PICKER_CONTEXT_KEY = "oc_model_picker";
const MODELS_PAGE_SIZE = 8;
const ACTION_IDS = {
providers: "mdlprov",
list: "mdllist",
select: "mdlsel",
back: "mdlback",
} as const;
export type MattermostModelPickerEntry =
| { kind: "summary" }
| { kind: "providers" }
| { kind: "models"; provider: string };
export type MattermostModelPickerState =
| { action: "providers"; ownerUserId: string }
| { action: "back"; ownerUserId: string }
| { action: "list"; ownerUserId: string; provider: string; page: number }
| { action: "select"; ownerUserId: string; provider: string; page: number; model: string };
export type MattermostModelPickerRenderedView = {
text: string;
buttons: MattermostInteractiveButtonInput[][];
};
function splitModelRef(modelRef?: string | null): { provider: string; model: string } | null {
const trimmed = modelRef?.trim();
const match = trimmed?.match(/^([^/]+)\/(.+)$/u);
if (!match) {
return null;
}
const provider = normalizeProviderId(match[1]);
// Mattermost copy should normalize accidental whitespace around the model.
const model = match[2].trim();
if (!provider || !model) {
return null;
}
return { provider, model };
}
function normalizePage(value: number | undefined): number {
if (!Number.isFinite(value)) {
return 1;
}
return Math.max(1, Math.floor(value as number));
}
function paginateItems<T>(items: T[], page?: number, pageSize = MODELS_PAGE_SIZE) {
const totalPages = Math.max(1, Math.ceil(items.length / pageSize));
const safePage = Math.max(1, Math.min(normalizePage(page), totalPages));
const start = (safePage - 1) * pageSize;
return {
items: items.slice(start, start + pageSize),
page: safePage,
totalPages,
hasPrev: safePage > 1,
hasNext: safePage < totalPages,
totalItems: items.length,
};
}
function buildContext(state: MattermostModelPickerState): Record<string, unknown> {
return {
[MATTERMOST_MODEL_PICKER_CONTEXT_KEY]: true,
...state,
};
}
function buildButtonId(state: MattermostModelPickerState): string {
const digest = createHash("sha256").update(JSON.stringify(state)).digest("hex").slice(0, 12);
return `${ACTION_IDS[state.action]}${digest}`;
}
function buildButton(params: {
action: MattermostModelPickerState["action"];
ownerUserId: string;
text: string;
provider?: string;
page?: number;
model?: string;
style?: "default" | "primary" | "danger";
}): MattermostInteractiveButtonInput {
const baseState =
params.action === "providers" || params.action === "back"
? {
action: params.action,
ownerUserId: params.ownerUserId,
}
: params.action === "list"
? {
action: "list" as const,
ownerUserId: params.ownerUserId,
provider: normalizeProviderId(params.provider ?? ""),
page: normalizePage(params.page),
}
: {
action: "select" as const,
ownerUserId: params.ownerUserId,
provider: normalizeProviderId(params.provider ?? ""),
page: normalizePage(params.page),
model: String(params.model ?? "").trim(),
};
return {
// Mattermost requires action IDs to be unique within a post.
id: buildButtonId(baseState),
text: params.text,
...(params.style ? { style: params.style } : {}),
context: buildContext(baseState),
};
}
function getProviderModels(data: ModelsProviderData, provider: string): string[] {
return [...(data.byProvider.get(normalizeProviderId(provider)) ?? new Set<string>())].sort();
}
function formatCurrentModelLine(currentModel?: string): string {
const parsed = splitModelRef(currentModel);
if (!parsed) {
return "Current: default";
}
return `Current: ${parsed.provider}/${parsed.model}`;
}
export function resolveMattermostModelPickerEntry(
commandText: string,
): MattermostModelPickerEntry | null {
const normalized = commandText.trim().replace(/\s+/g, " ");
if (/^\/model$/i.test(normalized)) {
return { kind: "summary" };
}
if (/^\/models$/i.test(normalized)) {
return { kind: "providers" };
}
const providerMatch = normalized.match(/^\/models\s+(\S+)$/i);
if (!providerMatch?.[1]) {
return null;
}
return {
kind: "models",
provider: normalizeProviderId(providerMatch[1]),
};
}
export function parseMattermostModelPickerContext(
context: Record<string, unknown>,
): MattermostModelPickerState | null {
if (!context || context[MATTERMOST_MODEL_PICKER_CONTEXT_KEY] !== true) {
return null;
}
const ownerUserId = String(context.ownerUserId ?? "").trim();
const action = String(context.action ?? "").trim();
if (!ownerUserId) {
return null;
}
if (action === "providers" || action === "back") {
return { action, ownerUserId };
}
const provider = normalizeProviderId(String(context.provider ?? ""));
const page = Number.parseInt(String(context.page ?? "1"), 10);
if (!provider) {
return null;
}
if (action === "list") {
return {
action,
ownerUserId,
provider,
page: normalizePage(page),
};
}
if (action === "select") {
const model = String(context.model ?? "").trim();
if (!model) {
return null;
}
return {
action,
ownerUserId,
provider,
page: normalizePage(page),
model,
};
}
return null;
}
export function buildMattermostAllowedModelRefs(data: ModelsProviderData): Set<string> {
const refs = new Set<string>();
for (const provider of data.providers) {
for (const model of data.byProvider.get(provider) ?? []) {
refs.add(`${provider}/${model}`);
}
}
return refs;
}
export function resolveMattermostModelPickerCurrentModel(params: {
cfg: OpenClawConfig;
route: { agentId: string; sessionKey: string };
data: ModelsProviderData;
skipCache?: boolean;
}): string {
const fallback = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
try {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.route.agentId,
});
const sessionStore = params.skipCache
? loadSessionStore(storePath, { skipCache: true })
: loadSessionStore(storePath);
const sessionEntry = sessionStore[params.route.sessionKey];
const override = resolveStoredModelOverride({
sessionEntry,
sessionStore,
sessionKey: params.route.sessionKey,
defaultProvider: params.data.resolvedDefault.provider,
});
if (!override?.model) {
return fallback;
}
const provider = (override.provider || params.data.resolvedDefault.provider).trim();
return provider ? `${provider}/${override.model}` : fallback;
} catch {
return fallback;
}
}
export function renderMattermostModelSummaryView(params: {
ownerUserId: string;
currentModel?: string;
}): MattermostModelPickerRenderedView {
return {
text: [
formatCurrentModelLine(params.currentModel),
"",
"Tap below to browse models, or use:",
"/oc_model <provider/model> to switch",
"/oc_model status for details",
].join("\n"),
buttons: [
[
buildButton({
action: "providers",
ownerUserId: params.ownerUserId,
text: "Browse providers",
style: "primary",
}),
],
],
};
}
export function renderMattermostProviderPickerView(params: {
ownerUserId: string;
data: ModelsProviderData;
currentModel?: string;
}): MattermostModelPickerRenderedView {
const currentProvider = splitModelRef(params.currentModel)?.provider;
const rows = params.data.providers.map((provider) => [
buildButton({
action: "list",
ownerUserId: params.ownerUserId,
text: `${provider} (${params.data.byProvider.get(provider)?.size ?? 0})`,
provider,
page: 1,
style: provider === currentProvider ? "primary" : "default",
}),
]);
return {
text: [formatCurrentModelLine(params.currentModel), "", "Select a provider:"].join("\n"),
buttons: rows,
};
}
export function renderMattermostModelsPickerView(params: {
ownerUserId: string;
data: ModelsProviderData;
provider: string;
page?: number;
currentModel?: string;
}): MattermostModelPickerRenderedView {
const provider = normalizeProviderId(params.provider);
const models = getProviderModels(params.data, provider);
const current = splitModelRef(params.currentModel);
if (models.length === 0) {
return {
text: [formatCurrentModelLine(params.currentModel), "", `Unknown provider: ${provider}`].join(
"\n",
),
buttons: [
[
buildButton({
action: "back",
ownerUserId: params.ownerUserId,
text: "Back to providers",
}),
],
],
};
}
const page = paginateItems(models, params.page);
const rows: MattermostInteractiveButtonInput[][] = page.items.map((model) => {
const isCurrent = current?.provider === provider && current.model === model;
return [
buildButton({
action: "select",
ownerUserId: params.ownerUserId,
text: isCurrent ? `${model} [current]` : model,
provider,
model,
page: page.page,
style: isCurrent ? "primary" : "default",
}),
];
});
const navRow: MattermostInteractiveButtonInput[] = [];
if (page.hasPrev) {
navRow.push(
buildButton({
action: "list",
ownerUserId: params.ownerUserId,
text: "Prev",
provider,
page: page.page - 1,
}),
);
}
if (page.hasNext) {
navRow.push(
buildButton({
action: "list",
ownerUserId: params.ownerUserId,
text: "Next",
provider,
page: page.page + 1,
}),
);
}
if (navRow.length > 0) {
rows.push(navRow);
}
rows.push([
buildButton({
action: "back",
ownerUserId: params.ownerUserId,
text: "Back to providers",
}),
]);
return {
text: [
`Models (${provider}) - ${page.totalItems} available`,
formatCurrentModelLine(params.currentModel),
`Page ${page.page}/${page.totalPages}`,
"Select a model to switch immediately.",
].join("\n"),
buttons: rows,
};
}
@@ -0,0 +1,165 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const evaluateSenderGroupAccessForPolicy = vi.hoisted(() => vi.fn());
const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn());
const resolveAllowlistMatchSimple = vi.hoisted(() => vi.fn());
const resolveControlCommandGate = vi.hoisted(() => vi.fn());
const resolveEffectiveAllowFromLists = vi.hoisted(() => vi.fn());
vi.mock("./runtime-api.js", () => ({
evaluateSenderGroupAccessForPolicy,
isDangerousNameMatchingEnabled,
resolveAllowlistMatchSimple,
resolveControlCommandGate,
resolveEffectiveAllowFromLists,
}));
describe("mattermost monitor auth", () => {
let authorizeMattermostCommandInvocation: typeof import("./monitor-auth.js").authorizeMattermostCommandInvocation;
let isMattermostSenderAllowed: typeof import("./monitor-auth.js").isMattermostSenderAllowed;
let normalizeMattermostAllowEntry: typeof import("./monitor-auth.js").normalizeMattermostAllowEntry;
let normalizeMattermostAllowList: typeof import("./monitor-auth.js").normalizeMattermostAllowList;
let resolveMattermostEffectiveAllowFromLists: typeof import("./monitor-auth.js").resolveMattermostEffectiveAllowFromLists;
beforeAll(async () => {
({
authorizeMattermostCommandInvocation,
isMattermostSenderAllowed,
normalizeMattermostAllowEntry,
normalizeMattermostAllowList,
resolveMattermostEffectiveAllowFromLists,
} = await import("./monitor-auth.js"));
});
beforeEach(() => {
evaluateSenderGroupAccessForPolicy.mockReset();
isDangerousNameMatchingEnabled.mockReset();
resolveAllowlistMatchSimple.mockReset();
resolveControlCommandGate.mockReset();
resolveEffectiveAllowFromLists.mockReset();
});
it("normalizes allowlist entries and resolves effective lists", () => {
resolveEffectiveAllowFromLists.mockReturnValue({
effectiveAllowFrom: ["alice"],
effectiveGroupAllowFrom: ["team"],
});
expect(normalizeMattermostAllowEntry(" @Alice ")).toBe("alice");
expect(normalizeMattermostAllowEntry("mattermost:Bob")).toBe("bob");
expect(normalizeMattermostAllowEntry("*")).toBe("*");
expect(normalizeMattermostAllowList([" Alice ", "user:alice", "ALICE", "*"])).toEqual([
"alice",
"*",
]);
expect(
resolveMattermostEffectiveAllowFromLists({
allowFrom: [" Alice "],
groupAllowFrom: [" Team "],
storeAllowFrom: ["Store"],
dmPolicy: "pairing",
}),
).toEqual({
effectiveAllowFrom: ["alice"],
effectiveGroupAllowFrom: ["team"],
});
expect(resolveEffectiveAllowFromLists).toHaveBeenCalledWith({
allowFrom: ["alice"],
groupAllowFrom: ["team"],
storeAllowFrom: ["store"],
dmPolicy: "pairing",
});
});
it("checks sender allowlists against normalized ids and names", () => {
resolveAllowlistMatchSimple.mockReturnValue({ allowed: true });
expect(
isMattermostSenderAllowed({
senderId: "@Alice",
senderName: "Alice",
allowFrom: [" mattermost:alice "],
allowNameMatching: true,
}),
).toBe(true);
expect(resolveAllowlistMatchSimple).toHaveBeenCalledWith({
allowFrom: ["alice"],
senderId: "alice",
senderName: "alice",
allowNameMatching: true,
});
});
it("authorizes direct messages in open mode and blocks disabled/group-restricted channels", async () => {
isDangerousNameMatchingEnabled.mockReturnValue(false);
resolveEffectiveAllowFromLists.mockReturnValue({
effectiveAllowFrom: [],
effectiveGroupAllowFrom: [],
});
resolveControlCommandGate.mockReturnValue({
commandAuthorized: false,
shouldBlock: false,
});
evaluateSenderGroupAccessForPolicy.mockReturnValue({
allowed: false,
reason: "empty_allowlist",
});
resolveAllowlistMatchSimple.mockReturnValue({ allowed: false });
expect(
authorizeMattermostCommandInvocation({
account: {
config: { dmPolicy: "open" },
} as never,
cfg: {} as never,
senderId: "alice",
senderName: "Alice",
channelId: "dm-1",
channelInfo: { type: "D", name: "alice", display_name: "Alice" } as never,
allowTextCommands: false,
hasControlCommand: false,
}),
).toMatchObject({
ok: true,
commandAuthorized: true,
kind: "direct",
roomLabel: "#alice",
});
expect(
authorizeMattermostCommandInvocation({
account: {
config: { dmPolicy: "disabled" },
} as never,
cfg: {} as never,
senderId: "alice",
senderName: "Alice",
channelId: "dm-1",
channelInfo: { type: "D", name: "alice", display_name: "Alice" } as never,
allowTextCommands: false,
hasControlCommand: false,
}),
).toMatchObject({
ok: false,
denyReason: "dm-disabled",
});
expect(
authorizeMattermostCommandInvocation({
account: {
config: { groupPolicy: "allowlist" },
} as never,
cfg: {} as never,
senderId: "alice",
senderName: "Alice",
channelId: "chan-1",
channelInfo: { type: "O", name: "town-square", display_name: "Town Square" } as never,
allowTextCommands: true,
hasControlCommand: false,
}),
).toMatchObject({
ok: false,
denyReason: "channel-no-allowlist",
kind: "channel",
});
});
});
@@ -0,0 +1,315 @@
import type { ResolvedMattermostAccount } from "./accounts.js";
import type { MattermostChannel } from "./client.js";
import type { OpenClawConfig } from "./runtime-api.js";
import {
evaluateSenderGroupAccessForPolicy,
isDangerousNameMatchingEnabled,
resolveAllowlistMatchSimple,
resolveControlCommandGate,
resolveEffectiveAllowFromLists,
} from "./runtime-api.js";
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;
}
function mapMattermostChannelKind(channelType?: string | null): "direct" | "group" | "channel" {
const normalized = channelType?.trim().toUpperCase();
if (normalized === "D") {
return "direct";
}
if (normalized === "G" || normalized === "P") {
return "group";
}
return "channel";
}
export type MattermostCommandAuthDecision =
| {
ok: true;
commandAuthorized: boolean;
channelInfo: MattermostChannel;
kind: "direct" | "group" | "channel";
chatType: "direct" | "group" | "channel";
channelName: string;
channelDisplay: string;
roomLabel: string;
}
| {
ok: false;
denyReason:
| "unknown-channel"
| "dm-disabled"
| "dm-pairing"
| "unauthorized"
| "channels-disabled"
| "channel-no-allowlist";
commandAuthorized: false;
channelInfo: MattermostChannel | null;
kind: "direct" | "group" | "channel";
chatType: "direct" | "group" | "channel";
channelName: string;
channelDisplay: string;
roomLabel: string;
};
export function authorizeMattermostCommandInvocation(params: {
account: ResolvedMattermostAccount;
cfg: OpenClawConfig;
senderId: string;
senderName: string;
channelId: string;
channelInfo: MattermostChannel | null;
storeAllowFrom?: Array<string | number> | null;
allowTextCommands: boolean;
hasControlCommand: boolean;
}): MattermostCommandAuthDecision {
const {
account,
cfg,
senderId,
senderName,
channelId,
channelInfo,
storeAllowFrom,
allowTextCommands,
hasControlCommand,
} = params;
if (!channelInfo) {
return {
ok: false,
denyReason: "unknown-channel",
commandAuthorized: false,
channelInfo: null,
kind: "channel",
chatType: "channel",
channelName: "",
channelDisplay: "",
roomLabel: `#${channelId}`,
};
}
const kind = mapMattermostChannelKind(channelInfo.type);
const chatType = kind;
const channelName = channelInfo.name ?? "";
const channelDisplay = channelInfo.display_name ?? channelName;
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
const dmPolicy = account.config.dmPolicy ?? "pairing";
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
const configAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []);
const configGroupAllowFrom = normalizeMattermostAllowList(account.config.groupAllowFrom ?? []);
const normalizedStoreAllowFrom = normalizeMattermostAllowList(storeAllowFrom ?? []);
const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveMattermostEffectiveAllowFromLists({
allowFrom: configAllowFrom,
groupAllowFrom: configGroupAllowFrom,
storeAllowFrom: normalizedStoreAllowFrom,
dmPolicy,
});
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : configAllowFrom;
const commandGroupAllowFrom =
kind === "direct"
? effectiveGroupAllowFrom
: configGroupAllowFrom.length > 0
? configGroupAllowFrom
: configAllowFrom;
const senderAllowedForCommands = isMattermostSenderAllowed({
senderId,
senderName,
allowFrom: commandDmAllowFrom,
allowNameMatching,
});
const groupAllowedForCommands = isMattermostSenderAllowed({
senderId,
senderName,
allowFrom: commandGroupAllowFrom,
allowNameMatching,
});
const commandGate = resolveControlCommandGate({
useAccessGroups,
authorizers: [
{ configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands },
{
configured: commandGroupAllowFrom.length > 0,
allowed: groupAllowedForCommands,
},
],
allowTextCommands,
hasControlCommand: allowTextCommands && hasControlCommand,
});
const commandAuthorized =
kind === "direct"
? dmPolicy === "open" || senderAllowedForCommands
: commandGate.commandAuthorized;
if (kind === "direct") {
if (dmPolicy === "disabled") {
return {
ok: false,
denyReason: "dm-disabled",
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
if (dmPolicy !== "open" && !senderAllowedForCommands) {
return {
ok: false,
denyReason: dmPolicy === "pairing" ? "dm-pairing" : "unauthorized",
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
} else {
const senderGroupAccess = evaluateSenderGroupAccessForPolicy({
groupPolicy,
groupAllowFrom: effectiveGroupAllowFrom,
senderId,
isSenderAllowed: (_senderId, allowFrom) =>
isMattermostSenderAllowed({
senderId,
senderName,
allowFrom,
allowNameMatching,
}),
});
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
return {
ok: false,
denyReason: "channels-disabled",
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
return {
ok: false,
denyReason: "channel-no-allowlist",
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "sender_not_allowlisted") {
return {
ok: false,
denyReason: "unauthorized",
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
if (commandGate.shouldBlock) {
return {
ok: false,
denyReason: "unauthorized",
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
}
return {
ok: true,
commandAuthorized,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
@@ -0,0 +1,84 @@
import { describe, expect, it, vi } from "vitest";
import {
evaluateMattermostMentionGate,
mapMattermostChannelTypeToChatType,
} from "./monitor-gating.js";
describe("mattermost monitor gating", () => {
it("maps mattermost channel types to chat types", () => {
expect(mapMattermostChannelTypeToChatType("D")).toBe("direct");
expect(mapMattermostChannelTypeToChatType("G")).toBe("group");
expect(mapMattermostChannelTypeToChatType("P")).toBe("group");
expect(mapMattermostChannelTypeToChatType("O")).toBe("channel");
expect(mapMattermostChannelTypeToChatType(undefined)).toBe("channel");
});
it("drops non-mentioned traffic when onchar is enabled but not triggered", () => {
const resolveRequireMention = vi.fn(() => true);
expect(
evaluateMattermostMentionGate({
kind: "channel",
cfg: {} as never,
accountId: "default",
channelId: "chan-1",
resolveRequireMention,
wasMentioned: false,
isControlCommand: false,
commandAuthorized: false,
oncharEnabled: true,
oncharTriggered: false,
canDetectMention: true,
}),
).toEqual({
shouldRequireMention: true,
shouldBypassMention: false,
effectiveWasMentioned: false,
dropReason: "onchar-not-triggered",
});
});
it("bypasses mention for authorized control commands and allows direct chats", () => {
const resolveRequireMention = vi.fn(() => true);
expect(
evaluateMattermostMentionGate({
kind: "channel",
cfg: {} as never,
accountId: "default",
channelId: "chan-1",
resolveRequireMention,
wasMentioned: false,
isControlCommand: true,
commandAuthorized: true,
oncharEnabled: false,
oncharTriggered: false,
canDetectMention: true,
}),
).toEqual({
shouldRequireMention: true,
shouldBypassMention: true,
effectiveWasMentioned: true,
dropReason: null,
});
expect(
evaluateMattermostMentionGate({
kind: "direct",
cfg: {} as never,
accountId: "default",
channelId: "chan-1",
resolveRequireMention,
wasMentioned: false,
isControlCommand: false,
commandAuthorized: false,
oncharEnabled: false,
oncharTriggered: false,
canDetectMention: true,
}),
).toMatchObject({
shouldRequireMention: false,
dropReason: null,
});
});
});
@@ -0,0 +1,99 @@
import type { ChatType, OpenClawConfig } from "./runtime-api.js";
export function mapMattermostChannelTypeToChatType(channelType?: string | null): ChatType {
if (!channelType) {
return "channel";
}
const normalized = channelType.trim().toUpperCase();
if (normalized === "D") {
return "direct";
}
if (normalized === "G" || normalized === "P") {
return "group";
}
return "channel";
}
export type MattermostRequireMentionResolverInput = {
cfg: OpenClawConfig;
channel: "mattermost";
accountId: string;
groupId: string;
requireMentionOverride?: boolean;
};
export type MattermostMentionGateInput = {
kind: ChatType;
cfg: OpenClawConfig;
accountId: string;
channelId: string;
threadRootId?: string;
requireMentionOverride?: boolean;
resolveRequireMention: (params: MattermostRequireMentionResolverInput) => boolean;
wasMentioned: boolean;
isControlCommand: boolean;
commandAuthorized: boolean;
oncharEnabled: boolean;
oncharTriggered: boolean;
canDetectMention: boolean;
};
type MattermostMentionGateDecision = {
shouldRequireMention: boolean;
shouldBypassMention: boolean;
effectiveWasMentioned: boolean;
dropReason: "onchar-not-triggered" | "missing-mention" | null;
};
export function evaluateMattermostMentionGate(
params: MattermostMentionGateInput,
): MattermostMentionGateDecision {
const shouldRequireMention =
params.kind !== "direct" &&
params.resolveRequireMention({
cfg: params.cfg,
channel: "mattermost",
accountId: params.accountId,
groupId: params.channelId,
requireMentionOverride: params.requireMentionOverride,
});
const shouldBypassMention =
params.isControlCommand &&
shouldRequireMention &&
!params.wasMentioned &&
params.commandAuthorized;
const effectiveWasMentioned =
params.wasMentioned || shouldBypassMention || params.oncharTriggered;
if (
params.oncharEnabled &&
!params.oncharTriggered &&
!params.wasMentioned &&
!params.isControlCommand
) {
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: "onchar-not-triggered",
};
}
if (
params.kind !== "direct" &&
shouldRequireMention &&
params.canDetectMention &&
!effectiveWasMentioned
) {
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: "missing-mention",
};
}
return {
shouldRequireMention,
shouldBypassMention,
effectiveWasMentioned,
dropReason: null,
};
}
@@ -0,0 +1,82 @@
import { describe, expect, it } from "vitest";
import { normalizeMention } from "./monitor-helpers.js";
describe("normalizeMention", () => {
it("returns trimmed text when no mention provided", () => {
expect(normalizeMention(" hello world ", undefined)).toBe("hello world");
});
it("strips bot mention from text", () => {
expect(normalizeMention("@echobot hello", "echobot")).toBe("hello");
});
it("strips mention case-insensitively", () => {
expect(normalizeMention("@EchoBot hello", "echobot")).toBe("hello");
});
it("preserves newlines in multi-line messages", () => {
const input = "@echobot\nline1\nline2\nline3";
const result = normalizeMention(input, "echobot");
expect(result).toBe("line1\nline2\nline3");
});
it("preserves Markdown headings", () => {
const input = "@echobot\n# Heading\n\nSome text";
const result = normalizeMention(input, "echobot");
expect(result).toContain("# Heading");
expect(result).toContain("\n");
});
it("preserves Markdown blockquotes", () => {
const input = "@echobot\n> quoted line\n> second line";
const result = normalizeMention(input, "echobot");
expect(result).toContain("> quoted line");
expect(result).toContain("> second line");
});
it("preserves Markdown lists", () => {
const input = "@echobot\n- item A\n- item B\n - sub B1";
const result = normalizeMention(input, "echobot");
expect(result).toContain("- item A");
expect(result).toContain("- item B");
});
it("preserves task lists", () => {
const input = "@echobot\n- [ ] todo\n- [x] done";
const result = normalizeMention(input, "echobot");
expect(result).toContain("- [ ] todo");
expect(result).toContain("- [x] done");
});
it("handles mention in middle of text", () => {
const input = "hey @echobot check this\nout";
const result = normalizeMention(input, "echobot");
expect(result).toBe("hey check this\nout");
});
it("preserves leading indentation for nested lists", () => {
const input = "@echobot\n- item\n - nested\n - deep";
const result = normalizeMention(input, "echobot");
expect(result).toContain(" - nested");
expect(result).toContain(" - deep");
});
it("preserves first-line indentation for nested list items", () => {
const input = "@echobot\n - nested\n - deep";
const result = normalizeMention(input, "echobot");
expect(result).toBe(" - nested\n - deep");
});
it("preserves indented code blocks", () => {
const input = "@echobot\ntext\n code line 1\n code line 2";
const result = normalizeMention(input, "echobot");
expect(result).toContain(" code line 1");
expect(result).toContain(" code line 2");
});
it("preserves first-line indentation for indented code blocks", () => {
const input = "@echobot\n code line 1\n code line 2";
const result = normalizeMention(input, "echobot");
expect(result).toBe(" code line 1\n code line 2");
});
});
@@ -0,0 +1,110 @@
import {
createDedupeCache,
formatInboundFromLabel as formatInboundFromLabelShared,
rawDataToString,
resolveThreadSessionKeys as resolveThreadSessionKeysShared,
type OpenClawConfig,
} from "./runtime-api.js";
export { createDedupeCache, rawDataToString };
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 isAgentEntry(entry: unknown): entry is AgentEntry {
return Boolean(entry && typeof entry === "object");
}
function listAgents(cfg: OpenClawConfig): AgentEntry[] {
return Array.isArray(cfg.agents?.list) ? cfg.agents.list.filter(isAgentEntry) : [];
}
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,
});
}
/**
* Strip bot mention from message text while preserving newlines and
* block-level Markdown formatting (headings, lists, blockquotes).
*/
export function normalizeMention(text: string, mention: string | undefined): string {
if (!mention) {
return text.trim();
}
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
const hasMentionRe = new RegExp(`@${escaped}\\b`, "i");
const leadingMentionRe = new RegExp(`^([\\t ]*)@${escaped}\\b[\\t ]*`, "i");
const trailingMentionRe = new RegExp(`[\\t ]*@${escaped}\\b[\\t ]*$`, "i");
const normalizedLines = text.split("\n").map((line) => {
const hadMention = hasMentionRe.test(line);
const normalizedLine = line
.replace(leadingMentionRe, "$1")
.replace(trailingMentionRe, "")
.replace(new RegExp(`@${escaped}\\b`, "gi"), "")
.replace(/(\S)[ \t]{2,}/g, "$1 ");
return {
text: normalizedLine,
mentionOnlyBlank: hadMention && normalizedLine.trim() === "",
};
});
while (normalizedLines[0]?.mentionOnlyBlank) {
normalizedLines.shift();
}
while (normalizedLines.at(-1)?.text.trim() === "") {
normalizedLines.pop();
}
return normalizedLines.map((line) => line.text).join("\n");
}
@@ -0,0 +1,32 @@
import { describe, expect, it } from "vitest";
import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js";
describe("mattermost monitor onchar", () => {
it("uses defaults when prefixes are missing or empty after trimming", () => {
expect(resolveOncharPrefixes(undefined)).toEqual([">", "!"]);
expect(resolveOncharPrefixes([" ", ""])).toEqual([">", "!"]);
});
it("trims configured prefixes and preserves order", () => {
expect(resolveOncharPrefixes([" ?? ", " !", " /bot "])).toEqual(["??", "!", "/bot"]);
});
it("strips the first matching prefix after leading whitespace", () => {
expect(stripOncharPrefix(" ! hello world", ["!", ">"])).toEqual({
triggered: true,
stripped: "hello world",
});
expect(stripOncharPrefix("??multi prefix", ["??", "?"])).toEqual({
triggered: true,
stripped: "multi prefix",
});
});
it("returns the original text when no prefix matches", () => {
expect(stripOncharPrefix("hello world", ["!", ">"])).toEqual({
triggered: false,
stripped: "hello world",
});
});
});
@@ -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 };
}
@@ -0,0 +1,155 @@
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const fetchMattermostChannel = vi.hoisted(() => vi.fn());
const fetchMattermostUser = vi.hoisted(() => vi.fn());
const sendMattermostTyping = vi.hoisted(() => vi.fn());
const updateMattermostPost = vi.hoisted(() => vi.fn());
const buildButtonProps = vi.hoisted(() => vi.fn());
vi.mock("./client.js", () => ({
fetchMattermostChannel,
fetchMattermostUser,
sendMattermostTyping,
updateMattermostPost,
}));
vi.mock("./interactions.js", () => ({
buildButtonProps,
}));
describe("mattermost monitor resources", () => {
let createMattermostMonitorResources: typeof import("./monitor-resources.js").createMattermostMonitorResources;
beforeAll(async () => {
({ createMattermostMonitorResources } = await import("./monitor-resources.js"));
});
beforeEach(() => {
fetchMattermostChannel.mockReset();
fetchMattermostUser.mockReset();
sendMattermostTyping.mockReset();
updateMattermostPost.mockReset();
buildButtonProps.mockReset();
});
it("downloads media, preserves auth headers, and infers media kind", async () => {
const fetchRemoteMedia = vi.fn(async () => ({
buffer: new Uint8Array([1, 2, 3]),
contentType: "image/png",
}));
const saveMediaBuffer = vi.fn(async () => ({
path: "/tmp/file.png",
contentType: "image/png",
}));
const resources = createMattermostMonitorResources({
accountId: "default",
callbackUrl: "https://openclaw.test/callback",
client: {
apiBaseUrl: "https://chat.example.com/api/v4",
baseUrl: "https://chat.example.com",
// pragma: allowlist secret
token: "bot-token",
} as never,
logger: {},
mediaMaxBytes: 1024,
fetchRemoteMedia,
saveMediaBuffer,
mediaKindFromMime: () => "image",
});
await expect(resources.resolveMattermostMedia([" file-1 "])).resolves.toEqual([
{
path: "/tmp/file.png",
contentType: "image/png",
kind: "image",
},
]);
expect(fetchRemoteMedia).toHaveBeenCalledWith({
url: "https://chat.example.com/api/v4/files/file-1",
requestInit: {
headers: {
// pragma: allowlist secret
Authorization: "Bearer bot-token",
},
},
filePathHint: "file-1",
maxBytes: 1024,
ssrfPolicy: { allowedHostnames: ["chat.example.com"] },
});
});
it("caches channel and user lookups and falls back to empty picker props", async () => {
fetchMattermostChannel.mockResolvedValue({ id: "chan-1", name: "town-square" });
fetchMattermostUser.mockResolvedValue({ id: "user-1", username: "alice" });
buildButtonProps.mockReturnValue(undefined);
const resources = createMattermostMonitorResources({
accountId: "default",
callbackUrl: "https://openclaw.test/callback",
client: {} as never,
logger: {},
mediaMaxBytes: 1024,
fetchRemoteMedia: vi.fn(),
saveMediaBuffer: vi.fn(),
mediaKindFromMime: () => "document",
});
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
id: "chan-1",
name: "town-square",
});
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
id: "chan-1",
name: "town-square",
});
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
id: "user-1",
username: "alice",
});
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
id: "user-1",
username: "alice",
});
expect(fetchMattermostChannel).toHaveBeenCalledTimes(1);
expect(fetchMattermostUser).toHaveBeenCalledTimes(1);
await resources.updateModelPickerPost({
channelId: "chan-1",
postId: "post-1",
message: "Pick a model",
});
expect(updateMattermostPost).toHaveBeenCalledWith(
{},
"post-1",
expect.objectContaining({
message: "Pick a model",
props: { attachments: [] },
}),
);
});
it("proxies typing indicators to the mattermost client helper", async () => {
const client = {} as never;
const resources = createMattermostMonitorResources({
accountId: "default",
callbackUrl: "https://openclaw.test/callback",
client,
logger: {},
mediaMaxBytes: 1024,
fetchRemoteMedia: vi.fn(),
saveMediaBuffer: vi.fn(),
mediaKindFromMime: () => "document",
});
await resources.sendTypingIndicator("chan-1", "root-1");
expect(sendMattermostTyping).toHaveBeenCalledWith(client, {
channelId: "chan-1",
parentId: "root-1",
});
});
});
@@ -0,0 +1,183 @@
import {
fetchMattermostChannel,
fetchMattermostUser,
sendMattermostTyping,
updateMattermostPost,
type MattermostChannel,
type MattermostClient,
type MattermostUser,
} from "./client.js";
import { buildButtonProps, type MattermostInteractionResponse } from "./interactions.js";
export type MattermostMediaKind = "image" | "audio" | "video" | "document" | "unknown";
export type MattermostMediaInfo = {
path: string;
contentType?: string;
kind: MattermostMediaKind;
};
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
const USER_CACHE_TTL_MS = 10 * 60_000;
type FetchRemoteMedia = (params: {
url: string;
requestInit?: RequestInit;
filePathHint?: string;
maxBytes: number;
ssrfPolicy?: { allowedHostnames?: string[] };
}) => Promise<{ buffer: Uint8Array; contentType?: string | null }>;
type SaveMediaBuffer = (
buffer: Uint8Array,
contentType: string | undefined,
direction: "inbound" | "outbound",
maxBytes: number,
) => Promise<{ path: string; contentType?: string | null }>;
export function createMattermostMonitorResources(params: {
accountId: string;
callbackUrl: string;
client: MattermostClient;
logger: { debug?: (...args: unknown[]) => void };
mediaMaxBytes: number;
fetchRemoteMedia: FetchRemoteMedia;
saveMediaBuffer: SaveMediaBuffer;
mediaKindFromMime: (contentType?: string) => MattermostMediaKind | null | undefined;
}) {
const {
accountId,
callbackUrl,
client,
logger,
mediaMaxBytes,
fetchRemoteMedia,
saveMediaBuffer,
mediaKindFromMime,
} = params;
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
const resolveMattermostMedia = async (
fileIds?: string[] | null,
): Promise<MattermostMediaInfo[]> => {
const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean);
if (ids.length === 0) {
return [];
}
const out: MattermostMediaInfo[] = [];
for (const fileId of ids) {
try {
const fetched = await fetchRemoteMedia({
url: `${client.apiBaseUrl}/files/${fileId}`,
requestInit: {
headers: {
Authorization: `Bearer ${client.token}`,
},
},
filePathHint: fileId,
maxBytes: mediaMaxBytes,
ssrfPolicy: { allowedHostnames: [new URL(client.baseUrl).hostname] },
});
const saved = await saveMediaBuffer(
Buffer.from(fetched.buffer),
fetched.contentType ?? undefined,
"inbound",
mediaMaxBytes,
);
const contentType = saved.contentType ?? fetched.contentType ?? undefined;
out.push({
path: saved.path,
contentType,
kind: mediaKindFromMime(contentType) ?? "unknown",
});
} catch (err) {
logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
}
}
return out;
};
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
await sendMattermostTyping(client, { channelId, parentId });
};
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 buildModelPickerProps = (
channelId: string,
buttons: Array<unknown>,
): Record<string, unknown> | undefined =>
buildButtonProps({
callbackUrl,
accountId,
channelId,
buttons,
});
const updateModelPickerPost = async (params: {
channelId: string;
postId: string;
message: string;
buttons?: Array<unknown>;
}): Promise<MattermostInteractionResponse> => {
const props = buildModelPickerProps(params.channelId, params.buttons ?? []) ?? {
attachments: [],
};
await updateMattermostPost(client, params.postId, {
message: params.message,
props,
});
return {};
};
return {
resolveMattermostMedia,
sendTypingIndicator,
resolveChannelInfo,
resolveUserInfo,
updateModelPickerPost,
};
}
@@ -0,0 +1,183 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const listSkillCommandsForAgents = vi.hoisted(() => vi.fn());
const parseStrictPositiveInteger = vi.hoisted(() => vi.fn());
const fetchMattermostUserTeams = vi.hoisted(() => vi.fn());
const normalizeMattermostBaseUrl = vi.hoisted(() => vi.fn((value: string | undefined) => value));
const isSlashCommandsEnabled = vi.hoisted(() => vi.fn());
const registerSlashCommands = vi.hoisted(() => vi.fn());
const resolveCallbackUrl = vi.hoisted(() => vi.fn());
const resolveSlashCommandConfig = vi.hoisted(() => vi.fn());
const activateSlashCommands = vi.hoisted(() => vi.fn());
vi.mock("./runtime-api.js", () => ({
listSkillCommandsForAgents,
parseStrictPositiveInteger,
}));
vi.mock("./client.js", async () => {
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
return {
...actual,
fetchMattermostUserTeams,
normalizeMattermostBaseUrl,
};
});
vi.mock("./slash-commands.js", () => ({
DEFAULT_COMMAND_SPECS: [
{ trigger: "ping", description: "ping" },
{ trigger: "ping", description: "duplicate" },
],
isSlashCommandsEnabled,
registerSlashCommands,
resolveCallbackUrl,
resolveSlashCommandConfig,
}));
vi.mock("./slash-state.js", () => ({
activateSlashCommands,
}));
describe("mattermost monitor slash", () => {
let registerMattermostMonitorSlashCommands: typeof import("./monitor-slash.js").registerMattermostMonitorSlashCommands;
beforeAll(async () => {
({ registerMattermostMonitorSlashCommands } = await import("./monitor-slash.js"));
});
beforeEach(() => {
listSkillCommandsForAgents.mockReset();
parseStrictPositiveInteger.mockReset();
fetchMattermostUserTeams.mockReset();
normalizeMattermostBaseUrl.mockClear();
isSlashCommandsEnabled.mockReset();
registerSlashCommands.mockReset();
resolveCallbackUrl.mockReset();
resolveSlashCommandConfig.mockReset();
activateSlashCommands.mockReset();
});
afterEach(() => {
vi.unstubAllEnvs();
});
it("returns early when slash commands are disabled", async () => {
resolveSlashCommandConfig.mockReturnValue({ enabled: false });
isSlashCommandsEnabled.mockReturnValue(false);
await registerMattermostMonitorSlashCommands({
client: {} as never,
cfg: {} as never,
runtime: {} as never,
account: { config: {} } as never,
baseUrl: "https://chat.example.com",
botUserId: "bot-user",
});
expect(fetchMattermostUserTeams).not.toHaveBeenCalled();
expect(activateSlashCommands).not.toHaveBeenCalled();
});
it("registers deduped default and native skill commands across teams", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18888");
resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: true });
isSlashCommandsEnabled.mockReturnValue(true);
parseStrictPositiveInteger.mockReturnValue(18888);
fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }, { id: "team-2" }]);
resolveCallbackUrl.mockReturnValue("https://openclaw.test/slash");
listSkillCommandsForAgents.mockReturnValue([
{ name: "skill", description: "Skill run" },
{ name: "oc_ping", description: "Already prefixed" },
{ name: " ", description: "ignored" },
]);
registerSlashCommands
.mockResolvedValueOnce([{ token: "token-1", trigger: "ping" }])
.mockResolvedValueOnce([{ token: "token-2", trigger: "oc_skill" }]);
const runtime = {
log: vi.fn(),
error: vi.fn(),
};
await registerMattermostMonitorSlashCommands({
client: {} as never,
cfg: { gateway: { port: 18789 } } as never,
runtime: runtime as never,
account: { config: { commands: {} }, accountId: "default" } as never,
baseUrl: "https://chat.example.com",
botUserId: "bot-user",
});
expect(registerSlashCommands).toHaveBeenCalledTimes(2);
expect(registerSlashCommands.mock.calls[0]?.[0]).toMatchObject({
teamId: "team-1",
creatorUserId: "bot-user",
callbackUrl: "https://openclaw.test/slash",
});
expect(registerSlashCommands.mock.calls[0]?.[0].commands).toEqual([
{ trigger: "ping", description: "ping" },
{
trigger: "oc_skill",
description: "Skill run",
autoComplete: true,
autoCompleteHint: "[args]",
originalName: "skill",
},
{
trigger: "oc_ping",
description: "Already prefixed",
autoComplete: true,
autoCompleteHint: "[args]",
originalName: "oc_ping",
},
]);
expect(activateSlashCommands).toHaveBeenCalledWith(
expect.objectContaining({
commandTokens: ["token-1", "token-2"],
triggerMap: new Map([
["oc_skill", "skill"],
["oc_ping", "oc_ping"],
]),
}),
);
expect(runtime.log).toHaveBeenCalledWith(
"mattermost: slash commands registered (2 commands across 2 teams, callback=https://openclaw.test/slash)",
);
});
it("warns on loopback callback urls and reports partial team failures", async () => {
resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: false });
isSlashCommandsEnabled.mockReturnValue(true);
parseStrictPositiveInteger.mockReturnValue(undefined);
fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }, { id: "team-2" }]);
resolveCallbackUrl.mockReturnValue("http://127.0.0.1:18789/slash");
registerSlashCommands
.mockResolvedValueOnce([{ token: "token-1", trigger: "ping" }])
.mockRejectedValueOnce(new Error("boom"));
const runtime = {
log: vi.fn(),
error: vi.fn(),
};
await registerMattermostMonitorSlashCommands({
client: {} as never,
cfg: { gateway: { customBindHost: "loopback" } } as never,
runtime: runtime as never,
account: { config: { commands: {} }, accountId: "default" } as never,
baseUrl: "https://chat.example.com",
botUserId: "bot-user",
});
expect(runtime.error).toHaveBeenCalledWith(
expect.stringContaining(
"slash commands callbackUrl resolved to http://127.0.0.1:18789/slash",
),
);
expect(runtime.error).toHaveBeenCalledWith(
"mattermost: failed to register slash commands for team team-2: Error: boom",
);
expect(runtime.error).toHaveBeenCalledWith(
"mattermost: slash command registration completed with 1 team error(s)",
);
});
});
@@ -0,0 +1,211 @@
import type { ResolvedMattermostAccount } from "./accounts.js";
import {
fetchMattermostUserTeams,
normalizeMattermostBaseUrl,
type MattermostClient,
} from "./client.js";
import {
listSkillCommandsForAgents,
parseStrictPositiveInteger,
type OpenClawConfig,
type RuntimeEnv,
} from "./runtime-api.js";
import {
DEFAULT_COMMAND_SPECS,
isSlashCommandsEnabled,
registerSlashCommands,
resolveCallbackUrl,
resolveSlashCommandConfig,
type MattermostCommandSpec,
type MattermostRegisteredCommand,
type MattermostSlashCommandConfig,
} from "./slash-commands.js";
import { activateSlashCommands } from "./slash-state.js";
function isLoopbackHost(hostname: string): boolean {
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
}
function buildSlashCommands(params: {
cfg: OpenClawConfig;
runtime: RuntimeEnv;
nativeSkills: boolean;
}): MattermostCommandSpec[] {
const commandsToRegister: MattermostCommandSpec[] = [...DEFAULT_COMMAND_SPECS];
if (!params.nativeSkills) {
return commandsToRegister;
}
try {
const skillCommands = listSkillCommandsForAgents({ cfg: params.cfg });
for (const spec of skillCommands) {
const name = typeof spec.name === "string" ? spec.name.trim() : "";
if (!name) continue;
const trigger = name.startsWith("oc_") ? name : `oc_${name}`;
commandsToRegister.push({
trigger,
description: spec.description || `Run skill ${name}`,
autoComplete: true,
autoCompleteHint: "[args]",
originalName: name,
});
}
} catch (err) {
params.runtime.error?.(`mattermost: failed to list skill commands: ${String(err)}`);
}
return commandsToRegister;
}
function dedupeSlashCommands(commands: MattermostCommandSpec[]): MattermostCommandSpec[] {
const seen = new Set<string>();
return commands.filter((cmd) => {
const key = cmd.trigger.trim();
if (!key || seen.has(key)) {
return false;
}
seen.add(key);
return true;
});
}
function buildTriggerMap(commands: MattermostCommandSpec[]): Map<string, string> {
const triggerMap = new Map<string, string>();
for (const cmd of commands) {
if (cmd.originalName) {
triggerMap.set(cmd.trigger, cmd.originalName);
}
}
return triggerMap;
}
function warnOnSuspiciousCallbackUrl(params: {
runtime: RuntimeEnv;
baseUrl: string;
callbackUrl: string;
}) {
try {
const mmHost = new URL(normalizeMattermostBaseUrl(params.baseUrl) ?? params.baseUrl).hostname;
const callbackHost = new URL(params.callbackUrl).hostname;
if (isLoopbackHost(callbackHost) && !isLoopbackHost(mmHost)) {
params.runtime.error?.(
`mattermost: slash commands callbackUrl resolved to ${params.callbackUrl} (loopback) while baseUrl is ${params.baseUrl}. This MAY be unreachable depending on your deployment. If native slash commands don't work, set channels.mattermost.commands.callbackUrl to a URL reachable from the Mattermost server (e.g. your public reverse proxy URL).`,
);
}
} catch {
// Ignore malformed URLs and let the downstream registration fail naturally.
}
}
async function registerSlashCommandsAcrossTeams(params: {
client: MattermostClient;
teams: Array<{ id: string }>;
botUserId: string;
callbackUrl: string;
commands: MattermostCommandSpec[];
runtime: RuntimeEnv;
}): Promise<{
registered: MattermostRegisteredCommand[];
teamRegistrationFailures: number;
}> {
const registered: MattermostRegisteredCommand[] = [];
let teamRegistrationFailures = 0;
for (const team of params.teams) {
try {
const created = await registerSlashCommands({
client: params.client,
teamId: team.id,
creatorUserId: params.botUserId,
callbackUrl: params.callbackUrl,
commands: params.commands,
log: (msg) => params.runtime.log?.(msg),
});
registered.push(...created);
} catch (err) {
teamRegistrationFailures += 1;
params.runtime.error?.(
`mattermost: failed to register slash commands for team ${team.id}: ${String(err)}`,
);
}
}
return { registered, teamRegistrationFailures };
}
export async function registerMattermostMonitorSlashCommands(params: {
client: MattermostClient;
cfg: OpenClawConfig;
runtime: RuntimeEnv;
account: ResolvedMattermostAccount;
baseUrl: string;
botUserId: string;
}) {
const commandsRaw = params.account.config.commands as
| Partial<MattermostSlashCommandConfig>
| undefined;
const slashConfig = resolveSlashCommandConfig(commandsRaw);
if (!isSlashCommandsEnabled(slashConfig)) {
return;
}
try {
const teams = await fetchMattermostUserTeams(params.client, params.botUserId);
const envPort = parseStrictPositiveInteger(process.env.OPENCLAW_GATEWAY_PORT?.trim());
const slashGatewayPort = envPort ?? params.cfg.gateway?.port ?? 18789;
const slashCallbackUrl = resolveCallbackUrl({
config: slashConfig,
gatewayPort: slashGatewayPort,
gatewayHost: params.cfg.gateway?.customBindHost ?? undefined,
});
warnOnSuspiciousCallbackUrl({
runtime: params.runtime,
baseUrl: params.baseUrl,
callbackUrl: slashCallbackUrl,
});
const dedupedCommands = dedupeSlashCommands(
buildSlashCommands({
cfg: params.cfg,
runtime: params.runtime,
nativeSkills: slashConfig.nativeSkills === true,
}),
);
const { registered, teamRegistrationFailures } = await registerSlashCommandsAcrossTeams({
client: params.client,
teams,
botUserId: params.botUserId,
callbackUrl: slashCallbackUrl,
commands: dedupedCommands,
runtime: params.runtime,
});
if (registered.length === 0) {
params.runtime.error?.(
"mattermost: native slash commands enabled but no commands could be registered; keeping slash callbacks inactive",
);
return;
}
if (teamRegistrationFailures > 0) {
params.runtime.error?.(
`mattermost: slash command registration completed with ${teamRegistrationFailures} team error(s)`,
);
}
activateSlashCommands({
account: params.account,
commandTokens: registered.map((cmd) => cmd.token).filter(Boolean),
registeredCommands: registered,
triggerMap: buildTriggerMap(dedupedCommands),
api: { cfg: params.cfg, runtime: params.runtime },
log: (msg) => params.runtime.log?.(msg),
});
params.runtime.log?.(
`mattermost: slash commands registered (${registered.length} commands across ${teams.length} teams, callback=${slashCallbackUrl})`,
);
} catch (err) {
params.runtime.error?.(`mattermost: failed to register slash commands: ${String(err)}`);
}
}
@@ -0,0 +1,405 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../runtime-api.js";
import {
createMattermostConnectOnce,
type MattermostWebSocketLike,
WebSocketClosedBeforeOpenError,
} from "./monitor-websocket.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", () => {
beforeEach(() => {
vi.useRealTimers();
});
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 patches: Array<Record<string, unknown>> = [];
const sockets: FakeWebSocket[] = [];
const connectOnce = createMattermostConnectOnce({
wsUrl: "wss://example.invalid/api/v4/websocket",
botToken: "token",
runtime: testRuntime(),
nextSeq: (() => {
let seq = 1;
return () => seq++;
})(),
onPosted: async () => {},
statusSink: (patch) => {
patches.push(patch as Record<string, unknown>);
},
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;
},
});
const firstAttempt = connectOnce();
await expect(firstAttempt).rejects.toBeInstanceOf(WebSocketClosedBeforeOpenError);
await connectOnce();
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(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",
}),
);
});
it("terminates when bot update_at changes (disable/enable cycle)", async () => {
vi.useFakeTimers();
const socket = new FakeWebSocket();
const runtime = testRuntime();
let updateAt = 1000;
const connectOnce = createMattermostConnectOnce({
wsUrl: "wss://example.invalid/api/v4/websocket",
botToken: "token",
runtime,
nextSeq: () => 1,
onPosted: async () => {},
webSocketFactory: () => socket,
getBotUpdateAt: async () => updateAt,
healthCheckIntervalMs: 100,
});
const connected = connectOnce();
socket.emitOpen();
// Let initial getBotUpdateAt resolve
await vi.advanceTimersByTimeAsync(0);
// update_at unchanged — no terminate
await vi.advanceTimersByTimeAsync(100);
expect(socket.terminateCalls).toBe(0);
// Simulate disable/enable — update_at changes
updateAt = 2000;
await vi.advanceTimersByTimeAsync(100);
expect(socket.terminateCalls).toBe(1);
expect(runtime.log).toHaveBeenCalledWith(
"mattermost: bot account updated (update_at changed: 1000 → 2000) — reconnecting",
);
socket.emitClose(1006);
await connected;
vi.useRealTimers();
});
it("keeps connection alive when update_at stays the same", async () => {
vi.useFakeTimers();
const socket = new FakeWebSocket();
const connectOnce = createMattermostConnectOnce({
wsUrl: "wss://example.invalid/api/v4/websocket",
botToken: "token",
runtime: testRuntime(),
nextSeq: () => 1,
onPosted: async () => {},
webSocketFactory: () => socket,
getBotUpdateAt: async () => 1000,
healthCheckIntervalMs: 100,
});
const connected = connectOnce();
socket.emitOpen();
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(300);
expect(socket.terminateCalls).toBe(0);
socket.emitClose(1000);
await connected;
vi.useRealTimers();
});
it("does not terminate when getBotUpdateAt throws", async () => {
vi.useFakeTimers();
const socket = new FakeWebSocket();
const runtime = testRuntime();
let shouldThrow = false;
const connectOnce = createMattermostConnectOnce({
wsUrl: "wss://example.invalid/api/v4/websocket",
botToken: "token",
runtime,
nextSeq: () => 1,
onPosted: async () => {},
webSocketFactory: () => socket,
getBotUpdateAt: async () => {
if (shouldThrow) throw new Error("network error");
return 1000;
},
healthCheckIntervalMs: 100,
});
const connected = connectOnce();
socket.emitOpen();
await vi.advanceTimersByTimeAsync(0);
// API error — should log but not terminate
shouldThrow = true;
await vi.advanceTimersByTimeAsync(100);
expect(socket.terminateCalls).toBe(0);
expect(runtime.error).toHaveBeenCalledWith(
"mattermost: health check error: Error: network error",
);
socket.emitClose(1000);
await connected;
vi.useRealTimers();
});
it("keeps polling when the initial getBotUpdateAt call fails", async () => {
vi.useFakeTimers();
const socket = new FakeWebSocket();
const runtime = testRuntime();
const responses: Array<number | Error> = [new Error("network error"), 1000, 2000];
const connectOnce = createMattermostConnectOnce({
wsUrl: "wss://example.invalid/api/v4/websocket",
botToken: "token",
runtime,
nextSeq: () => 1,
onPosted: async () => {},
webSocketFactory: () => socket,
getBotUpdateAt: async () => {
const next = responses.shift();
if (next instanceof Error) {
throw next;
}
return next ?? 2000;
},
healthCheckIntervalMs: 100,
});
const connected = connectOnce();
socket.emitOpen();
await vi.advanceTimersByTimeAsync(0);
expect(runtime.error).toHaveBeenCalledWith(
"mattermost: failed to get initial update_at: Error: network error",
);
await vi.advanceTimersByTimeAsync(100);
expect(socket.terminateCalls).toBe(0);
await vi.advanceTimersByTimeAsync(100);
expect(socket.terminateCalls).toBe(1);
expect(runtime.log).toHaveBeenCalledWith(
"mattermost: bot account updated (update_at changed: 1000 → 2000) — reconnecting",
);
socket.emitClose(1006);
await connected;
vi.useRealTimers();
});
it("does not overlap health checks when a prior poll is still running", async () => {
vi.useFakeTimers();
const socket = new FakeWebSocket();
const resolvers: Array<(value: number) => void> = [];
let pollCount = 0;
const connectOnce = createMattermostConnectOnce({
wsUrl: "wss://example.invalid/api/v4/websocket",
botToken: "token",
runtime: testRuntime(),
nextSeq: () => 1,
onPosted: async () => {},
webSocketFactory: () => socket,
getBotUpdateAt: async () => {
pollCount++;
return await new Promise<number>((resolve) => {
resolvers.push(resolve);
});
},
healthCheckIntervalMs: 100,
});
const connected = connectOnce();
socket.emitOpen();
await vi.advanceTimersByTimeAsync(0);
expect(pollCount).toBe(1);
await vi.advanceTimersByTimeAsync(300);
expect(pollCount).toBe(1);
resolvers[0]?.(1000);
await vi.advanceTimersByTimeAsync(0);
await vi.advanceTimersByTimeAsync(100);
expect(pollCount).toBe(2);
socket.emitClose(1000);
await connected;
vi.useRealTimers();
});
});
@@ -0,0 +1,329 @@
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
import { z } from "openclaw/plugin-sdk/zod";
import WebSocket from "ws";
import { MattermostPostSchema, type MattermostPost } from "./client.js";
import { rawDataToString } from "./monitor-helpers.js";
import type { ChannelAccountSnapshot, RuntimeEnv } from "./runtime-api.js";
export type MattermostEventPayload = {
event?: string;
data?: {
post?: string | MattermostPost;
reaction?: string | Record<string, unknown>;
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;
const MattermostEventPayloadSchema = z.object({
event: z.string().optional(),
data: z
.object({
post: z.union([z.string(), MattermostPostSchema]).optional(),
reaction: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
channel_id: z.string().optional(),
channel_name: z.string().optional(),
channel_display_name: z.string().optional(),
channel_type: z.string().optional(),
sender_name: z.string().optional(),
team_id: z.string().optional(),
})
.optional(),
broadcast: z
.object({
channel_id: z.string().optional(),
team_id: z.string().optional(),
user_id: z.string().optional(),
})
.optional(),
}) as z.ZodType<MattermostEventPayload>;
function parseMattermostEventPayload(raw: string): MattermostEventPayload | null {
return safeParseJsonWithSchema(MattermostEventPayloadSchema, raw);
}
function parseMattermostPost(value: unknown): MattermostPost | null {
if (typeof value === "string") {
return safeParseJsonWithSchema(MattermostPostSchema, value);
}
return safeParseWithSchema(MattermostPostSchema, value);
}
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;
/**
* Called periodically to check whether the bot account has been modified
* (e.g. disabled then re-enabled) since the WebSocket was opened.
* Returns the bot's current `update_at` timestamp. When it differs from
* the value recorded at connect time, the connection is terminated so the
* reconnect loop can establish a fresh one.
*/
getBotUpdateAt?: () => Promise<number>;
healthCheckIntervalMs?: number;
};
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;
}
const post = parseMattermostPost(postData);
if (!post) {
return null;
}
return { payload, post };
}
export function parsePostedEvent(
data: WebSocket.RawData,
): { payload: MattermostEventPayload; post: MattermostPost } | null {
const raw = rawDataToString(data);
const payload = parseMattermostEventPayload(raw);
if (!payload) {
return null;
}
return parsePostedPayload(payload);
}
export function createMattermostConnectOnce(
opts: CreateMattermostConnectOnceOpts,
): () => Promise<void> {
const webSocketFactory = opts.webSocketFactory ?? defaultMattermostWebSocketFactory;
const healthCheckIntervalMs = opts.healthCheckIntervalMs ?? 30_000;
return async () => {
const ws = webSocketFactory(opts.wsUrl);
const onAbort = () => ws.terminate();
opts.abortSignal?.addEventListener("abort", onAbort, { once: true });
const getBotUpdateAt = opts.getBotUpdateAt;
try {
return await new Promise<void>((resolve, reject) => {
let opened = false;
let settled = false;
let healthCheckEnabled = getBotUpdateAt != null;
let healthCheckInFlight = false;
let healthCheckTimer: ReturnType<typeof setTimeout> | undefined;
let initialUpdateAt: number | undefined;
const clearTimers = () => {
if (healthCheckTimer !== undefined) {
clearTimeout(healthCheckTimer);
healthCheckTimer = undefined;
}
};
const stopHealthChecks = () => {
healthCheckEnabled = false;
clearTimers();
};
const scheduleHealthCheck = () => {
if (!getBotUpdateAt || !healthCheckEnabled || settled || healthCheckInFlight) {
return;
}
healthCheckTimer = setTimeout(() => {
healthCheckTimer = undefined;
void runHealthCheck();
}, healthCheckIntervalMs);
};
const runHealthCheck = async () => {
if (!getBotUpdateAt || !healthCheckEnabled || settled || healthCheckInFlight) {
return;
}
healthCheckInFlight = true;
try {
const current = await getBotUpdateAt();
if (!healthCheckEnabled || settled) {
return;
}
if (initialUpdateAt === undefined) {
initialUpdateAt = current;
return;
}
if (current !== initialUpdateAt) {
opts.runtime.log?.(
`mattermost: bot account updated (update_at changed: ${initialUpdateAt}${current}) — reconnecting`,
);
stopHealthChecks();
ws.terminate();
}
} catch (err) {
if (!healthCheckEnabled || settled) {
return;
}
const label =
initialUpdateAt === undefined
? "mattermost: failed to get initial update_at"
: "mattermost: health check error";
opts.runtime.error?.(`${label}: ${String(err)}`);
} finally {
healthCheckInFlight = false;
scheduleHealthCheck();
}
};
const resolveOnce = () => {
if (settled) {
return;
}
settled = true;
stopHealthChecks();
resolve();
};
const rejectOnce = (error: Error) => {
if (settled) {
return;
}
settled = true;
stopHealthChecks();
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 },
}),
);
// Periodically check if the bot account was modified (e.g. disable/enable).
// After such a cycle the WebSocket silently stops delivering events even
// though the connection itself stays alive. Comparing update_at detects
// this reliably regardless of how quickly the cycle happens.
if (getBotUpdateAt) {
// Use a recursive timeout so only one REST poll can be in flight at a time.
void runHealthCheck();
}
});
ws.on("message", async (data) => {
const raw = rawDataToString(data);
const payload = parseMattermostEventPayload(raw);
if (!payload) {
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) => {
stopHealthChecks();
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") : "";
}
@@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { resolveControlCommandGate } from "../../runtime-api.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import {
authorizeMattermostCommandInvocation,
resolveMattermostEffectiveAllowFromLists,
} from "./monitor-auth.js";
const accountFixture: ResolvedMattermostAccount = {
accountId: "default",
enabled: true,
// pragma: allowlist secret
botToken: "bot-token",
baseUrl: "https://chat.example.com",
botTokenSource: "config",
baseUrlSource: "config",
config: {},
};
function authorizeGroupCommand(senderId: string) {
return authorizeMattermostCommandInvocation({
account: {
...accountFixture,
config: {
groupPolicy: "allowlist",
allowFrom: ["trusted-user"],
},
},
cfg: {
commands: {
useAccessGroups: true,
},
},
senderId,
senderName: senderId,
channelId: "chan-1",
channelInfo: {
id: "chan-1",
type: "O",
name: "general",
display_name: "General",
},
storeAllowFrom: [],
allowTextCommands: true,
hasControlCommand: true,
});
}
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);
});
it("denies group control commands when the sender is outside the allowlist", () => {
const decision = authorizeGroupCommand("attacker");
expect(decision).toMatchObject({
ok: false,
denyReason: "unauthorized",
kind: "channel",
});
});
it("authorizes group control commands for allowlisted senders", () => {
const decision = authorizeGroupCommand("trusted-user");
expect(decision).toMatchObject({
ok: true,
commandAuthorized: true,
kind: "channel",
});
});
});
@@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { mapMattermostChannelTypeToChatType } from "./monitor.js";
describe("mapMattermostChannelTypeToChatType", () => {
it("maps direct and group dm channel types", () => {
expect(mapMattermostChannelTypeToChatType("D")).toBe("direct");
expect(mapMattermostChannelTypeToChatType("g")).toBe("group");
});
it("maps private channels to group", () => {
expect(mapMattermostChannelTypeToChatType("P")).toBe("group");
expect(mapMattermostChannelTypeToChatType(" p ")).toBe("group");
});
it("keeps public channels and unknown values as channel", () => {
expect(mapMattermostChannelTypeToChatType("O")).toBe("channel");
expect(mapMattermostChannelTypeToChatType("x")).toBe("channel");
expect(mapMattermostChannelTypeToChatType(undefined)).toBe("channel");
});
});
@@ -0,0 +1,300 @@
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig } from "../../runtime-api.js";
import { resolveMattermostAccount } from "./accounts.js";
import {
evaluateMattermostMentionGate,
resolveMattermostReactionChannelId,
resolveMattermostEffectiveReplyToId,
resolveMattermostReplyRootId,
resolveMattermostThreadSessionContext,
type MattermostMentionGateInput,
type MattermostRequireMentionResolverInput,
} from "./monitor.js";
function resolveRequireMentionForTest(params: MattermostRequireMentionResolverInput): boolean {
const root = params.cfg.channels?.mattermost;
const accountGroups = root?.accounts?.[params.accountId]?.groups;
const groups = accountGroups ?? root?.groups;
const groupConfig = params.groupId ? groups?.[params.groupId] : undefined;
const defaultGroupConfig = groups?.["*"];
const configMention =
typeof groupConfig?.requireMention === "boolean"
? groupConfig.requireMention
: typeof defaultGroupConfig?.requireMention === "boolean"
? defaultGroupConfig.requireMention
: undefined;
if (typeof configMention === "boolean") {
return configMention;
}
if (typeof params.requireMentionOverride === "boolean") {
return params.requireMentionOverride;
}
return true;
}
function evaluateMentionGateForMessage(params: { cfg: OpenClawConfig; threadRootId?: string }) {
const account = resolveMattermostAccount({ cfg: params.cfg, accountId: "default" });
const resolver = vi.fn(resolveRequireMentionForTest);
const input: MattermostMentionGateInput = {
kind: "channel",
cfg: params.cfg,
accountId: account.accountId,
channelId: "chan-1",
threadRootId: params.threadRootId,
requireMentionOverride: account.requireMention,
resolveRequireMention: resolver,
wasMentioned: false,
isControlCommand: false,
commandAuthorized: false,
oncharEnabled: false,
oncharTriggered: false,
canDetectMention: true,
};
const decision = evaluateMattermostMentionGate(input);
return { account, resolver, decision };
}
describe("mattermost mention gating", () => {
it("accepts unmentioned root channel posts in onmessage mode", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
chatmode: "onmessage",
groupPolicy: "open",
},
},
};
const { resolver, decision } = evaluateMentionGateForMessage({ cfg });
expect(decision.dropReason).toBeNull();
expect(decision.shouldRequireMention).toBe(false);
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({
accountId: "default",
groupId: "chan-1",
requireMentionOverride: false,
}),
);
});
it("accepts unmentioned thread replies in onmessage mode", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
chatmode: "onmessage",
groupPolicy: "open",
},
},
};
const { resolver, decision } = evaluateMentionGateForMessage({
cfg,
threadRootId: "thread-root-1",
});
expect(decision.dropReason).toBeNull();
expect(decision.shouldRequireMention).toBe(false);
const resolverCall = resolver.mock.calls.at(-1)?.[0];
expect(resolverCall?.groupId).toBe("chan-1");
expect(resolverCall?.groupId).not.toBe("thread-root-1");
});
it("rejects unmentioned channel posts in oncall mode", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
chatmode: "oncall",
groupPolicy: "open",
},
},
};
const { decision, account } = evaluateMentionGateForMessage({ cfg });
expect(account.requireMention).toBe(true);
expect(decision.shouldRequireMention).toBe(true);
expect(decision.dropReason).toBe("missing-mention");
});
});
describe("resolveMattermostReplyRootId with block streaming payloads", () => {
it("uses threadRootId for block-streamed payloads with replyToId", () => {
// When block streaming sends a payload with replyToId from the threading
// mode, the deliver callback should still use the existing threadRootId.
expect(
resolveMattermostReplyRootId({
threadRootId: "thread-root-1",
replyToId: "streamed-reply-id",
}),
).toBe("thread-root-1");
});
it("falls back to payload replyToId when no threadRootId in block streaming", () => {
// Top-level channel message: no threadRootId, payload carries the
// inbound post id as replyToId from the "all" threading mode.
expect(
resolveMattermostReplyRootId({
replyToId: "inbound-post-for-threading",
}),
).toBe("inbound-post-for-threading");
});
});
describe("resolveMattermostReplyRootId", () => {
it("uses replyToId for top-level replies", () => {
expect(
resolveMattermostReplyRootId({
replyToId: "inbound-post-123",
}),
).toBe("inbound-post-123");
});
it("keeps the thread root when replying inside an existing thread", () => {
expect(
resolveMattermostReplyRootId({
threadRootId: "thread-root-456",
replyToId: "child-post-789",
}),
).toBe("thread-root-456");
});
it("falls back to undefined when neither reply target is available", () => {
expect(resolveMattermostReplyRootId({})).toBeUndefined();
});
});
describe("resolveMattermostEffectiveReplyToId", () => {
it("keeps an existing thread root", () => {
expect(
resolveMattermostEffectiveReplyToId({
kind: "channel",
postId: "post-123",
replyToMode: "all",
threadRootId: "thread-root-456",
}),
).toBe("thread-root-456");
});
it("suppresses existing thread roots when replyToMode is off", () => {
expect(
resolveMattermostEffectiveReplyToId({
kind: "channel",
postId: "post-123",
replyToMode: "off",
threadRootId: "thread-root-456",
}),
).toBeUndefined();
});
it("starts a thread for top-level channel messages when replyToMode is all", () => {
expect(
resolveMattermostEffectiveReplyToId({
kind: "channel",
postId: "post-123",
replyToMode: "all",
}),
).toBe("post-123");
});
it("starts a thread for top-level group messages when replyToMode is first", () => {
expect(
resolveMattermostEffectiveReplyToId({
kind: "group",
postId: "post-123",
replyToMode: "first",
}),
).toBe("post-123");
});
it("keeps direct messages non-threaded", () => {
expect(
resolveMattermostEffectiveReplyToId({
kind: "direct",
postId: "post-123",
replyToMode: "all",
}),
).toBeUndefined();
});
});
describe("resolveMattermostThreadSessionContext", () => {
it("forks channel sessions by top-level post when replyToMode is all", () => {
expect(
resolveMattermostThreadSessionContext({
baseSessionKey: "agent:main:mattermost:default:chan-1",
kind: "channel",
postId: "post-123",
replyToMode: "all",
}),
).toEqual({
effectiveReplyToId: "post-123",
sessionKey: "agent:main:mattermost:default:chan-1:thread:post-123",
parentSessionKey: "agent:main:mattermost:default:chan-1",
});
});
it("keeps existing thread roots for threaded follow-ups", () => {
expect(
resolveMattermostThreadSessionContext({
baseSessionKey: "agent:main:mattermost:default:chan-1",
kind: "group",
postId: "post-123",
replyToMode: "first",
threadRootId: "root-456",
}),
).toEqual({
effectiveReplyToId: "root-456",
sessionKey: "agent:main:mattermost:default:chan-1:thread:root-456",
parentSessionKey: "agent:main:mattermost:default:chan-1",
});
});
it("keeps threaded messages top-level when replyToMode is off", () => {
expect(
resolveMattermostThreadSessionContext({
baseSessionKey: "agent:main:mattermost:default:chan-1",
kind: "group",
postId: "post-123",
replyToMode: "off",
threadRootId: "root-456",
}),
).toEqual({
effectiveReplyToId: undefined,
sessionKey: "agent:main:mattermost:default:chan-1",
parentSessionKey: undefined,
});
});
it("keeps direct-message sessions linear", () => {
expect(
resolveMattermostThreadSessionContext({
baseSessionKey: "agent:main:mattermost:default:user-1",
kind: "direct",
postId: "post-123",
replyToMode: "all",
}),
).toEqual({
effectiveReplyToId: undefined,
sessionKey: "agent:main:mattermost:default:user-1",
parentSessionKey: undefined,
});
});
});
describe("resolveMattermostReactionChannelId", () => {
it("prefers broadcast channel_id when present", () => {
expect(
resolveMattermostReactionChannelId({
broadcast: { channel_id: "chan-broadcast" },
data: { channel_id: "chan-data" },
}),
).toBe("chan-broadcast");
});
it("falls back to data.channel_id when broadcast channel_id is missing", () => {
expect(
resolveMattermostReactionChannelId({
data: { channel_id: "chan-data" },
}),
).toBe("chan-data");
});
it("returns undefined when neither payload location includes channel_id", () => {
expect(resolveMattermostReactionChannelId({})).toBeUndefined();
});
});
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,136 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { probeMattermost } from "./probe.js";
const { mockFetchGuard, mockRelease } = vi.hoisted(() => ({
mockFetchGuard: vi.fn(),
mockRelease: vi.fn(async () => {}),
}));
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
const original = (await vi.importActual("openclaw/plugin-sdk/ssrf-runtime")) as Record<
string,
unknown
>;
return { ...original, fetchWithSsrFGuard: mockFetchGuard };
});
describe("probeMattermost", () => {
beforeEach(() => {
mockFetchGuard.mockReset();
mockRelease.mockClear();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("returns baseUrl missing for empty base URL", async () => {
await expect(probeMattermost(" ", "token")).resolves.toEqual({
ok: false,
error: "baseUrl missing",
});
expect(mockFetchGuard).not.toHaveBeenCalled();
});
it("normalizes base URL and returns bot info", async () => {
mockFetchGuard.mockResolvedValueOnce({
response: new Response(JSON.stringify({ id: "bot-1", username: "clawbot" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
release: mockRelease,
});
// pragma: allowlist secret
const result = await probeMattermost("https://mm.example.com/api/v4/", "bot-token");
expect(mockFetchGuard).toHaveBeenCalledWith({
url: "https://mm.example.com/api/v4/users/me",
init: expect.objectContaining({
headers: { Authorization: "Bearer bot-token" },
}),
auditContext: "mattermost-probe",
policy: undefined,
});
expect(result).toEqual(
expect.objectContaining({
ok: true,
status: 200,
bot: { id: "bot-1", username: "clawbot" },
}),
);
expect(result.elapsedMs).toBeGreaterThanOrEqual(0);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("forwards allowPrivateNetwork to the SSRF guard policy", async () => {
mockFetchGuard.mockResolvedValueOnce({
response: new Response(JSON.stringify({ id: "bot-1" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
release: mockRelease,
});
// pragma: allowlist secret
await probeMattermost("https://mm.example.com", "bot-token", 2500, true);
expect(mockFetchGuard).toHaveBeenCalledWith(
expect.objectContaining({
policy: { allowPrivateNetwork: true },
}),
);
});
it("returns API error details from JSON response", async () => {
mockFetchGuard.mockResolvedValueOnce({
response: new Response(JSON.stringify({ message: "invalid auth token" }), {
status: 401,
statusText: "Unauthorized",
headers: { "content-type": "application/json" },
}),
release: mockRelease,
});
await expect(probeMattermost("https://mm.example.com", "bad-token")).resolves.toEqual(
expect.objectContaining({
ok: false,
status: 401,
error: "invalid auth token",
}),
);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("falls back to statusText when error body is empty", async () => {
mockFetchGuard.mockResolvedValueOnce({
response: new Response("", {
status: 403,
statusText: "Forbidden",
headers: { "content-type": "text/plain" },
}),
release: mockRelease,
});
await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual(
expect.objectContaining({
ok: false,
status: 403,
error: "Forbidden",
}),
);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("returns fetch error when request throws", async () => {
mockFetchGuard.mockRejectedValueOnce(new Error("network down"));
await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual(
expect.objectContaining({
ok: false,
status: null,
error: "network down",
}),
);
});
});
@@ -0,0 +1,72 @@
import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js";
import type { BaseProbeResult } from "./runtime-api.js";
export type MattermostProbe = BaseProbeResult & {
status?: number | null;
elapsedMs?: number | null;
bot?: MattermostUser;
};
export async function probeMattermost(
baseUrl: string,
botToken: string,
timeoutMs = 2500,
allowPrivateNetwork = false,
): 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 { response: res, release } = await fetchWithSsrFGuard({
url,
init: {
headers: { Authorization: `Bearer ${botToken}` },
signal: controller?.signal,
},
auditContext: "mattermost-probe",
policy: allowPrivateNetwork ? { allowPrivateNetwork: true } : undefined,
});
try {
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,
};
} finally {
await release();
}
} 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);
}
}
}
@@ -0,0 +1,88 @@
import { expect, vi } from "vitest";
import type { OpenClawConfig } from "../../runtime-api.js";
import type { MattermostFetch } from "./client.js";
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<typeof fetch>(async (url, init) => {
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");
const requestBody = init?.body;
if (typeof requestBody !== "string") {
throw new Error("expected string POST body");
}
expect(JSON.parse(requestBody)).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: MattermostFetch,
run: () => Promise<T>,
): Promise<T> {
const prevFetch = globalThis.fetch;
globalThis.fetch = fetchImpl;
try {
return await run();
} finally {
globalThis.fetch = prevFetch;
}
}
@@ -0,0 +1,106 @@
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();
});
async function addReactionWithFetch(fetchMock: typeof fetch) {
return addMattermostReaction({
cfg: createMattermostTestConfig(),
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock,
});
}
async function removeReactionWithFetch(fetchMock: typeof fetch) {
return removeMattermostReaction({
cfg: createMattermostTestConfig(),
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock,
});
}
it("adds reactions by calling /users/me then POST /reactions", async () => {
const fetchMock = createMattermostReactionFetchMock({
mode: "add",
postId: "POST1",
emojiName: "thumbsup",
});
const result = await addReactionWithFetch(fetchMock);
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 addReactionWithFetch(fetchMock);
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 removeReactionWithFetch(fetchMock);
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,
});
const removeResult = await removeMattermostReaction({
cfg,
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock,
});
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);
});
});
@@ -0,0 +1,130 @@
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
fetchMattermostMe,
type MattermostClient,
type MattermostFetch,
} from "./client.js";
import type { OpenClawConfig } from "./runtime-api.js";
type Result = { ok: true } | { ok: false; error: string };
type ReactionParams = {
cfg: OpenClawConfig;
postId: string;
emojiName: string;
accountId?: string | null;
fetchImpl?: MattermostFetch;
};
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?: MattermostFetch;
}): Promise<Result> {
return runMattermostReaction(params, {
action: "add",
mutation: createReaction,
});
}
export async function removeMattermostReaction(params: {
cfg: OpenClawConfig;
postId: string;
emojiName: string;
accountId?: string | null;
fetchImpl?: MattermostFetch;
}): 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,
allowPrivateNetwork: resolved.config?.allowPrivateNetwork === true,
});
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",
},
);
}
@@ -0,0 +1,198 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { runWithReconnect } from "./reconnect.js";
beforeEach(() => {
vi.restoreAllMocks();
vi.useFakeTimers();
vi.clearAllMocks();
});
afterEach(() => {
vi.restoreAllMocks();
vi.useRealTimers();
});
async function resolveReconnectRun(promise: Promise<void>): Promise<void> {
await vi.runAllTimersAsync();
await promise;
}
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();
}
});
const run = runWithReconnect(connectFn, {
abortSignal: abort.signal,
initialDelayMs: 1,
});
await resolveReconnectRun(run);
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();
});
const run = runWithReconnect(connectFn, {
abortSignal: abort.signal,
onError,
initialDelayMs: 1,
});
await resolveReconnectRun(run);
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");
});
const run = runWithReconnect(connectFn, {
abortSignal: abort.signal,
onReconnect: (delayMs) => delays.push(delayMs),
initialDelayMs: 1,
maxDelayMs: 10,
});
await resolveReconnectRun(run);
expect(connectFn).toHaveBeenCalledTimes(6);
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;
}
if (callCount === 3) {
throw new Error("second failure");
}
abort.abort();
});
const run = runWithReconnect(connectFn, {
abortSignal: abort.signal,
onReconnect: (delayMs) => delays.push(delayMs),
initialDelayMs: 1,
maxDelayMs: 60_000,
});
await resolveReconnectRun(run);
expect(connectFn).toHaveBeenCalledTimes(4);
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 () => {
setTimeout(() => abort.abort(), 10);
});
const run = runWithReconnect(connectFn, {
abortSignal: abort.signal,
initialDelayMs: 60_000,
});
await resolveReconnectRun(run);
expect(connectFn).toHaveBeenCalledTimes(1);
});
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();
});
const run = runWithReconnect(connectFn, {
abortSignal: abort.signal,
onReconnect: (delayMs) => delays.push(delayMs),
initialDelayMs: 10,
jitterRatio: 0.5,
random: () => 1,
});
await resolveReconnectRun(run);
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();
});
});
@@ -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 });
});
}
@@ -0,0 +1,9 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../../runtime-api.js";
import { deliverMattermostReplyPayload } from "./reply-delivery.js";
describe("reply-delivery placeholder", () => {
it("placeholder test", () => {
expect(true).toBe(true);
});
});
@@ -0,0 +1,95 @@
import {
deliverTextOrMediaReply,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import {
getAgentScopedMediaLocalRoots,
type OpenClawConfig,
type PluginRuntime,
type ReplyPayload,
} from "./runtime-api.js";
import { compileMattermostInteractiveReplies } from "../interactive-replies.js";
type MarkdownTableMode = Parameters<PluginRuntime["channel"]["text"]["convertMarkdownTables"]>[1];
type SendMattermostMessage = (
to: string,
text: string,
opts: {
cfg?: OpenClawConfig;
accountId?: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
replyToId?: string;
buttons?: Array<{ text: string; value: string; style?: string }>;
},
) => Promise<unknown>;
export async function deliverMattermostReplyPayload(params: {
core: PluginRuntime;
cfg: OpenClawConfig;
payload: ReplyPayload;
to: string;
accountId: string;
agentId?: string;
replyToId?: string;
textLimit: number;
tableMode: MarkdownTableMode;
sendMessage: SendMattermostMessage;
}): Promise<void> {
// Compile interactive directives (buttons, selects) from [[...]] syntax
const compiledPayload = compileMattermostInteractiveReplies(params.payload);
// Extract buttons from interactive blocks for Mattermost API
const buttons: Array<{ text: string; value: string; style?: string }> = [];
for (const block of compiledPayload.interactive?.blocks || []) {
if (block.type === "buttons" && "buttons" in block && Array.isArray(block.buttons)) {
for (const btn of block.buttons) {
if (btn && typeof btn === "object") {
buttons.push({
text: (btn as { label?: string }).label || "",
value: (btn as { value?: string }).value || "",
style: (btn as { style?: string }).style || "default",
});
}
}
}
}
const reply = resolveSendableOutboundReplyParts(compiledPayload, {
text: params.core.channel.text.convertMarkdownTables(
compiledPayload.text ?? "",
params.tableMode,
),
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
const chunkMode = params.core.channel.text.resolveChunkMode(
params.cfg,
"mattermost",
params.accountId,
);
await deliverTextOrMediaReply({
payload: compiledPayload,
text: reply.text,
chunkText: (value) =>
params.core.channel.text.chunkMarkdownTextWithMode(value, params.textLimit, chunkMode),
sendText: async (chunk) => {
await params.sendMessage(params.to, chunk, {
cfg: params.cfg,
accountId: params.accountId,
replyToId: params.replyToId,
buttons,
});
},
sendMedia: async ({ mediaUrl, caption }) => {
await params.sendMessage(params.to, caption ?? "", {
cfg: params.cfg,
accountId: params.accountId,
mediaUrl,
mediaLocalRoots,
replyToId: params.replyToId,
buttons,
});
},
});
}
@@ -0,0 +1 @@
export * from "../../runtime-api.js";
@@ -0,0 +1,642 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
expectProvidedCfgSkipsRuntimeLoad,
expectRuntimeCfgFallback,
} from "../../../../test/helpers/plugins/send-config.js";
let parseMattermostTarget: typeof import("./send.js").parseMattermostTarget;
let sendMessageMattermost: typeof import("./send.js").sendMessageMattermost;
let resetMattermostOpaqueTargetCacheForTests: typeof import("./target-resolution.js").resetMattermostOpaqueTargetCacheForTests;
type SendMessageMattermostOptions = NonNullable<
Parameters<typeof import("./send.js").sendMessageMattermost>[2]
>;
const mockState = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
loadOutboundMediaFromUrl: vi.fn(),
recordActivity: vi.fn(),
resolveMattermostAccount: vi.fn(() => ({
accountId: "default",
// pragma: allowlist secret
// pragma: allowlist secret
botToken: "bot-token",
baseUrl: "https://mattermost.example.com",
config: {},
})),
createMattermostClient: vi.fn(),
createMattermostDirectChannel: vi.fn(),
createMattermostDirectChannelWithRetry: vi.fn(),
createMattermostPost: vi.fn(),
fetchMattermostChannelByName: vi.fn(),
fetchMattermostMe: vi.fn(),
fetchMattermostUser: vi.fn(),
fetchMattermostUserTeams: vi.fn(),
fetchMattermostUserByUsername: vi.fn(),
normalizeMattermostBaseUrl: vi.fn((input: string | undefined) => input?.trim() ?? ""),
uploadMattermostFile: vi.fn(),
}));
vi.mock("../../runtime-api.js", () => ({
loadOutboundMediaFromUrl: mockState.loadOutboundMediaFromUrl,
}));
vi.mock("openclaw/plugin-sdk/config-runtime", () => ({
resolveMarkdownTableMode: vi.fn(() => "off"),
}));
vi.mock("openclaw/plugin-sdk/text-runtime", () => ({
convertMarkdownTables: vi.fn((text: string) => text),
}));
vi.mock("./accounts.js", () => ({
resolveMattermostAccount: mockState.resolveMattermostAccount,
}));
vi.mock("./client.js", () => ({
createMattermostClient: mockState.createMattermostClient,
createMattermostDirectChannel: mockState.createMattermostDirectChannel,
createMattermostDirectChannelWithRetry: mockState.createMattermostDirectChannelWithRetry,
createMattermostPost: mockState.createMattermostPost,
fetchMattermostChannelByName: mockState.fetchMattermostChannelByName,
fetchMattermostMe: mockState.fetchMattermostMe,
fetchMattermostUser: mockState.fetchMattermostUser,
fetchMattermostUserTeams: mockState.fetchMattermostUserTeams,
fetchMattermostUserByUsername: mockState.fetchMattermostUserByUsername,
normalizeMattermostBaseUrl: mockState.normalizeMattermostBaseUrl,
uploadMattermostFile: mockState.uploadMattermostFile,
}));
vi.mock("../runtime.js", () => ({
getMattermostRuntime: () => ({
config: {
loadConfig: mockState.loadConfig,
},
logging: {
shouldLogVerbose: () => false,
getChildLogger: () => ({ debug: vi.fn(), info: vi.fn(), warn: vi.fn(), error: vi.fn() }),
},
channel: {
text: {
resolveMarkdownTableMode: () => "off",
convertMarkdownTables: (text: string) => text,
},
activity: {
record: mockState.recordActivity,
},
},
}),
}));
describe("sendMessageMattermost", () => {
beforeEach(async () => {
vi.resetModules();
mockState.loadConfig.mockReset();
mockState.loadConfig.mockReturnValue({});
mockState.recordActivity.mockReset();
mockState.resolveMattermostAccount.mockReset();
mockState.resolveMattermostAccount.mockReturnValue({
accountId: "default",
// pragma: allowlist secret
botToken: "bot-token",
baseUrl: "https://mattermost.example.com",
config: {},
});
mockState.loadOutboundMediaFromUrl.mockReset();
mockState.createMattermostClient.mockReset();
mockState.createMattermostDirectChannel.mockReset();
mockState.createMattermostDirectChannelWithRetry.mockReset();
mockState.createMattermostPost.mockReset();
mockState.fetchMattermostChannelByName.mockReset();
mockState.fetchMattermostMe.mockReset();
mockState.fetchMattermostUser.mockReset();
mockState.fetchMattermostUserTeams.mockReset();
mockState.fetchMattermostUserByUsername.mockReset();
mockState.uploadMattermostFile.mockReset();
mockState.createMattermostClient.mockReturnValue({});
mockState.createMattermostPost.mockResolvedValue({ id: "post-1" });
mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-1" });
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-user" });
mockState.fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }]);
mockState.fetchMattermostChannelByName.mockResolvedValue({ id: "town-square" });
mockState.uploadMattermostFile.mockResolvedValue({ id: "file-1" });
({ parseMattermostTarget, sendMessageMattermost } = await import("./send.js"));
({ resetMattermostOpaqueTargetCacheForTests } = await import("./target-resolution.js"));
resetMattermostOpaqueTargetCacheForTests();
});
it("uses provided cfg and skips runtime loadConfig", async () => {
const providedCfg = {
channels: {
mattermost: {
botToken: "provided-token",
},
},
};
mockState.resolveMattermostAccount.mockReturnValue({
accountId: "work",
botToken: "provided-token",
baseUrl: "https://mattermost.example.com",
config: {},
});
const options: SendMessageMattermostOptions = {
cfg: providedCfg,
accountId: "work",
};
await sendMessageMattermost("channel:town-square", "hello", {
...options,
});
expectProvidedCfgSkipsRuntimeLoad({
loadConfig: mockState.loadConfig,
resolveAccount: mockState.resolveMattermostAccount,
cfg: providedCfg,
accountId: "work",
});
});
it("falls back to runtime loadConfig when cfg is omitted", async () => {
const runtimeCfg = {
channels: {
mattermost: {
botToken: "runtime-token",
},
},
};
mockState.loadConfig.mockReturnValueOnce(runtimeCfg);
mockState.resolveMattermostAccount.mockReturnValue({
accountId: "default",
botToken: "runtime-token",
baseUrl: "https://mattermost.example.com",
config: {},
});
await sendMessageMattermost("channel:town-square", "hello");
expectRuntimeCfgFallback({
loadConfig: mockState.loadConfig,
resolveAccount: mockState.resolveMattermostAccount,
cfg: runtimeCfg,
accountId: undefined,
});
});
it("sends with provided cfg even when the runtime store is not initialized", async () => {
const providedCfg = {
channels: {
mattermost: {
botToken: "provided-token",
},
},
};
mockState.resolveMattermostAccount.mockReturnValue({
accountId: "work",
botToken: "provided-token",
baseUrl: "https://mattermost.example.com",
config: {},
});
mockState.recordActivity.mockImplementation(() => {
throw new Error("Mattermost runtime not initialized");
});
await expect(
sendMessageMattermost("channel:town-square", "hello", {
cfg: providedCfg,
accountId: "work",
}),
).resolves.toEqual({
messageId: "post-1",
channelId: "town-square",
});
expect(mockState.loadConfig).not.toHaveBeenCalled();
});
it("loads outbound media with trusted local roots before upload", async () => {
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
buffer: Buffer.from("media-bytes"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
});
mockState.resolveMattermostAccount.mockReturnValue({
accountId: "default",
// pragma: allowlist secret
botToken: "bot-token",
baseUrl: "https://mattermost.example.com",
config: {},
});
await sendMessageMattermost("channel:town-square", "hello", {
mediaUrl: "file:///tmp/agent-workspace/photo.png",
mediaLocalRoots: ["/tmp/agent-workspace"],
});
expect(mockState.loadOutboundMediaFromUrl).toHaveBeenCalledWith(
"file:///tmp/agent-workspace/photo.png",
{
mediaLocalRoots: ["/tmp/agent-workspace"],
},
);
expect(mockState.uploadMattermostFile).toHaveBeenCalledWith(
{},
expect.objectContaining({
channelId: "town-square",
fileName: "photo.png",
contentType: "image/png",
}),
);
});
it("builds interactive button props when buttons are provided", async () => {
mockState.resolveMattermostAccount.mockReturnValue({
accountId: "default",
// pragma: allowlist secret
botToken: "bot-token",
baseUrl: "https://mattermost.example.com",
config: {},
});
await sendMessageMattermost("channel:town-square", "Pick a model", {
buttons: [[{ callback_data: "mdlprov", text: "Browse providers" }]],
});
expect(mockState.createMattermostPost).toHaveBeenCalledWith(
{},
expect.objectContaining({
channelId: "town-square",
message: "Pick a model",
props: expect.objectContaining({
attachments: expect.arrayContaining([
expect.objectContaining({
actions: expect.arrayContaining([
expect.objectContaining({
id: "mdlprov",
name: "Browse providers",
}),
]),
}),
]),
}),
}),
);
});
it("resolves a bare Mattermost user id as a DM target before upload", async () => {
const userId = "dthcxgoxhifn3pwh65cut3ud3w";
mockState.resolveMattermostAccount.mockReturnValue({
accountId: "default",
// pragma: allowlist secret
botToken: "bot-token",
baseUrl: "https://mattermost.example.com",
config: {},
});
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
buffer: Buffer.from("media-bytes"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
});
const result = await sendMessageMattermost(userId, "hello", {
mediaUrl: "file:///tmp/agent-workspace/photo.png",
mediaLocalRoots: ["/tmp/agent-workspace"],
});
expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, userId);
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith(
{},
["bot-user", userId],
expect.any(Object),
);
expect(mockState.uploadMattermostFile).toHaveBeenCalledWith(
{},
expect.objectContaining({
channelId: "dm-channel-1",
}),
);
expect(result.channelId).toBe("dm-channel-1");
});
it("falls back to a channel target when bare Mattermost id is not a user", async () => {
const channelId = "aaaaaaaaaaaaaaaaaaaaaaaaaa";
mockState.resolveMattermostAccount.mockReturnValue({
accountId: "default",
// pragma: allowlist secret
botToken: "bot-token",
baseUrl: "https://mattermost.example.com",
config: {},
});
mockState.fetchMattermostUser.mockRejectedValueOnce(
new Error("Mattermost API 404 Not Found: user not found"),
);
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
buffer: Buffer.from("media-bytes"),
fileName: "photo.png",
contentType: "image/png",
kind: "image",
});
const result = await sendMessageMattermost(channelId, "hello", {
mediaUrl: "file:///tmp/agent-workspace/photo.png",
mediaLocalRoots: ["/tmp/agent-workspace"],
});
expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, channelId);
expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled();
expect(mockState.uploadMattermostFile).toHaveBeenCalledWith(
{},
expect.objectContaining({
channelId,
}),
);
expect(result.channelId).toBe(channelId);
});
});
describe("parseMattermostTarget", () => {
it("parses channel: prefix with valid ID as channel id", () => {
const target = parseMattermostTarget("channel:dthcxgoxhifn3pwh65cut3ud3w");
expect(target).toEqual({ kind: "channel", id: "dthcxgoxhifn3pwh65cut3ud3w" });
});
it("parses channel: prefix with non-ID as channel name", () => {
const target = parseMattermostTarget("channel:abc123");
expect(target).toEqual({ kind: "channel-name", name: "abc123" });
});
it("parses user: prefix as user id", () => {
const target = parseMattermostTarget("user:usr456");
expect(target).toEqual({ kind: "user", id: "usr456" });
});
it("parses mattermost: prefix as user id", () => {
const target = parseMattermostTarget("mattermost:usr789");
expect(target).toEqual({ kind: "user", id: "usr789" });
});
it("parses @ prefix as username", () => {
const target = parseMattermostTarget("@alice");
expect(target).toEqual({ kind: "user", username: "alice" });
});
it("parses # prefix as channel name", () => {
const target = parseMattermostTarget("#off-topic");
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
});
it("parses # prefix with spaces", () => {
const target = parseMattermostTarget(" #general ");
expect(target).toEqual({ kind: "channel-name", name: "general" });
});
it("treats 26-char alphanumeric bare string as channel id", () => {
const target = parseMattermostTarget("dthcxgoxhifn3pwh65cut3ud3w");
expect(target).toEqual({ kind: "channel", id: "dthcxgoxhifn3pwh65cut3ud3w" });
});
it("treats non-ID bare string as channel name", () => {
const target = parseMattermostTarget("off-topic");
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
});
it("treats channel: with non-ID value as channel name", () => {
const target = parseMattermostTarget("channel:off-topic");
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
});
it("throws on empty string", () => {
expect(() => parseMattermostTarget("")).toThrow("Recipient is required");
});
it("throws on empty # prefix", () => {
expect(() => parseMattermostTarget("#")).toThrow("Channel name is required");
});
it("throws on empty @ prefix", () => {
expect(() => parseMattermostTarget("@")).toThrow("Username is required");
});
it("parses channel:#name as channel name", () => {
const target = parseMattermostTarget("channel:#off-topic");
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
});
it("parses channel:#name with spaces", () => {
const target = parseMattermostTarget(" channel: #general ");
expect(target).toEqual({ kind: "channel-name", name: "general" });
});
it("is case-insensitive for prefixes", () => {
expect(parseMattermostTarget("CHANNEL:dthcxgoxhifn3pwh65cut3ud3w")).toEqual({
kind: "channel",
id: "dthcxgoxhifn3pwh65cut3ud3w",
});
expect(parseMattermostTarget("User:XYZ")).toEqual({ kind: "user", id: "XYZ" });
expect(parseMattermostTarget("Mattermost:QRS")).toEqual({ kind: "user", id: "QRS" });
});
});
// Each test uses a unique (token, id) pair to avoid module-level cache collisions.
// userIdResolutionCache and dmChannelCache are module singletons that survive across tests.
// Using unique cache keys per test ensures full isolation without needing a cache reset API.
describe("sendMessageMattermost user-first resolution", () => {
function makeAccount(token: string, config = {}) {
return {
accountId: "default",
botToken: token,
baseUrl: "https://mattermost.example.com",
config,
};
}
beforeEach(() => {
vi.clearAllMocks();
mockState.createMattermostClient.mockReturnValue({});
mockState.createMattermostPost.mockResolvedValue({ id: "post-id" });
mockState.createMattermostDirectChannel.mockResolvedValue({ id: "dm-channel-id" });
mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" });
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" });
});
it("resolves unprefixed 26-char id as user and sends via DM channel", async () => {
// Unique token + id to avoid cache pollution from other tests
const userId = "aaaaaa1111111111aaaaaa1111"; // 26 chars
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-user-dm-t1"));
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
const res = await sendMessageMattermost(userId, "hello");
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1);
const params = mockState.createMattermostPost.mock.calls[0]?.[1];
expect(params.channelId).toBe("dm-channel-id");
expect(res.channelId).toBe("dm-channel-id");
expect(res.messageId).toBe("post-id");
});
it("falls back to channel id when user lookup returns 404", async () => {
// Unique token + id for this test
const channelId = "bbbbbb2222222222bbbbbb2222"; // 26 chars
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-404-t2"));
const err = new Error("Mattermost API 404: user not found");
mockState.fetchMattermostUser.mockRejectedValueOnce(err);
const res = await sendMessageMattermost(channelId, "hello");
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled();
const params = mockState.createMattermostPost.mock.calls[0]?.[1];
expect(params.channelId).toBe(channelId);
expect(res.channelId).toBe(channelId);
});
it("falls back to channel id without caching negative result on transient error", async () => {
// Two unique tokens so each call has its own cache namespace
const userId = "cccccc3333333333cccccc3333"; // 26 chars
const tokenA = "token-transient-t3a";
const tokenB = "token-transient-t3b";
const transientErr = new Error("Mattermost API 503: service unavailable");
// First call: transient error → fall back to channel id, do NOT cache negative
mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenA));
mockState.fetchMattermostUser.mockRejectedValueOnce(transientErr);
const res1 = await sendMessageMattermost(userId, "first");
expect(res1.channelId).toBe(userId);
// Second call with a different token (new cache key) → retries user lookup
vi.clearAllMocks();
mockState.createMattermostClient.mockReturnValue({});
mockState.createMattermostPost.mockResolvedValue({ id: "post-id-2" });
mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" });
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" });
mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenB));
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
const res2 = await sendMessageMattermost(userId, "second");
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
expect(res2.channelId).toBe("dm-channel-id");
});
it("does not apply user-first resolution for explicit user: prefix", async () => {
// Unique token + id — explicit user: prefix bypasses probe, goes straight to DM
const userId = "dddddd4444444444dddddd4444"; // 26 chars
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-user-t4"));
mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" });
const res = await sendMessageMattermost(`user:${userId}`, "hello");
expect(mockState.fetchMattermostUser).not.toHaveBeenCalled();
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1);
expect(res.channelId).toBe("dm-channel-id");
});
it("does not apply user-first resolution for explicit channel: prefix", async () => {
// Unique token + id — explicit channel: prefix, no probe, no DM
const chanId = "eeeeee5555555555eeeeee5555"; // 26 chars
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-chan-t5"));
const res = await sendMessageMattermost(`channel:${chanId}`, "hello");
expect(mockState.fetchMattermostUser).not.toHaveBeenCalled();
expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled();
const params = mockState.createMattermostPost.mock.calls[0]?.[1];
expect(params.channelId).toBe(chanId);
expect(res.channelId).toBe(chanId);
});
it("passes dmRetryOptions from opts to createMattermostDirectChannelWithRetry", async () => {
const userId = "ffffff6666666666ffffff6666"; // 26 chars
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-retry-opts-t6"));
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
const retryOptions = {
maxRetries: 5,
initialDelayMs: 500,
maxDelayMs: 5000,
timeoutMs: 10000,
};
await sendMessageMattermost(`user:${userId}`, "hello", {
dmRetryOptions: retryOptions,
});
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith(
{},
["bot-id", userId],
expect.objectContaining(retryOptions),
);
});
it("uses dmChannelRetry from account config when opts.dmRetryOptions not provided", async () => {
const userId = "gggggg7777777777gggggg7777"; // 26 chars
mockState.resolveMattermostAccount.mockReturnValue({
accountId: "default",
botToken: "token-retry-config-t7",
baseUrl: "https://mattermost.example.com",
config: {
dmChannelRetry: {
maxRetries: 4,
initialDelayMs: 2000,
maxDelayMs: 8000,
timeoutMs: 15000,
},
},
});
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
await sendMessageMattermost(`user:${userId}`, "hello");
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith(
{},
["bot-id", userId],
expect.objectContaining({
maxRetries: 4,
initialDelayMs: 2000,
maxDelayMs: 8000,
timeoutMs: 15000,
}),
);
});
it("opts.dmRetryOptions overrides provided fields and preserves account defaults", async () => {
const userId = "hhhhhh8888888888hhhhhh8888"; // 26 chars
mockState.resolveMattermostAccount.mockReturnValue({
accountId: "default",
botToken: "token-retry-override-t8",
baseUrl: "https://mattermost.example.com",
config: {
dmChannelRetry: {
maxRetries: 2,
initialDelayMs: 1000,
},
},
});
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
const overrideOptions = {
maxRetries: 7,
timeoutMs: 20000,
};
await sendMessageMattermost(`user:${userId}`, "hello", {
dmRetryOptions: overrideOptions,
});
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith(
{},
["bot-id", userId],
expect.objectContaining(overrideOptions),
);
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith(
{},
["bot-id", userId],
expect.objectContaining({
initialDelayMs: 1000,
}),
);
});
});
@@ -0,0 +1,471 @@
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
import { getMattermostRuntime } from "../runtime.js";
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
createMattermostDirectChannelWithRetry,
createMattermostPost,
fetchMattermostChannelByName,
fetchMattermostMe,
fetchMattermostUserByUsername,
fetchMattermostUserTeams,
normalizeMattermostBaseUrl,
uploadMattermostFile,
type MattermostUser,
type CreateDmChannelRetryOptions,
} from "./client.js";
import {
buildButtonProps,
resolveInteractionCallbackUrl,
setInteractionSecret,
type MattermostInteractiveButtonInput,
} from "./interactions.js";
import { loadOutboundMediaFromUrl, type OpenClawConfig } from "./runtime-api.js";
import { isMattermostId, resolveMattermostOpaqueTarget } from "./target-resolution.js";
export type MattermostSendOpts = {
cfg?: OpenClawConfig;
botToken?: string;
baseUrl?: string;
accountId?: string;
mediaUrl?: string;
mediaLocalRoots?: readonly string[];
mediaReadFile?: (filePath: string) => Promise<Buffer>;
replyToId?: string;
props?: Record<string, unknown>;
buttons?: Array<unknown>;
attachmentText?: string;
/** Retry options for DM channel creation */
dmRetryOptions?: CreateDmChannelRetryOptions;
};
export type MattermostSendResult = {
messageId: string;
channelId: string;
};
export type MattermostReplyButtons = Array<
MattermostInteractiveButtonInput | MattermostInteractiveButtonInput[]
>;
type MattermostTarget =
| { kind: "channel"; id: string }
| { kind: "channel-name"; name: string }
| { kind: "user"; id?: string; username?: string };
const botUserCache = new Map<string, MattermostUser>();
const userByNameCache = new Map<string, MattermostUser>();
const channelByNameCache = new Map<string, string>();
const dmChannelCache = new Map<string, string>();
const getCore = () => getMattermostRuntime();
function recordMattermostOutboundActivity(accountId: string): void {
try {
getCore().channel.activity.record({
channel: "mattermost",
accountId,
direction: "outbound",
});
} catch (error) {
if (!(error instanceof Error) || error.message !== "Mattermost runtime not initialized") {
throw error;
}
}
}
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);
}
export 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");
}
if (id.startsWith("#")) {
const name = id.slice(1).trim();
if (!name) {
throw new Error("Channel name is required for Mattermost sends");
}
return { kind: "channel-name", name };
}
if (!isMattermostId(id)) {
return { kind: "channel-name", name: id };
}
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 };
}
if (trimmed.startsWith("#")) {
const name = trimmed.slice(1).trim();
if (!name) {
throw new Error("Channel name is required for Mattermost sends");
}
return { kind: "channel-name", name };
}
if (!isMattermostId(trimmed)) {
return { kind: "channel-name", name: trimmed };
}
return { kind: "channel", id: trimmed };
}
async function resolveBotUser(
baseUrl: string,
token: string,
allowPrivateNetwork?: boolean,
): Promise<MattermostUser> {
const key = cacheKey(baseUrl, token);
const cached = botUserCache.get(key);
if (cached) {
return cached;
}
const client = createMattermostClient({ baseUrl, botToken: token, allowPrivateNetwork });
const user = await fetchMattermostMe(client);
botUserCache.set(key, user);
return user;
}
async function resolveUserIdByUsername(params: {
baseUrl: string;
token: string;
username: string;
allowPrivateNetwork?: boolean;
}): 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,
allowPrivateNetwork: params.allowPrivateNetwork,
});
const user = await fetchMattermostUserByUsername(client, username);
userByNameCache.set(key, user);
return user.id;
}
async function resolveChannelIdByName(params: {
baseUrl: string;
token: string;
name: string;
allowPrivateNetwork?: boolean;
}): Promise<string> {
const { baseUrl, token, name } = params;
const key = `${cacheKey(baseUrl, token)}::channel::${name.toLowerCase()}`;
const cached = channelByNameCache.get(key);
if (cached) {
return cached;
}
const client = createMattermostClient({
baseUrl,
botToken: token,
allowPrivateNetwork: params.allowPrivateNetwork,
});
const me = await fetchMattermostMe(client);
const teams = await fetchMattermostUserTeams(client, me.id);
for (const team of teams) {
try {
const channel = await fetchMattermostChannelByName(client, team.id, name);
if (channel?.id) {
channelByNameCache.set(key, channel.id);
return channel.id;
}
} catch {
// Channel not found in this team, try next
}
}
throw new Error(`Mattermost channel "#${name}" not found in any team the bot belongs to`);
}
type ResolveTargetChannelIdParams = {
target: MattermostTarget;
baseUrl: string;
token: string;
allowPrivateNetwork?: boolean;
dmRetryOptions?: CreateDmChannelRetryOptions;
logger?: { debug?: (msg: string) => void; warn?: (msg: string) => void };
};
function mergeDmRetryOptions(
base?: CreateDmChannelRetryOptions,
override?: CreateDmChannelRetryOptions,
): CreateDmChannelRetryOptions | undefined {
const merged: CreateDmChannelRetryOptions = {
maxRetries: override?.maxRetries ?? base?.maxRetries,
initialDelayMs: override?.initialDelayMs ?? base?.initialDelayMs,
maxDelayMs: override?.maxDelayMs ?? base?.maxDelayMs,
timeoutMs: override?.timeoutMs ?? base?.timeoutMs,
onRetry: override?.onRetry,
};
if (
merged.maxRetries === undefined &&
merged.initialDelayMs === undefined &&
merged.maxDelayMs === undefined &&
merged.timeoutMs === undefined &&
merged.onRetry === undefined
) {
return undefined;
}
return merged;
}
async function resolveTargetChannelId(params: ResolveTargetChannelIdParams): Promise<string> {
if (params.target.kind === "channel") {
return params.target.id;
}
if (params.target.kind === "channel-name") {
return await resolveChannelIdByName({
baseUrl: params.baseUrl,
token: params.token,
name: params.target.name,
allowPrivateNetwork: params.allowPrivateNetwork,
});
}
const userId = params.target.id
? params.target.id
: await resolveUserIdByUsername({
baseUrl: params.baseUrl,
token: params.token,
username: params.target.username ?? "",
allowPrivateNetwork: params.allowPrivateNetwork,
});
const dmKey = `${cacheKey(params.baseUrl, params.token)}::dm::${userId}`;
const cachedDm = dmChannelCache.get(dmKey);
if (cachedDm) {
return cachedDm;
}
const botUser = await resolveBotUser(params.baseUrl, params.token, params.allowPrivateNetwork);
const client = createMattermostClient({
baseUrl: params.baseUrl,
botToken: params.token,
allowPrivateNetwork: params.allowPrivateNetwork,
});
const channel = await createMattermostDirectChannelWithRetry(client, [botUser.id, userId], {
...params.dmRetryOptions,
onRetry: (attempt, delayMs, error) => {
// Call user's onRetry if provided
params.dmRetryOptions?.onRetry?.(attempt, delayMs, error);
// Log if verbose mode is enabled
if (params.logger) {
params.logger.warn?.(
`DM channel creation retry ${attempt} after ${delayMs}ms: ${error.message}`,
);
}
},
});
dmChannelCache.set(dmKey, channel.id);
return channel.id;
}
type MattermostSendContext = {
cfg: OpenClawConfig;
accountId: string;
token: string;
baseUrl: string;
channelId: string;
allowPrivateNetwork?: boolean;
};
async function resolveMattermostSendContext(
to: string,
opts: MattermostSendOpts = {},
): Promise<MattermostSendContext> {
const core = getCore();
const logger = core.logging.getChildLogger({ module: "mattermost" });
const cfg = opts.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 trimmedTo = to?.trim() ?? "";
const opaqueTarget = await resolveMattermostOpaqueTarget({
input: trimmedTo,
token,
baseUrl,
});
const target =
opaqueTarget?.kind === "user"
? { kind: "user" as const, id: opaqueTarget.id }
: opaqueTarget?.kind === "channel"
? { kind: "channel" as const, id: opaqueTarget.id }
: parseMattermostTarget(trimmedTo);
// Build retry options from account config, allowing opts to override
const accountRetryConfig: CreateDmChannelRetryOptions | undefined = account.config.dmChannelRetry
? {
maxRetries: account.config.dmChannelRetry.maxRetries,
initialDelayMs: account.config.dmChannelRetry.initialDelayMs,
maxDelayMs: account.config.dmChannelRetry.maxDelayMs,
timeoutMs: account.config.dmChannelRetry.timeoutMs,
}
: undefined;
const dmRetryOptions = mergeDmRetryOptions(accountRetryConfig, opts.dmRetryOptions);
const allowPrivateNetwork = account.config.allowPrivateNetwork === true;
const channelId = await resolveTargetChannelId({
target,
baseUrl,
token,
allowPrivateNetwork,
dmRetryOptions,
logger: core.logging.shouldLogVerbose() ? logger : undefined,
});
return {
cfg,
accountId: account.accountId,
token,
baseUrl,
channelId,
allowPrivateNetwork,
};
}
export async function resolveMattermostSendChannelId(
to: string,
opts: MattermostSendOpts = {},
): Promise<string> {
return (await resolveMattermostSendContext(to, opts)).channelId;
}
export async function sendMessageMattermost(
to: string,
text: string,
opts: MattermostSendOpts = {},
): Promise<MattermostSendResult> {
const core = getCore();
const logger = core.logging.getChildLogger({ module: "mattermost" });
const { cfg, accountId, token, baseUrl, channelId, allowPrivateNetwork } =
await resolveMattermostSendContext(to, opts);
const client = createMattermostClient({ baseUrl, botToken: token, allowPrivateNetwork });
let props = opts.props;
if (!props && Array.isArray(opts.buttons) && opts.buttons.length > 0) {
setInteractionSecret(accountId, token);
props = buildButtonProps({
callbackUrl: resolveInteractionCallbackUrl(accountId, {
gateway: cfg.gateway,
interactions: resolveMattermostAccount({
cfg,
accountId,
}).config?.interactions,
}),
accountId,
channelId,
buttons: opts.buttons,
text: opts.attachmentText,
});
}
let message = text?.trim() ?? "";
let fileIds: string[] | undefined;
let uploadError: Error | undefined;
const mediaUrl = opts.mediaUrl?.trim();
if (mediaUrl) {
try {
const media = await loadOutboundMediaFromUrl(mediaUrl, {
mediaLocalRoots: opts.mediaLocalRoots,
mediaReadFile: opts.mediaReadFile,
});
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 = resolveMarkdownTableMode({
cfg,
channel: "mattermost",
accountId,
});
message = 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,
props,
});
recordMattermostOutboundActivity(accountId);
return {
messageId: post.id ?? "unknown",
channelId,
};
}
@@ -0,0 +1,164 @@
import { describe, expect, it, vi } from "vitest";
import type { MattermostClient } from "./client.js";
import {
DEFAULT_COMMAND_SPECS,
parseSlashCommandPayload,
registerSlashCommands,
resolveCallbackUrl,
resolveCommandText,
resolveSlashCommandConfig,
} from "./slash-commands.js";
describe("slash-commands", () => {
async function registerSingleStatusCommand(
requestImpl: (path: string, init?: { method?: string }) => Promise<unknown>,
) {
const client: MattermostClient = {
baseUrl: "https://chat.example.com",
apiBaseUrl: "https://chat.example.com/api/v4",
// pragma: allowlist secret
token: "bot-token",
request: async <T>(path: string, init?: RequestInit) => (await requestImpl(path, init)) as T,
fetchImpl: vi.fn<typeof fetch>(),
};
return registerSlashCommands({
client,
teamId: "team-1",
creatorUserId: "bot-user",
callbackUrl: "http://gateway/callback",
commands: [
{
trigger: "oc_status",
description: "status",
autoComplete: true,
},
],
});
}
it("parses application/x-www-form-urlencoded payloads", () => {
const payload = parseSlashCommandPayload(
"token=t1&team_id=team&channel_id=ch1&user_id=u1&command=%2Foc_status&text=now",
"application/x-www-form-urlencoded",
);
expect(payload).toMatchObject({
token: "t1",
team_id: "team",
channel_id: "ch1",
user_id: "u1",
command: "/oc_status",
text: "now",
});
});
it("parses application/json payloads", () => {
const payload = parseSlashCommandPayload(
JSON.stringify({
token: "t2",
team_id: "team",
channel_id: "ch2",
user_id: "u2",
command: "/oc_model",
text: "gpt-5",
}),
"application/json; charset=utf-8",
);
expect(payload).toMatchObject({
token: "t2",
command: "/oc_model",
text: "gpt-5",
});
});
it("returns null for malformed payloads missing required fields", () => {
const payload = parseSlashCommandPayload(
JSON.stringify({ token: "t3", command: "/oc_help" }),
"application/json",
);
expect(payload).toBeNull();
});
it("resolves command text with trigger map fallback", () => {
const triggerMap = new Map<string, string>([["oc_status", "status"]]);
expect(resolveCommandText("oc_status", " ", triggerMap)).toBe("/status");
expect(resolveCommandText("oc_status", " now ", triggerMap)).toBe("/status now");
expect(resolveCommandText("oc_models", " openai ", undefined)).toBe("/models openai");
expect(resolveCommandText("oc_help", "", undefined)).toBe("/help");
});
it("registers both public model slash commands", () => {
expect(
DEFAULT_COMMAND_SPECS.filter(
(spec) => spec.trigger === "oc_model" || spec.trigger === "oc_models",
).map((spec) => spec.trigger),
).toEqual(["oc_model", "oc_models"]);
});
it("normalizes callback path in slash config", () => {
const config = resolveSlashCommandConfig({ callbackPath: "api/channels/mattermost/command" });
expect(config.callbackPath).toBe("/api/channels/mattermost/command");
});
it("falls back to localhost callback URL for wildcard bind hosts", () => {
const config = resolveSlashCommandConfig({ callbackPath: "/api/channels/mattermost/command" });
const callbackUrl = resolveCallbackUrl({
config,
gatewayPort: 18789,
gatewayHost: "0.0.0.0",
});
expect(callbackUrl).toBe("http://localhost:18789/api/channels/mattermost/command");
});
it("reuses existing command when trigger already points to callback URL", async () => {
const request = vi.fn(async (path: string) => {
if (path.startsWith("/commands?team_id=")) {
return [
{
id: "cmd-1",
token: "tok-1",
team_id: "team-1",
creator_id: "bot-user",
trigger: "oc_status",
method: "P",
url: "http://gateway/callback",
auto_complete: true,
},
];
}
throw new Error(`unexpected request path: ${path}`);
});
const result = await registerSingleStatusCommand(request);
expect(result).toHaveLength(1);
expect(result[0]?.managed).toBe(false);
expect(result[0]?.id).toBe("cmd-1");
expect(request).toHaveBeenCalledTimes(1);
});
it("skips foreign command trigger collisions instead of mutating non-owned commands", async () => {
const request = vi.fn(async (path: string, init?: { method?: string }) => {
if (path.startsWith("/commands?team_id=")) {
return [
{
id: "cmd-foreign-1",
token: "tok-foreign-1",
team_id: "team-1",
creator_id: "another-bot-user",
trigger: "oc_status",
method: "P",
url: "http://foreign/callback",
auto_complete: true,
},
];
}
if (init?.method === "POST" || init?.method === "PUT" || init?.method === "DELETE") {
throw new Error("should not mutate foreign commands");
}
throw new Error(`unexpected request path: ${path}`);
});
const result = await registerSingleStatusCommand(request);
expect(result).toHaveLength(0);
expect(request).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,588 @@
/**
* Mattermost native slash command support.
*
* Registers custom slash commands via the Mattermost REST API and handles
* incoming command callbacks via an HTTP endpoint on the gateway.
*
* Architecture:
* - On startup, registers commands with MM via POST /api/v4/commands
* - MM sends HTTP POST to callbackUrl when a user invokes a command
* - The callback handler reconstructs the text as `/<command> <args>` and
* routes it through the standard inbound reply pipeline
* - On shutdown, cleans up registered commands via DELETE /api/v4/commands/{id}
*/
import type { MattermostClient } from "./client.js";
// ─── Types ───────────────────────────────────────────────────────────────────
export type MattermostSlashCommandConfig = {
/** Enable native slash commands. "auto" resolves to false for now (opt-in). */
native: boolean | "auto";
/** Also register skill-based commands. */
nativeSkills: boolean | "auto";
/** Path for the callback endpoint on the gateway HTTP server. */
callbackPath: string;
/**
* Explicit callback URL override (e.g. behind a reverse proxy).
* If not set, auto-derived from baseUrl + gateway port + callbackPath.
*/
callbackUrl?: string;
};
export type MattermostCommandSpec = {
trigger: string;
description: string;
autoComplete: boolean;
autoCompleteHint?: string;
/** Original command name (for skill commands that start with oc_) */
originalName?: string;
};
export type MattermostRegisteredCommand = {
id: string;
trigger: string;
teamId: string;
token: string;
/** True when this process created the command and should delete it on shutdown. */
managed: boolean;
};
/**
* Payload sent by Mattermost when a slash command is invoked.
* Can arrive as application/x-www-form-urlencoded or application/json.
*/
export type MattermostSlashCommandPayload = {
token: string;
team_id: string;
team_domain?: string;
channel_id: string;
channel_name?: string;
user_id: string;
user_name?: string;
command: string; // e.g. "/status"
text: string; // args after the trigger word
trigger_id?: string;
response_url?: string;
};
/**
* Response format for Mattermost slash command callbacks.
*/
export type MattermostSlashCommandResponse = {
response_type?: "ephemeral" | "in_channel";
text: string;
username?: string;
icon_url?: string;
goto_location?: string;
attachments?: unknown[];
};
// ─── MM API types ────────────────────────────────────────────────────────────
type MattermostCommandCreate = {
team_id: string;
trigger: string;
method: "P" | "G";
url: string;
description?: string;
auto_complete: boolean;
auto_complete_desc?: string;
auto_complete_hint?: string;
token?: string;
creator_id?: string;
};
type MattermostCommandUpdate = {
id: string;
team_id: string;
trigger: string;
method: "P" | "G";
url: string;
description?: string;
auto_complete: boolean;
auto_complete_desc?: string;
auto_complete_hint?: string;
};
type MattermostCommandResponse = {
id: string;
token: string;
team_id: string;
trigger: string;
method: string;
url: string;
auto_complete: boolean;
auto_complete_desc?: string;
auto_complete_hint?: string;
creator_id?: string;
create_at?: number;
update_at?: number;
delete_at?: number;
};
// ─── Default commands ────────────────────────────────────────────────────────
/**
* Built-in OpenClaw commands to register as native slash commands.
* These mirror the text-based commands already handled by the gateway.
*/
export const DEFAULT_COMMAND_SPECS: MattermostCommandSpec[] = [
{
trigger: "oc_status",
originalName: "status",
description: "Show session status (model, usage, uptime)",
autoComplete: true,
},
{
trigger: "oc_model",
originalName: "model",
description: "View or change the current model",
autoComplete: true,
autoCompleteHint: "[model-name]",
},
{
trigger: "oc_models",
originalName: "models",
description: "Browse available models",
autoComplete: true,
autoCompleteHint: "[provider]",
},
{
trigger: "oc_new",
originalName: "new",
description: "Start a new conversation session",
autoComplete: true,
},
{
trigger: "oc_help",
originalName: "help",
description: "Show available commands",
autoComplete: true,
},
{
trigger: "oc_think",
originalName: "think",
description: "Set thinking/reasoning level",
autoComplete: true,
autoCompleteHint: "[off|low|medium|high]",
},
{
trigger: "oc_reasoning",
originalName: "reasoning",
description: "Toggle reasoning mode",
autoComplete: true,
autoCompleteHint: "[on|off]",
},
{
trigger: "oc_verbose",
originalName: "verbose",
description: "Toggle verbose mode",
autoComplete: true,
autoCompleteHint: "[on|off]",
},
];
// ─── Command registration ────────────────────────────────────────────────────
/**
* List existing custom slash commands for a team.
*/
export async function listMattermostCommands(
client: MattermostClient,
teamId: string,
): Promise<MattermostCommandResponse[]> {
return await client.request<MattermostCommandResponse[]>(
`/commands?team_id=${encodeURIComponent(teamId)}&custom_only=true`,
);
}
/**
* Create a custom slash command on a Mattermost team.
*/
export async function createMattermostCommand(
client: MattermostClient,
params: MattermostCommandCreate,
): Promise<MattermostCommandResponse> {
return await client.request<MattermostCommandResponse>("/commands", {
method: "POST",
body: JSON.stringify(params),
});
}
/**
* Delete a custom slash command.
*/
export async function deleteMattermostCommand(
client: MattermostClient,
commandId: string,
): Promise<void> {
await client.request<Record<string, unknown>>(`/commands/${encodeURIComponent(commandId)}`, {
method: "DELETE",
});
}
/**
* Update an existing custom slash command.
*/
export async function updateMattermostCommand(
client: MattermostClient,
params: MattermostCommandUpdate,
): Promise<MattermostCommandResponse> {
return await client.request<MattermostCommandResponse>(
`/commands/${encodeURIComponent(params.id)}`,
{
method: "PUT",
body: JSON.stringify(params),
},
);
}
/**
* Register all OpenClaw slash commands for a given team.
* Skips commands that are already registered with the same trigger + callback URL.
* Returns the list of newly created command IDs.
*/
export async function registerSlashCommands(params: {
client: MattermostClient;
teamId: string;
creatorUserId: string;
callbackUrl: string;
commands: MattermostCommandSpec[];
log?: (msg: string) => void;
}): Promise<MattermostRegisteredCommand[]> {
const { client, teamId, creatorUserId, callbackUrl, commands, log } = params;
const normalizedCreatorUserId = creatorUserId.trim();
if (!normalizedCreatorUserId) {
throw new Error("creatorUserId is required for slash command reconciliation");
}
// Fetch existing commands to avoid duplicates
let existing: MattermostCommandResponse[] = [];
try {
existing = await listMattermostCommands(client, teamId);
} catch (err) {
log?.(`mattermost: failed to list existing commands: ${String(err)}`);
// Fail closed: if we can't list existing commands, we should not attempt to
// create/update anything because we may create duplicates and end up with an
// empty/partial token set (causing callbacks to be rejected until restart).
throw err;
}
const existingByTrigger = new Map<string, MattermostCommandResponse[]>();
for (const cmd of existing) {
const list = existingByTrigger.get(cmd.trigger) ?? [];
list.push(cmd);
existingByTrigger.set(cmd.trigger, list);
}
const registered: MattermostRegisteredCommand[] = [];
for (const spec of commands) {
const existingForTrigger = existingByTrigger.get(spec.trigger) ?? [];
const ownedCommands = existingForTrigger.filter(
(cmd) => cmd.creator_id?.trim() === normalizedCreatorUserId,
);
const foreignCommands = existingForTrigger.filter(
(cmd) => cmd.creator_id?.trim() !== normalizedCreatorUserId,
);
if (ownedCommands.length === 0 && foreignCommands.length > 0) {
log?.(
`mattermost: trigger /${spec.trigger} already used by non-OpenClaw command(s); skipping to avoid mutating external integrations`,
);
continue;
}
if (ownedCommands.length > 1) {
log?.(
`mattermost: multiple owned commands found for /${spec.trigger}; using the first and leaving extras untouched`,
);
}
const existingCmd = ownedCommands[0];
// Already registered with the correct callback URL
if (existingCmd && existingCmd.url === callbackUrl) {
log?.(`mattermost: command /${spec.trigger} already registered (id=${existingCmd.id})`);
registered.push({
id: existingCmd.id,
trigger: spec.trigger,
teamId,
token: existingCmd.token,
managed: false,
});
continue;
}
// Exists but points to a different URL: attempt to reconcile by updating
// (useful during callback URL migrations).
if (existingCmd && existingCmd.url !== callbackUrl) {
log?.(
`mattermost: command /${spec.trigger} exists with different callback URL; updating (id=${existingCmd.id})`,
);
try {
const updated = await updateMattermostCommand(client, {
id: existingCmd.id,
team_id: teamId,
trigger: spec.trigger,
method: "P",
url: callbackUrl,
description: spec.description,
auto_complete: spec.autoComplete,
auto_complete_desc: spec.description,
auto_complete_hint: spec.autoCompleteHint,
});
registered.push({
id: updated.id,
trigger: spec.trigger,
teamId,
token: updated.token,
managed: false,
});
continue;
} catch (err) {
log?.(
`mattermost: failed to update command /${spec.trigger} (id=${existingCmd.id}): ${String(err)}`,
);
// Fallback: try delete+recreate for commands owned by this bot user.
try {
await deleteMattermostCommand(client, existingCmd.id);
log?.(`mattermost: deleted stale command /${spec.trigger} (id=${existingCmd.id})`);
} catch (deleteErr) {
log?.(
`mattermost: failed to delete stale command /${spec.trigger} (id=${existingCmd.id}): ${String(deleteErr)}`,
);
// Can't reconcile; skip this command.
continue;
}
// Continue on to create below.
}
}
try {
const created = await createMattermostCommand(client, {
team_id: teamId,
trigger: spec.trigger,
method: "P",
url: callbackUrl,
description: spec.description,
auto_complete: spec.autoComplete,
auto_complete_desc: spec.description,
auto_complete_hint: spec.autoCompleteHint,
});
log?.(`mattermost: registered command /${spec.trigger} (id=${created.id})`);
registered.push({
id: created.id,
trigger: spec.trigger,
teamId,
token: created.token,
managed: true,
});
} catch (err) {
log?.(`mattermost: failed to register command /${spec.trigger}: ${String(err)}`);
}
}
return registered;
}
/**
* Clean up all registered slash commands.
*/
export async function cleanupSlashCommands(params: {
client: MattermostClient;
commands: MattermostRegisteredCommand[];
log?: (msg: string) => void;
}): Promise<void> {
const { client, commands, log } = params;
for (const cmd of commands) {
if (!cmd.managed) {
continue;
}
try {
await deleteMattermostCommand(client, cmd.id);
log?.(`mattermost: deleted command /${cmd.trigger} (id=${cmd.id})`);
} catch (err) {
log?.(`mattermost: failed to delete command /${cmd.trigger}: ${String(err)}`);
}
}
}
// ─── Callback parsing ────────────────────────────────────────────────────────
/**
* Parse a Mattermost slash command callback payload from a URL-encoded or JSON body.
*/
export function parseSlashCommandPayload(
body: string,
contentType?: string,
): MattermostSlashCommandPayload | null {
if (!body) {
return null;
}
try {
if (contentType?.includes("application/json")) {
const parsed = JSON.parse(body) as Record<string, unknown>;
// Validate required fields (same checks as the form-encoded branch)
const token = typeof parsed.token === "string" ? parsed.token : "";
const teamId = typeof parsed.team_id === "string" ? parsed.team_id : "";
const channelId = typeof parsed.channel_id === "string" ? parsed.channel_id : "";
const userId = typeof parsed.user_id === "string" ? parsed.user_id : "";
const command = typeof parsed.command === "string" ? parsed.command : "";
if (!token || !teamId || !channelId || !userId || !command) {
return null;
}
return {
token,
team_id: teamId,
team_domain: typeof parsed.team_domain === "string" ? parsed.team_domain : undefined,
channel_id: channelId,
channel_name: typeof parsed.channel_name === "string" ? parsed.channel_name : undefined,
user_id: userId,
user_name: typeof parsed.user_name === "string" ? parsed.user_name : undefined,
command,
text: typeof parsed.text === "string" ? parsed.text : "",
trigger_id: typeof parsed.trigger_id === "string" ? parsed.trigger_id : undefined,
response_url: typeof parsed.response_url === "string" ? parsed.response_url : undefined,
};
}
// Default: application/x-www-form-urlencoded
const params = new URLSearchParams(body);
const token = params.get("token");
const teamId = params.get("team_id");
const channelId = params.get("channel_id");
const userId = params.get("user_id");
const command = params.get("command");
if (!token || !teamId || !channelId || !userId || !command) {
return null;
}
return {
token,
team_id: teamId,
team_domain: params.get("team_domain") ?? undefined,
channel_id: channelId,
channel_name: params.get("channel_name") ?? undefined,
user_id: userId,
user_name: params.get("user_name") ?? undefined,
command,
text: params.get("text") ?? "",
trigger_id: params.get("trigger_id") ?? undefined,
response_url: params.get("response_url") ?? undefined,
};
} catch {
return null;
}
}
/**
* Map the trigger word back to the original OpenClaw command name.
* e.g. "oc_status" -> "/status", "oc_model" -> "/model"
*/
export function resolveCommandText(
trigger: string,
text: string,
triggerMap?: ReadonlyMap<string, string>,
): string {
// Use the trigger map if available for accurate name resolution
const commandName =
triggerMap?.get(trigger) ?? (trigger.startsWith("oc_") ? trigger.slice(3) : trigger);
const args = text.trim();
return args ? `/${commandName} ${args}` : `/${commandName}`;
}
// ─── Config resolution ───────────────────────────────────────────────────────
const DEFAULT_CALLBACK_PATH = "/api/channels/mattermost/command";
/**
* Ensure the callback path starts with a leading `/` to prevent
* malformed URLs like `http://host:portapi/...`.
*/
function normalizeCallbackPath(path: string): string {
const trimmed = path.trim();
if (!trimmed) return DEFAULT_CALLBACK_PATH;
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
export function resolveSlashCommandConfig(
raw?: Partial<MattermostSlashCommandConfig>,
): MattermostSlashCommandConfig {
return {
native: raw?.native ?? "auto",
nativeSkills: raw?.nativeSkills ?? "auto",
callbackPath: normalizeCallbackPath(raw?.callbackPath ?? DEFAULT_CALLBACK_PATH),
callbackUrl: raw?.callbackUrl?.trim() || undefined,
};
}
export function isSlashCommandsEnabled(config: MattermostSlashCommandConfig): boolean {
if (config.native === true) {
return true;
}
if (config.native === false) {
return false;
}
// "auto" defaults to false for mattermost (opt-in)
return false;
}
export function collectMattermostSlashCallbackPaths(raw?: Partial<MattermostSlashCommandConfig>) {
const config = resolveSlashCommandConfig(raw);
const paths = new Set<string>([config.callbackPath]);
if (typeof config.callbackUrl === "string" && config.callbackUrl.trim()) {
try {
const pathname = new URL(config.callbackUrl).pathname;
if (pathname) {
paths.add(pathname);
}
} catch {
// Ignore invalid callback URLs and keep the normalized callback path only.
}
}
return [...paths];
}
/**
* Build the callback URL that Mattermost will POST to when a command is invoked.
*/
export function resolveCallbackUrl(params: {
config: MattermostSlashCommandConfig;
gatewayPort: number;
gatewayHost?: string;
}): string {
if (params.config.callbackUrl) {
return params.config.callbackUrl;
}
const isWildcardBindHost = (rawHost: string): boolean => {
const trimmed = rawHost.trim();
if (!trimmed) return false;
const host = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
// NOTE: Wildcard listen hosts are valid bind addresses but are not routable callback
// destinations. Don't emit callback URLs like http://0.0.0.0:3015/... or http://[::]:3015/...
// when an operator sets gateway.customBindHost.
return host === "0.0.0.0" || host === "::" || host === "0:0:0:0:0:0:0:0" || host === "::0";
};
let host =
params.gatewayHost && !isWildcardBindHost(params.gatewayHost)
? params.gatewayHost
: "localhost";
const path = normalizeCallbackPath(params.config.callbackPath);
// Bracket IPv6 literals so the URL is valid: http://[::1]:3015/...
if (host.includes(":") && !(host.startsWith("[") && host.endsWith("]"))) {
host = `[${host}]`;
}
return `http://${host}:${params.gatewayPort}${path}`;
}
@@ -0,0 +1,265 @@
import { ServerResponse, type IncomingMessage } from "node:http";
import { PassThrough } from "node:stream";
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/mattermost";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { ResolvedMattermostAccount } from "./accounts.js";
const mockState = vi.hoisted(() => ({
readRequestBodyWithLimit: vi.fn(async () => "token=valid-token"),
parseSlashCommandPayload: vi.fn(() => ({
token: "valid-token",
command: "/oc_models",
text: "models",
channel_id: "chan-1",
user_id: "user-1",
user_name: "alice",
team_id: "team-1",
})),
resolveCommandText: vi.fn((_trigger: string, text: string) => text),
buildModelsProviderData: vi.fn(async () => ({ providers: [], modelNames: new Map() })),
resolveMattermostModelPickerEntry: vi.fn(() => ({ kind: "summary" })),
authorizeMattermostCommandInvocation: vi.fn(() => ({
ok: true,
commandAuthorized: true,
channelInfo: { id: "chan-1", type: "O", name: "town-square", display_name: "Town Square" },
kind: "channel",
chatType: "channel",
channelName: "town-square",
channelDisplay: "Town Square",
roomLabel: "#town-square",
})),
createMattermostClient: vi.fn(() => ({})),
fetchMattermostChannel: vi.fn(async () => ({
id: "chan-1",
type: "O",
name: "town-square",
display_name: "Town Square",
})),
sendMessageMattermost: vi.fn(async () => ({ messageId: "post-1", channelId: "chan-1" })),
normalizeMattermostAllowList: vi.fn((value: unknown) => value),
}));
vi.mock("./runtime-api.js", () => {
return {
buildModelsProviderData: mockState.buildModelsProviderData,
createChannelReplyPipeline: vi.fn(() => ({
onModelSelected: vi.fn(),
typingCallbacks: {},
})),
createDedupeCache: vi.fn(() => ({
check: () => false,
})),
createReplyPrefixOptions: vi.fn(() => ({})),
createTypingCallbacks: vi.fn(() => ({ onReplyStart: vi.fn() })),
isRequestBodyLimitError: vi.fn(() => false),
logTypingFailure: vi.fn(),
formatInboundFromLabel: vi.fn(() => ""),
rawDataToString: vi.fn((value: unknown) => String(value ?? "")),
readRequestBodyWithLimit: mockState.readRequestBodyWithLimit,
resolveThreadSessionKeys: vi.fn((params: { baseSessionKey: string }) => ({
sessionKey: params.baseSessionKey,
parentSessionKey: undefined,
})),
};
});
vi.mock("../runtime.js", () => ({
getMattermostRuntime: () => ({
channel: {
commands: {
shouldHandleTextCommands: () => true,
},
text: {
hasControlCommand: () => false,
},
pairing: {
readAllowFromStore: vi.fn(async () => []),
},
routing: {
resolveAgentRoute: vi.fn(() => ({
agentId: "agent-1",
sessionKey: "mattermost:session:1",
accountId: "default",
})),
},
},
}),
}));
vi.mock("./client.js", async () => {
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
return {
...actual,
createMattermostClient: mockState.createMattermostClient,
fetchMattermostChannel: mockState.fetchMattermostChannel,
normalizeMattermostBaseUrl: vi.fn((value: string | undefined) => value?.trim() ?? ""),
sendMattermostTyping: vi.fn(),
};
});
vi.mock("./model-picker.js", () => ({
renderMattermostModelSummaryView: vi.fn(),
renderMattermostModelsPickerView: vi.fn(),
renderMattermostProviderPickerView: vi.fn(),
resolveMattermostModelPickerCurrentModel: vi.fn(),
resolveMattermostModelPickerEntry: mockState.resolveMattermostModelPickerEntry,
}));
vi.mock("./monitor-auth.js", () => ({
authorizeMattermostCommandInvocation: mockState.authorizeMattermostCommandInvocation,
normalizeMattermostAllowList: mockState.normalizeMattermostAllowList,
}));
vi.mock("./reply-delivery.js", () => ({
deliverMattermostReplyPayload: vi.fn(),
}));
vi.mock("./send.js", () => ({
sendMessageMattermost: mockState.sendMessageMattermost,
}));
vi.mock("./slash-commands.js", () => ({
parseSlashCommandPayload: mockState.parseSlashCommandPayload,
resolveCommandText: mockState.resolveCommandText,
}));
let createSlashCommandHttpHandler: typeof import("./slash-http.js").createSlashCommandHttpHandler;
function createRequest(body = "token=valid-token"): IncomingMessage {
const req = new PassThrough();
const incoming = req as PassThrough & IncomingMessage;
incoming.method = "POST";
incoming.headers = {
"content-type": "application/x-www-form-urlencoded",
};
process.nextTick(() => {
req.end(body);
});
return incoming;
}
function createResponse(): {
res: ServerResponse;
getBody: () => string;
} {
let body = "";
class TestServerResponse extends ServerResponse {
override setHeader() {
return this;
}
override end(): this;
override end(cb: () => void): this;
override end(chunk: string | Buffer | Uint8Array, cb?: () => void): this;
override end(
chunk: string | Buffer | Uint8Array,
encoding: BufferEncoding,
cb?: () => void,
): this;
override end(
chunkOrCb?: string | Buffer | Uint8Array | (() => void),
encodingOrCb?: BufferEncoding | (() => void),
cb?: () => void,
): this {
const chunk = typeof chunkOrCb === "function" ? undefined : chunkOrCb;
const callback =
typeof chunkOrCb === "function"
? chunkOrCb
: typeof encodingOrCb === "function"
? encodingOrCb
: cb;
body = chunk ? String(chunk) : "";
callback?.();
return this;
}
}
const res = new TestServerResponse(createRequest(""));
return {
res,
getBody: () => body,
};
}
const accountFixture: ResolvedMattermostAccount = {
accountId: "default",
enabled: true,
// pragma: allowlist secret
botToken: "bot-token",
baseUrl: "https://chat.example.com",
botTokenSource: "config",
baseUrlSource: "config",
config: {},
};
describe("slash-http cfg threading", () => {
beforeEach(async () => {
vi.resetModules();
mockState.readRequestBodyWithLimit.mockClear();
mockState.parseSlashCommandPayload.mockClear();
mockState.resolveCommandText.mockClear();
mockState.buildModelsProviderData.mockClear();
mockState.resolveMattermostModelPickerEntry.mockClear();
mockState.authorizeMattermostCommandInvocation.mockClear();
mockState.createMattermostClient.mockClear();
mockState.fetchMattermostChannel.mockClear();
mockState.sendMessageMattermost.mockClear();
mockState.normalizeMattermostAllowList.mockClear();
({ createSlashCommandHttpHandler } = await import("./slash-http.js"));
});
it("passes cfg through the no-models slash reply send path", async () => {
const cfg = {
channels: {
mattermost: {
botToken: "exec:secret-ref",
},
},
} as OpenClawConfig;
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg,
runtime: {} as RuntimeEnv,
commandTokens: new Set(["valid-token"]),
});
const response = createResponse();
await handler(createRequest(), response.res);
expect(response.res.statusCode).toBe(200);
expect(response.getBody()).toContain("Processing");
expect(mockState.sendMessageMattermost).toHaveBeenCalledWith(
"channel:chan-1",
"No models available.",
expect.objectContaining({
cfg,
accountId: "default",
}),
);
});
it("does not rely on Set.has for command token validation", async () => {
const commandTokens = new Set(["valid-token"]);
const hasSpy = vi.fn(() => {
throw new Error("Set.has should not be used for slash token validation");
});
Object.defineProperty(commandTokens, "has", {
value: hasSpy,
configurable: true,
});
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
commandTokens,
});
const response = createResponse();
await handler(createRequest(), response.res);
expect(response.res.statusCode).toBe(200);
expect(response.getBody()).toContain("Processing");
expect(hasSpy).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,158 @@
import type { IncomingMessage, ServerResponse } from "node:http";
import { PassThrough } from "node:stream";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, RuntimeEnv } from "../../runtime-api.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import { createSlashCommandHttpHandler } from "./slash-http.js";
function createRequest(params: {
method?: string;
body?: string;
contentType?: string;
autoEnd?: boolean;
}): IncomingMessage {
const req = new PassThrough();
const incoming = req as PassThrough & IncomingMessage;
incoming.method = params.method ?? "POST";
incoming.headers = {
"content-type": params.contentType ?? "application/x-www-form-urlencoded",
};
process.nextTick(() => {
if (params.body) {
req.write(params.body);
}
if (params.autoEnd !== false) {
req.end();
}
});
return incoming;
}
function createResponse(): {
res: ServerResponse;
getBody: () => string;
getHeaders: () => Map<string, string>;
} {
let body = "";
const headers = new Map<string, string>();
const res = {
statusCode: 200,
setHeader(name: string, value: string) {
headers.set(name.toLowerCase(), value);
},
end(chunk?: string | Buffer) {
body = chunk ? String(chunk) : "";
},
} as ServerResponse;
return {
res,
getBody: () => body,
getHeaders: () => headers,
};
}
const accountFixture: ResolvedMattermostAccount = {
accountId: "default",
enabled: true,
// pragma: allowlist secret
botToken: "bot-token",
baseUrl: "https://chat.example.com",
botTokenSource: "config",
baseUrlSource: "config",
config: {},
};
async function runSlashRequest(params: {
commandTokens: Set<string>;
body: string;
method?: string;
}) {
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
commandTokens: params.commandTokens,
});
const req = createRequest({ method: params.method, body: params.body });
const response = createResponse();
await handler(req, response.res);
return response;
}
describe("slash-http", () => {
it("rejects non-POST methods", async () => {
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
commandTokens: new Set(["valid-token"]),
});
const req = createRequest({ method: "GET", body: "" });
const response = createResponse();
await handler(req, response.res);
expect(response.res.statusCode).toBe(405);
expect(response.getBody()).toBe("Method Not Allowed");
expect(response.getHeaders().get("allow")).toBe("POST");
});
it("rejects malformed payloads", async () => {
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
commandTokens: new Set(["valid-token"]),
});
const req = createRequest({ body: "token=abc&command=%2Foc_status" });
const response = createResponse();
await handler(req, response.res);
expect(response.res.statusCode).toBe(400);
expect(response.getBody()).toContain("Invalid slash command payload");
});
it("fails closed when no command tokens are registered", async () => {
const response = await runSlashRequest({
commandTokens: new Set<string>(),
body: "token=tok1&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_status&text=",
});
expect(response.res.statusCode).toBe(401);
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
});
it("rejects unknown command tokens", async () => {
const response = await runSlashRequest({
commandTokens: new Set(["known-token"]),
body: "token=unknown&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_status&text=",
});
expect(response.res.statusCode).toBe(401);
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
});
it("returns 408 when the request body stalls", async () => {
vi.useFakeTimers();
try {
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
commandTokens: new Set(["valid-token"]),
});
const req = createRequest({ autoEnd: false });
const response = createResponse();
const pending = handler(req, response.res);
await vi.advanceTimersByTimeAsync(5_000);
await pending;
expect(response.res.statusCode).toBe(408);
expect(response.getBody()).toBe("Request body timeout");
} finally {
vi.useRealTimers();
}
});
});
@@ -0,0 +1,546 @@
/**
* HTTP callback handler for Mattermost slash commands.
*
* Receives POST requests from Mattermost when a slash command is invoked,
* validates the token, and routes the command through the standard inbound pipeline.
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-support";
import type { ResolvedMattermostAccount } from "../mattermost/accounts.js";
import { getMattermostRuntime } from "../runtime.js";
import {
createMattermostClient,
fetchMattermostChannel,
normalizeMattermostBaseUrl,
sendMattermostTyping,
type MattermostChannel,
} from "./client.js";
import {
renderMattermostModelSummaryView,
renderMattermostModelsPickerView,
renderMattermostProviderPickerView,
resolveMattermostModelPickerCurrentModel,
resolveMattermostModelPickerEntry,
} from "./model-picker.js";
import {
authorizeMattermostCommandInvocation,
normalizeMattermostAllowList,
} from "./monitor-auth.js";
import { deliverMattermostReplyPayload } from "./reply-delivery.js";
import {
buildModelsProviderData,
createChannelReplyPipeline,
isRequestBodyLimitError,
logTypingFailure,
readRequestBodyWithLimit,
type OpenClawConfig,
type ReplyPayload,
type RuntimeEnv,
} from "./runtime-api.js";
import { sendMessageMattermost } from "./send.js";
import {
parseSlashCommandPayload,
resolveCommandText,
type MattermostSlashCommandResponse,
} from "./slash-commands.js";
type SlashHttpHandlerParams = {
account: ResolvedMattermostAccount;
cfg: OpenClawConfig;
runtime: RuntimeEnv;
/** Expected token from registered commands (for validation). */
commandTokens: Set<string>;
/** Map from trigger to original command name (for skill commands that start with oc_). */
triggerMap?: ReadonlyMap<string, string>;
log?: (msg: string) => void;
};
const MAX_BODY_BYTES = 64 * 1024;
const BODY_READ_TIMEOUT_MS = 5_000;
/**
* Read the full request body as a string.
*/
function readBody(req: IncomingMessage, maxBytes: number): Promise<string> {
return readRequestBodyWithLimit(req, {
maxBytes,
timeoutMs: BODY_READ_TIMEOUT_MS,
});
}
function sendJsonResponse(
res: ServerResponse,
status: number,
body: MattermostSlashCommandResponse,
) {
res.statusCode = status;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(JSON.stringify(body));
}
function matchesRegisteredCommandToken(
commandTokens: ReadonlySet<string>,
candidate: string,
): boolean {
for (const token of commandTokens) {
if (safeEqualSecret(candidate, token)) {
return true;
}
}
return false;
}
type SlashInvocationAuth = {
ok: boolean;
denyResponse?: MattermostSlashCommandResponse;
commandAuthorized: boolean;
channelInfo: MattermostChannel | null;
kind: "direct" | "group" | "channel";
chatType: "direct" | "group" | "channel";
channelName: string;
channelDisplay: string;
roomLabel: string;
};
async function authorizeSlashInvocation(params: {
account: ResolvedMattermostAccount;
cfg: OpenClawConfig;
client: ReturnType<typeof createMattermostClient>;
commandText: string;
channelId: string;
senderId: string;
senderName: string;
log?: (msg: string) => void;
}): Promise<SlashInvocationAuth> {
const { account, cfg, client, commandText, channelId, senderId, senderName, log } = params;
const core = getMattermostRuntime();
// Resolve channel info so we can enforce DM vs group/channel policies.
let channelInfo: MattermostChannel | null = null;
try {
channelInfo = await fetchMattermostChannel(client, channelId);
} catch (err) {
log?.(`mattermost: slash channel lookup failed for ${channelId}: ${String(err)}`);
}
if (!channelInfo) {
return {
ok: false,
denyResponse: {
response_type: "ephemeral",
text: "Temporary error: unable to determine channel type. Please try again.",
},
commandAuthorized: false,
channelInfo: null,
kind: "channel",
chatType: "channel",
channelName: "",
channelDisplay: "",
roomLabel: `#${channelId}`,
};
}
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg,
surface: "mattermost",
});
const hasControlCommand = core.channel.text.hasControlCommand(commandText, cfg);
const storeAllowFrom = normalizeMattermostAllowList(
await core.channel.pairing
.readAllowFromStore({
channel: "mattermost",
accountId: account.accountId,
})
.catch(() => []),
);
const decision = authorizeMattermostCommandInvocation({
account,
cfg,
senderId,
senderName,
channelId,
channelInfo,
storeAllowFrom,
allowTextCommands,
hasControlCommand,
});
if (!decision.ok) {
if (decision.denyReason === "dm-pairing") {
const { code } = await core.channel.pairing.upsertPairingRequest({
channel: "mattermost",
accountId: account.accountId,
id: senderId,
meta: { name: senderName },
});
return {
...decision,
denyResponse: {
response_type: "ephemeral",
text: core.channel.pairing.buildPairingReply({
channel: "mattermost",
idLine: `Your Mattermost user id: ${senderId}`,
code,
}),
},
};
}
const denyText =
decision.denyReason === "unknown-channel"
? "Temporary error: unable to determine channel type. Please try again."
: decision.denyReason === "dm-disabled"
? "This bot is not accepting direct messages."
: decision.denyReason === "channels-disabled"
? "Slash commands are disabled in channels."
: decision.denyReason === "channel-no-allowlist"
? "Slash commands are not configured for this channel (no allowlist)."
: "Unauthorized.";
return {
...decision,
denyResponse: {
response_type: "ephemeral",
text: denyText,
},
};
}
return {
...decision,
denyResponse: undefined,
};
}
/**
* Create the HTTP request handler for Mattermost slash command callbacks.
*
* This handler is registered as a plugin HTTP route and receives POSTs
* from the Mattermost server when a user invokes a registered slash command.
*/
export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
const { account, cfg, runtime, commandTokens, triggerMap, log } = params;
return async (req: IncomingMessage, res: ServerResponse): Promise<void> => {
if (req.method !== "POST") {
res.statusCode = 405;
res.setHeader("Allow", "POST");
res.end("Method Not Allowed");
return;
}
let body: string;
try {
body = await readBody(req, MAX_BODY_BYTES);
} catch (error) {
if (isRequestBodyLimitError(error, "REQUEST_BODY_TIMEOUT")) {
res.statusCode = 408;
res.end("Request body timeout");
return;
}
res.statusCode = 413;
res.end("Payload Too Large");
return;
}
const contentType = req.headers["content-type"] ?? "";
const payload = parseSlashCommandPayload(body, contentType);
if (!payload) {
sendJsonResponse(res, 400, {
response_type: "ephemeral",
text: "Invalid slash command payload.",
});
return;
}
// Validate token — fail closed: reject when no tokens are registered
// (e.g. registration failed or startup was partial)
if (commandTokens.size === 0 || !matchesRegisteredCommandToken(commandTokens, payload.token)) {
sendJsonResponse(res, 401, {
response_type: "ephemeral",
text: "Unauthorized: invalid command token.",
});
return;
}
// Extract command info
const trigger = payload.command.replace(/^\//, "").trim();
const commandText = resolveCommandText(trigger, payload.text, triggerMap);
const channelId = payload.channel_id;
const senderId = payload.user_id;
const senderName = payload.user_name ?? senderId;
const client = createMattermostClient({
baseUrl: account.baseUrl ?? "",
botToken: account.botToken ?? "",
allowPrivateNetwork: account.config?.allowPrivateNetwork === true,
});
const auth = await authorizeSlashInvocation({
account,
cfg,
client,
commandText,
channelId,
senderId,
senderName,
log,
});
if (!auth.ok) {
sendJsonResponse(
res,
200,
auth.denyResponse ?? { response_type: "ephemeral", text: "Unauthorized." },
);
return;
}
log?.(`mattermost: slash command /${trigger} from ${senderName} in ${channelId}`);
// Acknowledge immediately — we'll send the actual reply asynchronously
sendJsonResponse(res, 200, {
response_type: "ephemeral",
text: "Processing...",
});
// Now handle the command asynchronously (post reply as a message)
try {
await handleSlashCommandAsync({
account,
cfg,
runtime,
client,
commandText,
channelId,
senderId,
senderName,
teamId: payload.team_id,
triggerId: payload.trigger_id,
kind: auth.kind,
chatType: auth.chatType,
channelName: auth.channelName,
channelDisplay: auth.channelDisplay,
roomLabel: auth.roomLabel,
commandAuthorized: auth.commandAuthorized,
log,
});
} catch (err) {
log?.(`mattermost: slash command handler error: ${String(err)}`);
try {
const to = `channel:${channelId}`;
await sendMessageMattermost(to, "Sorry, something went wrong processing that command.", {
cfg,
accountId: account.accountId,
});
} catch {
// best-effort error reply
}
}
};
}
async function handleSlashCommandAsync(params: {
account: ResolvedMattermostAccount;
cfg: OpenClawConfig;
runtime: RuntimeEnv;
client: ReturnType<typeof createMattermostClient>;
commandText: string;
channelId: string;
senderId: string;
senderName: string;
teamId: string;
kind: "direct" | "group" | "channel";
chatType: "direct" | "group" | "channel";
channelName: string;
channelDisplay: string;
roomLabel: string;
commandAuthorized: boolean;
triggerId?: string;
log?: (msg: string) => void;
}) {
const {
account,
cfg,
runtime,
client,
commandText,
channelId,
senderId,
senderName,
teamId,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
commandAuthorized,
triggerId,
log,
} = params;
const core = getMattermostRuntime();
const route = core.channel.routing.resolveAgentRoute({
cfg,
channel: "mattermost",
accountId: account.accountId,
teamId,
peer: {
kind,
id: kind === "direct" ? senderId : channelId,
},
});
const fromLabel =
kind === "direct"
? `Mattermost DM from ${senderName}`
: `Mattermost message in ${roomLabel} from ${senderName}`;
const to = kind === "direct" ? `user:${senderId}` : `channel:${channelId}`;
const pickerEntry = resolveMattermostModelPickerEntry(commandText);
if (pickerEntry) {
const data = await buildModelsProviderData(cfg, route.agentId);
if (data.providers.length === 0) {
await sendMessageMattermost(to, "No models available.", {
cfg,
accountId: account.accountId,
});
return;
}
const currentModel = resolveMattermostModelPickerCurrentModel({
cfg,
route,
data,
});
const view =
pickerEntry.kind === "summary"
? renderMattermostModelSummaryView({
ownerUserId: senderId,
currentModel,
})
: pickerEntry.kind === "providers"
? renderMattermostProviderPickerView({
ownerUserId: senderId,
data,
currentModel,
})
: renderMattermostModelsPickerView({
ownerUserId: senderId,
data,
provider: pickerEntry.provider,
page: 1,
currentModel,
});
await sendMessageMattermost(to, view.text, {
cfg,
accountId: account.accountId,
buttons: view.buttons,
});
runtime.log?.(`delivered model picker to ${to}`);
return;
}
// Build inbound context — the command text is the body
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: commandText,
BodyForAgent: commandText,
RawBody: commandText,
CommandBody: commandText,
From:
kind === "direct"
? `mattermost:${senderId}`
: kind === "group"
? `mattermost:group:${channelId}`
: `mattermost:channel:${channelId}`,
To: to,
SessionKey: route.sessionKey,
AccountId: route.accountId,
ChatType: chatType,
ConversationLabel: fromLabel,
GroupSubject: kind !== "direct" ? channelDisplay || roomLabel : undefined,
SenderName: senderName,
SenderId: senderId,
Provider: "mattermost" as const,
Surface: "mattermost" as const,
MessageSid: triggerId ?? `slash-${Date.now()}`,
Timestamp: Date.now(),
WasMentioned: true,
CommandAuthorized: commandAuthorized,
CommandSource: "native" as const,
OriginatingChannel: "mattermost" as const,
OriginatingTo: to,
});
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
fallbackLimit: account.textChunkLimit ?? 4000,
});
const tableMode = core.channel.text.resolveMarkdownTableMode({
cfg,
channel: "mattermost",
accountId: account.accountId,
});
const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({
cfg,
agentId: route.agentId,
channel: "mattermost",
accountId: account.accountId,
typing: {
start: () => sendMattermostTyping(client, { channelId }),
onStartError: (err) => {
logTypingFailure({
log: (message) => log?.(message),
channel: "mattermost",
target: channelId,
error: err,
});
},
},
});
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
...replyPipeline,
humanDelay,
deliver: async (payload: ReplyPayload) => {
await deliverMattermostReplyPayload({
core,
cfg,
payload,
to,
accountId: account.accountId,
agentId: route.agentId,
textLimit,
tableMode,
sendMessage: sendMessageMattermost,
});
runtime.log?.(`delivered slash reply to ${to}`);
},
onError: (err, info) => {
runtime.error?.(`mattermost slash ${info.kind} reply failed: ${String(err)}`);
},
onReplyStart: typingCallbacks?.onReplyStart,
});
await core.channel.reply.withReplyDispatcher({
dispatcher,
onSettled: () => {
markDispatchIdle();
},
run: () =>
core.channel.reply.dispatchReplyFromConfig({
ctx: ctxPayload,
cfg,
dispatcher,
replyOptions: {
...replyOptions,
disableBlockStreaming:
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
onModelSelected,
},
}),
});
}
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import {
activateSlashCommands,
deactivateSlashCommands,
resolveSlashHandlerForToken,
} from "./slash-state.js";
function createResolvedMattermostAccount(accountId: string): ResolvedMattermostAccount {
return {
accountId,
enabled: true,
botTokenSource: "config",
baseUrlSource: "config",
config: {},
};
}
const slashApi = {
cfg: {},
runtime: {
log: () => {},
error: () => {},
exit: () => {},
},
} satisfies {
cfg: OpenClawConfig;
runtime: RuntimeEnv;
};
describe("slash-state token routing", () => {
it("returns single match when token belongs to one account", () => {
deactivateSlashCommands();
activateSlashCommands({
account: createResolvedMattermostAccount("a1"),
commandTokens: ["tok-a"],
registeredCommands: [],
api: slashApi,
});
const match = resolveSlashHandlerForToken("tok-a");
expect(match.kind).toBe("single");
expect(match.accountIds).toEqual(["a1"]);
});
it("returns ambiguous when same token exists in multiple accounts", () => {
deactivateSlashCommands();
activateSlashCommands({
account: createResolvedMattermostAccount("a1"),
commandTokens: ["tok-shared"],
registeredCommands: [],
api: slashApi,
});
activateSlashCommands({
account: createResolvedMattermostAccount("a2"),
commandTokens: ["tok-shared"],
registeredCommands: [],
api: slashApi,
});
const match = resolveSlashHandlerForToken("tok-shared");
expect(match.kind).toBe("ambiguous");
expect(match.accountIds?.sort()).toEqual(["a1", "a2"]);
});
});
@@ -0,0 +1,311 @@
/**
* Shared state for Mattermost slash commands.
*
* Bridges the plugin registration phase (HTTP route) with the monitor phase
* (command registration with MM API). The HTTP handler needs to know which
* tokens are valid, and the monitor needs to store registered command IDs.
*
* State is kept per-account so that multi-account deployments don't
* overwrite each other's tokens, registered commands, or handlers.
*/
import type { IncomingMessage, ServerResponse } from "node:http";
import type { MattermostConfig } from "../types.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import type { OpenClawPluginApi } from "./runtime-api.js";
import { resolveSlashCommandConfig, type MattermostRegisteredCommand } from "./slash-commands.js";
import { createSlashCommandHttpHandler } from "./slash-http.js";
// ─── Per-account state ───────────────────────────────────────────────────────
export type SlashCommandAccountState = {
/** Tokens from registered commands, used for validation. */
commandTokens: Set<string>;
/** Registered command IDs for cleanup on shutdown. */
registeredCommands: MattermostRegisteredCommand[];
/** Current HTTP handler for this account. */
handler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | null;
/** The account that activated slash commands. */
account: ResolvedMattermostAccount;
/** Map from trigger to original command name (for skill commands that start with oc_). */
triggerMap: Map<string, string>;
};
/** Map from accountId → per-account slash command state. */
const accountStates = new Map<string, SlashCommandAccountState>();
export function resolveSlashHandlerForToken(token: string): {
kind: "none" | "single" | "ambiguous";
handler?: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
accountIds?: string[];
} {
const matches: Array<{
accountId: string;
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
}> = [];
for (const [accountId, state] of accountStates) {
if (state.commandTokens.has(token) && state.handler) {
matches.push({ accountId, handler: state.handler });
}
}
if (matches.length === 0) {
return { kind: "none" };
}
if (matches.length === 1) {
return { kind: "single", handler: matches[0]!.handler, accountIds: [matches[0]!.accountId] };
}
return {
kind: "ambiguous",
accountIds: matches.map((entry) => entry.accountId),
};
}
/**
* Get the slash command state for a specific account, or null if not activated.
*/
export function getSlashCommandState(accountId: string): SlashCommandAccountState | null {
return accountStates.get(accountId) ?? null;
}
/**
* Get all active slash command account states.
*/
export function getAllSlashCommandStates(): ReadonlyMap<string, SlashCommandAccountState> {
return accountStates;
}
/**
* Activate slash commands for a specific account.
* Called from the monitor after bot connects.
*/
export function activateSlashCommands(params: {
account: ResolvedMattermostAccount;
commandTokens: string[];
registeredCommands: MattermostRegisteredCommand[];
triggerMap?: Map<string, string>;
api: {
cfg: import("./runtime-api.js").OpenClawConfig;
runtime: import("./runtime-api.js").RuntimeEnv;
};
log?: (msg: string) => void;
}) {
const { account, commandTokens, registeredCommands, triggerMap, api, log } = params;
const accountId = account.accountId;
const tokenSet = new Set(commandTokens);
const handler = createSlashCommandHttpHandler({
account,
cfg: api.cfg,
runtime: api.runtime,
commandTokens: tokenSet,
triggerMap,
log,
});
accountStates.set(accountId, {
commandTokens: tokenSet,
registeredCommands,
handler,
account,
triggerMap: triggerMap ?? new Map(),
});
log?.(
`mattermost: slash commands activated for account ${accountId} (${registeredCommands.length} commands)`,
);
}
/**
* Deactivate slash commands for a specific account (on shutdown/disconnect).
*/
export function deactivateSlashCommands(accountId?: string) {
if (accountId) {
const state = accountStates.get(accountId);
if (state) {
state.commandTokens.clear();
state.registeredCommands = [];
state.handler = null;
accountStates.delete(accountId);
}
} else {
// Deactivate all accounts (full shutdown)
for (const [, state] of accountStates) {
state.commandTokens.clear();
state.registeredCommands = [];
state.handler = null;
}
accountStates.clear();
}
}
/**
* Register the HTTP route for slash command callbacks.
* Called during plugin registration.
*
* The single HTTP route dispatches to the correct per-account handler
* by matching the inbound token against each account's registered tokens.
*/
export function registerSlashCommandRoute(api: OpenClawPluginApi) {
const mmConfig = api.config.channels?.mattermost as MattermostConfig | undefined;
// Collect callback paths from both top-level and per-account config.
// Command registration uses account.config.commands, so the HTTP route
// registration must include any account-specific callbackPath overrides.
// Also extract the pathname from an explicit callbackUrl when it differs
// from callbackPath, so that Mattermost callbacks hit a registered route.
const callbackPaths = new Set<string>();
const addCallbackPaths = (
raw: Partial<import("./slash-commands.js").MattermostSlashCommandConfig> | undefined,
) => {
const resolved = resolveSlashCommandConfig(raw);
callbackPaths.add(resolved.callbackPath);
if (resolved.callbackUrl) {
try {
const urlPath = new URL(resolved.callbackUrl).pathname;
if (urlPath && urlPath !== resolved.callbackPath) {
callbackPaths.add(urlPath);
}
} catch {
// Invalid URL — ignore, will be caught during registration
}
}
};
const commandsRaw = mmConfig?.commands as
| Partial<import("./slash-commands.js").MattermostSlashCommandConfig>
| undefined;
addCallbackPaths(commandsRaw);
const accountsRaw = mmConfig?.accounts ?? {};
for (const accountId of Object.keys(accountsRaw)) {
const accountCommandsRaw = accountsRaw[accountId]?.commands;
addCallbackPaths(accountCommandsRaw);
}
const routeHandler = async (req: IncomingMessage, res: ServerResponse) => {
if (accountStates.size === 0) {
res.statusCode = 503;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(
JSON.stringify({
response_type: "ephemeral",
text: "Slash commands are not yet initialized. Please try again in a moment.",
}),
);
return;
}
// We need to peek at the token to route to the right account handler.
// Since each account handler also validates the token, we find the
// account whose token set contains the inbound token and delegate.
// If there's only one active account (common case), route directly.
if (accountStates.size === 1) {
const [, state] = [...accountStates.entries()][0]!;
if (!state.handler) {
res.statusCode = 503;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(
JSON.stringify({
response_type: "ephemeral",
text: "Slash commands are not yet initialized. Please try again in a moment.",
}),
);
return;
}
await state.handler(req, res);
return;
}
// Multi-account: buffer the body, find the matching account by token,
// then replay the request to the correct handler.
const chunks: Buffer[] = [];
const MAX_BODY = 64 * 1024;
let size = 0;
for await (const chunk of req) {
size += (chunk as Buffer).length;
if (size > MAX_BODY) {
res.statusCode = 413;
res.end("Payload Too Large");
return;
}
chunks.push(chunk as Buffer);
}
const bodyStr = Buffer.concat(chunks).toString("utf8");
// Parse just the token to find the right account
let token: string | null = null;
const ct = req.headers["content-type"] ?? "";
try {
if (ct.includes("application/json")) {
token = (JSON.parse(bodyStr) as { token?: string }).token ?? null;
} else {
token = new URLSearchParams(bodyStr).get("token");
}
} catch {
// parse failed — will be caught by handler
}
const match = token ? resolveSlashHandlerForToken(token) : { kind: "none" as const };
if (match.kind === "none") {
// No matching account — reject
res.statusCode = 401;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(
JSON.stringify({
response_type: "ephemeral",
text: "Unauthorized: invalid command token.",
}),
);
return;
}
if (match.kind === "ambiguous") {
api.logger.warn?.(
`mattermost: slash callback token matched multiple accounts (${match.accountIds?.join(", ")})`,
);
res.statusCode = 409;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(
JSON.stringify({
response_type: "ephemeral",
text: "Conflict: command token is not unique across accounts.",
}),
);
return;
}
const matchedHandler = match.handler!;
// Replay: create a synthetic readable that re-emits the buffered body
const { Readable } = await import("node:stream");
const syntheticReq = new Readable({
read() {
this.push(Buffer.from(bodyStr, "utf8"));
this.push(null);
},
}) as IncomingMessage;
// Copy necessary IncomingMessage properties
syntheticReq.method = req.method;
syntheticReq.url = req.url;
syntheticReq.headers = req.headers;
await matchedHandler(syntheticReq, res);
};
for (const callbackPath of callbackPaths) {
api.registerHttpRoute({
path: callbackPath,
auth: "plugin",
handler: routeHandler,
});
api.logger.info?.(`mattermost: registered slash command callback at ${callbackPath}`);
}
}
@@ -0,0 +1,128 @@
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const resolveMattermostAccount = vi.fn();
const createMattermostClient = vi.fn();
const fetchMattermostUser = vi.fn();
const normalizeMattermostBaseUrl = vi.fn((value: string | undefined) => value?.trim());
vi.mock("./accounts.js", () => ({
resolveMattermostAccount,
}));
vi.mock("./client.js", () => ({
createMattermostClient,
fetchMattermostUser,
normalizeMattermostBaseUrl,
}));
describe("mattermost target resolution", () => {
let isExplicitMattermostTarget: typeof import("./target-resolution.js").isExplicitMattermostTarget;
let isMattermostId: typeof import("./target-resolution.js").isMattermostId;
let parseMattermostApiStatus: typeof import("./target-resolution.js").parseMattermostApiStatus;
let resolveMattermostOpaqueTarget: typeof import("./target-resolution.js").resolveMattermostOpaqueTarget;
let resetMattermostOpaqueTargetCacheForTests: typeof import("./target-resolution.js").resetMattermostOpaqueTargetCacheForTests;
beforeAll(async () => {
({
isExplicitMattermostTarget,
isMattermostId,
parseMattermostApiStatus,
resolveMattermostOpaqueTarget,
resetMattermostOpaqueTargetCacheForTests,
} = await import("./target-resolution.js"));
});
beforeEach(() => {
resolveMattermostAccount.mockReset();
createMattermostClient.mockReset();
fetchMattermostUser.mockReset();
normalizeMattermostBaseUrl.mockClear();
});
afterEach(() => {
resetMattermostOpaqueTargetCacheForTests();
});
it("recognizes explicit targets and ID-shaped values", () => {
expect(isExplicitMattermostTarget("@alice")).toBe(true);
expect(isExplicitMattermostTarget("#town-square")).toBe(true);
expect(isExplicitMattermostTarget("mattermost:chan")).toBe(true);
expect(isExplicitMattermostTarget(" plain ")).toBe(false);
expect(isMattermostId("abcd1234abcd1234abcd1234ab")).toBe(true);
expect(isMattermostId("short")).toBe(false);
expect(parseMattermostApiStatus(new Error("Mattermost API 404 Not Found"))).toBe(404);
expect(parseMattermostApiStatus(new Error("other error"))).toBeUndefined();
});
it("resolves opaque ids as users and caches the result", async () => {
createMattermostClient.mockReturnValue({ client: true });
fetchMattermostUser.mockResolvedValue({ id: "abcd1234abcd1234abcd1234ab" });
const input = "abcd1234abcd1234abcd1234ab";
await expect(
resolveMattermostOpaqueTarget({
input,
token: "token",
baseUrl: "https://mm.example.com",
}),
).resolves.toEqual({
kind: "user",
id: input,
to: `user:${input}`,
});
await expect(
resolveMattermostOpaqueTarget({
input,
token: "token",
baseUrl: "https://mm.example.com",
}),
).resolves.toEqual({
kind: "user",
id: input,
to: `user:${input}`,
});
expect(createMattermostClient).toHaveBeenCalledTimes(1);
expect(fetchMattermostUser).toHaveBeenCalledTimes(1);
});
it("falls back to channel targets on 404 lookups", async () => {
createMattermostClient.mockReturnValue({ client: true });
fetchMattermostUser.mockRejectedValue(new Error("Mattermost API 404 Not Found"));
const input = "bcde1234abcd1234abcd1234ab";
await expect(
resolveMattermostOpaqueTarget({
input,
token: "token",
baseUrl: "https://mm.example.com",
}),
).resolves.toEqual({
kind: "channel",
id: input,
to: `channel:${input}`,
});
});
it("uses account resolution when token/base url are not passed", async () => {
resolveMattermostAccount.mockReturnValue({
baseUrl: "https://mm.example.com",
botToken: "token",
});
createMattermostClient.mockReturnValue({ client: true });
fetchMattermostUser.mockResolvedValue({ id: "cdef1234abcd1234abcd1234ab" });
const input = "cdef1234abcd1234abcd1234ab";
await resolveMattermostOpaqueTarget({
input,
cfg: { channels: { mattermost: {} } },
accountId: "acct-1",
});
expect(resolveMattermostAccount).toHaveBeenCalledWith({
cfg: { channels: { mattermost: {} } },
accountId: "acct-1",
});
});
});
@@ -0,0 +1,101 @@
import { resolveMattermostAccount } from "./accounts.js";
import {
createMattermostClient,
fetchMattermostUser,
normalizeMattermostBaseUrl,
} from "./client.js";
import type { OpenClawConfig } from "./runtime-api.js";
export type MattermostOpaqueTargetResolution = {
kind: "user" | "channel";
id: string;
to: string;
};
const mattermostOpaqueTargetCache = new Map<string, boolean>();
function cacheKey(baseUrl: string, token: string, id: string): string {
return `${baseUrl}::${token}::${id}`;
}
/** Mattermost IDs are 26-character lowercase alphanumeric strings. */
export function isMattermostId(value: string): boolean {
return /^[a-z0-9]{26}$/.test(value);
}
export function isExplicitMattermostTarget(raw: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
return (
/^(channel|user|mattermost):/i.test(trimmed) ||
trimmed.startsWith("@") ||
trimmed.startsWith("#")
);
}
export function parseMattermostApiStatus(err: unknown): number | undefined {
if (!err || typeof err !== "object") {
return undefined;
}
const msg = "message" in err ? String((err as { message?: unknown }).message ?? "") : "";
const match = /Mattermost API (\d{3})\b/.exec(msg);
if (!match) {
return undefined;
}
const code = Number(match[1]);
return Number.isFinite(code) ? code : undefined;
}
export async function resolveMattermostOpaqueTarget(params: {
input: string;
cfg?: OpenClawConfig;
accountId?: string | null;
token?: string;
baseUrl?: string;
}): Promise<MattermostOpaqueTargetResolution | null> {
const input = params.input.trim();
if (!input || isExplicitMattermostTarget(input) || !isMattermostId(input)) {
return null;
}
const account =
params.cfg && (!params.token || !params.baseUrl)
? resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId })
: null;
const token = params.token?.trim() || account?.botToken?.trim();
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl ?? account?.baseUrl);
if (!token || !baseUrl) {
return null;
}
const key = cacheKey(baseUrl, token, input);
const cached = mattermostOpaqueTargetCache.get(key);
if (cached === true) {
return { kind: "user", id: input, to: `user:${input}` };
}
if (cached === false) {
return { kind: "channel", id: input, to: `channel:${input}` };
}
const client = createMattermostClient({
baseUrl,
botToken: token,
allowPrivateNetwork: account?.config?.allowPrivateNetwork === true,
});
try {
await fetchMattermostUser(client, input);
mattermostOpaqueTargetCache.set(key, true);
return { kind: "user", id: input, to: `user:${input}` };
} catch (err) {
if (parseMattermostApiStatus(err) === 404) {
mattermostOpaqueTargetCache.set(key, false);
}
return { kind: "channel", id: input, to: `channel:${input}` };
}
}
export function resetMattermostOpaqueTargetCacheForTests(): void {
mattermostOpaqueTargetCache.clear();
}
@@ -0,0 +1,96 @@
import { describe, expect, it } from "vitest";
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
describe("normalizeMattermostMessagingTarget", () => {
it("returns undefined for empty input", () => {
expect(normalizeMattermostMessagingTarget("")).toBeUndefined();
expect(normalizeMattermostMessagingTarget(" ")).toBeUndefined();
});
it("normalizes channel: prefix", () => {
expect(normalizeMattermostMessagingTarget("channel:abc123")).toBe("channel:abc123");
expect(normalizeMattermostMessagingTarget("Channel:ABC")).toBe("channel:ABC");
});
it("normalizes group: prefix to channel:", () => {
expect(normalizeMattermostMessagingTarget("group:abc123")).toBe("channel:abc123");
});
it("normalizes user: prefix", () => {
expect(normalizeMattermostMessagingTarget("user:abc123")).toBe("user:abc123");
});
it("normalizes mattermost: prefix to user:", () => {
expect(normalizeMattermostMessagingTarget("mattermost:abc123")).toBe("user:abc123");
});
it("keeps @username targets", () => {
expect(normalizeMattermostMessagingTarget("@alice")).toBe("@alice");
expect(normalizeMattermostMessagingTarget("@Alice")).toBe("@Alice");
});
it("returns undefined for #channel (triggers directory lookup)", () => {
expect(normalizeMattermostMessagingTarget("#bookmarks")).toBeUndefined();
expect(normalizeMattermostMessagingTarget("#off-topic")).toBeUndefined();
expect(normalizeMattermostMessagingTarget("# ")).toBeUndefined();
});
it("returns undefined for bare names (triggers directory lookup)", () => {
expect(normalizeMattermostMessagingTarget("bookmarks")).toBeUndefined();
expect(normalizeMattermostMessagingTarget("off-topic")).toBeUndefined();
});
it("returns undefined for empty prefixed values", () => {
expect(normalizeMattermostMessagingTarget("channel:")).toBeUndefined();
expect(normalizeMattermostMessagingTarget("user:")).toBeUndefined();
expect(normalizeMattermostMessagingTarget("@")).toBeUndefined();
expect(normalizeMattermostMessagingTarget("#")).toBeUndefined();
});
});
describe("looksLikeMattermostTargetId", () => {
it("returns false for empty input", () => {
expect(looksLikeMattermostTargetId("")).toBe(false);
expect(looksLikeMattermostTargetId(" ")).toBe(false);
});
it("recognizes prefixed targets", () => {
expect(looksLikeMattermostTargetId("channel:abc")).toBe(true);
expect(looksLikeMattermostTargetId("Channel:abc")).toBe(true);
expect(looksLikeMattermostTargetId("user:abc")).toBe(true);
expect(looksLikeMattermostTargetId("group:abc")).toBe(true);
expect(looksLikeMattermostTargetId("mattermost:abc")).toBe(true);
});
it("recognizes @username", () => {
expect(looksLikeMattermostTargetId("@alice")).toBe(true);
});
it("does NOT recognize #channel (should go to directory)", () => {
expect(looksLikeMattermostTargetId("#bookmarks")).toBe(false);
expect(looksLikeMattermostTargetId("#off-topic")).toBe(false);
});
it("recognizes 26-char alphanumeric Mattermost IDs", () => {
expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz")).toBe(true);
expect(looksLikeMattermostTargetId("12345678901234567890123456")).toBe(true);
expect(looksLikeMattermostTargetId("AbCdEf1234567890abcdef1234")).toBe(true); // pragma: allowlist secret
});
it("recognizes DM channel format (26__26)", () => {
expect(
looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz__12345678901234567890123456"), // pragma: allowlist secret
).toBe(true);
});
it("rejects short strings that are not Mattermost IDs", () => {
expect(looksLikeMattermostTargetId("password")).toBe(false);
expect(looksLikeMattermostTargetId("hi")).toBe(false);
expect(looksLikeMattermostTargetId("bookmarks")).toBe(false);
expect(looksLikeMattermostTargetId("off-topic")).toBe(false);
});
it("rejects strings longer than 26 chars that are not DM format", () => {
expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz1")).toBe(false); // pragma: allowlist secret
});
});
+50
View File
@@ -0,0 +1,50 @@
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("#")) {
// Strip # prefix and fall through to directory lookup (same as bare names).
// The core's resolveMessagingTarget will use the directory adapter to
// resolve the channel name to its Mattermost ID.
return undefined;
}
// Bare name without prefix — return undefined to allow directory lookup
return undefined;
}
export function looksLikeMattermostTargetId(raw: string, normalized?: string): boolean {
const trimmed = raw.trim();
if (!trimmed) {
return false;
}
if (/^(user|channel|group|mattermost):/i.test(trimmed)) {
return true;
}
if (trimmed.startsWith("@")) {
return true;
}
// Mattermost IDs: 26-char alnum, or DM channels like "abc123__xyz789" (53 chars)
return /^[a-z0-9]{26}$/i.test(trimmed) || /^[a-z0-9]{26}__[a-z0-9]{26}$/i.test(trimmed);
}
@@ -0,0 +1,14 @@
export function createAccountListHelpers(channelId: string) {
return {
listAccountIds: (cfg: any) => {
const accounts = cfg?.channels?.[channelId]?.accounts;
if (accounts && typeof accounts === "object") {
return Object.keys(accounts);
}
return ["default"];
},
resolveDefaultAccountId: (cfg: any) => {
return "default";
},
};
}
@@ -0,0 +1,5 @@
export const DEFAULT_ACCOUNT_ID = "default";
export function normalizeAccountId(id: string): string {
return id || "default";
}
@@ -0,0 +1,14 @@
export function resolveMergedAccountConfig<T>(params: {
channelConfig?: Record<string, unknown>;
accounts?: Record<string, Record<string, unknown>>;
accountId: string;
omitKeys?: string[];
nestedObjectKeys?: string[];
}): T {
const { channelConfig = {}, accounts = {}, accountId } = params;
const accountConfig = accounts[accountId] || {};
return {
...channelConfig,
...accountConfig,
} as T;
}
@@ -0,0 +1,7 @@
export function createResolvedApproverActionAuthAdapter() {
return {};
}
export function resolveApprovalApprovers() {
return [];
}
@@ -0,0 +1,3 @@
export function resolveChannelGroupRequireMention() {
return false;
}
@@ -0,0 +1,39 @@
export interface OpenClawConfig {
channels?: {
mattermost?: {
enabled?: boolean;
botToken?: string;
baseUrl?: string;
accounts?: Record<string, Record<string, unknown>>;
[key: string]: unknown;
};
};
commands?: {
useAccessGroups?: boolean;
native?: boolean | "auto";
nativeSkills?: boolean | "auto";
};
[key: string]: unknown;
}
export function resolveNativeCommandsEnabled(params: {
providerId: string;
providerSetting?: boolean | "auto";
globalSetting?: boolean | "auto";
}): boolean {
if (params.providerSetting === true) return true;
if (params.providerSetting === false) return false;
if (params.globalSetting === true) return true;
return false;
}
export function resolveNativeSkillsEnabled(params: {
providerId: string;
providerSetting?: boolean | "auto";
globalSetting?: boolean | "auto";
}): boolean {
if (params.providerSetting === true) return true;
if (params.providerSetting === false) return false;
if (params.globalSetting === true) return true;
return false;
}
@@ -0,0 +1,7 @@
export async function readChannelAllowFromStore(
channelId: string,
env: Record<string, string | undefined>,
accountId?: string
): Promise<string[]> {
return [];
}
@@ -0,0 +1,7 @@
export * from "./config-runtime.js";
export * from "./account-id.js";
export { buildChannelConfigSchema } from "./index.js";
export { buildDmSessionRoute } from "./index.js";
export { buildChannelOutboundSessionRoute } from "./index.js";
export { resolveThreadSessionKeys } from "./index.js";
export { resolveChannelGroupRequireMention } from "./index.js";
@@ -0,0 +1,10 @@
export function extractErrorCode(error: unknown): string | undefined {
if (error instanceof Error) {
return error.name;
}
return undefined;
}
export function formatErrorMessage(error: Error): string {
return error.message;
}
@@ -0,0 +1,22 @@
export const DEFAULT_ACCOUNT_ID = "default";
export function buildChannelConfigSchema() {
return {};
}
export function buildDmSessionRoute() {
return {};
}
export function buildChannelOutboundSessionRoute() {
return {};
}
export function resolveThreadSessionKeys() {
return [];
}
export function createResolvedApproverActionAuthAdapter() {
return {};
}
export function resolveApprovalApprovers() {
return [];
}
export function resolveChannelGroupRequireMention() {
return false;
}
@@ -0,0 +1,9 @@
export interface ReplyPayload {
text?: string;
interactive?: {
blocks: Array<{
type: string;
[key: string]: unknown;
}>;
};
}
@@ -0,0 +1,43 @@
export interface RetryOptions {
maxRetries?: number;
initialDelayMs?: number;
maxDelayMs?: number;
onRetry?: (info: RetryInfo) => void;
}
export interface RetryInfo {
attempt: number;
maxRetries: number;
delayMs: number;
error: Error;
}
export async function retryAsync<T>(
fn: () => Promise<T>,
options: RetryOptions = {}
): Promise<T> {
const maxRetries = options.maxRetries ?? 3;
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
return await fn();
} catch (error) {
lastError = error instanceof Error ? error : new Error(String(error));
if (attempt >= maxRetries) {
throw lastError;
}
const delayMs = (options.initialDelayMs ?? 1000) * Math.pow(2, attempt);
const cappedDelay = Math.min(delayMs, options.maxDelayMs ?? 30000);
options.onRetry?.({
attempt: attempt + 1,
maxRetries,
delayMs: cappedDelay,
error: lastError,
});
await new Promise((resolve) => setTimeout(resolve, cappedDelay));
}
}
throw lastError ?? new Error("Retry failed");
}
@@ -0,0 +1,13 @@
export function normalizeSecretInputString(value: unknown): string | undefined {
if (typeof value === "string") {
return value.trim() || undefined;
}
return undefined;
}
export function normalizeResolvedSecretInputString(params: {
value: unknown;
path: string;
}): string | undefined {
return normalizeSecretInputString(params.value);
}
@@ -0,0 +1 @@
export { DEFAULT_ACCOUNT_ID } from "./account-id.js";
@@ -0,0 +1,15 @@
export async function fetchWithSsrFGuard(params: {
url: string;
init?: RequestInit;
auditContext?: string;
policy?: { allowPrivateNetwork?: boolean };
}): Promise<{
response: Response;
release: () => Promise<void>;
}> {
const response = await fetch(params.url, params.init);
return {
response,
release: async () => {},
};
}
@@ -0,0 +1,19 @@
export const z = {
string: () => ({
nullable: () => z,
optional: () => z,
}),
number: () => ({
nullable: () => z,
optional: () => z,
}),
array: () => ({
nullable: () => z,
optional: () => z,
}),
object: (schema: Record<string, unknown>) => ({
passthrough: () => z,
}),
record: () => z,
infer: () => ({} as any),
};
+1
View File
@@ -0,0 +1 @@
export * from "../runtime-api.js";
+6
View File
@@ -0,0 +1,6 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "./runtime-api.js";
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } =
createPluginRuntimeStore<PluginRuntime>("Mattermost runtime not initialized");
export { getMattermostRuntime, setMattermostRuntime };
@@ -0,0 +1,54 @@
import {
collectSimpleChannelFieldAssignments,
getChannelSurface,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/security-runtime";
export const secretTargetRegistryEntries = [
{
id: "channels.mattermost.accounts.*.botToken",
targetType: "channels.mattermost.accounts.*.botToken",
configFile: "openclaw.json",
pathPattern: "channels.mattermost.accounts.*.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.mattermost.botToken",
targetType: "channels.mattermost.botToken",
configFile: "openclaw.json",
pathPattern: "channels.mattermost.botToken",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
] satisfies SecretTargetRegistryEntry[];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults: SecretDefaults | undefined;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "mattermost");
if (!resolved) {
return;
}
const { channel: mattermost, surface } = resolved;
collectSimpleChannelFieldAssignments({
channelKey: "mattermost",
field: "botToken",
channel: mattermost,
surface,
defaults: params.defaults,
context: params.context,
topInactiveReason: "no enabled account inherits this top-level Mattermost botToken.",
accountInactiveReason: "Mattermost account is disabled.",
});
}
@@ -0,0 +1,7 @@
export type { SecretInput } from "openclaw/plugin-sdk/secret-input";
export {
buildSecretInputSchema,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";
+385
View File
@@ -0,0 +1,385 @@
import {
resolveNativeCommandsEnabled,
resolveNativeSkillsEnabled,
} from "openclaw/plugin-sdk/config-runtime";
import { readChannelAllowFromStore } from "openclaw/plugin-sdk/conversation-runtime";
import type { ResolvedMattermostAccount } from "./mattermost/accounts.js";
import type { MattermostAccountConfig, MattermostConfig } from "./types.js";
import type { OpenClawConfig } from "./runtime-api.js";
export type SecurityAuditFinding = {
checkId: string;
severity: "info" | "warn" | "critical";
title: string;
detail: string;
remediation?: string;
};
function normalizeAllowFromList(list: Array<string | number> | undefined | null): string[] {
if (!Array.isArray(list)) {
return [];
}
return list.map((value) => String(value).trim()).filter(Boolean);
}
function coerceNativeSetting(value: unknown): boolean | "auto" | undefined {
if (value === true || value === false || value === "auto") {
return value;
}
return undefined;
}
function isHttpsUrl(url: string): boolean {
try {
const parsed = new URL(url);
return parsed.protocol === "https:";
} catch {
return false;
}
}
function isValidMattermostId(id: string): boolean {
// Mattermost IDs are 26-character alphanumeric strings
const normalized = id
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, "")
.trim()
.toLowerCase();
return /^[a-z0-9]{26}$/.test(normalized);
}
function isMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
if (isValidMattermostId(text)) {
return false;
}
return true;
}
function hasEnvVarReference(value: string | undefined): boolean {
if (!value) return false;
// Check for common env var patterns: $VAR, ${VAR}, %VAR%
return /\$\w+|\$\{[^}]+\}|%[^%]+%/.test(value);
}
function hasHardcodedSecret(value: string | undefined): boolean {
if (!value) return false;
// Mattermost tokens are typically 26+ character alphanumeric
// Pattern: looks like a token but not an env reference
const trimmed = value.trim();
if (trimmed.length < 20) return false;
// Check if it looks like a token (alphanumeric with possible hyphens/underscores)
if (!/^[a-zA-Z0-9_-]+$/.test(trimmed)) return false;
// Check for common env var patterns that would indicate it's not hardcoded
if (hasEnvVarReference(trimmed)) return false;
return true;
}
function sanitizeForLog(value: string): string {
return value.replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
}
/**
* Collect security audit findings for Mattermost configuration.
* This function performs comprehensive security checks including:
* - PAT (Personal Access Token) security (env var usage, no hardcoding)
* - HTTPS enforcement verification
* - Input validation audit
* - File download restrictions check
* - Token permission checks
* - Config migration detection
* - Warnings for insecure configurations
*/
export async function collectMattermostSecurityAuditFindings(params: {
cfg: OpenClawConfig;
accountId?: string | null;
account: ResolvedMattermostAccount;
}): Promise<SecurityAuditFinding[]> {
const findings: SecurityAuditFinding[] = [];
const mmCfg = params.account.config ?? {};
const accountId = params.accountId?.trim() || params.account.accountId || "default";
// ===== 1. PAT SECURITY CHECKS =====
// Check 1.1: Bot token should use environment variables
if (params.account.botToken && params.account.botTokenSource === "config") {
const botToken = params.account.botToken;
if (hasHardcodedSecret(botToken)) {
findings.push({
checkId: "channels.mattermost.pat.hardcoded_token",
severity: "critical",
title: "Hardcoded Mattermost bot token detected",
detail: `The bot token for account "${accountId}" appears to be hardcoded in configuration. Hardcoded credentials pose a security risk and may be exposed in version control or logs.`,
remediation: "Move the token to the MATTERMOST_BOT_TOKEN environment variable or use a secret management system.",
});
} else if (botToken.length < 20) {
findings.push({
checkId: "channels.mattermost.pat.short_token",
severity: "warn",
title: "Short Mattermost bot token detected",
detail: `The bot token for account "${accountId}" appears unusually short (${botToken.length} chars). This may indicate an invalid or incomplete token.`,
remediation: "Verify the bot token is complete and valid.",
});
}
}
// Check 1.2: No bot token configured
if (!params.account.botToken) {
findings.push({
checkId: "channels.mattermost.pat.missing_token",
severity: "critical",
title: "No Mattermost bot token configured",
detail: `Account "${accountId}" has no bot token configured. The bot will not be able to authenticate with the Mattermost server.`,
remediation: "Set the MATTERMOST_BOT_TOKEN environment variable or configure channels.mattermost.botToken.",
});
}
// ===== 2. HTTPS ENFORCEMENT CHECK =====
// Check 2.1: Base URL should use HTTPS
if (params.account.baseUrl) {
if (!isHttpsUrl(params.account.baseUrl)) {
findings.push({
checkId: "channels.mattermost.https.insecure_url",
severity: "critical",
title: "Mattermost base URL uses HTTP (insecure)",
detail: `The base URL "${sanitizeForLog(params.account.baseUrl)}" for account "${accountId}" uses HTTP instead of HTTPS. This exposes all communications (including authentication tokens) to interception.`,
remediation: "Change the baseUrl to use HTTPS, e.g., https://chat.example.com",
});
}
} else {
findings.push({
checkId: "channels.mattermost.https.missing_url",
severity: "critical",
title: "No Mattermost base URL configured",
detail: `Account "${accountId}" has no base URL configured. The bot will not be able to connect to the Mattermost server.`,
remediation: "Set the MATTERMOST_URL environment variable or configure channels.mattermost.baseUrl with a HTTPS URL.",
});
}
// ===== 3. INPUT VALIDATION AUDIT =====
// Check 3.1: Validate interaction callback base URL
const interactions = mmCfg.interactions as { callbackBaseUrl?: string; allowedSourceIps?: string[] } | undefined;
if (interactions?.callbackBaseUrl) {
if (!isHttpsUrl(interactions.callbackBaseUrl)) {
findings.push({
checkId: "channels.mattermost.input.insecure_callback",
severity: "critical",
title: "Mattermost interaction callback uses HTTP",
detail: `The interaction callbackBaseUrl "${sanitizeForLog(interactions.callbackBaseUrl)}" uses HTTP. This exposes interaction payloads to interception.`,
remediation: "Configure interaction.callbackBaseUrl to use HTTPS.",
});
}
// Check for allowed source IPs
if (!interactions.allowedSourceIps || interactions.allowedSourceIps.length === 0) {
findings.push({
checkId: "channels.mattermost.input.no_source_ip_restriction",
severity: "warn",
title: "No IP restrictions on Mattermost interaction callbacks",
detail: "Interaction callbacks have no source IP allowlist configured. This may allow spoofed requests from unauthorized sources.",
remediation: "Configure interactions.allowedSourceIps to restrict callback sources to your Mattermost server IP/CIDR.",
});
}
}
// ===== 4. FILE DOWNLOAD RESTRICTIONS CHECK =====
// Check 4.1: Private network access
if (mmCfg.allowPrivateNetwork === true) {
findings.push({
checkId: "channels.mattermost.network.private_access_enabled",
severity: "warn",
title: "Private network access enabled for Mattermost",
detail: "The allowPrivateNetwork setting is enabled, allowing the bot to fetch from private/internal IP addresses. This is required for self-hosted Mattermost but increases attack surface.",
remediation: "If not using self-hosted Mattermost on LAN/VPN, set allowPrivateNetwork to false.",
});
}
// ===== 5. TOKEN PERMISSION CHECKS =====
// Check 5.1: Slash command security
const nativeEnabled = resolveNativeCommandsEnabled({
providerId: "mattermost",
providerSetting: coerceNativeSetting(
(mmCfg.commands as { native?: unknown } | undefined)?.native,
),
globalSetting: params.cfg.commands?.native,
});
const nativeSkillsEnabled = resolveNativeSkillsEnabled({
providerId: "mattermost",
providerSetting: coerceNativeSetting(
(mmCfg.commands as { nativeSkills?: unknown } | undefined)?.nativeSkills,
),
globalSetting: params.cfg.commands?.nativeSkills,
});
const slashCommandEnabled =
nativeEnabled ||
nativeSkillsEnabled ||
(mmCfg.commands as { enabled?: unknown } | undefined)?.enabled === true;
if (slashCommandEnabled) {
const useAccessGroups = params.cfg.commands?.useAccessGroups !== false;
if (!useAccessGroups) {
findings.push({
checkId: "channels.mattermost.commands.access_groups_disabled",
severity: "critical",
title: "Mattermost slash commands bypass access groups",
detail: "Mattermost slash/native commands are enabled while commands.useAccessGroups=false; this can allow unrestricted command execution from channels/users you didn't explicitly authorize.",
remediation: "Set commands.useAccessGroups=true (recommended).",
});
}
// Check for allowlists on slash commands
const allowFromRaw = mmCfg.allowFrom;
// eslint-disable-next-line no-process-env
const storeAllowFrom = await readChannelAllowFromStore("mattermost", process.env, accountId).catch(
() => [],
);
const ownerAllowFromConfigured =
normalizeAllowFromList([...(allowFromRaw || []), ...storeAllowFrom]).length > 0;
if (!ownerAllowFromConfigured) {
findings.push({
checkId: "channels.mattermost.commands.no_allowlist",
severity: "warn",
title: "Mattermost slash commands have no allowlist",
detail: "Mattermost slash/native commands are enabled, but no owner allowFrom list is configured; commands may be rejected for everyone or accepted from anyone depending on dmPolicy.",
remediation: "Configure channels.mattermost.allowFrom with authorized user IDs, or use pairing to approve users.",
});
}
}
// ===== 6. CONFIG MIGRATION DETECTION =====
// Check 6.1: Legacy dmPolicy migration needed
const legacyDmPolicy = (mmCfg as { dm?: { policy?: string } }).dm?.policy;
if (legacyDmPolicy !== undefined && mmCfg.dmPolicy === undefined) {
findings.push({
checkId: "channels.mattermost.config.legacy_dm_policy",
severity: "info",
title: "Legacy dm.policy configuration detected",
detail: "The configuration uses the legacy dm.policy setting which should be migrated to dmPolicy.",
remediation: "Run 'openclaw doctor --fix' to migrate legacy configuration automatically.",
});
}
// Check 6.2: Legacy allowFrom migration needed
const legacyAllowFrom = (mmCfg as { dm?: { allowFrom?: unknown[] } }).dm?.allowFrom;
if (legacyAllowFrom !== undefined && mmCfg.allowFrom === undefined) {
findings.push({
checkId: "channels.mattermost.config.legacy_allow_from",
severity: "info",
title: "Legacy dm.allowFrom configuration detected",
detail: "The configuration uses the legacy dm.allowFrom setting which should be migrated to top-level allowFrom.",
remediation: "Run 'openclaw doctor --fix' to migrate legacy configuration automatically.",
});
}
// ===== 7. MUTABLE ALLOWLIST WARNINGS =====
// Check 7.1: Mutable entries in allowFrom without dangerous name matching
const allowFromList = mmCfg.allowFrom || [];
const mutableAllowEntries = normalizeAllowFromList(allowFromList).filter(isMutableAllowEntry);
const dangerousNameMatchingEnabled = mmCfg.dangerouslyAllowNameMatching === true;
if (mutableAllowEntries.length > 0 && !dangerousNameMatchingEnabled) {
const entriesPreview = mutableAllowEntries.slice(0, 3).join(", ");
const moreCount = mutableAllowEntries.length > 3 ? ` (+${mutableAllowEntries.length - 3} more)` : "";
findings.push({
checkId: "channels.mattermost.allowlist.mutable_entries",
severity: "warn",
title: "Mutable allowlist entries detected",
detail: `Found ${mutableAllowEntries.length} mutable (non-ID) entries in allowFrom: "${entriesPreview}${moreCount}". These entries match by username/display name which can change, potentially allowing unauthorized access.`,
remediation: "Option A: Enable channels.mattermost.dangerouslyAllowNameMatching=true as a break-glass measure. Option B: Resolve names to stable Mattermost IDs and update the allowlist entries.",
});
}
// ===== 8. ACTION PERMISSIONS CHECK =====
const actions = mmCfg.actions || {};
// Check 8.1: Delete action enabled
const deleteEnabled = actions.delete ?? true;
if (deleteEnabled !== false) {
findings.push({
checkId: "channels.mattermost.actions.delete_enabled",
severity: "info",
title: "Message delete action is enabled",
detail: "The delete action is enabled, allowing the bot to delete messages. This is generally safe but increases the attack surface.",
remediation: "If message deletion is not needed, disable with channels.mattermost.actions.delete=false.",
});
}
// ===== 9. GROUP POLICY SECURITY =====
const groupPolicy = mmCfg.groupPolicy;
if (groupPolicy === "open") {
findings.push({
checkId: "channels.mattermost.groups.open_policy",
severity: "warn",
title: "Open group policy configured",
detail: "The groupPolicy is set to 'open', allowing any Mattermost user to interact with the bot in group channels. This increases the attack surface.",
remediation: "Consider setting groupPolicy to 'allowlist' and configuring groupAllowFrom with authorized users.",
});
}
// ===== 10. DM POLICY SECURITY =====
const dmPolicy = mmCfg.dmPolicy;
if (dmPolicy === "open") {
findings.push({
checkId: "channels.mattermost.dm.open_policy",
severity: "warn",
title: "Open DM policy configured",
detail: "The dmPolicy is set to 'open', allowing any Mattermost user to send direct messages to the bot. This increases the attack surface.",
remediation: "Consider setting dmPolicy to 'allowlist' or 'pairing' to restrict DM access.",
});
} else if (dmPolicy === "pairing") {
findings.push({
checkId: "channels.mattermost.dm.pairing_mode",
severity: "info",
title: "DM pairing mode enabled",
detail: "The dmPolicy is set to 'pairing', requiring users to explicitly approve access before sending DMs to the bot. This is the recommended secure configuration.",
});
}
return findings;
}
/**
* Check if an allowlist entry is mutable (uses name/username instead of stable ID).
* Exported for use in doctor.ts
*/
export function isMattermostMutableAllowEntry(raw: string): boolean {
return isMutableAllowEntry(raw);
}
/**
* Collect security audit findings for all enabled Mattermost accounts.
*/
export async function collectAllMattermostSecurityAuditFindings(params: {
cfg: OpenClawConfig;
listAccounts: (cfg: OpenClawConfig) => ResolvedMattermostAccount[];
}): Promise<SecurityAuditFinding[]> {
const accounts = params.listAccounts(params.cfg);
const allFindings: SecurityAuditFinding[] = [];
for (const account of accounts) {
if (!account.enabled) continue;
const findings = await collectMattermostSecurityAuditFindings({
cfg: params.cfg,
accountId: account.accountId,
account,
});
allFindings.push(...findings);
}
return allFindings;
}

Some files were not shown because too many files have changed in this diff Show More