Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled

Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11),
free to diverge. Tree copied sans upstream .git; upstream remote added for
future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19.
Preserves docs/ARCHITECTURE.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
2026-07-05 09:36:54 +00:00
parent 3216769225
commit bedb527145
21108 changed files with 6010766 additions and 0 deletions

View File

@@ -0,0 +1,29 @@
// Mattermost tests cover approval auth plugin behavior.
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 });
});
});

View File

@@ -0,0 +1,31 @@
// Mattermost plugin module implements approval auth behavior.
import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-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();
const lowered = normalizeLowercaseStringOrEmpty(normalized);
return MATTERMOST_USER_ID_RE.test(lowered) ? lowered : 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),
});

View File

@@ -0,0 +1,127 @@
// Mattermost tests cover channel actions setup status.contract plugin behavior.
import {
installChannelActionsContractSuite,
installChannelSetupContractSuite,
installChannelStatusContractSuite,
} from "openclaw/plugin-sdk/channel-test-helpers";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { describe, expect } from "vitest";
import { mattermostPlugin, mattermostSetupPlugin } from "../channel-plugin-api.js";
describe("mattermost actions contract", () => {
installChannelActionsContractSuite({
plugin: mattermostPlugin,
unsupportedAction: "poll",
cases: [
{
name: "configured account exposes send and react",
cfg: {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
},
},
} as OpenClawConfig,
expectedActions: ["send", "react"],
expectedCapabilities: ["presentation"],
},
{
name: "reactions can be disabled while send stays available",
cfg: {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
actions: { reactions: false },
},
},
} as OpenClawConfig,
expectedActions: ["send"],
expectedCapabilities: ["presentation"],
},
{
name: "missing bot credentials disables the actions surface",
cfg: {
channels: {
mattermost: {
enabled: true,
},
},
} as OpenClawConfig,
expectedActions: [],
expectedCapabilities: [],
},
],
});
});
describe("mattermost setup contract", () => {
installChannelSetupContractSuite({
plugin: mattermostSetupPlugin,
cases: [
{
name: "default account stores token and normalized base URL",
cfg: {} as OpenClawConfig,
input: {
botToken: "test-token",
httpUrl: "https://chat.example.com/",
},
expectedAccountId: "default",
assertPatchedConfig: (cfg) => {
const mattermostConfig = cfg.channels?.mattermost;
if (!mattermostConfig) {
throw new Error("expected Mattermost config patch");
}
expect(mattermostConfig.enabled).toBe(true);
expect(mattermostConfig.botToken).toBe("test-token");
expect(mattermostConfig.baseUrl).toBe("https://chat.example.com");
},
},
{
name: "missing credentials are rejected",
cfg: {} as OpenClawConfig,
input: {
httpUrl: "",
},
expectedAccountId: "default",
expectedValidation: "Mattermost requires --bot-token and --http-url (or --use-env).",
},
],
});
});
describe("mattermost status contract", () => {
installChannelStatusContractSuite({
plugin: mattermostPlugin,
cases: [
{
name: "configured account preserves connectivity details in the snapshot",
cfg: {
channels: {
mattermost: {
enabled: true,
botToken: "test-token",
baseUrl: "https://chat.example.com",
},
},
} as OpenClawConfig,
runtime: {
accountId: "default",
connected: true,
lastConnectedAt: 1234,
},
probe: { ok: true },
assertSnapshot: (snapshot) => {
expect(snapshot.accountId).toBe("default");
expect(snapshot.enabled).toBe(true);
expect(snapshot.configured).toBe(true);
expect(snapshot.connected).toBe(true);
expect(snapshot.baseUrl).toBe("https://chat.example.com");
},
},
],
});
});

View File

@@ -0,0 +1,5 @@
// Mattermost API module exposes the plugin public contract.
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-outbound";
export type { ChannelPlugin } from "openclaw/plugin-sdk/core";
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/core";
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";

View File

@@ -0,0 +1,80 @@
// Mattermost helper module supports channel config shared behavior.
import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { formatNormalizedAllowFromEntries } from "openclaw/plugin-sdk/allow-from";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { resolveMattermostGatewayAuthBypassPaths } from "./gateway-auth-bypass.js";
import {
listMattermostAccountIds,
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
type ResolvedMattermostAccount,
} from "./mattermost/accounts.js";
export const mattermostMeta = {
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;
export function normalizeMattermostAllowEntry(entry: string): string {
return normalizeLowercaseStringOrEmpty(
entry
.trim()
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, ""),
);
}
function formatMattermostAllowEntry(entry: string): string {
const trimmed = entry.trim();
if (!trimmed) {
return "";
}
if (trimmed.startsWith("@")) {
const username = trimmed.slice(1).trim();
return username ? `@${normalizeLowercaseStringOrEmpty(username)}` : "";
}
return normalizeLowercaseStringOrEmpty(trimmed.replace(/^(mattermost|user):/i, ""));
}
export { resolveMattermostGatewayAuthBypassPaths };
export const mattermostConfigAdapter = createScopedChannelConfigAdapter<ResolvedMattermostAccount>({
sectionKey: "mattermost",
listAccountIds: listMattermostAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveMattermostAccount),
defaultAccountId: resolveDefaultMattermostAccountId,
clearBaseFields: ["botToken", "baseUrl", "name"],
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatNormalizedAllowFromEntries({
allowFrom,
normalizeEntry: formatMattermostAllowEntry,
}),
});
export function isMattermostConfigured(account: ResolvedMattermostAccount): boolean {
return Boolean(account.botToken && account.baseUrl);
}
export function describeMattermostAccount(account: ResolvedMattermostAccount) {
return describeAccountSnapshot({
account,
configured: isMattermostConfigured(account),
extra: {
botTokenSource: account.botTokenSource,
baseUrl: account.baseUrl,
},
});
}

View File

@@ -0,0 +1,256 @@
// Mattermost tests cover channel.message adapter plugin behavior.
import {
verifyChannelMessageAdapterCapabilityProofs,
verifyChannelMessageLiveCapabilityAdapterProofs,
verifyChannelMessageLiveFinalizerProofs,
} from "openclaw/plugin-sdk/channel-outbound";
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const sendMessageMattermostMock = vi.hoisted(() => vi.fn());
vi.mock("./mattermost/send.js", () => ({
sendMessageMattermost: sendMessageMattermostMock,
}));
import { mattermostPlugin } from "./channel.js";
type MattermostMessageAdapter = NonNullable<typeof mattermostPlugin.message>;
type MattermostMessageSender = NonNullable<MattermostMessageAdapter["send"]>;
function requireMattermostMessageAdapter(): MattermostMessageAdapter {
const adapter = mattermostPlugin.message;
if (!adapter) {
throw new Error("Expected mattermost plugin to expose a channel message adapter");
}
return adapter;
}
function requireTextSender(
adapter: MattermostMessageAdapter,
): NonNullable<MattermostMessageSender["text"]> {
const text = adapter.send?.text;
if (!text) {
throw new Error("Expected mattermost message adapter text sender");
}
return text;
}
function requireMediaSender(
adapter: MattermostMessageAdapter,
): NonNullable<MattermostMessageSender["media"]> {
const media = adapter.send?.media;
if (!media) {
throw new Error("Expected mattermost message adapter media sender");
}
return media;
}
function requirePayloadSender(
adapter: MattermostMessageAdapter,
): NonNullable<MattermostMessageSender["payload"]> {
const payload = adapter.send?.payload;
if (!payload) {
throw new Error("Expected mattermost message adapter payload sender");
}
return payload;
}
describe("mattermost channel message adapter", () => {
beforeAll(async () => {
sendMessageMattermostMock.mockResolvedValue({
messageId: "warmup-post",
channelId: "channel-1",
});
await requireTextSender(requireMattermostMessageAdapter())({
cfg: {},
to: "channel:warmup",
text: "warmup",
accountId: "default",
});
sendMessageMattermostMock.mockReset();
});
beforeEach(() => {
sendMessageMattermostMock.mockReset();
sendMessageMattermostMock.mockResolvedValue({
messageId: "post-1",
channelId: "channel-1",
});
});
it("declares durable-final capabilities covered by outbound proof tests", async () => {
const adapter = requireMattermostMessageAdapter();
const sendPayload = requirePayloadSender(adapter);
const provePayload = async () => {
sendMessageMattermostMock.mockClear();
sendMessageMattermostMock.mockResolvedValueOnce({
messageId: "post-1",
channelId: "channel-1",
receipt: {
primaryPlatformMessageId: "post-1",
platformMessageIds: ["post-1"],
parts: [{ platformMessageId: "post-1", kind: "card", index: 0 }],
sentAt: Date.now(),
},
});
const result = await sendPayload({
cfg: {},
to: "channel:team-1",
text: "card",
accountId: "default",
payload: {
text: "card",
channelData: {
mattermost: {
presentationButtons: [[{ text: "Open", callback_data: "open" }]],
},
},
},
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "card", {
cfg: {},
accountId: "default",
mediaUrl: undefined,
mediaLocalRoots: undefined,
mediaReadFile: undefined,
replyToId: undefined,
buttons: [[{ text: "Open", callback_data: "open" }]],
});
expect(result.receipt.platformMessageIds).toEqual(["post-1"]);
expect(result.receipt.parts[0]?.kind).toBe("card");
};
await verifyChannelMessageAdapterCapabilityProofs({
adapterName: "mattermostMessageAdapter",
adapter,
proofs: {
payload: provePayload,
text: () => undefined,
media: () => undefined,
replyTo: () => undefined,
thread: () => undefined,
messageSendingHooks: () => {
expect(requireTextSender(adapter)).toBeTypeOf("function");
},
},
});
});
it("sends text through Mattermost", async () => {
const sendText = requireTextSender(requireMattermostMessageAdapter());
const result = await sendText({
cfg: {},
to: "channel:team-1",
text: "hello",
accountId: "default",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "hello", {
cfg: {},
accountId: "default",
replyToId: undefined,
});
expect(result.receipt.platformMessageIds).toEqual(["post-1"]);
expect(result.receipt.parts[0]?.kind).toBe("text");
});
it("sends media through Mattermost", async () => {
const sendMedia = requireMediaSender(requireMattermostMessageAdapter());
const result = await sendMedia({
cfg: {},
to: "channel:team-1",
text: "caption",
mediaUrl: "https://example.com/a.png",
mediaLocalRoots: ["/tmp/media"],
accountId: "default",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:team-1", "caption", {
cfg: {},
accountId: "default",
mediaUrl: "https://example.com/a.png",
mediaLocalRoots: ["/tmp/media"],
replyToId: undefined,
});
expect(result.receipt.parts[0]?.kind).toBe("media");
});
it("maps thread ids to Mattermost reply targets", async () => {
const sendText = requireTextSender(requireMattermostMessageAdapter());
const result = await sendText({
cfg: {},
to: "channel:parent-1",
text: "threaded",
accountId: "default",
threadId: "thread-1",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "threaded", {
cfg: {},
accountId: "default",
replyToId: "thread-1",
});
expect(result.receipt.threadId).toBe("thread-1");
});
it("prefers explicit Mattermost reply ids over thread ids", async () => {
const sendText = requireTextSender(requireMattermostMessageAdapter());
const result = await sendText({
cfg: {},
to: "channel:parent-1",
text: "reply",
accountId: "default",
replyToId: "post-parent-1",
threadId: "thread-1",
});
expect(sendMessageMattermostMock).toHaveBeenLastCalledWith("channel:parent-1", "reply", {
cfg: {},
accountId: "default",
replyToId: "post-parent-1",
});
expect(result.receipt.replyToId).toBe("post-parent-1");
});
it("backs declared live preview finalizer capabilities with adapter proofs", async () => {
const adapter = requireMattermostMessageAdapter();
const sendText = requireTextSender(adapter);
await verifyChannelMessageLiveCapabilityAdapterProofs({
adapterName: "mattermostMessageAdapter",
adapter,
proofs: {
draftPreview: () => {
expect(adapter.live?.finalizer?.capabilities?.discardPending).toBe(true);
},
previewFinalization: () => {
expect(adapter.live?.finalizer?.capabilities?.finalEdit).toBe(true);
},
progressUpdates: () => {
expect(adapter.live?.capabilities?.draftPreview).toBe(true);
},
},
});
await verifyChannelMessageLiveFinalizerProofs({
adapterName: "mattermostMessageAdapter",
adapter,
proofs: {
finalEdit: () => {
expect(adapter.live?.capabilities?.previewFinalization).toBe(true);
},
normalFallback: () => {
expect(sendText).toBeTypeOf("function");
},
discardPending: () => {
expect(adapter.live?.capabilities?.draftPreview).toBe(true);
},
},
});
});
});

View File

@@ -0,0 +1,10 @@
// Mattermost plugin module implements channel behavior.
export {
listMattermostDirectoryGroups,
listMattermostDirectoryPeers,
} from "./mattermost/directory.js";
export { monitorMattermostProvider } from "./mattermost/monitor.js";
export { probeMattermost } from "./mattermost/probe.js";
export { addMattermostReaction, removeMattermostReaction } from "./mattermost/reactions.js";
export { sendMessageMattermost } from "./mattermost/send.js";
export { resolveMattermostOpaqueTarget } from "./mattermost/target-resolution.js";

View File

@@ -0,0 +1,39 @@
// Mattermost plugin module implements channel.setup behavior.
import type { ChannelPlugin } from "./channel-api.js";
import {
describeMattermostAccount,
isMattermostConfigured,
mattermostConfigAdapter,
mattermostMeta,
resolveMattermostGatewayAuthBypassPaths,
} from "./channel-config-shared.js";
import { MattermostChannelConfigSchema } from "./config-surface.js";
import type { ResolvedMattermostAccount } from "./mattermost/accounts.js";
import { mattermostSetupAdapter } from "./setup-core.js";
import { mattermostSetupWizard } from "./setup-surface.js";
export const mattermostSetupPlugin: ChannelPlugin<ResolvedMattermostAccount> = {
id: "mattermost",
meta: {
...mattermostMeta,
},
capabilities: {
chatTypes: ["direct", "channel", "group", "thread"],
reactions: true,
threads: true,
media: true,
nativeCommands: true,
},
reload: { configPrefixes: ["channels.mattermost"] },
configSchema: MattermostChannelConfigSchema,
config: {
...mattermostConfigAdapter,
isConfigured: isMattermostConfigured,
describeAccount: describeMattermostAccount,
},
gateway: {
resolveGatewayAuthBypassPaths: ({ cfg }) => resolveMattermostGatewayAuthBypassPaths(cfg),
},
setup: mattermostSetupAdapter,
setupWizard: mattermostSetupWizard,
};

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,941 @@
// Mattermost plugin module implements channel behavior.
import type {
ChannelMessageActionAdapter,
ChannelMessageActionName,
ChannelMessageToolDiscovery,
ChannelThreadingContext,
ChannelThreadingToolContext,
ChannelToolSend,
} from "openclaw/plugin-sdk/channel-contract";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createChannelMessageAdapterFromOutbound } from "openclaw/plugin-sdk/channel-outbound";
import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel-pairing";
import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
import {
attachChannelToResult,
createAttachedChannelResultAdapter,
type ChannelOutboundAdapter,
} from "openclaw/plugin-sdk/channel-send-result";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
import {
type MessagePresentation,
normalizeMessagePresentation,
renderMessagePresentationFallbackText,
resolveMessagePresentationControlValue,
} from "openclaw/plugin-sdk/interactive-runtime";
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
import { resolvePayloadMediaUrls, sendTextMediaPayload } from "openclaw/plugin-sdk/reply-payload";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import {
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import { mattermostApprovalAuth } from "./approval-auth.js";
import {
chunkTextForOutbound,
createAccountStatusSink,
DEFAULT_ACCOUNT_ID,
type ChannelPlugin,
} from "./channel-api.js";
import {
describeMattermostAccount,
isMattermostConfigured,
mattermostConfigAdapter,
mattermostMeta as meta,
normalizeMattermostAllowEntry as normalizeAllowEntry,
resolveMattermostGatewayAuthBypassPaths,
} from "./channel-config-shared.js";
import { MattermostChannelConfigSchema } from "./config-surface.js";
import { mattermostDoctor } from "./doctor.js";
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
import {
listMattermostAccountIds,
resolveDefaultMattermostAccountId,
resolveMattermostAccount,
resolveMattermostReplyToMode,
type ResolvedMattermostAccount,
} from "./mattermost/accounts.js";
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.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";
const loadMattermostChannelRuntime = createLazyRuntimeModule(() => import("./channel.runtime.js"));
function buildMattermostPresentationButtons(presentation: MessagePresentation) {
return presentation.blocks
.filter((block) => block.type === "buttons")
.map((block) =>
block.buttons.flatMap((button) => {
if (button.action) {
return [];
}
const value = resolveMessagePresentationControlValue(button);
return value
? [
{
id: value,
text: button.label,
callback_data: value,
context: {
callback_data: value,
},
style: button.style,
},
]
: [];
}),
)
.filter((row) => row.length > 0);
}
const MATTERMOST_PRESENTATION_CAPABILITIES = {
supported: true,
buttons: true,
selects: false,
context: true,
divider: false,
limits: {
text: {
markdownDialect: "markdown",
},
},
} satisfies ChannelOutboundAdapter["presentationCapabilities"];
function hasMattermostPresentationButtons(presentation: MessagePresentation): boolean {
return buildMattermostPresentationButtons(presentation).some((row) => row.length > 0);
}
function readMattermostPresentationButtons(payload: {
channelData?: Record<string, unknown>;
}): Array<unknown> | undefined {
const buttons = (payload.channelData?.mattermost as { presentationButtons?: unknown } | undefined)
?.presentationButtons;
return Array.isArray(buttons) ? buttons : undefined;
}
type MattermostDirectoryListParams = Parameters<
NonNullable<NonNullable<ChannelPlugin["directory"]>["listGroups"]>
>[0];
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 } | undefined;
const baseReactions = actionsConfig?.reactions;
const hasReactionCapableAccount = enabledAccounts.some((account) => {
const accountActions = account.config.actions as { reactions?: boolean } | undefined;
return accountActions?.reactions ?? baseReactions ?? true;
});
if (hasReactionCapableAccount) {
actions.push("react");
}
return {
actions,
capabilities: enabledAccounts.length > 0 ? ["presentation"] : [],
};
}
function hasConfiguredMattermostDirectoryAccount({
cfg,
accountId,
}: Pick<MattermostDirectoryListParams, "cfg" | "accountId">): boolean {
const accounts = accountId
? [resolveMattermostAccount({ cfg, accountId })]
: listMattermostAccountIds(cfg).map((listedAccountId) =>
resolveMattermostAccount({ cfg, accountId: listedAccountId }),
);
return accounts.some((account) =>
Boolean(account.enabled && account.botToken?.trim() && account.baseUrl?.trim()),
);
}
function extractMattermostToolSend(args: Record<string, unknown>): ChannelToolSend | null {
if (normalizeOptionalString(args.action) !== "send") {
return null;
}
const to = normalizeOptionalString(args.to) ?? normalizeOptionalString(args.target);
if (!to) {
return null;
}
const threadId =
normalizeOptionalString(args.threadId) ??
normalizeOptionalString(args.replyToId) ??
normalizeOptionalString(args.replyTo);
const threadSuppressed = args.topLevel === true || args.threadId === null;
return {
to,
accountId: normalizeOptionalString(args.accountId),
...(threadId ? { threadId } : {}),
...(!threadId && !threadSuppressed ? { threadImplicit: true } : {}),
...(threadSuppressed ? { threadSuppressed: true } : {}),
};
}
function extractMattermostToolSendResult(
result: unknown,
send: ChannelToolSend,
): ChannelToolSend | null {
if (!result || typeof result !== "object") {
return null;
}
const details = (result as { details?: unknown }).details;
if (!details || typeof details !== "object") {
return null;
}
const toolSend = (details as { toolSend?: unknown }).toolSend;
if (!toolSend || typeof toolSend !== "object") {
return null;
}
const record = toolSend as Record<string, unknown>;
const to = normalizeOptionalString(record.to);
if (!to) {
return null;
}
const threadId = normalizeOptionalString(record.threadId);
const originalTarget = normalizeOptionalString(send.to);
const preserveOriginalTarget =
originalTarget?.startsWith("user:") === true || originalTarget?.startsWith("@") === true;
return {
to: preserveOriginalTarget ? originalTarget : to,
...(threadId ? { threadId } : {}),
};
}
function resolveMattermostAutoThreadId(params: {
to: string;
replyToId?: string | null;
toolContext?: {
currentChannelId?: string;
currentThreadTs?: string;
currentMessageId?: string | number;
replyToMode?: "off" | "first" | "all" | "batched";
hasRepliedRef?: { value: boolean };
};
}): string | undefined {
const replyToId = normalizeOptionalString(params.replyToId);
const context = params.toolContext;
const currentThreadId = normalizeOptionalString(context?.currentThreadTs);
const currentMessageId =
typeof context?.currentMessageId === "number"
? String(context.currentMessageId)
: normalizeOptionalString(context?.currentMessageId);
const currentTarget = normalizeMattermostThreadTarget(context?.currentChannelId);
if (currentThreadId && currentTarget === normalizeMattermostThreadTarget(params.to)) {
if (replyToId === currentMessageId) {
return currentThreadId;
}
if (!replyToId) {
const replyToMode = context?.replyToMode;
const canInheritThread =
replyToMode === "all" ||
(replyToMode === "first" && context?.hasRepliedRef?.value !== true);
return canInheritThread ? currentThreadId : undefined;
}
}
return replyToId;
}
function normalizeMattermostThreadTarget(raw: string | undefined): string | undefined {
const normalized = raw ? normalizeMattermostMessagingTarget(raw) : undefined;
if (normalized) {
return normalized;
}
const trimmed = normalizeOptionalString(raw);
return trimmed && /^[a-z0-9]{26}$/i.test(trimmed) ? `channel:${trimmed}` : undefined;
}
function matchesMattermostToolContextTarget(params: {
target: string;
toolContext: ChannelThreadingToolContext;
}): boolean {
const target = normalizeMattermostThreadTarget(params.target);
if (!target) {
return false;
}
return [params.toolContext.currentChannelId, params.toolContext.currentMessagingTarget].some(
(currentTarget) => normalizeMattermostThreadTarget(currentTarget) === target,
);
}
function normalizeMattermostThreadId(value: string | number | undefined): string | undefined {
return typeof value === "number" ? String(value) : normalizeOptionalString(value);
}
function buildMattermostThreadingToolContext(params: {
cfg: OpenClawConfig;
accountId?: string | null;
context: ChannelThreadingContext;
hasRepliedRef?: { value: boolean };
}): ChannelThreadingToolContext {
const account = resolveMattermostAccount({
cfg: params.cfg,
accountId: params.accountId ?? resolveDefaultMattermostAccountId(params.cfg),
});
const chatType =
params.context.ChatType === "direct" ||
params.context.ChatType === "group" ||
params.context.ChatType === "channel"
? params.context.ChatType
: "channel";
const configuredReplyToMode = resolveMattermostReplyToMode(account, chatType);
const currentThreadTs =
normalizeMattermostThreadId(params.context.MessageThreadId) ??
normalizeMattermostThreadId(params.context.TransportThreadId) ??
normalizeOptionalString(params.context.ReplyToId);
const currentMessageId = normalizeMattermostThreadId(params.context.CurrentMessageId);
const hasExistingThread =
Boolean(currentThreadTs) && (!currentMessageId || currentThreadTs !== currentMessageId);
const currentChannelId = params.context.To
? normalizeMattermostMessagingTarget(params.context.To)
: undefined;
return {
currentChannelId,
currentThreadTs,
currentMessageId: params.context.CurrentMessageId,
replyToMode: hasExistingThread ? "all" : configuredReplyToMode,
hasRepliedRef: params.hasRepliedRef,
sameChannelThreadRequired: Boolean(currentThreadTs),
};
}
async function listMattermostDirectoryGroups(params: MattermostDirectoryListParams) {
if (!hasConfiguredMattermostDirectoryAccount(params)) {
return [];
}
return (await loadMattermostChannelRuntime()).listMattermostDirectoryGroups(params);
}
async function listMattermostDirectoryPeers(params: MattermostDirectoryListParams) {
if (!hasConfiguredMattermostDirectoryAccount(params)) {
return [];
}
return (await loadMattermostChannelRuntime()).listMattermostDirectoryPeers(params);
}
const mattermostMessageActions: ChannelMessageActionAdapter = {
describeMessageTool: describeMattermostMessageTool,
extractToolSend: ({ args }) => extractMattermostToolSend(args),
extractToolSendResult: ({ result, send }) => extractMattermostToolSendResult(result, send),
supportsAction: ({ action }) => {
return action === "send" || action === "react";
},
handleAction: async ({
action,
params,
cfg,
accountId,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
}) => {
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 (
await loadMattermostChannelRuntime()
).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 (
await loadMattermostChannelRuntime()
).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 !== "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 presentation = normalizeMessagePresentation(params.presentation);
const message = presentation
? renderMessagePresentationFallbackText({
text: typeof params.message === "string" ? params.message : "",
presentation,
})
: typeof params.message === "string"
? params.message
: "";
// Mattermost post root_id is the thread root. A generic replyTo can name
// the current child post, so prefer threadId unless the caller supplied the
// Mattermost-specific replyToId root directly.
const replyToId =
normalizeOptionalString(params.replyToId) ??
normalizeOptionalString(params.threadId) ??
normalizeOptionalString(params.replyTo);
const resolvedAccountId = accountId || undefined;
const attachmentMedia = collectMattermostAttachmentMedia(params);
if (attachmentMedia.hasUnsupportedAttachmentPayload) {
throw new Error(
"Mattermost send attachments require media, mediaUrl, path, filePath, fileUrl, mediaUrls, or attachments[] with one of those fields; buffer/base64 payloads are not supported.",
);
}
if (attachmentMedia.mediaUrls.length > 1) {
throw new Error(
"Mattermost send supports one attachment per message; split multiple mediaUrls or attachments[] entries into separate sends.",
);
}
const buttons = presentation ? buildMattermostPresentationButtons(presentation) : [];
const result = await (
await loadMattermostChannelRuntime()
).sendMessageMattermost(to, message, {
cfg,
accountId: resolvedAccountId,
replyToId,
buttons: buttons.length > 0 ? buttons : undefined,
attachmentText: typeof params.attachmentText === "string" ? params.attachmentText : undefined,
mediaUrl: attachmentMedia.mediaUrls[0],
mediaLocalRoots: mediaLocalRoots ?? mediaAccess?.localRoots,
mediaReadFile: mediaReadFile ?? mediaAccess?.readFile,
...(mediaAccess?.workspaceDir ? { workspaceDir: mediaAccess.workspaceDir } : {}),
requireMediaUpload: requiresMattermostMediaUpload(attachmentMedia.mediaUrls[0])
? true
: undefined,
});
return {
content: [
{
type: "text" as const,
text: JSON.stringify({
ok: true,
channel: "mattermost",
messageId: result.messageId,
channelId: result.channelId,
}),
},
],
details: {
toolSend: {
to: `channel:${result.channelId}`,
...(replyToId ? { threadId: replyToId } : {}),
},
},
};
},
};
function parseMattermostReactActionParams(params: Record<string, unknown>): {
postId: string;
emojiName: string;
remove: boolean;
} {
const postId =
normalizeOptionalString(params.messageId) ?? normalizeOptionalString(params.postId);
if (!postId) {
throw new Error("Mattermost react requires messageId (post id)");
}
const emojiName = normalizeOptionalString(params.emoji)?.replace(/^:+|:+$/g, "");
if (!emojiName) {
throw new Error("Mattermost react requires emoji");
}
return {
postId,
emojiName,
remove: params.remove === true,
};
}
function collectNonBlankStrings(values: Array<string | undefined>): string[] {
const collected: string[] = [];
const seen = new Set<string>();
for (const value of values) {
const trimmed = value?.trim();
if (trimmed && !seen.has(trimmed)) {
seen.add(trimmed);
collected.push(trimmed);
}
}
return collected;
}
function toSnakeCaseKey(key: string): string {
return key
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1_$2")
.replace(/([a-z0-9])([A-Z])/g, "$1_$2")
.toLowerCase();
}
function readMattermostParam(params: Record<string, unknown>, key: string): unknown {
if (Object.hasOwn(params, key)) {
return params[key];
}
const snakeKey = toSnakeCaseKey(key);
return snakeKey === key || !Object.hasOwn(params, snakeKey) ? undefined : params[snakeKey];
}
function readMattermostStringParam(
params: Record<string, unknown>,
key: string,
): string | undefined {
const raw = readMattermostParam(params, key);
return typeof raw === "string" ? normalizeOptionalString(raw) : undefined;
}
function readMattermostStringArrayParam(params: Record<string, unknown>, key: string): string[] {
const raw = readMattermostParam(params, key);
if (Array.isArray(raw)) {
return raw
.filter((entry): entry is string => typeof entry === "string")
.flatMap((entry) => {
const normalized = normalizeOptionalString(entry);
return normalized ? [normalized] : [];
});
}
if (typeof raw === "string") {
const normalized = normalizeOptionalString(raw);
return normalized ? [normalized] : [];
}
return [];
}
function requiresMattermostMediaUpload(mediaUrl: string | undefined): boolean {
const normalized = normalizeOptionalString(mediaUrl);
return Boolean(normalized && !/^https?:\/\//i.test(normalized));
}
function collectMattermostAttachmentMedia(params: Record<string, unknown>): {
mediaUrls: string[];
hasUnsupportedAttachmentPayload: boolean;
} {
const mediaUrlCandidates: Array<string | undefined> = [
readMattermostStringParam(params, "media"),
readMattermostStringParam(params, "mediaUrl"),
readMattermostStringParam(params, "path"),
readMattermostStringParam(params, "filePath"),
readMattermostStringParam(params, "fileUrl"),
];
mediaUrlCandidates.push(...readMattermostStringArrayParam(params, "mediaUrls"));
let hasUnsupportedAttachmentPayload =
typeof params.buffer === "string" || typeof params.base64 === "string";
if (Array.isArray(params.attachments)) {
for (const attachment of params.attachments) {
if (!attachment || typeof attachment !== "object" || Array.isArray(attachment)) {
continue;
}
const record = attachment as Record<string, unknown>;
mediaUrlCandidates.push(
readMattermostStringParam(record, "media"),
readMattermostStringParam(record, "mediaUrl"),
readMattermostStringParam(record, "path"),
readMattermostStringParam(record, "filePath"),
readMattermostStringParam(record, "fileUrl"),
readMattermostStringParam(record, "url"),
);
hasUnsupportedAttachmentPayload ||= typeof record.buffer === "string";
hasUnsupportedAttachmentPayload ||= typeof record.base64 === "string";
}
}
return {
mediaUrls: collectNonBlankStrings(mediaUrlCandidates),
hasUnsupportedAttachmentPayload,
};
}
const mattermostOutbound: ChannelOutboundAdapter = {
deliveryMode: "direct",
chunker: chunkTextForOutbound,
chunkerMode: "markdown",
textChunkLimit: 4000,
deliveryCapabilities: {
durableFinal: {
text: true,
media: true,
payload: true,
replyTo: true,
thread: true,
messageSendingHooks: true,
},
},
presentationCapabilities: MATTERMOST_PRESENTATION_CAPABILITIES,
renderPresentation: ({ payload, presentation }) => {
if (payload.mediaUrls && payload.mediaUrls.length > 1) {
return null;
}
const buttons = buildMattermostPresentationButtons(presentation);
if (!hasMattermostPresentationButtons(presentation)) {
return null;
}
return {
...payload,
text: renderMessagePresentationFallbackText({ text: payload.text, presentation }),
channelData: {
...payload.channelData,
mattermost: {
...(payload.channelData?.mattermost as Record<string, unknown> | undefined),
presentationButtons: buttons,
},
},
};
},
sendPayload: async (ctx) => {
const buttons = readMattermostPresentationButtons(ctx.payload);
if (buttons?.length) {
const mediaUrl = resolvePayloadMediaUrls({
...ctx.payload,
mediaUrl: ctx.payload.mediaUrl ?? ctx.mediaUrl,
})
.map((url) => url.trim())
.find(Boolean);
const result = await (
await loadMattermostChannelRuntime()
).sendMessageMattermost(ctx.to, ctx.payload.text ?? ctx.text, {
cfg: ctx.cfg,
accountId: ctx.accountId ?? undefined,
mediaUrl,
mediaLocalRoots: ctx.mediaLocalRoots ?? ctx.mediaAccess?.localRoots,
mediaReadFile: ctx.mediaReadFile ?? ctx.mediaAccess?.readFile,
...(ctx.mediaAccess?.workspaceDir ? { workspaceDir: ctx.mediaAccess.workspaceDir } : {}),
requireMediaUpload: requiresMattermostMediaUpload(mediaUrl) ? true : undefined,
replyToId: ctx.replyToId ?? (ctx.threadId != null ? String(ctx.threadId) : undefined),
buttons,
});
return attachChannelToResult("mattermost", result);
}
return await sendTextMediaPayload({ channel: "mattermost", ctx, adapter: mattermostOutbound });
},
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 };
},
...createAttachedChannelResultAdapter({
channel: "mattermost",
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) =>
await (
await loadMattermostChannelRuntime()
).sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
}),
sendMedia: async ({
cfg,
to,
text,
mediaUrl,
mediaAccess,
mediaLocalRoots,
mediaReadFile,
accountId,
replyToId,
threadId,
}) =>
await (
await loadMattermostChannelRuntime()
).sendMessageMattermost(to, text, {
cfg,
accountId: accountId ?? undefined,
mediaUrl,
mediaLocalRoots: mediaLocalRoots ?? mediaAccess?.localRoots,
mediaReadFile: mediaReadFile ?? mediaAccess?.readFile,
...(mediaAccess?.workspaceDir ? { workspaceDir: mediaAccess.workspaceDir } : {}),
requireMediaUpload: requiresMattermostMediaUpload(mediaUrl) ? true : undefined,
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
}),
}),
};
const mattermostMessageAdapter = createChannelMessageAdapterFromOutbound({
id: "mattermost",
outbound: mattermostOutbound,
live: {
capabilities: {
draftPreview: true,
previewFinalization: true,
progressUpdates: true,
},
finalizer: {
capabilities: {
finalEdit: true,
normalFallback: true,
discardPending: true,
},
},
},
});
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: isMattermostConfigured,
describeAccount: describeMattermostAccount,
},
approvalCapability: mattermostApprovalAuth,
doctor: mattermostDoctor,
groups: {
resolveRequireMention: resolveMattermostGroupRequireMention,
},
actions: mattermostMessageActions,
message: mattermostMessageAdapter,
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
directory: createChannelDirectoryAdapter({
listGroups: listMattermostDirectoryGroups,
listGroupsLive: listMattermostDirectoryGroups,
listPeers: listMattermostDirectoryPeers,
listPeersLive: listMattermostDirectoryPeers,
}),
messaging: {
targetPrefixes: ["mattermost"],
defaultMarkdownTableMode: "off",
normalizeTarget: normalizeMattermostMessagingTarget,
resolveDeliveryTarget: ({ conversationId, parentConversationId }) => {
const parent = parentConversationId?.trim();
const child = conversationId.trim();
return parent && parent !== child
? { to: `channel:${parent}`, threadId: child }
: { to: normalizeMattermostMessagingTarget(`channel:${child}`) };
},
resolveOutboundSessionRoute: (params) => resolveMattermostOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeMattermostTargetId,
hint: "<channelId|user:ID|channel:ID>",
resolveTarget: async ({ cfg, accountId, input }) => {
const resolved = await (
await loadMattermostChannelRuntime()
).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 (
await loadMattermostChannelRuntime()
).probeMattermost(baseUrl, token, timeoutMs, isPrivateNetworkOptInEnabled(account.config));
},
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,
dmPolicy: account.config.dmPolicy ?? "pairing",
connected: runtime?.connected ?? false,
lastConnectedAt: runtime?.lastConnectedAt ?? null,
lastDisconnect: runtime?.lastDisconnect ?? null,
},
}),
}),
gateway: {
resolveGatewayAuthBypassPaths: ({ cfg }) => resolveMattermostGatewayAuthBypassPaths(cfg),
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 (await loadMattermostChannelRuntime()).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: {
buildToolContext: (params) => buildMattermostThreadingToolContext(params),
scopedAccountReplyToMode: {
resolveAccount: (cfg, accountId) =>
resolveMattermostAccount({
cfg,
accountId: accountId ?? resolveDefaultMattermostAccountId(cfg),
}),
resolveReplyToMode: (account, chatType) =>
resolveMattermostReplyToMode(
account,
chatType === "direct" || chatType === "group" || chatType === "channel"
? chatType
: "channel",
),
},
resolveAutoThreadId: ({ to, replyToId, toolContext }) =>
resolveMattermostAutoThreadId({ to, replyToId, toolContext }),
matchesToolContextTarget: ({ target, toolContext }) =>
matchesMattermostToolContextTarget({ target, toolContext }),
resolveReplyTransport: ({ threadId, replyToId, replyToIsExplicit, replyDelivery }) => {
const ambientThreadId = threadId != null ? String(threadId) : undefined;
const resolvedThreadId =
replyDelivery?.chatType === "direct"
? undefined
: replyDelivery
? replyToIsExplicit
? (replyToId ?? ambientThreadId)
: (ambientThreadId ?? replyToId ?? undefined)
: (ambientThreadId ?? replyToId);
return {
replyToId: replyDelivery?.chatType === "direct" ? null : resolvedThreadId,
threadId: resolvedThreadId ?? null,
};
},
},
security: mattermostSecurityAdapter,
outbound: mattermostOutbound,
});

View File

@@ -0,0 +1,179 @@
// Mattermost helper module supports config schema core behavior.
import {
BlockStreamingCoalesceSchema,
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
requireOpenAllowFrom,
} from "openclaw/plugin-sdk/channel-config-primitives";
import { z } from "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 MattermostNetworkSchema = z
.object({
/** Dangerous opt-in for self-hosted Mattermost on trusted private/internal hosts. */
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
})
.strict()
.optional();
const MattermostStreamingModeSchema = z.enum(["off", "partial", "block", "progress"]);
const MattermostStreamingProgressSchema = z
.object({
label: z.union([z.string(), z.literal(false)]).optional(),
labels: z.array(z.string()).optional(),
maxLines: z.number().int().positive().optional(),
maxLineChars: z.number().int().positive().optional(),
toolProgress: z.boolean().optional(),
})
.strict();
const MattermostStreamingPreviewSchema = z
.object({
toolProgress: z.boolean().optional(),
})
.strict();
const MattermostStreamingBlockSchema = z
.object({
enabled: z.boolean().optional(),
coalesce: BlockStreamingCoalesceSchema.optional(),
})
.strict();
const MattermostStreamingSchema = z.union([
MattermostStreamingModeSchema,
z.boolean(),
z
.object({
mode: MattermostStreamingModeSchema.optional(),
chunkMode: z.enum(["length", "newline"]).optional(),
preview: MattermostStreamingPreviewSchema.optional(),
progress: MattermostStreamingProgressSchema.optional(),
block: MattermostStreamingBlockSchema.optional(),
})
.strict(),
]);
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(),
streaming: MattermostStreamingSchema.optional(),
blockStreaming: z.boolean().optional(),
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
replyToMode: z.enum(["off", "first", "all", "batched"]).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(),
/** Network policy overrides for self-hosted Mattermost on trusted private/internal hosts. */
network: MattermostNetworkSchema,
/** 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,
});
});

View File

@@ -0,0 +1,117 @@
// Mattermost tests cover config schema plugin behavior.
import { describe, expect, it } from "vitest";
import { MattermostConfigSchema } from "./config-schema-core.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('rejects dmPolicy="open" without wildcard allowFrom', () => {
const result = MattermostConfigSchema.safeParse({
dmPolicy: "open",
});
expect(result.success).toBe(false);
});
it('accepts dmPolicy="open" with wildcard allowFrom', () => {
const result = MattermostConfigSchema.safeParse({
dmPolicy: "open",
allowFrom: ["*"],
});
expect(result.success).toBe(true);
});
it("accepts documented streaming modes and progress config", () => {
const result = MattermostConfigSchema.safeParse({
streaming: {
mode: "progress",
progress: {
label: "Shelling",
maxLines: 4,
toolProgress: false,
},
},
accounts: {
quiet: {
streaming: "off",
},
},
});
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);
});
});

View File

@@ -0,0 +1,8 @@
// Mattermost helper module supports config surface behavior.
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
import { MattermostConfigSchema } from "./config-schema-core.js";
import { mattermostChannelConfigUiHints } from "./config-ui-hints.js";
export const MattermostChannelConfigSchema = buildChannelConfigSchema(MattermostConfigSchema, {
uiHints: mattermostChannelConfigUiHints,
});

View File

@@ -0,0 +1,61 @@
// Mattermost helper module supports config ui hints behavior.
import type { ChannelConfigUiHint } from "openclaw/plugin-sdk/channel-core";
export const mattermostChannelConfigUiHints = {
"": {
label: "Mattermost",
help: "Mattermost channel provider configuration for bot auth, access policy, slash commands, and preview streaming.",
},
dmPolicy: {
label: "Mattermost DM Policy",
help: 'Direct message access control ("pairing" recommended). "open" requires channels.mattermost.allowFrom=["*"].',
},
streaming: {
label: "Mattermost Streaming Mode",
help: 'Unified Mattermost stream preview mode: "off" | "partial" | "block" | "progress". "progress" keeps a single editable progress draft until final delivery.',
},
"streaming.mode": {
label: "Mattermost Streaming Mode",
help: 'Canonical Mattermost preview mode: "off" | "partial" | "block" | "progress".',
},
"streaming.progress.label": {
label: "Mattermost Progress Label",
help: 'Initial progress draft title. Use "auto" for built-in single-word labels, a custom string, or false to hide the title.',
},
"streaming.progress.labels": {
label: "Mattermost Progress Label Pool",
help: 'Candidate labels for streaming.progress.label="auto". Leave unset to use OpenClaw built-in progress labels.',
},
"streaming.progress.maxLines": {
label: "Mattermost Progress Max Lines",
help: "Maximum number of compact progress lines to keep below the draft label (default: 8).",
},
"streaming.progress.maxLineChars": {
label: "Mattermost Progress Max Line Chars",
help: "Maximum characters per compact progress line before truncation (default: 120). Prose cuts at word boundaries; commands and paths keep useful suffixes.",
},
"streaming.progress.toolProgress": {
label: "Mattermost Progress Tool Lines",
help: "Show compact tool/progress lines in progress draft mode (default: true). Set false to keep only the label until final delivery.",
},
"streaming.progress.commandText": {
label: "Mattermost Progress Command Text",
help: 'Command/exec detail in progress draft lines: "raw" preserves released behavior; "status" shows only the tool label.',
},
"streaming.preview.toolProgress": {
label: "Mattermost Draft Tool Progress",
help: "Show tool/progress activity in the live draft preview post (default: true). Set false to hide interim tool updates while the draft preview stays active.",
},
"streaming.preview.commandText": {
label: "Mattermost Draft Command Text",
help: 'Command/exec detail in preview tool-progress lines: "raw" preserves released behavior; "status" shows only the tool label.',
},
"streaming.block.enabled": {
label: "Mattermost Block Streaming Enabled",
help: 'Enable chunked block-style Mattermost preview delivery when channels.mattermost.streaming.mode="block".',
},
"streaming.block.coalesce": {
label: "Mattermost Block Streaming Coalesce",
help: "Merge streamed Mattermost block replies before final delivery.",
},
} satisfies Record<string, ChannelConfigUiHint>;

View File

@@ -0,0 +1,10 @@
// Mattermost plugin module implements doctor contract behavior.
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
const contract = createLegacyPrivateNetworkDoctorContract({
channelKey: "mattermost",
});
export const legacyConfigRules = contract.legacyConfigRules;
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;

View File

@@ -0,0 +1,51 @@
// Mattermost tests cover doctor plugin behavior.
import { describe, expect, it } from "vitest";
import { mattermostDoctor } from "./doctor.js";
function getMattermostCompatibilityNormalizer(): NonNullable<
typeof mattermostDoctor.normalizeCompatibilityConfig
> {
const normalize = mattermostDoctor.normalizeCompatibilityConfig;
if (!normalize) {
throw new Error("Expected mattermost doctor to expose normalizeCompatibilityConfig");
}
return normalize;
}
describe("mattermost doctor", () => {
it("normalizes legacy private-network aliases", () => {
const normalize = getMattermostCompatibilityNormalizer();
const result = normalize({
cfg: {
channels: {
mattermost: {
allowPrivateNetwork: true,
accounts: {
work: {
allowPrivateNetwork: false,
},
},
},
},
} as never,
});
const mattermostConfig = result.config.channels?.mattermost;
if (!mattermostConfig) {
throw new Error("expected normalized Mattermost config");
}
expect(mattermostConfig.network).toEqual({
dangerouslyAllowPrivateNetwork: true,
});
const workAccount = mattermostConfig.accounts?.work as
| { network?: Record<string, unknown> }
| undefined;
if (!workAccount) {
throw new Error("expected Mattermost work account config");
}
expect(workAccount.network).toEqual({
dangerouslyAllowPrivateNetwork: false,
});
});
});

View File

@@ -0,0 +1,49 @@
// Mattermost plugin module implements doctor behavior.
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import {
legacyConfigRules as MATTERMOST_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeMattermostCompatibilityConfig,
} from "./doctor-contract.js";
function isMattermostMutableAllowEntry(raw: string): boolean {
const text = raw.trim();
if (!text || text === "*") {
return false;
}
const normalized = text
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, "")
.trim();
const lowered = normalizeLowercaseStringOrEmpty(normalized);
if (/^[a-z0-9]{26}$/.test(lowered)) {
return false;
}
return true;
}
const collectMattermostMutableAllowlistWarnings =
createDangerousNameMatchingMutableAllowlistWarningCollector({
channel: "mattermost",
detector: isMattermostMutableAllowEntry,
collectLists: (scope) => [
{
pathLabel: `${scope.prefix}.allowFrom`,
list: scope.account.allowFrom,
},
{
pathLabel: `${scope.prefix}.groupAllowFrom`,
list: scope.account.groupAllowFrom,
},
],
});
export const mattermostDoctor: ChannelDoctorAdapter = {
legacyConfigRules: MATTERMOST_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: normalizeMattermostCompatibilityConfig,
collectMutableAllowlistWarnings: collectMattermostMutableAllowlistWarnings,
};

View File

@@ -0,0 +1,39 @@
// Mattermost tests cover gateway auth bypass plugin behavior.
import { describe, expect, it } from "vitest";
import {
collectMattermostSlashCallbackPaths,
resolveMattermostGatewayAuthBypassPaths,
} from "./gateway-auth-bypass.js";
describe("Mattermost gateway auth bypass paths", () => {
it("normalizes slash callback paths and callback URL paths", () => {
expect(
collectMattermostSlashCallbackPaths({
callbackPath: "api/channels/mattermost/command",
callbackUrl: "https://gateway.example.com/api/channels/mattermost/custom",
}),
).toEqual(["/api/channels/mattermost/command", "/api/channels/mattermost/custom"]);
});
it("keeps only Mattermost channel callback paths", () => {
expect(
resolveMattermostGatewayAuthBypassPaths({
channels: {
mattermost: {
commands: {
callbackPath: "/api/channels/mattermost/command",
callbackUrl: "https://gateway.example.com/api/channels/nostr/default/profile",
},
accounts: {
work: {
commands: {
callbackPath: "/api/channels/mattermost/work",
},
},
},
},
},
}),
).toEqual(["/api/channels/mattermost/command", "/api/channels/mattermost/work"]);
});
});

View File

@@ -0,0 +1,84 @@
// Mattermost plugin module implements gateway auth bypass behavior.
const DEFAULT_SLASH_CALLBACK_PATH = "/api/channels/mattermost/command";
type MattermostSlashCommandConfigInput = {
callbackPath?: unknown;
callbackUrl?: unknown;
};
type MattermostAccountConfigInput = {
commands?: MattermostSlashCommandConfigInput;
};
type MattermostConfigInput = MattermostAccountConfigInput & {
accounts?: Record<string, unknown>;
};
function readTrimmedString(value: unknown): string | undefined {
return typeof value === "string" && value.trim() ? value.trim() : undefined;
}
function normalizeCallbackPath(value: unknown): string {
const trimmed = readTrimmedString(value);
if (!trimmed) {
return DEFAULT_SLASH_CALLBACK_PATH;
}
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
}
function readMattermostCommands(value: unknown): MattermostSlashCommandConfigInput | undefined {
return value && typeof value === "object" && !Array.isArray(value)
? (value as MattermostSlashCommandConfigInput)
: undefined;
}
function isMattermostBypassPath(path: string): boolean {
return path === DEFAULT_SLASH_CALLBACK_PATH || path.startsWith("/api/channels/mattermost/");
}
export function collectMattermostSlashCallbackPaths(
raw?: MattermostSlashCommandConfigInput,
): string[] {
const paths = new Set<string>([normalizeCallbackPath(raw?.callbackPath)]);
const callbackUrl = readTrimmedString(raw?.callbackUrl);
if (callbackUrl) {
try {
const pathname = new URL(callbackUrl).pathname;
if (pathname) {
paths.add(pathname);
}
} catch {
// Keep the normalized callback path when the configured URL is invalid.
}
}
return [...paths];
}
export function resolveMattermostGatewayAuthBypassPaths(cfg: {
channels?: Record<string, unknown>;
}): string[] {
const base =
cfg.channels?.mattermost && typeof cfg.channels.mattermost === "object"
? (cfg.channels.mattermost as MattermostConfigInput)
: undefined;
const callbackPaths = new Set(
collectMattermostSlashCallbackPaths(readMattermostCommands(base?.commands)).filter(
isMattermostBypassPath,
),
);
const accounts = base?.accounts ?? {};
for (const account of Object.values(accounts)) {
const accountConfig =
account && typeof account === "object" && !Array.isArray(account)
? (account as MattermostAccountConfigInput)
: undefined;
for (const path of collectMattermostSlashCallbackPaths(
readMattermostCommands(accountConfig?.commands),
)) {
if (isMattermostBypassPath(path)) {
callbackPaths.add(path);
}
}
}
return [...callbackPaths];
}

View File

@@ -0,0 +1,47 @@
// Mattermost tests cover group mentions plugin behavior.
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);
});
});

View File

@@ -0,0 +1,24 @@
// Mattermost plugin module implements group mentions behavior.
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,
});
}

View File

@@ -0,0 +1,207 @@
// Mattermost tests cover accounts plugin behavior.
import { describe, expect, it } from "vitest";
import type { OpenClawConfig } from "../../runtime-api.js";
import {
listMattermostAccountIds,
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");
});
it("keeps the implicit default account when named accounts are added to top-level credentials", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
botToken: "tok-default",
baseUrl: "https://chat.example.com",
accounts: {
work: {
enabled: false,
botToken: "tok-work",
baseUrl: "https://work.example.com",
},
},
},
},
};
expect(listMattermostAccountIds(cfg)).toEqual(["default", "work"]);
expect(resolveDefaultMattermostAccountId(cfg)).toBe("default");
});
it("inherits top-level access policy for named accounts before doctor migration", () => {
const cfg: OpenClawConfig = {
channels: {
mattermost: {
dmPolicy: "open",
groupPolicy: "open",
allowFrom: ["*"],
groupAllowFrom: ["*"],
accounts: {
tony: {
botToken: "tok-tony",
baseUrl: "https://chat.example.com",
},
},
},
},
};
const account = resolveMattermostAccount({ cfg, accountId: "tony" });
expect(account.config.dmPolicy).toBe("open");
expect(account.config.groupPolicy).toBe("open");
expect(account.config.allowFrom).toEqual(["*"]);
expect(account.config.groupAllowFrom).toEqual(["*"]);
});
});
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",
});
});
it("resolves documented streaming mode from account config", () => {
const account = resolveMattermostAccount({
cfg: {
channels: {
mattermost: {
streaming: "partial",
accounts: {
work: {
streaming: "off",
},
},
},
},
},
accountId: "work",
});
expect(account.streamingMode).toBe("off");
});
});

View File

@@ -0,0 +1,157 @@
// Mattermost plugin module implements accounts behavior.
import {
createAccountListHelpers,
hasConfiguredAccountValue,
} 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 {
resolveChannelStreamingBlockCoalesce,
resolveChannelStreamingBlockEnabled,
resolveChannelStreamingChunkMode,
resolveChannelPreviewStreamMode,
type StreamingMode,
} from "openclaw/plugin-sdk/channel-outbound";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
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";
type MattermostTokenSource = "env" | "config" | "none";
type MattermostBaseUrlSource = "env" | "config" | "none";
export type ResolvedMattermostAccount = {
accountId: string;
enabled: boolean;
name?: string;
botToken?: string;
baseUrl?: string;
botTokenSource: MattermostTokenSource;
baseUrlSource: MattermostBaseUrlSource;
config: MattermostAccountConfig;
chatmode?: MattermostChatMode;
oncharPrefixes?: string[];
requireMention?: boolean;
textChunkLimit?: number;
chunkMode?: MattermostAccountConfig["chunkMode"];
streamingMode: StreamingMode;
blockStreaming?: boolean;
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
};
const mattermostAccountHelpers = createAccountListHelpers("mattermost", {
hasImplicitDefaultAccount: (cfg) => {
const mattermost = cfg.channels?.mattermost;
return Boolean(
mattermost?.baseUrl?.trim() &&
(hasConfiguredAccountValue(mattermost.botToken) || process.env.MATTERMOST_BOT_TOKEN?.trim()),
);
},
});
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 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 configUrl = merged.baseUrl?.trim();
const botToken = configToken || envToken;
const baseUrl = normalizeMattermostBaseUrl(configUrl || envUrl);
const requireMention = resolveMattermostRequireMention(merged);
const botTokenSource: MattermostTokenSource = configToken ? "config" : envToken ? "env" : "none";
const baseUrlSource: MattermostBaseUrlSource = configUrl ? "config" : envUrl ? "env" : "none";
return {
accountId,
enabled,
name: normalizeOptionalString(merged.name),
botToken,
baseUrl,
botTokenSource,
baseUrlSource,
config: merged,
chatmode: merged.chatmode,
oncharPrefixes: merged.oncharPrefixes,
requireMention,
textChunkLimit: merged.textChunkLimit,
chunkMode: resolveChannelStreamingChunkMode(merged) ?? merged.chunkMode,
streamingMode: resolveChannelPreviewStreamMode(merged, "partial"),
blockStreaming: resolveChannelStreamingBlockEnabled(merged) ?? merged.blockStreaming,
blockStreamingCoalesce:
resolveChannelStreamingBlockCoalesce(merged) ?? 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";
}

View File

@@ -0,0 +1,491 @@
// Mattermost tests cover client.retry plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createMattermostClient,
createMattermostDirectChannelWithRetry,
resolveMattermostReplyDeliveryBarrierTimeoutMs,
} from "./client.js";
describe("resolveMattermostReplyDeliveryBarrierTimeoutMs", () => {
it("uses the default barrier for non-DM deliveries", () => {
expect(
resolveMattermostReplyDeliveryBarrierTimeoutMs({
isDirect: false,
queuedCounts: { tool: 1, block: 1, final: 1 },
}),
).toBeUndefined();
});
it("uses the default barrier when no deliveries were queued", () => {
expect(
resolveMattermostReplyDeliveryBarrierTimeoutMs({
isDirect: true,
queuedCounts: { tool: 0, block: 0, final: 0 },
}),
).toBeUndefined();
});
it("covers the default retry envelope plus scheduling slack", () => {
expect(
resolveMattermostReplyDeliveryBarrierTimeoutMs({
isDirect: true,
queuedCounts: { tool: 0, block: 0, final: 1 },
}),
).toBe(210_000);
});
it("covers one maximum retry envelope per queued delivery", () => {
expect(
resolveMattermostReplyDeliveryBarrierTimeoutMs({
isDirect: true,
dmRetryOptions: {
maxRetries: 10,
maxDelayMs: 60_000,
timeoutMs: 120_000,
},
queuedCounts: { tool: 1, block: 0, final: 1 },
}),
).toBe(3_960_000);
});
it("includes the configured inter-block delay budget", () => {
expect(
resolveMattermostReplyDeliveryBarrierTimeoutMs({
isDirect: true,
queuedCounts: { tool: 0, block: 2, final: 0 },
humanDelayBudgetMs: 180_000,
}),
).toBe(600_000);
});
});
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",
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;
}
function jsonResponse(body: unknown, status = 200): Response {
return Response.json(body, { status });
}
it("succeeds on first attempt without retries", async () => {
mockFetch.mockResolvedValueOnce(jsonResponse({ id: "dm-channel-123" }, 201));
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(jsonResponse({ message: "Too many requests" }, 429))
.mockResolvedValueOnce(jsonResponse({ id: "dm-channel-456" }, 201));
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);
const retryCall = onRetry.mock.calls[0];
expect(retryCall?.[0]).toBe(1);
expect(retryCall?.[1]).toBeGreaterThanOrEqual(10);
expect(retryCall?.[1]).toBeLessThanOrEqual(20);
expect(retryCall?.[2]).toBeInstanceOf(Error);
expect((retryCall?.[2] as Error | undefined)?.message).toContain("Too many requests");
});
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(jsonResponse({ id: "dm-channel-port" }, 201));
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(jsonResponse({ message: "Invalid user ID: 4294967295" }, 400));
const client = createMockClient();
const run = suppressUnhandled(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 3,
initialDelayMs: 10,
}),
);
await expect(resolveRetryRun(run)).rejects.toThrow("Mattermost API 400");
// 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(jsonResponse({ message: "Service unavailable" }, 503))
.mockResolvedValueOnce(jsonResponse({ message: "Bad gateway" }, 502))
.mockResolvedValueOnce(jsonResponse({ id: "dm-channel-789" }, 201));
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(jsonResponse({ id: "dm-channel-abc" }, 201));
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(jsonResponse({ id: "dm-channel-fetch-failed" }, 201));
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(jsonResponse({ message: "Bad request" }, 400));
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(jsonResponse({ message: "User not found" }, 404));
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.mockImplementation(async () => jsonResponse({ message: "Service unavailable" }, 503));
const client = createMockClient();
const run = suppressUnhandled(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
maxRetries: 2,
initialDelayMs: 10,
}),
);
await expect(resolveRetryRun(run)).rejects.toThrow("Mattermost API 503");
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("AbortError");
expect(mockFetch).toHaveBeenCalledTimes(1);
expect(abortSignal).toBeInstanceOf(AbortSignal);
expect(abortSignal?.aborted).toBe(true);
expect(abortListenerCalled).toBe(true);
});
it("caps oversized request timeouts before scheduling aborts", async () => {
const timeoutSpy = vi
.spyOn(globalThis, "setTimeout")
.mockReturnValue(1 as unknown as ReturnType<typeof setTimeout>);
vi.spyOn(globalThis, "clearTimeout").mockImplementation(() => undefined);
mockFetch.mockResolvedValueOnce(jsonResponse({ id: "dm-channel-capped" }, 201));
const client = createMockClient();
await createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
timeoutMs: MAX_TIMER_TIMEOUT_MS + 1_000_000,
maxRetries: 0,
});
expect(timeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
});
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(jsonResponse({ id: "dm-channel-delay" }, 201));
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(jsonResponse({ id: "dm-channel-max" }, 201));
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(
jsonResponse({ message: "Request timeout: connection timed out" }, 400),
);
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(jsonResponse({ message: "Request aborted: forbidden" }, 403));
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(jsonResponse({ id: "dm-channel-signal" }, 201));
});
const client = createMockClient();
await resolveRetryRun(
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
timeoutMs: 5000,
}),
);
expect(capturedSignal).toBeInstanceOf(AbortSignal);
expect(capturedSignal?.aborted).toBe(false);
});
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(jsonResponse({ id: "dm-channel-5xx-with-404" }, 201));
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");
});
});

View File

@@ -0,0 +1,566 @@
// Mattermost tests cover client plugin behavior.
import { describe, expect, it, vi } from "vitest";
const fetchWithSsrFGuardMock = vi.hoisted(() => vi.fn());
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async (importOriginal) => {
const actual = await importOriginal<typeof import("openclaw/plugin-sdk/ssrf-runtime")>();
return {
...actual,
fetchWithSsrFGuard: (...args: unknown[]) => fetchWithSsrFGuardMock(...args),
};
});
import {
createMattermostClient,
createMattermostPost,
normalizeMattermostBaseUrl,
readMattermostError,
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 = requestUrl(url);
calls.push({ url: urlStr, init });
return new Response(JSON.stringify(body), {
status,
headers: { "content-type": contentType },
});
});
return { mockFetch: mockFetch as typeof fetch, calls };
}
function requestUrl(url: string | URL | Request): string {
if (typeof url === "string") {
return url;
}
if (url instanceof URL) {
return url.toString();
}
return url.url;
}
function parseRequestJson(init: RequestInit | undefined): Record<string, unknown> {
if (typeof init?.body !== "string") {
throw new Error("expected JSON request body");
}
const parsed: unknown = JSON.parse(init.body);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("expected JSON object request body");
}
return parsed as Record<string, unknown>;
}
function streamingMattermostResponse(body: unknown): {
response: Response;
arrayBuffer: ReturnType<typeof vi.fn>;
} {
const encoded = new TextEncoder().encode(JSON.stringify(body));
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(encoded);
controller.close();
},
});
const arrayBuffer = vi.fn(async () => {
throw new Error("guarded Mattermost responses must stay streaming");
});
return {
response: {
ok: true,
status: 200,
statusText: "OK",
headers: new Headers({ "content-type": "application/json" }),
body: stream,
arrayBuffer,
} as unknown as Response,
arrayBuffer,
};
}
function cancelTrackedResponse(
text: string,
init: ResponseInit,
): {
response: Response;
wasCanceled: () => boolean;
} {
let canceled = false;
const stream = new ReadableStream<Uint8Array>({
start(controller) {
controller.enqueue(new TextEncoder().encode(text));
},
cancel() {
canceled = true;
},
});
return {
response: new Response(stream, init),
wasCanceled: () => canceled,
};
}
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: parseRequestJson(calls[0].init),
};
}
// ── 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");
});
});
// ── readMattermostError ───────────────────────────────────────────────
describe("readMattermostError", () => {
it("bounds null-body JSON errors without response.json/text", async () => {
const response = new Response(null, {
status: 401,
headers: { "content-type": "application/json" },
});
const jsonSpy = vi.spyOn(response, "json").mockRejectedValue(new Error("unbounded"));
const textSpy = vi.spyOn(response, "text").mockRejectedValue(new Error("unbounded"));
await expect(readMattermostError(response)).resolves.toBe("");
expect(jsonSpy).not.toHaveBeenCalled();
expect(textSpy).not.toHaveBeenCalled();
});
it("parses bounded JSON error messages from response bodies", async () => {
const response = new Response(JSON.stringify({ message: "invalid token", id: "app.error" }), {
status: 401,
headers: { "content-type": "application/json" },
});
const jsonSpy = vi.spyOn(response, "json").mockRejectedValue(new Error("unbounded"));
const textSpy = vi.spyOn(response, "text").mockRejectedValue(new Error("unbounded"));
await expect(readMattermostError(response)).resolves.toBe("invalid token");
expect(jsonSpy).not.toHaveBeenCalled();
expect(textSpy).not.toHaveBeenCalled();
});
});
// ── createMattermostClient ───────────────────────────────────────────
describe("createMattermostClient", () => {
it("keeps guarded Mattermost responses streaming until callers consume them", async () => {
const release = vi.fn(async () => {});
const { response, arrayBuffer } = streamingMattermostResponse({ id: "u1" });
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response, release });
const client = createMattermostClient({
baseUrl: "https://chat.example.com",
botToken: "test-token",
});
await expect(client.request("/users/me")).resolves.toEqual({ id: "u1" });
expect(arrayBuffer).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledTimes(1);
});
it("reads guarded null-body Mattermost errors without response.json/text", async () => {
const release = vi.fn(async () => {});
const response = new Response(null, {
status: 503,
statusText: "Service Unavailable",
headers: { "content-type": "application/json" },
});
const jsonSpy = vi.spyOn(response, "json").mockRejectedValue(new Error("unbounded"));
const textSpy = vi.spyOn(response, "text").mockRejectedValue(new Error("unbounded"));
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response, release });
const client = createMattermostClient({
baseUrl: "https://chat.example.com",
botToken: "test-token",
});
await expect(client.request("/users/me")).rejects.toThrow(
"Mattermost API 503 Service Unavailable: unknown error",
);
expect(jsonSpy).not.toHaveBeenCalled();
expect(textSpy).not.toHaveBeenCalled();
expect(release).toHaveBeenCalledTimes(1);
});
it("bounds and cancels guarded Mattermost error bodies", async () => {
const release = vi.fn(async () => {});
const tracked = cancelTrackedResponse(`${"upstream unavailable ".repeat(512)}tail`, {
status: 503,
statusText: "Service Unavailable",
headers: { "content-type": "text/plain" },
});
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: tracked.response, release });
const client = createMattermostClient({
baseUrl: "https://chat.example.com",
botToken: "test-token",
});
let caught: Error | undefined;
try {
await client.request("/users/me");
} catch (error) {
caught = error as Error;
}
expect(caught?.message).toContain("Mattermost API 503 Service Unavailable");
expect(caught?.message).toContain("upstream unavailable");
expect(caught?.message).not.toContain("tail");
expect(caught?.message.length).toBeLessThan(8_300);
expect(tracked.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledTimes(1);
});
it("bounds and cancels oversized guarded Mattermost success JSON bodies", async () => {
const release = vi.fn(async () => {});
let canceled = false;
let pulled = 0;
const oversizeChunk = new Uint8Array(2 * 1024 * 1024).fill(0x7b); // 2 MiB of '{'
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pulled += 1;
// Flood far past the 16 MiB JSON cap; an unbounded reader would buffer
// the whole stream before parsing.
controller.enqueue(oversizeChunk);
},
cancel() {
canceled = true;
},
});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
release,
});
const client = createMattermostClient({
baseUrl: "https://chat.example.com",
botToken: "test-token",
});
let caught: Error | undefined;
try {
await client.request("/users/me");
} catch (error) {
caught = error as Error;
}
expect(caught?.message).toContain("JSON response exceeds 16777216 bytes");
// The reader is cancelled at the cap instead of draining the flood: ~8
// chunks of 2 MiB reach the 16 MiB ceiling, never the unbounded tail.
expect(canceled).toBe(true);
expect(pulled).toBeLessThanOrEqual(12);
expect(release).toHaveBeenCalledTimes(1);
});
it("rejects oversized guarded Mattermost success text bodies instead of truncating", async () => {
const release = vi.fn(async () => {});
const tracked = cancelTrackedResponse(`${"plain success ".repeat(7000)}tail`, {
status: 200,
headers: { "content-type": "text/plain" },
});
fetchWithSsrFGuardMock.mockResolvedValueOnce({ response: tracked.response, release });
const client = createMattermostClient({
baseUrl: "https://chat.example.com",
botToken: "test-token",
});
await expect(client.request("/users/me")).rejects.toThrow(
"Mattermost API /users/me: text response exceeds 65536 bytes",
);
expect(tracked.wasCanceled()).toBe(true);
expect(release).toHaveBeenCalledTimes(1);
});
it("releases guarded Mattermost responses when upstream body reads fail", async () => {
const release = vi.fn(async () => {});
const stream = new ReadableStream<Uint8Array>({
pull() {
throw new Error("upstream body failed");
},
});
fetchWithSsrFGuardMock.mockResolvedValueOnce({
response: new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
release,
});
const client = createMattermostClient({
baseUrl: "https://chat.example.com",
botToken: "test-token",
});
await expect(client.request("/users/me")).rejects.toThrow("upstream body failed");
expect(release).toHaveBeenCalledTimes(1);
});
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",
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 = parseRequestJson(calls[0].init);
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 = parseRequestJson(calls[0].init);
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 = parseRequestJson(calls[0].init);
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 = parseRequestJson(calls[0].init);
expect(body).toEqual({
channel_id: "ch123",
message: "Pick an option",
props,
});
});
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 = parseRequestJson(calls[0].init);
expect(body.props).toBeUndefined();
});
});
// ── updateMattermostPost ─────────────────────────────────────────────
describe("updateMattermostPost", () => {
it("sends PUT to /posts/{id}", async () => {
const { calls } = await updatePostAndCapture({ message: "Updated" });
const firstCall = calls[0];
if (!firstCall) {
throw new Error("expected Mattermost update post request");
}
expect(firstCall.url).toContain("/posts/post1");
if (!firstCall.init) {
throw new Error("expected Mattermost update post request init");
}
expect(firstCall.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).toEqual({
id: "post1",
message: "Original message",
props: {
attachments: [{ text: "✓ **do_now** selected by @tony" }],
},
});
});
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: [] });
});
});

View File

@@ -0,0 +1,700 @@
// Mattermost plugin module implements client behavior.
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import {
readProviderJsonResponse,
readResponseTextLimited,
} from "openclaw/plugin-sdk/provider-http";
import { readResponseWithLimit } from "openclaw/plugin-sdk/response-limit-runtime";
import { sleep } from "openclaw/plugin-sdk/runtime-env";
import {
fetchWithSsrFGuard,
ssrfPolicyFromPrivateNetworkOptIn,
} from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { z } from "zod";
const MATTERMOST_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
// Mattermost REST control-plane JSON (posts, users, channels, file-upload
// results) stays well under a megabyte; cap successful JSON the same way the
// shared provider path is capped so an untrusted/self-hosted homeserver cannot
// stream an unbounded body into the runtime before parsing.
// Non-JSON success bodies are a rare fallback (the API is JSON-first); keep a
// generous text budget but still bound it instead of buffering the whole stream.
const MATTERMOST_TEXT_RESPONSE_LIMIT_BYTES = 64 * 1024;
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
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) => Promise<T>;
/** Guarded fetch implementation; use in place of raw fetch for outbound requests. */
fetchImpl: MattermostFetch;
};
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): string {
const normalized = normalizeMattermostBaseUrl(baseUrl);
if (!normalized) {
throw new Error("Mattermost baseUrl is required");
}
const suffix = path.startsWith("/") ? path : `/${path}`;
return `${normalized}/api/v4${suffix}`;
}
async function readMattermostSuccessText(res: Response, path: string): Promise<string> {
const bytes = await readResponseWithLimit(res, MATTERMOST_TEXT_RESPONSE_LIMIT_BYTES, {
onOverflow: ({ maxBytes }) =>
new Error(`Mattermost API ${path}: text response exceeds ${maxBytes} bytes`),
});
return new TextDecoder().decode(bytes);
}
export async function readMattermostError(res: Response): Promise<string> {
const contentType = res.headers.get("content-type") ?? "";
const text = await readResponseTextLimited(res, MATTERMOST_ERROR_BODY_LIMIT_BYTES);
if (contentType.includes("application/json")) {
try {
const data = JSON.parse(text) as { message?: string } | undefined;
if (data?.message) {
return data.message;
}
return JSON.stringify(data);
} catch {
return text;
}
}
return text;
}
function responseWithRelease(response: Response, release: () => Promise<void>): Response {
let released = false;
const releaseOnce = async () => {
if (released) {
return;
}
released = true;
await release();
};
if (!response.body || NULL_BODY_STATUSES.has(response.status)) {
void releaseOnce();
return new Response(null, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
const reader = response.body.getReader();
const body = new ReadableStream<Uint8Array>({
async pull(controller) {
try {
const { done, value } = await reader.read();
if (done) {
await releaseOnce();
controller.close();
return;
}
if (value) {
controller.enqueue(value);
}
} catch (error) {
await releaseOnce();
throw error;
}
},
async cancel(reason) {
await reader.cancel(reason).catch(() => undefined);
await releaseOnce();
},
});
return new Response(body, {
status: response.status,
statusText: response.statusText,
headers: response.headers,
});
}
export function createMattermostClient(params: {
baseUrl: string;
botToken: string;
fetchImpl?: MattermostFetch;
/** Allow requests to private/internal IPs (self-hosted/LAN deployments). */
allowPrivateNetwork?: boolean;
}): MattermostClient {
const baseUrl = normalizeMattermostBaseUrl(params.baseUrl);
if (!baseUrl) {
throw new Error("Mattermost baseUrl is required");
}
const apiBaseUrl = `${baseUrl}/api/v4`;
const token = params.botToken.trim();
// 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;
const guardedFetchImpl: MattermostFetch = async (input, init) => {
const url =
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
const { response, release } = await fetchWithSsrFGuard({
url,
init,
auditContext: "mattermost-api",
policy: ssrfPolicyFromPrivateNetworkOptIn(params.allowPrivateNetwork),
});
return responseWithRelease(response, release);
};
const fetchImpl = externalFetchImpl ?? guardedFetchImpl;
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
const url = buildMattermostApiUrl(baseUrl, path);
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${token}`);
if (typeof init?.body === "string" && !headers.has("Content-Type")) {
headers.set("Content-Type", "application/json");
}
const res = await fetchImpl(url, { ...init, headers });
if (!res.ok) {
const detail = await readMattermostError(res);
throw new Error(
`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`,
);
}
if (res.status === 204) {
return undefined as T;
}
const contentType = res.headers.get("content-type") ?? "";
if (contentType.includes("application/json")) {
return await readProviderJsonResponse<T>(res, `Mattermost API ${path}`);
}
return (await readMattermostSuccessText(res, path)) as T;
};
return { baseUrl, apiBaseUrl, token, request, fetchImpl };
}
export async function fetchMattermostMe(client: MattermostClient): Promise<MattermostUser> {
return await client.request<MattermostUser>("/users/me");
}
export async function fetchMattermostUser(
client: MattermostClient,
userId: string,
): Promise<MattermostUser> {
return await client.request<MattermostUser>(`/users/${userId}`);
}
export async function fetchMattermostUserByUsername(
client: MattermostClient,
username: string,
): Promise<MattermostUser> {
return await client.request<MattermostUser>(`/users/username/${encodeURIComponent(username)}`);
}
export async function fetchMattermostChannel(
client: MattermostClient,
channelId: string,
): Promise<MattermostChannel> {
return await client.request<MattermostChannel>(`/channels/${channelId}`);
}
export async function 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;
};
const DM_REPLY_DELIVERY_BARRIER_SLACK_MS = 60_000;
/** Covers DM creation retries without extending channel-delivery stalls. */
export function resolveMattermostReplyDeliveryBarrierTimeoutMs(params: {
isDirect: boolean;
dmRetryOptions?: CreateDmChannelRetryOptions;
queuedCounts: Readonly<Record<"tool" | "block" | "final", number>>;
humanDelayBudgetMs?: number;
}): number | undefined {
if (!params.isDirect) {
return undefined;
}
const deliveryCount = Object.values(params.queuedCounts).reduce((sum, count) => sum + count, 0);
if (deliveryCount === 0) {
return undefined;
}
const maxRetries = params.dmRetryOptions?.maxRetries ?? 3;
const maxDelayMs = params.dmRetryOptions?.maxDelayMs ?? 10_000;
const timeoutMs = params.dmRetryOptions?.timeoutMs ?? 30_000;
const perDeliveryTimeoutMs =
(maxRetries + 1) * timeoutMs + maxRetries * maxDelayMs + DM_REPLY_DELIVERY_BARRIER_SLACK_MS;
const totalTimeoutMs =
perDeliveryTimeoutMs * deliveryCount + Math.max(0, params.humanDelayBudgetMs ?? 0);
return resolveTimerTimeoutMs(
Number.isFinite(totalTimeoutMs) ? totalTimeoutMs : Number.MAX_SAFE_INTEGER,
perDeliveryTimeoutMs,
);
}
const RETRYABLE_NETWORK_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_NETWORK_ERROR_NAMES = new Set([
"AbortError",
"TimeoutError",
"ConnectTimeoutError",
"HeadersTimeoutError",
"BodyTimeoutError",
]);
const RETRYABLE_NETWORK_MESSAGE_SNIPPETS = [
"network error",
"timeout",
"timed out",
"abort",
"connection refused",
"econnreset",
"econnrefused",
"etimedout",
"enotfound",
"socket hang up",
"getaddrinfo",
];
/**
* Creates a Mattermost DM channel with exponential backoff retry logic.
* Retries on transient errors (429, 5xx, network errors) but not on
* client errors (4xx except 429) or permanent failures.
*/
export async function createMattermostDirectChannelWithRetry(
client: MattermostClient,
userIds: string[],
options: CreateDmChannelRetryOptions = {},
): Promise<MattermostChannel> {
const {
maxRetries = 3,
initialDelayMs = 1000,
maxDelayMs = 10000,
timeoutMs: rawTimeoutMs = 30000,
onRetry,
} = options;
const timeoutMs = resolveTimerTimeoutMs(rawTimeoutMs, 30000);
let lastError: Error | undefined;
for (let attempt = 0; attempt <= maxRetries; attempt++) {
try {
// Use AbortController for per-request timeout
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
try {
const result = await createMattermostDirectChannel(client, userIds, controller.signal);
return result;
} finally {
clearTimeout(timeoutId);
}
} catch (err) {
lastError = err instanceof Error ? err : new Error(String(err));
// Don't retry on the last attempt
if (attempt >= maxRetries) {
break;
}
// Check if error is retryable
if (!isRetryableError(lastError)) {
throw lastError;
}
// Calculate exponential backoff delay with full-jitter
// Jitter is proportional to the exponential delay, not a fixed 1000ms
// This ensures backoff behaves correctly for small delay configurations
const exponentialDelay = initialDelayMs * 2 ** attempt;
const jitter = Math.random() * exponentialDelay;
const delayMs = Math.min(exponentialDelay + jitter, maxDelayMs);
if (onRetry) {
onRetry(attempt + 1, delayMs, lastError);
}
// Wait before retrying
await sleep(delayMs);
}
}
throw lastError ?? new Error("Failed to create DM channel after retries");
}
function isRetryableError(error: Error): boolean {
const candidates = collectErrorCandidates(error);
const messages = candidates
.map((candidate) => normalizeLowercaseStringOrEmpty(readErrorMessage(candidate)))
.filter((message): message is string => Boolean(message));
// Retry on 5xx server errors FIRST (before checking 4xx)
// Use "mattermost api" prefix to avoid matching port numbers (e.g., :443) or IP octets
// This prevents misclassification when a 5xx error detail contains a 4xx substring
// e.g., "Mattermost API 503: upstream returned 404"
if (messages.some((message) => /mattermost api 5\d{2}\b/.test(message))) {
return true;
}
// Check for explicit 429 rate limiting FIRST (before generic "429" text match)
// This avoids retrying when error detail contains "429" but it's not the status code
if (
messages.some(
(message) => /mattermost api 429\b/.test(message) || message.includes("too many requests"),
)
) {
return true;
}
// Check for explicit 4xx status codes - these are client errors and should NOT be retried
// (except 429 which is handled above)
// Use "mattermost api" prefix to avoid matching port numbers like :443
for (const message of messages) {
const clientErrorMatch = message.match(/mattermost api (4\d{2})\b/);
if (!clientErrorMatch) {
continue;
}
const statusCode = Number.parseInt(clientErrorMatch[1], 10);
if (statusCode >= 400 && statusCode < 500) {
return false;
}
}
// Retry on network/transient errors only if no explicit Mattermost API status code is present
// This avoids false positives like:
// - "400 Bad Request: connection timed out" (has status code)
// - "connect ECONNRESET 104.18.32.10:443" (has port number, not status)
const hasMattermostApiStatusCode = messages.some((message) =>
/mattermost api \d{3}\b/.test(message),
);
if (hasMattermostApiStatusCode) {
return false;
}
const codes: string[] = [];
for (const candidate of candidates) {
const code = readErrorCode(candidate);
if (code) {
codes.push(code);
}
}
if (codes.some((code) => RETRYABLE_NETWORK_ERROR_CODES.has(code))) {
return true;
}
const names: string[] = [];
for (const candidate of candidates) {
const name = readErrorName(candidate);
if (name) {
names.push(name);
}
}
if (names.some((name) => RETRYABLE_NETWORK_ERROR_NAMES.has(name))) {
return true;
}
return messages.some((message) =>
RETRYABLE_NETWORK_MESSAGE_SNIPPETS.some((pattern) => message.includes(pattern)),
);
}
function collectErrorCandidates(error: unknown): unknown[] {
const queue: unknown[] = [error];
let queueIndex = 0;
const seen = new Set<unknown>();
const candidates: unknown[] = [];
while (queueIndex < queue.length) {
const current = queue[queueIndex];
queueIndex += 1;
if (!current || seen.has(current)) {
continue;
}
seen.add(current);
candidates.push(current);
if (typeof current !== "object") {
continue;
}
const nested = current as {
cause?: unknown;
reason?: unknown;
errors?: unknown;
};
queue.push(nested.cause, nested.reason);
if (Array.isArray(nested.errors)) {
queue.push(...nested.errors);
}
}
return candidates;
}
function readErrorMessage(error: unknown): string | undefined {
if (!error || typeof error !== "object") {
return undefined;
}
const message = (error as { message?: unknown }).message;
return typeof message === "string" && message.trim() ? message : undefined;
}
function readErrorName(error: unknown): string | undefined {
if (!error || typeof error !== "object") {
return undefined;
}
const name = (error as { name?: unknown }).name;
return typeof name === "string" && name.trim() ? name : undefined;
}
function readErrorCode(error: unknown): string | undefined {
if (!error || typeof error !== "object") {
return undefined;
}
const { code, errno } = error as {
code?: unknown;
errno?: unknown;
};
const raw = typeof code === "string" && code.trim() ? code : errno;
if (typeof raw === "string" && raw.trim()) {
return raw.trim().toUpperCase();
}
if (typeof raw === "number" && Number.isFinite(raw)) {
return String(raw);
}
return undefined;
}
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 deleteMattermostPost(
client: MattermostClient,
postId: string,
): Promise<void> {
await client.request<void>(`/posts/${postId}`, {
method: "DELETE",
});
}
export async function uploadMattermostFile(
client: MattermostClient,
params: {
channelId: string;
buffer: Buffer;
fileName: string;
contentType?: string;
},
): Promise<MattermostFileInfo> {
const form = new FormData();
const fileName = normalizeOptionalString(params.fileName) ?? "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 readProviderJsonResponse<{ file_infos?: MattermostFileInfo[] }>(
res,
"Mattermost API /files",
);
const info = data.file_infos?.[0];
if (!info?.id) {
throw new Error("Mattermost file upload failed");
}
return info;
}

View File

@@ -0,0 +1,253 @@
// Mattermost tests cover directory plugin behavior.
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("paginates team members before resolving peer directory users in batches", async () => {
const firstPageMembers = Array.from({ length: 200 }, (_, index) => ({
user_id: `user-${index + 1}`,
}));
const client = {
token: "token-default",
request: vi
.fn()
.mockResolvedValueOnce([{ id: "team-1" }])
.mockResolvedValueOnce(firstPageMembers)
.mockResolvedValueOnce([{ user_id: "user-201" }, { user_id: "user-202" }])
.mockResolvedValueOnce([{ id: "user-1", username: "alice" }])
.mockResolvedValueOnce([
{ id: "user-201", username: "zara" },
{ id: "user-202", username: "yuki" },
]),
};
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: undefined },
{ kind: "user", id: "user:user-201", name: "zara", handle: undefined },
{ kind: "user", id: "user:user-202", name: "yuki", handle: undefined },
]);
expect(client.request).toHaveBeenNthCalledWith(2, "/teams/team-1/members?page=0&per_page=200");
expect(client.request).toHaveBeenNthCalledWith(3, "/teams/team-1/members?page=1&per_page=200");
expect(client.request).toHaveBeenNthCalledWith(4, "/users/ids", {
method: "POST",
body: JSON.stringify(firstPageMembers.map((member) => member.user_id)),
});
expect(client.request).toHaveBeenNthCalledWith(5, "/users/ids", {
method: "POST",
body: JSON.stringify(["user-201", "user-202"]),
});
});
it("applies peer limits after resolving users", async () => {
const client = {
token: "token-default",
request: vi
.fn()
.mockResolvedValueOnce([{ id: "team-1" }])
.mockResolvedValueOnce([{ user_id: "missing-user" }, { user_id: "user-2" }])
.mockResolvedValueOnce([{ id: "user-2", username: "bob" }]),
};
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,
limit: 1,
}),
).resolves.toEqual([{ kind: "user", id: "user:user-2", name: "bob", handle: undefined }]);
expect(client.request).toHaveBeenNthCalledWith(2, "/teams/team-1/members?page=0&per_page=200");
expect(client.request).toHaveBeenNthCalledWith(3, "/users/ids", {
method: "POST",
body: JSON.stringify(["missing-user", "user-2"]),
});
});
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", {
method: "POST",
body: JSON.stringify({ term: "ali", team_id: "team-1" }),
});
});
});

View File

@@ -0,0 +1,198 @@
// Mattermost plugin module implements directory behavior.
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
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: isPrivateNetworkOptInEnabled(account.config),
});
}
/**
* 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 = normalizeLowercaseStringOrEmpty(params.query);
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 = normalizeLowercaseStringOrEmpty(ch.name);
const display = normalizeLowercaseStringOrEmpty(ch.display_name);
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.
*
* Uses paginated member listing with per_page=200, the Mattermost API maximum.
*/
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 = normalizeLowercaseStringOrEmpty(params.query);
let users: MattermostUser[];
if (q) {
users = await client.request<MattermostUser[]>("/users/search", {
method: "POST",
body: JSON.stringify({ term: q, team_id: teamId }),
});
} else {
const pageSize = 200;
const userIds: string[] = [];
for (let page = 0; ; page += 1) {
const pageMembers = await client.request<ReadonlyArray<{ user_id: string }>>(
`/teams/${teamId}/members?page=${page}&per_page=${pageSize}`,
);
for (const member of pageMembers) {
if (member.user_id !== me.id) {
userIds.push(member.user_id);
}
}
if (pageMembers.length < pageSize) {
break;
}
}
if (!userIds.length) {
return [];
}
users = [];
for (let index = 0; index < userIds.length; index += pageSize) {
const userIdBatch = userIds.slice(index, index + pageSize);
users.push(
...(await client.request<MattermostUser[]>("/users/ids", {
method: "POST",
body: JSON.stringify(userIdBatch),
})),
);
}
}
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 [];
}
}

View File

@@ -0,0 +1,281 @@
// Mattermost tests cover draft stream plugin behavior.
import { describe, expect, it, vi } from "vitest";
import type { MattermostClient } from "./client.js";
import { createMattermostDraftStream } from "./draft-stream.js";
type RequestRecord = {
path: string;
init?: RequestInit;
};
function createMockClient(): {
client: MattermostClient;
calls: RequestRecord[];
requestMock: ReturnType<typeof vi.fn>;
} {
const calls: RequestRecord[] = [];
let nextId = 1;
const requestImpl: MattermostClient["request"] = async <T>(
path: string,
init?: RequestInit,
): Promise<T> => {
calls.push({ path, init });
if (path === "/posts") {
return { id: `post-${nextId++}` } as T;
}
if (path.startsWith("/posts/")) {
return { id: "patched" } as T;
}
return {} as T;
};
const requestMock = vi.fn(requestImpl);
const client: MattermostClient = {
baseUrl: "https://chat.example.com",
apiBaseUrl: "https://chat.example.com/api/v4",
token: "token",
request: requestMock as MattermostClient["request"],
fetchImpl: vi.fn() as MattermostClient["fetchImpl"],
};
return { client, calls, requestMock };
}
function parseRequestJson(init: RequestInit | undefined): Record<string, unknown> {
if (typeof init?.body !== "string") {
throw new Error("expected JSON request body");
}
const parsed: unknown = JSON.parse(init.body);
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
throw new Error("expected JSON object request body");
}
return parsed as Record<string, unknown>;
}
describe("createMattermostDraftStream", () => {
it("creates a preview post and updates it on later changes", async () => {
const { client, calls } = createMockClient();
const stream = createMattermostDraftStream({
client,
channelId: "channel-1",
rootId: "root-1",
throttleMs: 0,
});
stream.update("Running `read`…");
await stream.flush();
stream.update("Running `read`…");
await stream.flush();
expect(calls).toHaveLength(1);
expect(calls[0]?.path).toBe("/posts");
expect(parseRequestJson(calls[0]?.init)).toEqual({
channel_id: "channel-1",
root_id: "root-1",
message: "Running `read`…",
});
expect(stream.postId()).toBe("post-1");
});
it("does not resend identical updates", async () => {
const { client, calls } = createMockClient();
const stream = createMattermostDraftStream({
client,
channelId: "channel-1",
throttleMs: 0,
});
stream.update("Working...");
await stream.flush();
stream.update("Working...");
await stream.flush();
expect(calls).toHaveLength(1);
});
it("clears the preview post when no final reply is delivered", async () => {
const { client, calls } = createMockClient();
const stream = createMattermostDraftStream({
client,
channelId: "channel-1",
rootId: "root-1",
throttleMs: 0,
});
stream.update("Working...");
await stream.flush();
await stream.clear();
expect(calls).toHaveLength(2);
expect(calls[1]?.path).toBe("/posts/post-1");
expect(calls[1]?.init?.method).toBe("DELETE");
expect(stream.postId()).toBeUndefined();
});
it("discardPending keeps the preview post but ignores later updates", async () => {
const { client, calls } = createMockClient();
const stream = createMattermostDraftStream({
client,
channelId: "channel-1",
rootId: "root-1",
throttleMs: 0,
});
stream.update("Working...");
await stream.flush();
await stream.discardPending();
stream.update("Late update");
await stream.flush();
expect(calls).toHaveLength(1);
expect(calls[0]?.path).toBe("/posts");
expect(stream.postId()).toBe("post-1");
});
it("seal keeps the preview post and cancels pending final overwrites", async () => {
const { client, calls } = createMockClient();
const stream = createMattermostDraftStream({
client,
channelId: "channel-1",
rootId: "root-1",
throttleMs: 0,
});
stream.update("Working...");
await stream.flush();
stream.update("Stale final draft");
await stream.seal();
expect(calls).toHaveLength(1);
expect(calls[0]?.path).toBe("/posts");
expect(stream.postId()).toBe("post-1");
});
it("stop flushes the last pending update and ignores later ones", async () => {
const { client, calls } = createMockClient();
const stream = createMattermostDraftStream({
client,
channelId: "channel-1",
rootId: "root-1",
throttleMs: 1000,
});
stream.update("Working...");
await stream.flush();
stream.update("Stale partial");
await stream.stop();
stream.update("Late partial");
await stream.flush();
expect(calls).toHaveLength(2);
expect(calls[0]?.path).toBe("/posts");
expect(calls[1]?.path).toBe("/posts/post-1");
expect(parseRequestJson(calls[1]?.init)).toEqual({
id: "post-1",
message: "Stale partial",
});
});
it("warns and stops when preview creation fails", async () => {
const warn = vi.fn();
const requestImpl: MattermostClient["request"] = async () => {
throw new Error("boom");
};
const requestMock = vi.fn(requestImpl);
const client: MattermostClient = {
baseUrl: "https://chat.example.com",
apiBaseUrl: "https://chat.example.com/api/v4",
token: "token",
request: requestMock as MattermostClient["request"],
fetchImpl: vi.fn() as MattermostClient["fetchImpl"],
};
const stream = createMattermostDraftStream({
client,
channelId: "channel-1",
throttleMs: 0,
warn,
});
stream.update("Working...");
await stream.flush();
stream.update("Still working...");
await stream.flush();
expect(warn).toHaveBeenCalled();
expect(requestMock).toHaveBeenCalledTimes(1);
expect(stream.postId()).toBeUndefined();
});
it("truncates on a code-point boundary so a straddling emoji is dropped whole", async () => {
const { client, calls } = createMockClient();
// maxChars=12 => cut point is maxChars-3=9. The emoji 😀 occupies UTF-16
// indices 8-9, so a raw slice(0,9) would keep the lone high surrogate at
// index 8 and drop its low surrogate at index 9, leaking a dangling half.
const stream = createMattermostDraftStream({
client,
channelId: "channel-1",
throttleMs: 0,
maxChars: 12,
});
const input = `${"a".repeat(8)}\u{1F600}${"b".repeat(5)}`;
stream.update(input);
await stream.flush();
expect(calls).toHaveLength(1);
const message = parseRequestJson(calls[0]?.init).message;
expect(typeof message).toBe("string");
const sent = message as string;
// The straddling emoji must be dropped whole, leaving no dangling surrogate half.
expect(/[\uD800-\uDFFF]/u.test(sent)).toBe(false);
expect(sent.length).toBeLessThanOrEqual(12);
expect(sent).toBe("aaaaaaaa...");
});
it("does not resend after an update failure followed by stop", async () => {
const warn = vi.fn();
const calls: RequestRecord[] = [];
let failNextPatch = true;
const requestImpl: MattermostClient["request"] = async <T>(
path: string,
init?: RequestInit,
): Promise<T> => {
calls.push({ path, init });
if (path === "/posts") {
return { id: "post-1" } as T;
}
if (path === "/posts/post-1") {
if (failNextPatch) {
failNextPatch = false;
throw new Error("patch failed");
}
return { id: "patched" } as T;
}
return {} as T;
};
const requestMock = vi.fn(requestImpl);
const client: MattermostClient = {
baseUrl: "https://chat.example.com",
apiBaseUrl: "https://chat.example.com/api/v4",
token: "token",
request: requestMock as MattermostClient["request"],
fetchImpl: vi.fn() as MattermostClient["fetchImpl"],
};
const stream = createMattermostDraftStream({
client,
channelId: "channel-1",
throttleMs: 1000,
warn,
});
stream.update("Working...");
await stream.flush();
stream.update("Will fail");
await stream.flush();
await stream.stop();
expect(warn).toHaveBeenCalledWith("mattermost stream preview failed: patch failed");
expect(calls).toHaveLength(2);
expect(calls[0]?.path).toBe("/posts");
expect(calls[1]?.path).toBe("/posts/post-1");
});
});

View File

@@ -0,0 +1,132 @@
// Mattermost plugin module implements draft stream behavior.
import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-outbound";
import { sliceUtf16Safe } from "openclaw/plugin-sdk/text-utility-runtime";
import {
createMattermostPost,
deleteMattermostPost,
updateMattermostPost,
type MattermostClient,
} from "./client.js";
const MATTERMOST_STREAM_MAX_CHARS = 4000;
const DEFAULT_THROTTLE_MS = 1000;
type MattermostDraftStream = {
update: (text: string) => void;
flush: () => Promise<void>;
postId: () => string | undefined;
clear: () => Promise<void>;
discardPending: () => Promise<void>;
seal: () => Promise<void>;
stop: () => Promise<void>;
forceNewMessage: () => void;
};
function normalizeMattermostDraftText(text: string, maxChars: number): string {
const trimmed = text.trim();
if (!trimmed) {
return "";
}
if (trimmed.length <= maxChars) {
return trimmed;
}
return `${sliceUtf16Safe(trimmed, 0, Math.max(0, maxChars - 3)).trimEnd()}...`;
}
export function createMattermostDraftStream(params: {
client: MattermostClient;
channelId: string;
rootId?: string;
maxChars?: number;
throttleMs?: number;
renderText?: (text: string) => string;
log?: (message: string) => void;
warn?: (message: string) => void;
}): MattermostDraftStream {
const maxChars = Math.min(
params.maxChars ?? MATTERMOST_STREAM_MAX_CHARS,
MATTERMOST_STREAM_MAX_CHARS,
);
const throttleMs = Math.max(250, params.throttleMs ?? DEFAULT_THROTTLE_MS);
const streamState = { stopped: false, final: false };
let streamPostId: string | undefined;
let lastSentText = "";
const sendOrEditStreamMessage = async (text: string): Promise<boolean> => {
if (streamState.stopped && !streamState.final) {
return false;
}
const rendered = params.renderText?.(text) ?? text;
const normalized = normalizeMattermostDraftText(rendered, maxChars);
if (!normalized) {
return false;
}
if (normalized === lastSentText) {
return true;
}
try {
if (streamPostId) {
await updateMattermostPost(params.client, streamPostId, {
message: normalized,
});
} else {
const sent = await createMattermostPost(params.client, {
channelId: params.channelId,
message: normalized,
rootId: params.rootId,
});
const postId = sent.id?.trim();
if (!postId) {
streamState.stopped = true;
params.warn?.("mattermost stream preview stopped (missing post id from create)");
return false;
}
streamPostId = postId;
}
lastSentText = normalized;
return true;
} catch (err) {
streamState.stopped = true;
params.warn?.(
`mattermost stream preview failed: ${err instanceof Error ? err.message : String(err)}`,
);
return false;
}
};
const { loop, update, stop, clear, discardPending, seal } = createFinalizableDraftLifecycle({
throttleMs,
state: streamState,
sendOrEditStreamMessage,
readMessageId: () => streamPostId,
clearMessageId: () => {
streamPostId = undefined;
},
isValidMessageId: (value): value is string => typeof value === "string" && value.length > 0,
deleteMessage: async (postId) => {
await deleteMattermostPost(params.client, postId);
},
warn: params.warn,
warnPrefix: "mattermost stream preview cleanup failed",
});
const forceNewMessage = () => {
streamPostId = undefined;
lastSentText = "";
loop.resetPending();
loop.resetThrottleWindow();
};
params.log?.(`mattermost stream preview ready (maxChars=${maxChars}, throttleMs=${throttleMs})`);
return {
update,
flush: loop.flush,
postId: () => streamPostId,
clear,
discardPending,
seal,
stop,
forceNewMessage,
};
}

View File

@@ -0,0 +1,935 @@
// Mattermost tests cover interactions plugin behavior.
import type { IncomingMessage, 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,
getInteractionSecret,
resolveInteractionCallbackPath,
resolveInteractionCallbackUrl,
setInteractionCallbackUrl,
setInteractionSecret,
verifyInteractionToken,
} from "./interactions.js";
type ButtonAttachments = ReturnType<typeof buildButtonAttachments>;
type ButtonAttachment = ButtonAttachments[number];
type ButtonAction = NonNullable<ButtonAttachment["actions"]>[number];
function requireFirstAttachment(attachments: ButtonAttachments): ButtonAttachment {
const [attachment] = attachments;
if (!attachment) {
throw new Error("Expected button attachment fixture");
}
return attachment;
}
function requireActions(attachments: ButtonAttachments): ButtonAction[] {
const attachment = requireFirstAttachment(attachments);
if (!attachment.actions) {
throw new Error("Expected button attachment fixture actions");
}
return attachment.actions;
}
function requireAction(attachments: ButtonAttachments, index = 0): ButtonAction {
const action = requireActions(attachments).at(index);
if (!action) {
throw new Error(`Expected button attachment action at index ${index}`);
}
return action;
}
// ── HMAC token management ────────────────────────────────────────────
describe("setInteractionSecret / getInteractionSecret", () => {
beforeEach(() => {
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(() => {
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", () => {
setInteractionSecret("acct-a", "bot-token-a");
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);
});
});
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: {
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(requireActions(result)).toHaveLength(2);
});
it("sets type to 'button' on every action", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost:18789/cb",
buttons: [{ id: "a", name: "A" }],
});
expect(requireAction(result).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 = requireAction(result);
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 = requireAction(result);
// 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 = requireAction(result).integration.context;
expect(ctx.tweet_id).toBe("123");
expect(ctx.batch).toBe(true);
expect(ctx.action_id).toBe("btn");
expect(ctx["_token"]).toMatch(/^[0-9a-f]{64}$/);
});
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 requireActions(result)) {
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(requireAction(result, 0).style).toBe("primary");
expect(requireAction(result, 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(requireFirstAttachment(result).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(requireFirstAttachment(result).text).toBe("");
});
it("generates verifiable tokens", () => {
const result = buildButtonAttachments({
callbackUrl: "http://localhost/cb",
buttons: [{ id: "verify_me", name: "V", context: { extra: "data" } }],
});
const ctx = requireAction(result).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 = requireAction(result).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.toReversed()) {
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",
token: "bot-token",
request: async <T>(path: string, init?: RequestInit) => (await requestImpl(path, init)) as T,
fetchImpl: vi.fn<typeof fetch>(),
};
}
beforeEach(() => {
setInteractionRuntime();
setInteractionSecret("acct", "bot-token");
});
function createReq(params: {
method?: string;
body?: unknown;
remoteAddress?: string;
headers?: Record<string, string>;
}): IncomingMessage {
const body =
params.body === undefined
? ""
: typeof params.body === "string"
? params.body
: JSON.stringify(params.body);
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
const req = {
destroyed: false,
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;
},
removeListener(event: string, handler: (...args: unknown[]) => void) {
const existing = listeners.get(event) ?? [];
listeners.set(
event,
existing.filter((entry) => entry !== handler),
);
return this;
},
destroy() {
this.destroyed = true;
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("rejects malformed JSON callback requests with a stable parser error", async () => {
const log = vi.fn();
const handler = createMattermostInteractionHandler({
client: createMattermostClientMock(async () => {
throw new Error("unexpected client request");
}),
botUserId: "bot",
accountId: "acct",
log,
});
const res = await runHandler(handler, { body: "{not json" });
expect(res.statusCode).toBe(400);
expect(res.body).toBe(JSON.stringify({ error: "Invalid request body" }));
expect(log).toHaveBeenCalledWith(
"mattermost interaction: failed to parse body: Error: Mattermost interaction body was malformed JSON",
);
});
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(
'Mattermost button click: action="approve" by alice in channel chan-1',
{
sessionKey: "session:thread:root-9",
contextKey: "mattermost:interaction:post-1:approve",
},
);
expect(dispatchButtonClick).toHaveBeenCalledWith({
channelId: "chan-1",
userId: "user-1",
userName: "alice",
actionId: "approve",
actionName: "Approve",
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 originalPost = createActionPost({
actionId: "mdlprov",
actionName: "Browse providers",
});
const handler = createMattermostInteractionHandler({
client: createMattermostClientMock(async (path: string, init?: { method?: string }) => {
requestLog.push({ path, method: init?.method });
return originalPost;
}),
botUserId: "bot",
accountId: "acct",
handleInteraction,
dispatchButtonClick,
});
const body = createInteractionBody({
context,
token,
userId: "user-2",
userName: "alice",
});
const res = await runHandler(handler, { body });
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({
payload: body,
userName: "alice",
actionId: "mdlprov",
actionName: "Browse providers",
originalMessage: "Choose",
context,
post: originalPost,
});
expect(dispatchButtonClick).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,677 @@
// Mattermost plugin module implements interactions behavior.
import { createHmac } from "node:crypto";
import type { IncomingMessage, ServerResponse } from "node:http";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import {
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { getMattermostRuntime } from "../runtime.js";
import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js";
import {
isTrustedProxyAddress,
readRequestBodyWithLimit,
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/
*/
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;
};
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);
}
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 normalizeOptionalString(value[0]);
}
return normalizeOptionalString(value);
}
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 =
normalizeOptionalString(cfg?.interactions?.callbackBaseUrl) ??
normalizeOptionalString(cfg?.channels?.mattermost?.interactions?.callbackBaseUrl);
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)
.toSorted(([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 ─────────────────────────────────────────────
type MattermostButton = {
id: string;
type: "button" | "select";
name: string;
style?: "default" | "primary" | "danger";
integration: {
url: string;
context: Record<string, unknown>;
};
};
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: normalizeStringifiedOptionalString(btn.id ?? btn.callback_data) ?? "",
name: normalizeStringifiedOptionalString(btn.text ?? btn.name ?? btn.label) ?? "",
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 readRequestBodyWithLimit(req, {
maxBytes: INTERACTION_MAX_BODY_BYTES,
timeoutMs: INTERACTION_BODY_TIMEOUT_MS,
});
}
// ── 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();
function parseInteractionPayload(raw: string): MattermostInteractionPayload {
try {
return JSON.parse(raw) as MattermostInteractionPayload;
} catch {
throw new Error("Mattermost interaction body was malformed JSON");
}
}
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 = parseInteractionPayload(raw);
} 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;
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)}`);
}
}
};
}

View File

@@ -0,0 +1,279 @@
// Mattermost tests cover model picker plugin behavior.
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.text).toContain("Browse keeps the current runtime");
expect(view.text).toContain("/oc_model <provider/model> --runtime <runtime>");
const firstRow = view.buttons[0];
if (!firstRow) {
throw new Error("expected Mattermost model picker button row");
}
const browseButton = firstRow[0];
if (!browseButton) {
throw new Error("expected Mattermost browse providers button");
}
expect(browseButton.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("does not coerce partial page strings in signed picker contexts", () => {
expect(
parseMattermostModelPickerContext({
oc_model_picker: true,
action: "list",
ownerUserId: "user-1",
provider: "openai",
page: "+02",
}),
).toEqual({
action: "list",
ownerUserId: "user-1",
provider: "openai",
page: 2,
});
expect(
parseMattermostModelPickerContext({
oc_model_picker: true,
action: "list",
ownerUserId: "user-1",
provider: "openai",
page: "2next",
}),
).toEqual({
action: "list",
ownerUserId: "user-1",
provider: "openai",
page: 1,
});
});
it("falls back to the routed agent default model when no override is stored", () => {
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 });
}
});
it("resolves current and parent model overrides from targeted session entries", () => {
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-model-picker-"));
try {
const storePath = path.join(testDir, "{agentId}.json");
const supportStorePath = path.join(testDir, "support.json");
const parentSessionKey = "agent:support:mattermost:default:channel-1";
const childSessionKey = "agent:support:mattermost:default:child-with-explicit-parent";
const directSessionKey = "agent:support:mattermost:default:direct-1";
fs.writeFileSync(
supportStorePath,
JSON.stringify(
{
[parentSessionKey]: {
providerOverride: "anthropic",
modelOverride: "claude-sonnet-4-5",
sessionId: "parent-session",
},
[childSessionKey]: {
parentSessionKey,
sessionId: "child-session",
},
[directSessionKey]: {
providerOverride: "openai",
modelOverride: "gpt-5",
sessionId: "direct-session",
},
},
null,
2,
),
);
const cfg: OpenClawConfig = {
session: {
store: storePath,
},
};
expect(
resolveMattermostModelPickerCurrentModel({
cfg,
route: {
agentId: "support",
sessionKey: directSessionKey,
},
data,
}),
).toBe("openai/gpt-5");
expect(
resolveMattermostModelPickerCurrentModel({
cfg,
route: {
agentId: "support",
sessionKey: childSessionKey,
},
data,
}),
).toBe("anthropic/claude-sonnet-4-5");
} finally {
fs.rmSync(testDir, { recursive: true, force: true });
}
});
});

View File

@@ -0,0 +1,411 @@
// Mattermost plugin module implements model picker behavior.
import { createHash } from "node:crypto";
import {
resolveStoredModelOverride,
type ModelsProviderData,
} from "openclaw/plugin-sdk/command-auth-native";
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import { parseStrictInteger } from "openclaw/plugin-sdk/number-runtime";
import { normalizeProviderId } from "openclaw/plugin-sdk/provider-model-shared";
import { getSessionEntry, resolveStorePath } from "openclaw/plugin-sdk/session-store-runtime";
import {
normalizeOptionalString,
normalizeStringifiedOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import type { MattermostInteractiveButtonInput } from "./interactions.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;
type MattermostModelPickerEntry =
| { kind: "summary" }
| { kind: "providers" }
| { kind: "models"; provider: string };
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 };
type MattermostModelPickerRenderedView = {
text: string;
buttons: MattermostInteractiveButtonInput[][];
};
function splitModelRef(modelRef?: string | null): { provider: string; model: string } | null {
const trimmed = normalizeOptionalString(modelRef);
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 = normalizeOptionalString(match[2]);
if (!provider || !model) {
return null;
}
return { provider, model };
}
function readContextString(context: Record<string, unknown>, key: string, fallback = ""): string {
const value = context[key];
return typeof value === "string" ? value : fallback;
}
function readContextNumber(context: Record<string, unknown>, key: string): number | undefined {
const value = context[key];
if (typeof value === "number" && Number.isFinite(value)) {
return value;
}
if (typeof value === "string") {
return parseStrictInteger(value);
}
return undefined;
}
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: normalizeStringifiedOptionalString(params.model) ?? "",
};
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>())].toSorted();
}
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 = normalizeOptionalString(readContextString(context, "ownerUserId")) ?? "";
const action = normalizeOptionalString(readContextString(context, "action")) ?? "";
if (!ownerUserId) {
return null;
}
if (action === "providers" || action === "back") {
return { action, ownerUserId };
}
const provider = normalizeProviderId(readContextString(context, "provider"));
const page = readContextNumber(context, "page");
if (!provider) {
return null;
}
if (action === "list") {
return {
action,
ownerUserId,
provider,
page: normalizePage(page),
};
}
if (action === "select") {
const model = normalizeOptionalString(readContextString(context, "model")) ?? "";
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;
readConsistency?: "latest";
}): string {
const fallback = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
try {
const storePath = resolveStorePath(params.cfg.session?.store, {
agentId: params.route.agentId,
});
const sessionEntry = getSessionEntry({
storePath,
sessionKey: params.route.sessionKey,
...(params.readConsistency === "latest" ? { readConsistency: "latest" as const } : {}),
});
const override = resolveStoredModelOverride({
sessionEntry,
loadSessionEntry: (sessionKey) =>
getSessionEntry({
storePath,
sessionKey,
...(params.readConsistency === "latest" ? { readConsistency: "latest" as const } : {}),
}),
sessionKey: params.route.sessionKey,
parentSessionKey: sessionEntry?.parentSessionKey,
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",
"Browse keeps the current runtime; use /oc_model <provider/model> --runtime <runtime> to switch runtime too",
"/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,
};
}

View File

@@ -0,0 +1,180 @@
// Mattermost tests cover monitor auth plugin behavior.
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn());
const resolveAllowlistMatchSimple = vi.hoisted(() => vi.fn());
vi.mock("./runtime-api.js", () => ({
isDangerousNameMatchingEnabled,
resolveAllowlistMatchSimple,
}));
describe("mattermost monitor auth", () => {
let authorizeMattermostCommandInvocation: typeof import("./monitor-auth.js").authorizeMattermostCommandInvocation;
let formatMattermostDirectMessageDropLog: typeof import("./monitor-auth.js").formatMattermostDirectMessageDropLog;
let isMattermostSenderAllowed: typeof import("./monitor-auth.js").isMattermostSenderAllowed;
let normalizeMattermostAllowEntry: typeof import("./monitor-auth.js").normalizeMattermostAllowEntry;
let normalizeMattermostAllowList: typeof import("./monitor-auth.js").normalizeMattermostAllowList;
beforeAll(async () => {
({
authorizeMattermostCommandInvocation,
formatMattermostDirectMessageDropLog,
isMattermostSenderAllowed,
normalizeMattermostAllowEntry,
normalizeMattermostAllowList,
} = await import("./monitor-auth.js"));
});
beforeEach(() => {
isDangerousNameMatchingEnabled.mockReset();
resolveAllowlistMatchSimple.mockReset();
});
it("normalizes allowlist entries", () => {
expect(normalizeMattermostAllowEntry(" @Alice ")).toBe("alice");
expect(normalizeMattermostAllowEntry("mattermost:Bob")).toBe("bob");
expect(normalizeMattermostAllowEntry("accessGroup:Ops")).toBe("accessGroup:Ops");
expect(normalizeMattermostAllowEntry("*")).toBe("*");
expect(normalizeMattermostAllowList([" Alice ", "user:alice", "ALICE", "*"])).toEqual([
"alice",
"*",
]);
});
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("formats direct-message drops with the ingress reason and open-policy hint", () => {
expect(
formatMattermostDirectMessageDropLog({
senderId: "alice-id",
dmPolicy: "open",
reasonCode: "dm_policy_not_allowlisted",
}),
).toBe(
"mattermost: drop dm sender=alice-id (dmPolicy=open reason=dm_policy_not_allowlisted hint=add-allowFrom-wildcard)",
);
});
it("resolves direct command authorization from shared ingress", async () => {
isDangerousNameMatchingEnabled.mockReturnValue(false);
resolveAllowlistMatchSimple.mockReturnValue({ allowed: false });
await 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: true,
hasControlCommand: true,
}),
).resolves.toEqual({
ok: false,
denyReason: "unauthorized",
commandAuthorized: false,
channelInfo: { type: "D", name: "alice", display_name: "Alice" },
kind: "direct",
chatType: "direct",
channelName: "alice",
channelDisplay: "Alice",
roomLabel: "#alice",
});
resolveAllowlistMatchSimple.mockReturnValue({ allowed: true });
await expect(
authorizeMattermostCommandInvocation({
account: {
config: { dmPolicy: "open", allowFrom: ["*"] },
} 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,
}),
).resolves.toEqual({
ok: true,
commandAuthorized: true,
channelInfo: { type: "D", name: "alice", display_name: "Alice" },
kind: "direct",
chatType: "direct",
channelName: "alice",
channelDisplay: "Alice",
roomLabel: "#alice",
});
await 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,
}),
).resolves.toEqual({
ok: false,
denyReason: "dm-disabled",
commandAuthorized: false,
channelInfo: { type: "D", name: "alice", display_name: "Alice" },
kind: "direct",
chatType: "direct",
channelName: "alice",
channelDisplay: "Alice",
roomLabel: "#alice",
});
await 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,
}),
).resolves.toEqual({
ok: false,
denyReason: "channel-no-allowlist",
commandAuthorized: false,
channelInfo: { type: "O", name: "town-square", display_name: "Town Square" },
kind: "channel",
chatType: "channel",
channelName: "town-square",
channelDisplay: "Town Square",
roomLabel: "#town-square",
});
});
});

View File

@@ -0,0 +1,335 @@
// Mattermost plugin module implements monitor auth behavior.
import { parseAccessGroupAllowFromEntry } from "openclaw/plugin-sdk/access-groups";
import {
type ChannelIngressDecision,
type ChannelIngressEventInput,
type ChannelIngressIdentifierKind,
resolveStableChannelMessageIngress,
type StableChannelIngressIdentityParams,
} from "openclaw/plugin-sdk/channel-ingress-runtime";
import {
normalizeLowercaseStringOrEmpty,
uniqueStrings,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import type { ResolvedMattermostAccount } from "./accounts.js";
import type { MattermostChannel } from "./client.js";
import type { OpenClawConfig } from "./runtime-api.js";
import { isDangerousNameMatchingEnabled, resolveAllowlistMatchSimple } from "./runtime-api.js";
const MATTERMOST_USER_NAME_KIND =
"plugin:mattermost-user-name" as const satisfies ChannelIngressIdentifierKind;
const mattermostIngressIdentity = {
key: "sender-id",
normalize: normalizeMattermostAllowEntry,
aliases: [
{
key: "sender-name",
kind: MATTERMOST_USER_NAME_KIND,
normalizeEntry: normalizeMattermostAllowEntry,
normalizeSubject: normalizeMattermostAllowEntry,
dangerous: true,
},
],
isWildcardEntry: (entry) => normalizeMattermostAllowEntry(entry) === "*",
resolveEntryId: ({ entryIndex, fieldKey }) =>
`mattermost-entry-${entryIndex + 1}:${fieldKey === "sender-name" ? "name" : "user"}`,
} satisfies StableChannelIngressIdentityParams;
export function normalizeMattermostAllowEntry(entry: string): string {
const trimmed = entry.trim();
if (!trimmed) {
return "";
}
if (trimmed === "*") {
return "*";
}
const accessGroupName = parseAccessGroupAllowFromEntry(trimmed);
if (accessGroupName) {
return `accessGroup:${accessGroupName}`;
}
const normalized = trimmed
.replace(/^(mattermost|user):/i, "")
.replace(/^@/, "")
.trim();
return normalized ? normalizeLowercaseStringOrEmpty(normalized) : "";
}
export function normalizeMattermostAllowList(entries: Array<string | number>): string[] {
const normalized = entries
.map((entry) => normalizeMattermostAllowEntry(String(entry)))
.filter(Boolean);
return uniqueStrings(normalized);
}
export function formatMattermostDirectMessageDropLog(params: {
senderId: string;
dmPolicy: string;
reasonCode?: string;
}): string {
const reason = params.reasonCode ? ` reason=${params.reasonCode}` : "";
const hint =
params.dmPolicy === "open" && params.reasonCode === "dm_policy_not_allowlisted"
? " hint=add-allowFrom-wildcard"
: "";
return `mattermost: drop dm sender=${params.senderId} (dmPolicy=${params.dmPolicy}${reason}${hint})`;
}
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;
};
type MattermostCommandDenyReason = Extract<
MattermostCommandAuthDecision,
{ ok: false }
>["denyReason"];
export async function resolveMattermostMonitorInboundAccess(params: {
account: ResolvedMattermostAccount;
cfg: OpenClawConfig;
senderId: string;
senderName: string;
channelId: string;
kind: "direct" | "group" | "channel";
groupPolicy: "allowlist" | "open" | "disabled";
storeAllowFrom?: Array<string | number> | null;
readStoreAllowFrom?: () => Promise<Array<string | number>>;
allowTextCommands: boolean;
hasControlCommand: boolean;
eventKind?: ChannelIngressEventInput["kind"];
mayPair?: boolean;
}) {
const {
account,
cfg,
senderId,
senderName,
channelId,
kind,
groupPolicy,
storeAllowFrom,
allowTextCommands,
hasControlCommand,
} = params;
const dmPolicy = account.config.dmPolicy ?? "pairing";
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
const configAllowFrom = account.config.allowFrom ?? [];
const configGroupAllowFrom = account.config.groupAllowFrom ?? [];
const readStoreAllowFrom =
params.readStoreAllowFrom ??
(storeAllowFrom != null ? async () => [...storeAllowFrom] : undefined);
const ingress = await resolveStableChannelMessageIngress({
channelId: "mattermost",
accountId: account.accountId,
identity: mattermostIngressIdentity,
cfg,
...(readStoreAllowFrom ? { readStoreAllowFrom } : {}),
useDefaultPairingStore: params.readStoreAllowFrom === undefined && storeAllowFrom == null,
subject: {
stableId: senderId,
aliases: { "sender-name": senderName },
},
conversation: {
kind,
id: channelId,
},
event: {
kind: params.eventKind ?? "message",
authMode: "inbound",
mayPair: params.mayPair ?? true,
},
dmPolicy,
groupPolicy,
policy: {
groupAllowFromFallbackToAllowFrom: true,
mutableIdentifierMatching: allowNameMatching ? "enabled" : "disabled",
},
allowFrom: configAllowFrom,
groupAllowFrom: configGroupAllowFrom,
command: {
allowTextCommands,
hasControlCommand: allowTextCommands && hasControlCommand,
directGroupAllowFrom: kind === "direct" ? "effective" : "none",
},
});
return ingress;
}
function resolveMattermostCommandDenyReason(params: {
decision: ChannelIngressDecision;
kind: "direct" | "group" | "channel";
dmPolicy: string;
}): MattermostCommandDenyReason | null {
if (params.decision.decision === "allow") {
return null;
}
if (params.kind === "direct") {
if (params.decision.reasonCode === "dm_policy_disabled") {
return "dm-disabled";
}
if (
params.dmPolicy === "pairing" &&
(params.decision.admission === "pairing-required" ||
params.decision.reasonCode === "dm_policy_pairing_required")
) {
return "dm-pairing";
}
return "unauthorized";
}
if (params.decision.reasonCode === "group_policy_disabled") {
return "channels-disabled";
}
if (params.decision.reasonCode === "group_policy_empty_allowlist") {
return "channel-no-allowlist";
}
return "unauthorized";
}
export async function authorizeMattermostCommandInvocation(params: {
account: ResolvedMattermostAccount;
cfg: OpenClawConfig;
senderId: string;
senderName: string;
channelId: string;
channelInfo: MattermostChannel | null;
storeAllowFrom?: Array<string | number> | null;
readStoreAllowFrom?: () => Promise<Array<string | number>>;
allowTextCommands: boolean;
hasControlCommand: boolean;
}): Promise<MattermostCommandAuthDecision> {
const {
account,
cfg,
senderId,
senderName,
channelId,
channelInfo,
storeAllowFrom,
readStoreAllowFrom,
allowTextCommands,
hasControlCommand,
} = params;
if (!channelInfo?.type) {
return {
ok: false,
denyReason: "unknown-channel",
commandAuthorized: false,
channelInfo,
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 defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
const ingress = await resolveMattermostMonitorInboundAccess({
account,
cfg,
senderId,
senderName,
channelId,
kind,
groupPolicy,
storeAllowFrom,
readStoreAllowFrom,
allowTextCommands,
hasControlCommand,
eventKind: "native-command",
mayPair: true,
});
const denyReason = resolveMattermostCommandDenyReason({
decision: ingress.ingress,
kind,
dmPolicy: account.config.dmPolicy ?? "pairing",
});
if (denyReason) {
return {
ok: false,
denyReason,
commandAuthorized: false,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}
return {
ok: true,
commandAuthorized: ingress.commandAccess.authorized,
channelInfo,
kind,
chatType,
channelName,
channelDisplay,
roomLabel,
};
}

View File

@@ -0,0 +1,185 @@
// Mattermost tests cover monitor gating plugin behavior.
import { describe, expect, it, vi } from "vitest";
import {
evaluateMattermostMentionGate,
mapMattermostChannelTypeToChatType,
resolveMattermostTrustedChatKind,
} 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("direct");
expect(mapMattermostChannelTypeToChatType(null)).toBe("direct");
expect(mapMattermostChannelTypeToChatType("")).toBe("direct");
});
it("derives chat kind from trusted channel lookup before fallback state", () => {
expect(
resolveMattermostTrustedChatKind({
channelType: "O",
fallback: "direct",
}),
).toBe("channel");
expect(
resolveMattermostTrustedChatKind({
channelType: "D",
fallback: "channel",
}),
).toBe("direct");
expect(resolveMattermostTrustedChatKind({ fallback: "group" })).toBe("group");
expect(resolveMattermostTrustedChatKind({})).toBe("direct");
});
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("processes engaged thread follow-ups without a mention", () => {
const resolveRequireMention = vi.fn(() => true);
expect(
evaluateMattermostMentionGate({
kind: "channel",
cfg: {} as never,
accountId: "default",
channelId: "chan-1",
resolveRequireMention,
wasMentioned: false,
threadAlreadyEngaged: true,
isControlCommand: false,
commandAuthorized: false,
oncharEnabled: false,
oncharTriggered: false,
canDetectMention: true,
}),
).toEqual({
shouldRequireMention: true,
shouldBypassMention: false,
effectiveWasMentioned: true,
dropReason: null,
});
});
it("engaged threads respond even 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,
threadAlreadyEngaged: true,
isControlCommand: false,
commandAuthorized: false,
oncharEnabled: true,
oncharTriggered: false,
canDetectMention: true,
}),
).toEqual({
shouldRequireMention: true,
shouldBypassMention: false,
effectiveWasMentioned: true,
dropReason: null,
});
});
it("drops non-mentioned channel traffic outside an engaged thread", () => {
const resolveRequireMention = vi.fn(() => true);
expect(
evaluateMattermostMentionGate({
kind: "channel",
cfg: {} as never,
accountId: "default",
channelId: "chan-1",
resolveRequireMention,
wasMentioned: false,
threadAlreadyEngaged: false,
isControlCommand: false,
commandAuthorized: false,
oncharEnabled: false,
oncharTriggered: false,
canDetectMention: true,
}),
).toEqual({
shouldRequireMention: true,
shouldBypassMention: false,
effectiveWasMentioned: false,
dropReason: "missing-mention",
});
});
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,
}),
).toEqual({
shouldRequireMention: false,
shouldBypassMention: false,
effectiveWasMentioned: false,
dropReason: null,
});
});
});

View File

@@ -0,0 +1,118 @@
// Mattermost plugin module implements monitor gating behavior.
import type { ChatType, OpenClawConfig } from "./runtime-api.js";
export function mapMattermostChannelTypeToChatType(channelType?: string | null): ChatType {
const normalized = channelType?.trim().toUpperCase();
if (!normalized) {
return "direct";
}
if (normalized === "D") {
return "direct";
}
if (normalized === "G" || normalized === "P") {
return "group";
}
return "channel";
}
export function resolveMattermostTrustedChatKind(params: {
channelType?: string | null;
fallback?: ChatType;
}): ChatType {
const channelType = params.channelType?.trim();
if (channelType) {
return mapMattermostChannelTypeToChatType(channelType);
}
return params.fallback ?? "direct";
}
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;
// Bot has already replied in this thread; treat follow-ups as addressed so the
// user need not re-mention on every turn (parity with Slack thread participation).
threadAlreadyEngaged?: 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 ||
params.threadAlreadyEngaged === true;
if (
params.oncharEnabled &&
!params.oncharTriggered &&
!params.wasMentioned &&
!params.isControlCommand &&
params.threadAlreadyEngaged !== true
) {
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,
};
}

View File

@@ -0,0 +1,186 @@
// Mattermost tests cover monitor helpers plugin behavior.
import { describe, expect, it } from "vitest";
import { normalizeMention, shouldDropEmptyMattermostBody } 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");
});
});
describe("shouldDropEmptyMattermostBody", () => {
it("drops a non-mention message that normalizes to an empty body", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "",
rawText: " ",
botUsername: "openclaw",
}),
).toBe(true);
});
it("keeps a message that still has body text", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "hello",
rawText: "hello",
botUsername: "openclaw",
}),
).toBe(false);
});
it("keeps a bare mention in a group", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "",
rawText: "@openclaw",
botUsername: "openclaw",
}),
).toBe(false);
});
it("keeps a bare mention in a direct message", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "",
rawText: "@OpenClaw",
botUsername: "openclaw",
}),
).toBe(false);
});
it("drops an empty body when the bot username is unknown", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "",
rawText: "@someoneelse",
botUsername: undefined,
}),
).toBe(true);
});
it("drops a blank post even when a generic mention pattern matched it", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "",
rawText: "",
botUsername: "openclaw",
}),
).toBe(true);
});
it("drops a bot mention with only a Unicode control residual", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "\u0085",
rawText: "@openclaw\u0085",
botUsername: "openclaw",
}),
).toBe(true);
});
it("drops a bot mention with only a combining-mark residual", () => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "\ufe0f",
rawText: "@openclaw\ufe0f",
botUsername: "openclaw",
}),
).toBe(true);
});
it.each([
"@openclaw @openclaw",
"@openclaw\n@openclaw",
"@openclaw\n",
"\n@openclaw",
"@openclaw\r\n",
"@openclaw\u2028",
"@openclaw\u2029",
"\v@openclaw\f",
"@openclaw\u00a0",
"\u2003@openclaw",
])("drops an invalid empty-body candidate: %j", (rawText) => {
expect(
shouldDropEmptyMattermostBody({
bodyText: "",
rawText,
botUsername: "openclaw",
}),
).toBe(true);
});
});

View File

@@ -0,0 +1,69 @@
// Mattermost helper module supports monitor helpers behavior.
import { formatInboundFromLabel as formatInboundFromLabelShared } from "openclaw/plugin-sdk/channel-inbound";
import { resolveThreadSessionKeys as resolveThreadSessionKeysShared } from "openclaw/plugin-sdk/routing";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
import { rawDataToString } from "openclaw/plugin-sdk/webhook-ingress";
export { rawDataToString };
export const formatInboundFromLabel = formatInboundFromLabelShared;
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");
}
export function shouldDropEmptyMattermostBody(params: {
bodyText: string;
rawText: string;
botUsername?: string | null;
}): boolean {
if (/[^\p{White_Space}\p{Cc}\p{Cf}\p{M}]/u.test(params.bodyText)) {
return false;
}
const botUsername = normalizeLowercaseStringOrEmpty(params.botUsername ?? "");
const bareMention = params.rawText.match(/^[ \t]*(@\S+)[ \t]*$/u)?.[1];
return !botUsername || normalizeLowercaseStringOrEmpty(bareMention ?? "") !== `@${botUsername}`;
}

View File

@@ -0,0 +1,33 @@
// Mattermost tests cover monitor onchar plugin behavior.
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",
});
});
});

View File

@@ -0,0 +1,28 @@
// Mattermost plugin module implements monitor onchar behavior.
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
const DEFAULT_ONCHAR_PREFIXES = [">", "!"];
export function resolveOncharPrefixes(prefixes: string[] | undefined): string[] {
const cleaned = prefixes ? normalizeStringEntries(prefixes) : DEFAULT_ONCHAR_PREFIXES;
return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES;
}
export function stripOncharPrefix(
text: string,
prefixes: string[],
): { triggered: boolean; stripped: string } {
const trimmed = text.trimStart();
for (const prefix of prefixes) {
if (!prefix) {
continue;
}
if (trimmed.startsWith(prefix)) {
return {
triggered: true,
stripped: trimmed.slice(prefix.length).trimStart(),
};
}
}
return { triggered: false, stripped: text };
}

View File

@@ -0,0 +1,235 @@
// Mattermost tests cover monitor resources plugin behavior.
import { afterEach, 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;
let formatMattermostInboundMediaText: typeof import("./monitor-resources.js").formatMattermostInboundMediaText;
beforeAll(async () => {
({ createMattermostMonitorResources, formatMattermostInboundMediaText } =
await import("./monitor-resources.js"));
});
it("keeps media-only download failures visible to the agent", () => {
expect(
formatMattermostInboundMediaText({
body: "",
mediaPlaceholder: "",
expectedCount: 1,
mediaCount: 0,
}),
).toBe("[mattermost attachment unavailable]");
});
it("preserves successful media placeholders on partial failures", () => {
expect(
formatMattermostInboundMediaText({
body: "<media:document> (2 files)",
mediaPlaceholder: "<media:document> (2 files)",
expectedCount: 2,
mediaCount: 1,
}),
).toBe("<media:document> (2 files)\n\n[mattermost attachment unavailable]");
});
beforeEach(() => {
fetchMattermostChannel.mockReset();
fetchMattermostUser.mockReset();
sendMattermostTyping.mockReset();
updateMattermostPost.mockReset();
buildButtonProps.mockReset();
});
afterEach(() => {
vi.restoreAllMocks();
});
it("downloads media, preserves auth headers, and infers media kind", async () => {
const saveRemoteMedia = 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",
token: "bot-token",
} as never,
logger: {},
mediaMaxBytes: 1024,
saveRemoteMedia,
mediaKindFromMime: () => "image",
});
await expect(resources.resolveMattermostMedia([" file-1 "])).resolves.toEqual([
{
path: "/tmp/file.png",
contentType: "image/png",
kind: "image",
},
]);
expect(saveRemoteMedia).toHaveBeenCalledWith({
url: "https://chat.example.com/api/v4/files/file-1",
requestInit: {
headers: {
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,
saveRemoteMedia: 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", {
message: "Pick a model",
props: { attachments: [] },
});
});
it("does not reuse cached lookups while the process clock is invalid", async () => {
fetchMattermostChannel
.mockResolvedValueOnce({ id: "chan-1", name: "old" })
.mockResolvedValueOnce({ id: "chan-1", name: "fresh" })
.mockResolvedValueOnce({ id: "chan-1", name: "recovered" });
const resources = createMattermostMonitorResources({
accountId: "default",
callbackUrl: "https://openclaw.test/callback",
client: {} as never,
logger: {},
mediaMaxBytes: 1024,
saveRemoteMedia: vi.fn(),
mediaKindFromMime: () => "document",
});
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
id: "chan-1",
name: "old",
});
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
id: "chan-1",
name: "fresh",
});
vi.mocked(Date.now).mockReturnValue(1_000);
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
id: "chan-1",
name: "recovered",
});
expect(fetchMattermostChannel).toHaveBeenCalledTimes(3);
});
it("does not cache lookups when cache expiry would exceed the Date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
fetchMattermostUser
.mockResolvedValueOnce({ id: "user-1", username: "first" })
.mockResolvedValueOnce({ id: "user-1", username: "second" });
const resources = createMattermostMonitorResources({
accountId: "default",
callbackUrl: "https://openclaw.test/callback",
client: {} as never,
logger: {},
mediaMaxBytes: 1024,
saveRemoteMedia: vi.fn(),
mediaKindFromMime: () => "document",
});
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
id: "user-1",
username: "first",
});
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
id: "user-1",
username: "second",
});
expect(fetchMattermostUser).toHaveBeenCalledTimes(2);
});
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,
saveRemoteMedia: vi.fn(),
mediaKindFromMime: () => "document",
});
await resources.sendTypingIndicator("chan-1", "root-1");
expect(sendMattermostTyping).toHaveBeenCalledWith(client, {
channelId: "chan-1",
parentId: "root-1",
});
});
});

View File

@@ -0,0 +1,211 @@
// Mattermost plugin module implements monitor resources behavior.
import { formatInboundMediaUnavailableText } from "openclaw/plugin-sdk/channel-inbound";
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { normalizeStringEntries } from "openclaw/plugin-sdk/string-coerce-runtime";
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;
};
export function formatMattermostInboundMediaText(params: {
body: string;
mediaPlaceholder: string;
expectedCount: number;
mediaCount: number;
}): string {
const unavailableCount = Math.max(0, params.expectedCount - params.mediaCount);
if (unavailableCount === 0) {
return params.body;
}
return formatInboundMediaUnavailableText({
body: params.body,
mediaPlaceholder: params.mediaCount === 0 ? params.mediaPlaceholder : undefined,
notice: `[mattermost ${unavailableCount > 1 ? `${unavailableCount} attachments` : "attachment"} unavailable]`,
});
}
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
const USER_CACHE_TTL_MS = 10 * 60_000;
type SaveRemoteMedia = (params: {
url: string;
requestInit?: RequestInit;
filePathHint?: string;
maxBytes: number;
ssrfPolicy?: { allowedHostnames?: string[] };
}) => Promise<{ path: string; contentType?: string | null }>;
export function createMattermostMonitorResources(params: {
accountId: string;
callbackUrl: string;
client: MattermostClient;
logger: { debug?: (...args: unknown[]) => void };
mediaMaxBytes: number;
saveRemoteMedia: SaveRemoteMedia;
mediaKindFromMime: (contentType?: string) => MattermostMediaKind | null | undefined;
}) {
const {
accountId,
callbackUrl,
client,
logger,
mediaMaxBytes,
saveRemoteMedia,
mediaKindFromMime,
} = params;
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
const getCachedValue = <T>(
cache: Map<string, { value: T | null; expiresAt: number }>,
key: string,
nowMs: number | undefined,
): T | null | undefined => {
const cached = cache.get(key);
if (!cached) {
return undefined;
}
if (nowMs !== undefined && cached.expiresAt > nowMs) {
return cached.value;
}
cache.delete(key);
return undefined;
};
const setCachedValue = <T>(
cache: Map<string, { value: T | null; expiresAt: number }>,
key: string,
value: T | null,
ttlMs: number,
rawNowMs: number,
): void => {
const expiresAt = resolveExpiresAtMsFromDurationMs(ttlMs, { nowMs: rawNowMs });
if (expiresAt !== undefined) {
cache.set(key, { value, expiresAt });
}
};
const resolveMattermostMedia = async (
fileIds?: string[] | null,
): Promise<MattermostMediaInfo[]> => {
const ids = normalizeStringEntries(fileIds ?? []);
if (ids.length === 0) {
return [];
}
const out: MattermostMediaInfo[] = [];
for (const fileId of ids) {
try {
const saved = await saveRemoteMedia({
url: `${client.apiBaseUrl}/files/${fileId}`,
requestInit: {
headers: {
Authorization: `Bearer ${client.token}`,
},
},
filePathHint: fileId,
maxBytes: mediaMaxBytes,
ssrfPolicy: { allowedHostnames: [new URL(client.baseUrl).hostname] },
});
const contentType = saved.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 rawNow = Date.now();
const cached = getCachedValue(channelCache, channelId, asDateTimestampMs(rawNow));
if (cached !== undefined) {
return cached;
}
try {
const info = await fetchMattermostChannel(client, channelId);
setCachedValue(channelCache, channelId, info, CHANNEL_CACHE_TTL_MS, rawNow);
return info;
} catch (err) {
logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`);
setCachedValue(channelCache, channelId, null, CHANNEL_CACHE_TTL_MS, rawNow);
return null;
}
};
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
const rawNow = Date.now();
const cached = getCachedValue(userCache, userId, asDateTimestampMs(rawNow));
if (cached !== undefined) {
return cached;
}
try {
const info = await fetchMattermostUser(client, userId);
setCachedValue(userCache, userId, info, USER_CACHE_TTL_MS, rawNow);
return info;
} catch (err) {
logger.debug?.(`mattermost: user lookup failed: ${String(err)}`);
setCachedValue(userCache, userId, null, USER_CACHE_TTL_MS, rawNow);
return null;
}
};
const buildModelPickerProps = (
channelId: string,
buttons: Array<unknown>,
): Record<string, unknown> | undefined =>
buildButtonProps({
callbackUrl,
accountId,
channelId,
buttons,
});
const updateModelPickerPost = async (paramsLocal: {
channelId: string;
postId: string;
message: string;
buttons?: Array<unknown>;
}): Promise<MattermostInteractionResponse> => {
const props = buildModelPickerProps(paramsLocal.channelId, paramsLocal.buttons ?? []) ?? {
attachments: [],
};
await updateMattermostPost(client, paramsLocal.postId, {
message: paramsLocal.message,
props,
});
return {};
};
return {
resolveMattermostMedia,
sendTypingIndicator,
resolveChannelInfo,
resolveUserInfo,
updateModelPickerPost,
};
}

View File

@@ -0,0 +1,227 @@
// Mattermost tests cover monitor slash plugin behavior.
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
const listSkillCommandsForAgents = vi.hoisted(() => vi.fn());
const parseTcpPort = 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,
parseTcpPort,
}));
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,
}));
function requireFirstMockCall<TArgs extends unknown[]>(
mock: { mock: { calls: TArgs[] } },
label: string,
): TArgs {
const [call] = mock.mock.calls;
if (!call) {
throw new Error(`expected ${label}`);
}
return call;
}
describe("mattermost monitor slash", () => {
let registerMattermostMonitorSlashCommands: typeof import("./monitor-slash.js").registerMattermostMonitorSlashCommands;
beforeAll(async () => {
({ registerMattermostMonitorSlashCommands } = await import("./monitor-slash.js"));
});
beforeEach(() => {
listSkillCommandsForAgents.mockReset();
parseTcpPort.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);
parseTcpPort.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 client = {} as never;
const runtime = {
log: vi.fn(),
error: vi.fn(),
};
await registerMattermostMonitorSlashCommands({
client,
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);
const [firstRegistration] = requireFirstMockCall(
registerSlashCommands,
"first Mattermost slash command registration",
);
expect(firstRegistration).toEqual({
client,
teamId: "team-1",
creatorUserId: "bot-user",
callbackUrl: "https://openclaw.test/slash",
commands: [
{ 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",
},
],
log: firstRegistration.log,
});
expect(typeof firstRegistration.log).toBe("function");
const [activation] = requireFirstMockCall(
activateSlashCommands,
"Mattermost slash command activation",
);
expect(activation?.commandTokens).toStrictEqual(["token-1", "token-2"]);
expect(activation?.triggerMap).toStrictEqual(
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("falls back to the configured gateway port when the env port is out of range", async () => {
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "65536");
resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: false });
isSlashCommandsEnabled.mockReturnValue(true);
parseTcpPort.mockReturnValue(null);
fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }]);
resolveCallbackUrl.mockReturnValue("https://openclaw.test/slash");
registerSlashCommands.mockResolvedValue([{ token: "token-1", trigger: "ping" }]);
await registerMattermostMonitorSlashCommands({
client: {} as never,
cfg: { gateway: { port: 18789 } } as never,
runtime: { log: vi.fn(), error: vi.fn() } as never,
account: { config: { commands: {} }, accountId: "default" } as never,
baseUrl: "https://chat.example.com",
botUserId: "bot-user",
});
expect(parseTcpPort).toHaveBeenCalledWith("65536");
expect(resolveCallbackUrl).toHaveBeenCalledWith(
expect.objectContaining({ gatewayPort: 18789 }),
);
});
it("warns on loopback callback urls and reports partial team failures", async () => {
resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: false });
isSlashCommandsEnabled.mockReturnValue(true);
parseTcpPort.mockReturnValue(null);
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(
"mattermost: slash commands callbackUrl resolved to http://127.0.0.1:18789/slash (loopback) while baseUrl is https://chat.example.com. 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).",
);
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)",
);
});
});

View File

@@ -0,0 +1,211 @@
// Mattermost plugin module implements monitor slash behavior.
import { isLoopbackHost } from "openclaw/plugin-sdk/gateway-runtime";
import type { ResolvedMattermostAccount } from "./accounts.js";
import {
fetchMattermostUserTeams,
normalizeMattermostBaseUrl,
type MattermostClient,
} from "./client.js";
import {
listSkillCommandsForAgents,
parseTcpPort,
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 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 = parseTcpPort(process.env.OPENCLAW_GATEWAY_PORT);
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)}`);
}
}

View File

@@ -0,0 +1,510 @@
// Mattermost tests cover monitor websocket plugin behavior.
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RuntimeEnv } from "../../runtime-api.js";
import {
createMattermostConnectOnce,
type MattermostWebSocketLike,
WebSocketClosedBeforeOpenError,
} from "./monitor-websocket.js";
function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
let count = 0;
for (const item of items) {
if (predicate(item)) {
count += 1;
}
}
return count;
}
class FakeWebSocket implements MattermostWebSocketLike {
public readonly sent: string[] = [];
public pingCalls = 0;
public closeCalls = 0;
public terminateCalls = 0;
private openListeners: Array<() => void> = [];
private messageListeners: Array<(data: Buffer) => void | Promise<void>> = [];
private pongListeners: Array<(data: Buffer) => 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: "pong", listener: (data: Buffer) => void): void;
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
on(event: "error", listener: (err: unknown) => void): void;
on(event: "open" | "message" | "pong" | "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 === "pong") {
this.pongListeners.push(listener as (data: Buffer) => void);
return;
}
if (event === "close") {
this.closeListeners.push(listener as (code: number, reason: Buffer) => void);
return;
}
this.errorListeners.push(listener as (err: unknown) => void);
}
ping(): void {
this.pingCalls++;
}
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);
}
}
emitPong(data = Buffer.alloc(0)): void {
for (const listener of this.pongListeners) {
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");
});
let failure: unknown;
try {
await connectOnce();
} catch (caught) {
failure = caught;
}
expect(failure).toBeInstanceOf(WebSocketClosedBeforeOpenError);
expect((failure as WebSocketClosedBeforeOpenError).message).toBe(
"websocket closed before open (code 1006)",
);
expect((failure as WebSocketClosedBeforeOpenError).code).toBe(1006);
expect((failure as WebSocketClosedBeforeOpenError).reason).toBe("connection refused");
});
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] ?? "")).toEqual({
action: "authentication_challenge",
data: { token: "token" },
seq: 1,
});
expect(countMatching(patches, (patch) => patch.connected === true)).toBe(1);
expect(countMatching(patches, (patch) => patch.connected === false)).toBe(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 reaction = JSON.stringify({
user_id: "user-1",
post_id: "post-1",
emoji_name: "thumbsup",
});
const payload = onReaction.mock.calls.at(0)?.[0];
expect(payload).toEqual({
event: "reaction_added",
data: { reaction },
});
});
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("continues protocol keepalive when Mattermost responds with pong", 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,
pingIntervalMs: 100,
pongTimeoutMs: 25,
});
const connected = connectOnce();
socket.emitOpen();
await vi.advanceTimersByTimeAsync(100);
expect(socket.pingCalls).toBe(1);
socket.emitPong();
await vi.advanceTimersByTimeAsync(25);
expect(socket.terminateCalls).toBe(0);
await vi.advanceTimersByTimeAsync(75);
expect(socket.pingCalls).toBe(2);
socket.emitClose(1000);
await connected;
vi.useRealTimers();
});
it("terminates silent websocket drops when Mattermost misses pong timeout", async () => {
vi.useFakeTimers();
const socket = new FakeWebSocket();
const runtime = testRuntime();
let pollCount = 0;
const connectOnce = createMattermostConnectOnce({
wsUrl: "wss://example.invalid/api/v4/websocket",
botToken: "token",
runtime,
nextSeq: () => 1,
onPosted: async () => {},
webSocketFactory: () => socket,
getBotUpdateAt: async () => {
pollCount++;
return 1000;
},
healthCheckIntervalMs: 100,
pingIntervalMs: 50,
pongTimeoutMs: 25,
});
const connected = connectOnce();
socket.emitOpen();
await vi.advanceTimersByTimeAsync(0);
expect(pollCount).toBe(1);
await vi.advanceTimersByTimeAsync(50);
expect(socket.pingCalls).toBe(1);
expect(socket.terminateCalls).toBe(0);
await vi.advanceTimersByTimeAsync(25);
expect(socket.terminateCalls).toBe(1);
expect(runtime.error).toHaveBeenCalledWith("mattermost websocket pong timeout — reconnecting");
await vi.advanceTimersByTimeAsync(500);
expect(socket.pingCalls).toBe(1);
expect(pollCount).toBe(1);
socket.emitClose(1006);
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();
});
});

View File

@@ -0,0 +1,432 @@
// Mattermost plugin module implements monitor websocket behavior.
import { randomUUID } from "node:crypto";
import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
import {
captureWsEvent,
createDebugProxyWebSocketAgent,
resolveDebugProxySettings,
} from "openclaw/plugin-sdk/proxy-capture";
import WebSocket from "ws";
import { z } from "zod";
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: "pong", listener: (data: Buffer) => void): void;
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
on(event: "error", listener: (err: unknown) => void): void;
ping(): 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;
pingIntervalMs?: number;
pongTimeoutMs?: number;
};
const defaultMattermostWebSocketFactory: MattermostWebSocketFactory = (url) => {
const agent = createDebugProxyWebSocketAgent(resolveDebugProxySettings());
return new WebSocket(url, agent ? { agent } : undefined) as MattermostWebSocketLike;
};
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 createMattermostConnectOnce(
opts: CreateMattermostConnectOnceOpts,
): () => Promise<void> {
const webSocketFactory = opts.webSocketFactory ?? defaultMattermostWebSocketFactory;
const healthCheckIntervalMs = opts.healthCheckIntervalMs ?? 30_000;
const pingIntervalMs = opts.pingIntervalMs ?? 30_000;
const pongTimeoutMs = opts.pongTimeoutMs ?? 10_000;
return async () => {
const flowId = randomUUID();
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 protocolKeepaliveEnabled = true;
let protocolPingTimer: ReturnType<typeof setTimeout> | undefined;
let protocolPongTimer: ReturnType<typeof setTimeout> | undefined;
let initialUpdateAt: number | undefined;
const clearTimers = () => {
if (healthCheckTimer !== undefined) {
clearTimeout(healthCheckTimer);
healthCheckTimer = undefined;
}
if (protocolPingTimer !== undefined) {
clearTimeout(protocolPingTimer);
protocolPingTimer = undefined;
}
if (protocolPongTimer !== undefined) {
clearTimeout(protocolPongTimer);
protocolPongTimer = undefined;
}
};
const stopHealthChecks = () => {
healthCheckEnabled = false;
protocolKeepaliveEnabled = false;
clearTimers();
};
const sendProtocolPing = () => {
if (!protocolKeepaliveEnabled || settled) {
return;
}
if (protocolPongTimer !== undefined) {
clearTimeout(protocolPongTimer);
}
protocolPongTimer = setTimeout(() => {
protocolPongTimer = undefined;
if (!protocolKeepaliveEnabled || settled) {
return;
}
opts.runtime.error?.("mattermost websocket pong timeout — reconnecting");
stopHealthChecks();
ws.terminate();
}, pongTimeoutMs);
try {
ws.ping();
} catch (err) {
if (!protocolKeepaliveEnabled || settled) {
return;
}
opts.runtime.error?.(`mattermost websocket ping failed: ${String(err)}`);
stopHealthChecks();
ws.terminate();
}
};
const scheduleProtocolPing = () => {
if (!protocolKeepaliveEnabled || settled || protocolPingTimer !== undefined) {
return;
}
protocolPingTimer = setTimeout(() => {
protocolPingTimer = undefined;
sendProtocolPing();
}, pingIntervalMs);
};
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;
captureWsEvent({
url: opts.wsUrl,
direction: "local",
kind: "ws-open",
flowId,
meta: { subsystem: "mattermost-websocket" },
});
opts.statusSink?.({
connected: true,
lastConnectedAt: Date.now(),
lastError: null,
});
const authPayload = JSON.stringify({
seq: opts.nextSeq(),
action: "authentication_challenge",
data: { token: opts.botToken },
});
captureWsEvent({
url: opts.wsUrl,
direction: "outbound",
kind: "ws-frame",
flowId,
payload: authPayload,
meta: { subsystem: "mattermost-websocket", eventType: "authentication_challenge" },
});
ws.send(authPayload);
scheduleProtocolPing();
// 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("pong", () => {
if (protocolPongTimer !== undefined) {
clearTimeout(protocolPongTimer);
protocolPongTimer = undefined;
}
scheduleProtocolPing();
});
ws.on("message", async (data) => {
captureWsEvent({
url: opts.wsUrl,
direction: "inbound",
kind: "ws-frame",
flowId,
payload: Buffer.from(rawDataToString(data)),
meta: { subsystem: "mattermost-websocket" },
});
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) => {
captureWsEvent({
url: opts.wsUrl,
direction: "local",
kind: "ws-close",
flowId,
closeCode: code,
payload: reason,
meta: { subsystem: "mattermost-websocket" },
});
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) => {
captureWsEvent({
url: opts.wsUrl,
direction: "local",
kind: "error",
flowId,
errorText: String(err),
meta: { subsystem: "mattermost-websocket" },
});
opts.runtime.error?.(`mattermost websocket error: ${String(err)}`);
opts.statusSink?.({
lastError: String(err),
});
try {
ws.close();
} catch {}
});
});
} finally {
opts.abortSignal?.removeEventListener("abort", onAbort);
}
};
}
function reasonToString(reason: Buffer | string | undefined): string {
if (!reason) {
return "";
}
if (typeof reason === "string") {
return reason;
}
return reason.length > 0 ? reason.toString("utf8") : "";
}

View File

@@ -0,0 +1,304 @@
// Mattermost tests cover monitor.authz plugin behavior.
import { describe, expect, it } from "vitest";
import type { ResolvedMattermostAccount } from "./accounts.js";
import {
authorizeMattermostCommandInvocation,
resolveMattermostMonitorInboundAccess,
} from "./monitor-auth.js";
const accountFixture: ResolvedMattermostAccount = {
accountId: "default",
enabled: true,
botToken: "bot-token",
baseUrl: "https://chat.example.com",
botTokenSource: "config",
baseUrlSource: "config",
streamingMode: "partial",
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", async () => {
const resolved = await resolveMattermostMonitorInboundAccess({
account: {
...accountFixture,
config: {
allowFrom: ["@trusted-user"],
groupAllowFrom: ["@group-owner"],
},
},
cfg: {},
senderId: "trusted-user",
senderName: "Trusted User",
channelId: "dm-1",
kind: "direct",
groupPolicy: "allowlist",
storeAllowFrom: ["user:attacker"],
allowTextCommands: false,
hasControlCommand: false,
});
expect(resolved.senderAccess.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
});
it("uses explicit groupAllowFrom without pairing-store inheritance", async () => {
const resolved = await resolveMattermostMonitorInboundAccess({
account: {
...accountFixture,
config: {
allowFrom: ["@trusted-user"],
groupAllowFrom: ["@group-owner"],
},
},
cfg: {},
senderId: "group-owner",
senderName: "Group Owner",
channelId: "chan-1",
kind: "channel",
groupPolicy: "allowlist",
storeAllowFrom: ["user:attacker"],
allowTextCommands: false,
hasControlCommand: false,
});
expect(resolved.senderAccess.effectiveGroupAllowFrom).toEqual(["group-owner"]);
});
it("falls group allowlist back to allowFrom without pairing-store entries", async () => {
const resolved = await resolveMattermostMonitorInboundAccess({
account: {
...accountFixture,
config: {
allowFrom: ["@trusted-user"],
},
},
cfg: {},
senderId: "trusted-user",
senderName: "Trusted User",
channelId: "chan-1",
kind: "channel",
groupPolicy: "allowlist",
storeAllowFrom: ["user:attacker"],
allowTextCommands: false,
hasControlCommand: false,
});
expect(resolved.senderAccess.effectiveGroupAllowFrom).toEqual(["trusted-user"]);
});
it("does not auto-authorize DM commands in open mode without allowlists", async () => {
const access = await resolveMattermostMonitorInboundAccess({
account: {
...accountFixture,
config: {
dmPolicy: "open",
},
},
cfg: {
commands: {
useAccessGroups: true,
},
},
senderId: "alice",
senderName: "Alice",
channelId: "dm-1",
kind: "direct",
groupPolicy: "allowlist",
storeAllowFrom: [],
allowTextCommands: true,
hasControlCommand: true,
});
expect(access.ingress.decision).toBe("block");
expect(access.commandAccess.authorized).toBe(false);
});
it("denies group control commands when the sender is outside the allowlist", async () => {
const decision = await authorizeGroupCommand("attacker");
expect(decision).toEqual({
ok: false,
denyReason: "unauthorized",
commandAuthorized: false,
channelInfo: {
id: "chan-1",
type: "O",
name: "general",
display_name: "General",
},
kind: "channel",
chatType: "channel",
channelName: "general",
channelDisplay: "General",
roomLabel: "#general",
});
});
it("authorizes group control commands for allowlisted senders", async () => {
const decision = await authorizeGroupCommand("trusted-user");
expect(decision).toEqual({
ok: true,
commandAuthorized: true,
channelInfo: {
id: "chan-1",
type: "O",
name: "general",
display_name: "General",
},
kind: "channel",
chatType: "channel",
channelName: "general",
channelDisplay: "General",
roomLabel: "#general",
});
});
it("denies command invocations when channel type is unavailable", async () => {
const decision = await authorizeMattermostCommandInvocation({
account: {
...accountFixture,
config: {
dmPolicy: "allowlist",
groupPolicy: "open",
allowFrom: ["trusted-user"],
},
},
cfg: {},
senderId: "new-user",
senderName: "New User",
channelId: "dm-1",
channelInfo: {
id: "dm-1",
name: "",
display_name: "",
},
storeAllowFrom: [],
allowTextCommands: true,
hasControlCommand: true,
});
expect(decision).toEqual({
ok: false,
denyReason: "unknown-channel",
commandAuthorized: false,
channelInfo: {
id: "dm-1",
name: "",
display_name: "",
},
kind: "channel",
chatType: "channel",
channelName: "",
channelDisplay: "",
roomLabel: "#dm-1",
});
});
it("authorizes group senders through static access groups", async () => {
const decision = await authorizeMattermostCommandInvocation({
account: {
...accountFixture,
config: {
groupPolicy: "allowlist",
groupAllowFrom: ["accessGroup:oncall"],
},
},
cfg: {
commands: {
useAccessGroups: true,
},
accessGroups: {
oncall: {
type: "message.senders",
members: {
mattermost: ["mattermost:trusted-user"],
},
},
},
},
senderId: "trusted-user",
senderName: "Trusted User",
channelId: "chan-1",
channelInfo: {
id: "chan-1",
type: "O",
name: "general",
display_name: "General",
},
storeAllowFrom: [],
allowTextCommands: true,
hasControlCommand: true,
});
expect(decision).toEqual({
ok: true,
commandAuthorized: true,
channelInfo: {
id: "chan-1",
type: "O",
name: "general",
display_name: "General",
},
kind: "channel",
chatType: "channel",
channelName: "general",
channelDisplay: "General",
roomLabel: "#general",
});
});
it("fails direct reaction access without pairing admission", async () => {
const access = await resolveMattermostMonitorInboundAccess({
account: {
...accountFixture,
config: {
dmPolicy: "pairing",
},
},
cfg: {},
senderId: "new-user",
senderName: "New User",
channelId: "dm-1",
kind: "direct",
groupPolicy: "allowlist",
storeAllowFrom: [],
allowTextCommands: false,
hasControlCommand: false,
eventKind: "reaction",
mayPair: false,
});
expect(access.ingress.decision).toBe("block");
expect(access.ingress.reasonCode).toBe("event_pairing_not_allowed");
});
});

View File

@@ -0,0 +1,26 @@
// Mattermost tests cover monitor.channel kind plugin behavior.
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 typed values as channel", () => {
expect(mapMattermostChannelTypeToChatType("O")).toBe("channel");
expect(mapMattermostChannelTypeToChatType("x")).toBe("channel");
});
it("treats missing channel type as direct", () => {
expect(mapMattermostChannelTypeToChatType(undefined)).toBe("direct");
expect(mapMattermostChannelTypeToChatType(null)).toBe("direct");
expect(mapMattermostChannelTypeToChatType("")).toBe("direct");
});
});

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,141 @@
// Mattermost tests cover no visible reply diagnostic plugin behavior.
import { describe, expect, it } from "vitest";
import {
evaluateMattermostNoVisibleReply,
formatMattermostNoVisibleReplyLog,
} from "./no-visible-reply-diagnostic.js";
describe("evaluateMattermostNoVisibleReply", () => {
it("flags substantive text payloads that delivered empty (regression: #80501)", () => {
const violation = evaluateMattermostNoVisibleReply({
outcome: "empty",
payload: { text: "Here is the result of the work I did..." },
});
expect(violation).toStrictEqual({
reason: "no-visible-reply-after-final-delivery",
outcome: "empty",
finalTextLength: "Here is the result of the work I did...".length,
mediaUrlCount: 0,
});
});
it("flags payloads with media URLs that delivered empty (regression: #80501)", () => {
const violation = evaluateMattermostNoVisibleReply({
outcome: "empty",
payload: { mediaUrl: "https://example.org/a.png" },
});
expect(violation).toStrictEqual({
reason: "no-visible-reply-after-final-delivery",
outcome: "empty",
finalTextLength: 0,
mediaUrlCount: 1,
});
});
it("follows the SDK legacy media fallback when counting media URLs", () => {
const violation = evaluateMattermostNoVisibleReply({
outcome: "empty",
payload: {
mediaUrl: "https://example.org/a.png",
mediaUrls: ["https://example.org/b.png", "https://example.org/c.png"],
},
});
expect(violation?.mediaUrlCount).toBe(2);
});
it("does not flag reasoning_skipped outcome (intentional suppression)", () => {
expect(
evaluateMattermostNoVisibleReply({
outcome: "reasoning_skipped",
payload: { text: "Reasoning: hidden" },
}),
).toBeNull();
});
it("does not flag text outcome (visible delivery happened)", () => {
expect(
evaluateMattermostNoVisibleReply({
outcome: "text",
payload: { text: "hello" },
}),
).toBeNull();
});
it("does not flag media outcome (visible delivery happened)", () => {
expect(
evaluateMattermostNoVisibleReply({
outcome: "media",
payload: { mediaUrl: "https://example.org/a.png" },
}),
).toBeNull();
});
it("does not flag empty outcome when the payload was nominally empty (no text or media at all)", () => {
expect(
evaluateMattermostNoVisibleReply({
outcome: "empty",
payload: {},
}),
).toBeNull();
expect(
evaluateMattermostNoVisibleReply({
outcome: "empty",
payload: { text: "" },
}),
).toBeNull();
expect(
evaluateMattermostNoVisibleReply({
outcome: "empty",
payload: { text: " \n\t " },
}),
).toBeNull();
});
it("trims whitespace when measuring finalTextLength", () => {
const violation = evaluateMattermostNoVisibleReply({
outcome: "empty",
payload: { text: " hello " },
});
expect(violation?.finalTextLength).toBe("hello".length);
});
});
describe("formatMattermostNoVisibleReplyLog", () => {
it("emits a grep-friendly single-line diagnostic with the expected key/value pairs", () => {
const line = formatMattermostNoVisibleReplyLog({
violation: {
reason: "no-visible-reply-after-final-delivery",
outcome: "empty",
finalTextLength: 137,
mediaUrlCount: 0,
},
to: "channel:town-square",
accountId: "default",
agentId: "main",
});
expect(line).toBe(
"mattermost no-visible-reply: no-visible-reply-after-final-delivery" +
" to=channel:town-square" +
" accountId=default" +
" agentId=main" +
" outcome=empty" +
" finalTextLength=137" +
" mediaUrlCount=0",
);
});
it("falls back to unknown when agentId is undefined", () => {
const line = formatMattermostNoVisibleReplyLog({
violation: {
reason: "no-visible-reply-after-final-delivery",
outcome: "empty",
finalTextLength: 1,
mediaUrlCount: 0,
},
to: "channel:x",
accountId: "y",
agentId: undefined,
});
expect(line).toContain("agentId=unknown");
});
});

View File

@@ -0,0 +1,64 @@
// Mattermost plugin module implements no visible reply diagnostic behavior.
import { countOutboundMedia } from "openclaw/plugin-sdk/reply-payload";
import type { ReplyPayload } from "openclaw/plugin-sdk/reply-runtime";
import type { MattermostReplyDeliveryOutcome } from "./reply-delivery.js";
export type MattermostNoVisibleReplyViolation = {
reason: "no-visible-reply-after-final-delivery";
outcome: MattermostReplyDeliveryOutcome;
finalTextLength: number;
mediaUrlCount: number;
};
/**
* Detects the #80501 symptom: `deliverMattermostReplyPayload` accepted a
* substantive (non-reasoning) payload, called the underlying
* `deliverTextOrMediaReply`, and the outcome was `"empty"` — meaning the
* payload had no text and no media to send, so no Mattermost API call
* happened. The agent's run completes successfully, but no visible
* channel/thread reply ever surfaces to the user.
*
* Returns a structured violation when the outcome is `"empty"` for a payload
* that nominally carried user-facing content (text or media bytes that ended
* up dropped by `resolveSendableOutboundReplyParts`/`sendMediaWithLeadingCaption`).
* Returns `null` for `"reasoning_skipped"` (intentional suppression),
* `"text"`, or `"media"` (successful visible sends).
*/
export function evaluateMattermostNoVisibleReply(params: {
outcome: MattermostReplyDeliveryOutcome;
payload: ReplyPayload;
}): MattermostNoVisibleReplyViolation | null {
if (params.outcome !== "empty") {
return null;
}
const finalText = typeof params.payload.text === "string" ? params.payload.text.trim() : "";
const mediaUrlCount = countOutboundMedia(params.payload);
// If the payload had no text and no media even nominally, the run had
// nothing to send and "empty" is the correct outcome — do not flag.
if (finalText.length === 0 && mediaUrlCount === 0) {
return null;
}
return {
reason: "no-visible-reply-after-final-delivery",
outcome: params.outcome,
finalTextLength: finalText.length,
mediaUrlCount,
};
}
export function formatMattermostNoVisibleReplyLog(params: {
violation: MattermostNoVisibleReplyViolation;
to: string;
accountId: string;
agentId: string | undefined;
}): string {
return (
`mattermost no-visible-reply: ${params.violation.reason}` +
` to=${params.to}` +
` accountId=${params.accountId}` +
` agentId=${params.agentId ?? "unknown"}` +
` outcome=${params.violation.outcome}` +
` finalTextLength=${params.violation.finalTextLength}` +
` mediaUrlCount=${params.violation.mediaUrlCount}`
);
}

View File

@@ -0,0 +1,195 @@
// Mattermost tests cover probe plugin behavior.
import { MAX_TIMER_TIMEOUT_MS } from "openclaw/plugin-sdk/number-runtime";
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 };
});
function requireFirstFetchCall() {
const [call] = mockFetchGuard.mock.calls;
if (!call) {
throw new Error("expected Mattermost probe fetch call");
}
return call[0] as {
url?: string;
init?: { headers?: unknown; signal?: unknown };
auditContext?: string;
policy?: unknown;
};
}
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,
});
const result = await probeMattermost("https://mm.example.com/api/v4/", "bot-token");
const fetchCall = requireFirstFetchCall();
expect(fetchCall?.url).toBe("https://mm.example.com/api/v4/users/me");
expect(fetchCall?.init?.headers).toStrictEqual({ Authorization: "Bearer bot-token" });
expect(fetchCall?.init?.signal).toBeInstanceOf(AbortSignal);
expect(fetchCall?.auditContext).toBe("mattermost-probe");
expect(fetchCall?.policy).toBeUndefined();
const { elapsedMs, ...stableResult } = result;
expect(stableResult).toStrictEqual({
ok: true,
status: 200,
bot: { id: "bot-1", username: "clawbot" },
});
expect(elapsedMs).toBeGreaterThanOrEqual(0);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("bounds and cancels oversized probe success JSON bodies", async () => {
let canceled = false;
let pulled = 0;
const oversizeChunk = new Uint8Array(2 * 1024 * 1024).fill(0x7b); // 2 MiB of "{"
const stream = new ReadableStream<Uint8Array>({
pull(controller) {
pulled += 1;
controller.enqueue(oversizeChunk);
},
cancel() {
canceled = true;
},
});
mockFetchGuard.mockResolvedValueOnce({
response: new Response(stream, {
status: 200,
headers: { "content-type": "application/json" },
}),
release: mockRelease,
});
const result = await probeMattermost("https://mm.example.com", "bot-token");
expect(result.ok).toBe(false);
expect(result.status).toBeNull();
expect(result.error).toContain("JSON response exceeds 16777216 bytes");
expect(canceled).toBe(true);
expect(pulled).toBeLessThanOrEqual(12);
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,
});
await probeMattermost("https://mm.example.com", "bot-token", 2500, true);
const fetchCall = requireFirstFetchCall();
expect(fetchCall?.policy).toStrictEqual({ allowPrivateNetwork: true });
});
it("clamps oversized probe timeouts before scheduling", async () => {
mockFetchGuard.mockResolvedValueOnce({
response: new Response(JSON.stringify({ id: "bot-1" }), {
status: 200,
headers: { "content-type": "application/json" },
}),
release: mockRelease,
});
const setTimeoutSpy = vi.spyOn(globalThis, "setTimeout");
try {
await probeMattermost("https://mm.example.com", "bot-token", Number.MAX_SAFE_INTEGER);
expect(setTimeoutSpy).toHaveBeenCalledWith(expect.any(Function), MAX_TIMER_TIMEOUT_MS);
} finally {
setTimeoutSpy.mockRestore();
}
});
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,
});
const result = await probeMattermost("https://mm.example.com", "bad-token");
const { elapsedMs, ...stableResult } = result;
expect(stableResult).toStrictEqual({
ok: false,
status: 401,
error: "invalid auth token",
});
expect(elapsedMs).toBeGreaterThanOrEqual(0);
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,
});
const result = await probeMattermost("https://mm.example.com", "token");
const { elapsedMs, ...stableResult } = result;
expect(stableResult).toStrictEqual({
ok: false,
status: 403,
error: "Forbidden",
});
expect(elapsedMs).toBeGreaterThanOrEqual(0);
expect(mockRelease).toHaveBeenCalledTimes(1);
});
it("returns fetch error when request throws", async () => {
mockFetchGuard.mockRejectedValueOnce(new Error("network down"));
const result = await probeMattermost("https://mm.example.com", "token");
const { elapsedMs, ...stableResult } = result;
expect(stableResult).toStrictEqual({
ok: false,
status: null,
error: "network down",
});
expect(elapsedMs).toBeGreaterThanOrEqual(0);
});
});

View File

@@ -0,0 +1,80 @@
// Mattermost plugin module implements probe behavior.
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { resolveTimerTimeoutMs } from "openclaw/plugin-sdk/number-runtime";
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
import {
fetchWithSsrFGuard,
ssrfPolicyFromPrivateNetworkOptIn,
} from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js";
import type { BaseProbeResult } from "./runtime-api.js";
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 resolvedTimeoutMs = timeoutMs > 0 ? resolveTimerTimeoutMs(timeoutMs, 2500) : 0;
const controller = resolvedTimeoutMs > 0 ? new AbortController() : undefined;
let timer: NodeJS.Timeout | null = null;
if (controller) {
timer = setTimeout(() => controller.abort(), resolvedTimeoutMs);
}
try {
const { response: res, release } = await fetchWithSsrFGuard({
url,
init: {
headers: { Authorization: `Bearer ${botToken}` },
signal: controller?.signal,
},
auditContext: "mattermost-probe",
policy: ssrfPolicyFromPrivateNetworkOptIn(allowPrivateNetwork),
});
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 readProviderJsonResponse<MattermostUser>(res, "Mattermost probe /users/me");
return {
ok: true,
status: res.status,
elapsedMs,
bot,
};
} finally {
await release();
}
} catch (err) {
const message = formatErrorMessage(err);
return {
ok: false,
status: null,
error: message,
elapsedMs: Date.now() - start,
};
} finally {
if (timer) {
clearTimeout(timer);
}
}
}

View File

@@ -0,0 +1,100 @@
// Mattermost helper module supports reactions helpers behavior.
import { expect, vi } from "vitest";
import type { OpenClawConfig } from "../../runtime-api.js";
import type { MattermostFetch } from "./client.js";
export function requestUrl(url: string | URL | Request): string {
if (typeof url === "string") {
return url;
}
if (url instanceof URL) {
return url.toString();
}
return url.url;
}
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) => {
const urlText = requestUrl(url);
if (urlText.endsWith("/api/v4/users/me")) {
return new Response(JSON.stringify({ id: userId }), {
status: 200,
headers: { "content-type": "application/json" },
});
}
if (allowAdd && urlText.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 && urlText.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: ${urlText}`);
});
}
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;
}
}

View File

@@ -0,0 +1,202 @@
// Mattermost tests cover reactions plugin behavior.
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
addMattermostReaction,
removeMattermostReaction,
resetMattermostReactionBotUserCacheForTests,
} from "./reactions.js";
import {
createMattermostReactionFetchMock,
createMattermostTestConfig,
requestUrl,
} from "./reactions.test-helpers.js";
describe("mattermost reactions", () => {
beforeEach(() => {
resetMattermostReactionBotUserCacheForTests();
});
afterEach(() => {
vi.restoreAllMocks();
});
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) =>
requestUrl(call[0]).endsWith("/api/v4/users/me"),
);
expect(addResult).toEqual({ ok: true });
expect(removeResult).toEqual({ ok: true });
expect(usersMeCalls).toHaveLength(1);
});
it("does not reuse cached bot user ids while the process clock is invalid", async () => {
const cfg = createMattermostTestConfig();
const firstFetch = createMattermostReactionFetchMock({
mode: "add",
postId: "POST1",
emojiName: "thumbsup",
userId: "BOT_OLD",
});
const secondFetch = createMattermostReactionFetchMock({
mode: "add",
postId: "POST2",
emojiName: "thumbsup",
userId: "BOT_FRESH",
});
const thirdFetch = createMattermostReactionFetchMock({
mode: "add",
postId: "POST3",
emojiName: "thumbsup",
userId: "BOT_RECOVERED",
});
await expect(
addMattermostReaction({
cfg,
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: firstFetch,
}),
).resolves.toEqual({ ok: true });
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_001);
await expect(
addMattermostReaction({
cfg,
postId: "POST2",
emojiName: "thumbsup",
fetchImpl: secondFetch,
}),
).resolves.toEqual({ ok: true });
vi.mocked(Date.now).mockReturnValue(1_000);
await expect(
addMattermostReaction({
cfg,
postId: "POST3",
emojiName: "thumbsup",
fetchImpl: thirdFetch,
}),
).resolves.toEqual({ ok: true });
const usersMeCalls = [
...firstFetch.mock.calls,
...secondFetch.mock.calls,
...thirdFetch.mock.calls,
].filter((call) => requestUrl(call[0]).endsWith("/api/v4/users/me"));
expect(usersMeCalls).toHaveLength(3);
});
it("does not cache bot user ids when cache expiry would exceed the Date range", async () => {
vi.spyOn(Date, "now").mockReturnValue(8_640_000_000_000_000);
const cfg = createMattermostTestConfig();
const fetchMock = createMattermostReactionFetchMock({
mode: "both",
postId: "POST1",
emojiName: "thumbsup",
});
await expect(
addMattermostReaction({
cfg,
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock,
}),
).resolves.toEqual({ ok: true });
await expect(
removeMattermostReaction({
cfg,
postId: "POST1",
emojiName: "thumbsup",
fetchImpl: fetchMock,
}),
).resolves.toEqual({ ok: true });
const usersMeCalls = fetchMock.mock.calls.filter((call) =>
requestUrl(call[0]).endsWith("/api/v4/users/me"),
);
expect(usersMeCalls).toHaveLength(2);
});
});

View File

@@ -0,0 +1,144 @@
// Mattermost plugin module implements reactions behavior.
import {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
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 rawNow = Date.now();
const now = asDateTimestampMs(rawNow);
const cached = botUserIdCache.get(cacheKey);
if (cached) {
if (now !== undefined && cached.expiresAt > now) {
return cached.userId;
}
botUserIdCache.delete(cacheKey);
}
const me = await fetchMattermostMe(client);
const userId = me?.id?.trim();
if (!userId) {
return null;
}
const expiresAt = resolveExpiresAtMsFromDurationMs(BOT_USER_CACHE_TTL_MS, { nowMs: rawNow });
if (expiresAt !== undefined) {
botUserIdCache.set(cacheKey, { userId, expiresAt });
}
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: isPrivateNetworkOptInEnabled(resolved.config),
});
const cacheKey = `${baseUrl}:${botToken}`;
const userId = await resolveBotUserId(client, cacheKey);
if (!userId) {
return { ok: false, error: "Mattermost reactions failed: could not resolve bot user id." };
}
try {
await options.mutation(client, {
userId,
postId: params.postId,
emojiName: params.emojiName,
});
} catch (err) {
return { ok: false, error: `Mattermost ${options.action} reaction failed: ${String(err)}` };
}
return { ok: true };
}
async function createReaction(client: MattermostClient, params: MutationPayload): Promise<void> {
await client.request<Record<string, unknown>>("/reactions", {
method: "POST",
body: JSON.stringify({
user_id: params.userId,
post_id: params.postId,
emoji_name: params.emojiName,
}),
});
}
async function deleteReaction(client: MattermostClient, params: MutationPayload): Promise<void> {
const emoji = encodeURIComponent(params.emojiName);
await client.request<unknown>(
`/users/${params.userId}/posts/${params.postId}/reactions/${emoji}`,
{
method: "DELETE",
},
);
}

View File

@@ -0,0 +1,202 @@
// Mattermost tests cover reconnect plugin behavior.
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.mock.calls.map(([error]) => error)).toStrictEqual([
new Error("fetch failed"),
new Error("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();
});
});

View File

@@ -0,0 +1,115 @@
// Mattermost plugin module implements reconnect behavior.
type ReconnectOutcome = "resolved" | "rejected";
type ShouldReconnectParams = {
attempt: number;
delayMs: number;
outcome: ReconnectOutcome;
error?: unknown;
};
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;
const backoff = createReconnectBackoff(initialDelayMs, maxDelayMs);
let attempt = 0;
while (!opts.abortSignal?.aborted) {
let outcome: ReconnectOutcome = "resolved";
let error: unknown;
try {
await connectFn();
backoff.reset();
} catch (err) {
if (opts.abortSignal?.aborted) {
return;
}
outcome = "rejected";
error = err;
opts.onError?.(err);
}
if (opts.abortSignal?.aborted) {
return;
}
const delayMs = withJitter(backoff.current(), jitterRatio, random);
const shouldReconnect =
opts.shouldReconnect?.({
attempt,
delayMs,
outcome,
error,
}) ?? true;
if (!shouldReconnect) {
return;
}
opts.onReconnect?.(delayMs);
await sleepAbortable(delayMs, opts.abortSignal);
if (outcome === "rejected") {
backoff.increase();
}
attempt++;
}
}
function createReconnectBackoff(initialDelayMs: number, maxDelayMs: number) {
let retryDelay = initialDelayMs;
return {
current: () => retryDelay,
reset: () => {
retryDelay = initialDelayMs;
},
increase: () => {
retryDelay = Math.min(retryDelay * 2, maxDelayMs);
},
};
}
function withJitter(baseMs: number, jitterRatio: number, random: () => number): number {
if (jitterRatio <= 0) {
return baseMs;
}
const normalized = Math.max(0, Math.min(1, random()));
const spread = baseMs * jitterRatio;
return Math.max(1, Math.round(baseMs - spread + normalized * spread * 2));
}
function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
return new Promise((resolve) => {
if (signal?.aborted) {
resolve();
return;
}
const onAbort = () => {
clearTimeout(timer);
resolve();
};
const timer = setTimeout(() => {
signal?.removeEventListener("abort", onAbort);
resolve();
}, ms);
signal?.addEventListener("abort", onAbort, { once: true });
});
}

View File

@@ -0,0 +1,312 @@
// Mattermost tests cover reply delivery plugin behavior.
import fs from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import type { ChunkMode } from "openclaw/plugin-sdk/reply-runtime";
import { describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, PluginRuntime } from "../../runtime-api.js";
import {
createMattermostReplyDeliveryBarrier,
deliverMattermostReplyPayload,
} from "./reply-delivery.js";
type DeliverMattermostReplyPayloadParams = Parameters<typeof deliverMattermostReplyPayload>[0];
type ReplyDeliveryMarkdownTableMode = Parameters<
DeliverMattermostReplyPayloadParams["core"]["channel"]["text"]["convertMarkdownTables"]
>[1];
function createReplyDeliveryCore(): DeliverMattermostReplyPayloadParams["core"] {
return {
channel: {
text: {
chunkByNewline: vi.fn((text: string) => [text]),
chunkMarkdownText: vi.fn((text: string) => [text]),
convertMarkdownTables: vi.fn((text: string) => text),
chunkText: vi.fn((text: string) => [text]),
chunkTextWithMode: vi.fn((text: string) => [text]),
resolveMarkdownTableMode: vi.fn<() => ReplyDeliveryMarkdownTableMode>(() => "off"),
resolveChunkMode: vi.fn<() => ChunkMode>(() => "length"),
resolveTextChunkLimit: vi.fn(
(
_cfg?: OpenClawConfig,
_provider?: string,
_accountId?: string | null,
opts?: { fallbackLimit?: number },
) => opts?.fallbackLimit ?? 4000,
),
hasControlCommand: vi.fn(() => false),
chunkMarkdownTextWithMode: vi.fn((text: string) => [text]),
},
},
} as unknown as PluginRuntime;
}
describe("createMattermostReplyDeliveryBarrier", () => {
it("extends while direct deliveries or DM resolution remain unsettled", async () => {
const barrier = createMattermostReplyDeliveryBarrier({ isDirect: true });
const policy = barrier.resolveTimeoutPolicy({
queuedCounts: { tool: 1, block: 0, final: 1 },
humanDelayBudgetMs: 0,
});
expect(policy?.maxTimeoutMs).toBe(420_000);
expect(policy?.shouldExtend()).toBe(true);
let resolveResolution: () => void = () => {};
const resolution = new Promise<void>((resolve) => {
resolveResolution = resolve;
});
barrier.trackDmChannelResolution(resolution);
expect(policy?.shouldExtend()).toBe(true);
resolveResolution();
await resolution;
await Promise.resolve();
expect(policy?.shouldExtend()).toBe(true);
barrier.markDeliverySettled();
expect(policy?.shouldExtend()).toBe(true);
barrier.markDeliverySettled();
expect(policy?.shouldExtend()).toBe(false);
});
it("stays extended between failed retries until queued deliveries settle", async () => {
const barrier = createMattermostReplyDeliveryBarrier({ isDirect: true });
const policy = barrier.resolveTimeoutPolicy({
queuedCounts: { tool: 1, block: 0, final: 1 },
humanDelayBudgetMs: 0,
});
let rejectResolution: (error: Error) => void = () => {};
const resolution = new Promise<void>((_resolve, reject) => {
rejectResolution = reject;
});
barrier.trackDmChannelResolution(resolution);
rejectResolution(new Error("DM creation failed"));
await expect(resolution).rejects.toThrow("DM creation failed");
await Promise.resolve();
barrier.markDeliverySettled();
expect(policy?.shouldExtend()).toBe(true);
barrier.markDeliverySettled();
expect(policy?.shouldExtend()).toBe(false);
});
it("does not extend non-DM delivery", () => {
const barrier = createMattermostReplyDeliveryBarrier({ isDirect: false });
expect(
barrier.resolveTimeoutPolicy({
queuedCounts: { tool: 1, block: 1, final: 1 },
humanDelayBudgetMs: 0,
}),
).toBeUndefined();
});
});
describe("deliverMattermostReplyPayload", () => {
it("suppresses payloads flagged as reasoning", async () => {
const sendMessage = vi.fn(async () => undefined);
const cfg = {} satisfies OpenClawConfig;
const core = createReplyDeliveryCore();
const outcome = await deliverMattermostReplyPayload({
core,
cfg,
payload: { text: "hidden", isReasoning: true },
to: "channel:town-square",
accountId: "default",
agentId: "agent-1",
replyToId: "root-post",
textLimit: 4000,
tableMode: "off",
sendMessage,
});
expect(sendMessage).not.toHaveBeenCalled();
expect(outcome).toBe("reasoning_skipped");
});
it("returns 'empty' for substantive text that produced no send (regression: #80501)", async () => {
const sendMessage = vi.fn(async () => undefined);
const cfg = {} satisfies OpenClawConfig;
const core = createReplyDeliveryCore();
// Make the markdown table converter strip the text to empty so
// deliverTextOrMediaReply sees an empty chunked text and returns "empty".
core.channel.text.convertMarkdownTables = vi.fn(() => "");
core.channel.text.chunkMarkdownTextWithMode = vi.fn(() => []);
const outcome = await deliverMattermostReplyPayload({
core,
cfg,
payload: { text: "non-trivial input that the converter strips" },
to: "channel:town-square",
accountId: "default",
agentId: "agent-1",
replyToId: "root-post",
textLimit: 4000,
tableMode: "off",
sendMessage,
});
expect(sendMessage).not.toHaveBeenCalled();
expect(outcome).toBe("empty");
});
it("suppresses reasoning-prefixed payloads even without an explicit flag", async () => {
const sendMessage = vi.fn(async () => undefined);
const cfg = {} satisfies OpenClawConfig;
const core = createReplyDeliveryCore();
await deliverMattermostReplyPayload({
core,
cfg,
payload: { text: " \n Reasoning:\n_hidden_" },
to: "channel:town-square",
accountId: "default",
agentId: "agent-1",
replyToId: "root-post",
textLimit: 4000,
tableMode: "off",
sendMessage,
});
expect(sendMessage).not.toHaveBeenCalled();
});
it("suppresses reasoning payloads formatted as a Mattermost blockquote", async () => {
const sendMessage = vi.fn(async () => undefined);
const cfg = {} satisfies OpenClawConfig;
const core = createReplyDeliveryCore();
await deliverMattermostReplyPayload({
core,
cfg,
payload: { text: "> Reasoning:\n> _hidden_" },
to: "channel:town-square",
accountId: "default",
agentId: "agent-1",
replyToId: "root-post",
textLimit: 4000,
tableMode: "off",
sendMessage,
});
expect(sendMessage).not.toHaveBeenCalled();
});
it("does not suppress messages that mention Reasoning: mid-text", async () => {
const sendMessage = vi.fn(async () => undefined);
const cfg = {} satisfies OpenClawConfig;
const core = createReplyDeliveryCore();
await deliverMattermostReplyPayload({
core,
cfg,
payload: { text: "Intro line\nReasoning: appears in content but is not a prefix" },
to: "channel:town-square",
accountId: "default",
agentId: "agent-1",
replyToId: "root-post",
textLimit: 4000,
tableMode: "off",
sendMessage,
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
"channel:town-square",
"Intro line\nReasoning: appears in content but is not a prefix",
expect.objectContaining({
cfg,
accountId: "default",
replyToId: "root-post",
}),
);
});
it("passes agent-scoped mediaLocalRoots when sending media paths", async () => {
const previousStateDir = process.env.OPENCLAW_STATE_DIR;
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-mm-state-"));
process.env.OPENCLAW_STATE_DIR = stateDir;
try {
const sendMessage = vi.fn(async () => undefined);
const core = createReplyDeliveryCore();
const agentId = "agent-1";
const mediaUrl = `file://${path.join(stateDir, `workspace-${agentId}`, "photo.png")}`;
const cfg = {} satisfies OpenClawConfig;
await deliverMattermostReplyPayload({
core,
cfg,
payload: { text: "caption", mediaUrl },
to: "channel:town-square",
accountId: "default",
agentId,
replyToId: "root-post",
textLimit: 4000,
tableMode: "off",
sendMessage,
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
"channel:town-square",
"caption",
expect.objectContaining({
cfg,
accountId: "default",
mediaUrl,
replyToId: "root-post",
mediaLocalRoots: expect.arrayContaining([
path.join(stateDir, "media"),
path.join(stateDir, "canvas"),
path.join(stateDir, "workspace"),
path.join(stateDir, "sandboxes"),
path.join(stateDir, `workspace-${agentId}`),
]),
}),
);
} finally {
if (previousStateDir === undefined) {
delete process.env.OPENCLAW_STATE_DIR;
} else {
process.env.OPENCLAW_STATE_DIR = previousStateDir;
}
await fs.rm(stateDir, { recursive: true, force: true });
}
});
it("forwards replyToId for text-only chunked replies", async () => {
const sendMessage = vi.fn(async () => undefined);
const cfg = {} satisfies OpenClawConfig;
const core = createReplyDeliveryCore();
core.channel.text.chunkMarkdownTextWithMode = vi.fn(() => ["hello"]);
const outcome = await deliverMattermostReplyPayload({
core,
cfg,
payload: { text: "hello" },
to: "channel:town-square",
accountId: "default",
agentId: "agent-1",
replyToId: "root-post",
textLimit: 4000,
tableMode: "off",
sendMessage,
});
expect(sendMessage).toHaveBeenCalledTimes(1);
expect(sendMessage).toHaveBeenCalledWith(
"channel:town-square",
"hello",
expect.objectContaining({
cfg,
accountId: "default",
replyToId: "root-post",
}),
);
expect(outcome).toBe("text");
});
});

View File

@@ -0,0 +1,147 @@
// Mattermost plugin module implements reply delivery behavior.
import type { OpenClawConfig, PluginRuntime } from "openclaw/plugin-sdk/core";
import { getAgentScopedMediaLocalRoots } from "openclaw/plugin-sdk/media-runtime";
import {
deliverTextOrMediaReply,
isReasoningReplyPayload,
resolveSendableOutboundReplyParts,
} from "openclaw/plugin-sdk/reply-payload";
import type {
ReplyDispatchKind,
ReplyFollowupAdmissionBarrierTimeoutPolicy,
ReplyPayload,
} from "openclaw/plugin-sdk/reply-runtime";
import {
resolveMattermostReplyDeliveryBarrierTimeoutMs,
type CreateDmChannelRetryOptions,
} from "./client.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;
onDmChannelResolution?: (resolution: PromiseLike<unknown>) => void;
},
) => Promise<unknown>;
export function createMattermostReplyDeliveryBarrier(params: {
isDirect: boolean;
dmRetryOptions?: CreateDmChannelRetryOptions;
}) {
let activeDmChannelResolutions = 0;
let queuedDeliveryCount = 0;
let settledDeliveryCount = 0;
const trackDmChannelResolution = (resolution: PromiseLike<unknown>) => {
activeDmChannelResolutions += 1;
void Promise.resolve(resolution).then(
() => {
activeDmChannelResolutions -= 1;
},
() => {
activeDmChannelResolutions -= 1;
},
);
};
const markDeliverySettled = () => {
settledDeliveryCount += 1;
};
const resolveTimeoutPolicy = (context: {
queuedCounts: Readonly<Record<ReplyDispatchKind, number>>;
humanDelayBudgetMs: number;
}): ReplyFollowupAdmissionBarrierTimeoutPolicy | undefined => {
const { queuedCounts } = context;
queuedDeliveryCount = Object.values(queuedCounts).reduce((sum, count) => sum + count, 0);
const maxTimeoutMs = resolveMattermostReplyDeliveryBarrierTimeoutMs({
isDirect: params.isDirect,
dmRetryOptions: params.dmRetryOptions,
queuedCounts,
humanDelayBudgetMs: context.humanDelayBudgetMs,
});
if (maxTimeoutMs === undefined) {
return undefined;
}
return {
maxTimeoutMs,
shouldExtend: () =>
activeDmChannelResolutions > 0 || settledDeliveryCount < queuedDeliveryCount,
};
};
return {
trackDmChannelResolution,
markDeliverySettled,
resolveTimeoutPolicy,
};
}
/**
* Result of `deliverMattermostReplyPayload`. Callers in `monitor.ts` use this
* to distinguish a successful visible send from an intentionally suppressed
* reasoning payload from a substantive payload that ended up sending nothing
* (the silent-completion symptom in #80501).
*/
export type MattermostReplyDeliveryOutcome = "reasoning_skipped" | "empty" | "text" | "media";
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;
onDmChannelResolution?: (resolution: PromiseLike<unknown>) => void;
}): Promise<MattermostReplyDeliveryOutcome> {
if (isReasoningReplyPayload(params.payload)) {
return "reasoning_skipped";
}
const reply = resolveSendableOutboundReplyParts(params.payload, {
text: params.core.channel.text.convertMarkdownTables(
params.payload.text ?? "",
params.tableMode,
),
});
const mediaLocalRoots = getAgentScopedMediaLocalRoots(params.cfg, params.agentId);
const chunkMode = params.core.channel.text.resolveChunkMode(
params.cfg,
"mattermost",
params.accountId,
);
return await deliverTextOrMediaReply({
payload: params.payload,
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,
...(params.onDmChannelResolution
? { onDmChannelResolution: params.onDmChannelResolution }
: {}),
});
},
sendMedia: async ({ mediaUrl, caption }) => {
await params.sendMessage(params.to, caption ?? "", {
cfg: params.cfg,
accountId: params.accountId,
mediaUrl,
mediaLocalRoots,
replyToId: params.replyToId,
...(params.onDmChannelResolution
? { onDmChannelResolution: params.onDmChannelResolution }
: {}),
});
},
});
}

View File

@@ -0,0 +1,51 @@
// Mattermost API module exposes the plugin public contract.
export type {
BaseProbeResult,
ChannelAccountSnapshot,
ChannelDirectoryEntry,
ChatType,
HistoryEntry,
OpenClawConfig,
OpenClawPluginApi,
ReplyPayload,
} from "openclaw/plugin-sdk/core";
export type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
export { buildAgentMediaPayload } from "openclaw/plugin-sdk/agent-media-payload";
export { resolveAllowlistMatchSimple } from "openclaw/plugin-sdk/allow-from";
export { logInboundDrop } from "openclaw/plugin-sdk/channel-inbound";
export { createChannelPairingController } from "openclaw/plugin-sdk/channel-pairing";
export { createChannelMessageReplyPipeline } from "openclaw/plugin-sdk/channel-outbound";
export { logTypingFailure } from "openclaw/plugin-sdk/channel-feedback";
export {
listSkillCommandsForAgents,
resolveControlCommandGate,
} from "openclaw/plugin-sdk/command-auth-native";
export { buildModelsProviderData } from "openclaw/plugin-sdk/models-provider-runtime";
export { isDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
export {
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
warnMissingProviderGroupPolicyFallbackOnce,
} from "openclaw/plugin-sdk/runtime-group-policy";
export { resolveChannelMediaMaxBytes } from "openclaw/plugin-sdk/media-runtime";
export { loadOutboundMediaFromUrl } from "openclaw/plugin-sdk/outbound-media";
// Legacy map-helper exports stay for older plugin consumers. New message-turn
// code should use createChannelHistoryWindow.
export {
DEFAULT_GROUP_HISTORY_LIMIT,
createChannelHistoryWindow,
buildInboundHistoryFromMap,
buildPendingHistoryContextFromMap,
recordPendingHistoryEntryIfEnabled,
} from "openclaw/plugin-sdk/reply-history";
export { registerPluginHttpRoute } from "openclaw/plugin-sdk/webhook-targets";
export {
isRequestBodyLimitError,
readRequestBodyWithLimit,
} from "openclaw/plugin-sdk/webhook-ingress";
export {
isTrustedProxyAddress,
parseStrictPositiveInteger,
resolveClientIp,
} from "openclaw/plugin-sdk/core";
export { parseTcpPort } from "openclaw/plugin-sdk/number-runtime";

View File

@@ -0,0 +1,746 @@
// Mattermost tests cover send plugin behavior.
import { expectProvidedCfgSkipsRuntimeLoad } from "openclaw/plugin-sdk/channel-test-helpers";
import { beforeEach, describe, expect, it, vi } from "vitest";
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 TEST_CFG = {};
const mockState = vi.hoisted(() => ({
loadConfig: vi.fn(() => ({})),
loadOutboundMediaFromUrl: vi.fn(),
recordActivity: vi.fn(),
resolveMattermostAccount: vi.fn(() => ({
accountId: "default",
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(),
}));
type MattermostPostParams = {
channelId?: string;
message?: string;
props?: {
attachments?: Array<{
actions?: Array<{ id?: string; name?: string }>;
}>;
};
};
type MattermostUploadParams = {
channelId?: string;
fileName?: string;
contentType?: string;
};
type MattermostDirectRetryOptions = {
maxRetries?: number;
initialDelayMs?: number;
maxDelayMs?: number;
timeoutMs?: number;
onRetry?: () => void;
};
function mockCall(mock: unknown, label: string, index = 0): unknown[] {
const calls = (mock as { mock?: { calls?: unknown[][] } }).mock?.calls;
const call = calls?.at(index);
if (!call) {
throw new Error(`Expected ${label} call ${index + 1}`);
}
return call;
}
function uploadMattermostFileCall() {
return mockCall(mockState.uploadMattermostFile, "uploadMattermostFile") as [
unknown,
MattermostUploadParams?,
];
}
function createMattermostPostParams() {
const params = mockCall(mockState.createMattermostPost, "createMattermostPost")[1] as
| MattermostPostParams
| undefined;
if (!params) {
throw new Error("Expected createMattermostPost params");
}
return params;
}
function createMattermostPostCall() {
return mockCall(mockState.createMattermostPost, "createMattermostPost") as [
unknown,
MattermostPostParams?,
];
}
function directChannelRetryCall() {
return mockCall(
mockState.createMattermostDirectChannelWithRetry,
"createMattermostDirectChannelWithRetry",
) as [unknown, unknown, MattermostDirectRetryOptions?];
}
vi.mock("../../runtime-api.js", () => ({
loadOutboundMediaFromUrl: mockState.loadOutboundMediaFromUrl,
}));
vi.mock("./runtime-api.js", () => ({
loadOutboundMediaFromUrl: mockState.loadOutboundMediaFromUrl,
}));
vi.mock("openclaw/plugin-sdk/plugin-config-runtime", () => ({
requireRuntimeConfig: (cfg: unknown) => {
if (cfg) {
return cfg;
}
throw new Error("Mattermost send requires a resolved runtime config");
},
resolveMarkdownTableMode: vi.fn(() => "off"),
}));
vi.mock("openclaw/plugin-sdk/text-chunking", () => ({
convertMarkdownTables: vi.fn((text: string) => text),
}));
vi.mock("openclaw/plugin-sdk/string-coerce-runtime", () => ({
normalizeLowercaseStringOrEmpty: vi.fn((value: string | null | undefined) => {
if (typeof value !== "string") {
return "";
}
return value.trim().toLowerCase();
}),
normalizeOptionalString: vi.fn((value: string | null | undefined) => {
if (typeof value !== "string") {
return undefined;
}
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
}),
normalizeStringifiedOptionalString: vi.fn((value: unknown) => {
if (typeof value === "string") {
const normalized = value.trim();
return normalized.length > 0 ? normalized : undefined;
}
if (typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") {
const normalized = String(value).trim();
return normalized.length > 0 ? normalized : undefined;
}
return undefined;
}),
}));
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",
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("fails hard when cfg is omitted", async () => {
await expect(
sendMessageMattermost("channel:town-square", "hello", undefined as never),
).rejects.toThrow("Mattermost send requires a resolved runtime config");
expect(mockState.loadConfig).not.toHaveBeenCalled();
expect(mockState.resolveMattermostAccount).not.toHaveBeenCalled();
});
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");
});
const result = await sendMessageMattermost("channel:town-square", "hello", {
cfg: providedCfg,
accountId: "work",
});
expect(result.messageId).toBe("post-1");
expect(result.channelId).toBe("town-square");
expect(result.receipt.primaryPlatformMessageId).toBe("post-1");
expect(result.receipt.platformMessageIds).toEqual(["post-1"]);
expect(result.receipt.parts).toHaveLength(1);
expect(result.receipt.parts[0]?.platformMessageId).toBe("post-1");
expect(result.receipt.parts[0]?.kind).toBe("text");
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",
botToken: "bot-token",
baseUrl: "https://mattermost.example.com",
config: {},
});
await sendMessageMattermost("channel:town-square", "hello", {
cfg: TEST_CFG,
mediaUrl: "file:///tmp/agent-workspace/photo.png",
mediaLocalRoots: ["/tmp/agent-workspace"],
workspaceDir: "/tmp/agent-workspace",
});
expect(mockState.loadOutboundMediaFromUrl).toHaveBeenCalledWith(
"file:///tmp/agent-workspace/photo.png",
{
mediaLocalRoots: ["/tmp/agent-workspace"],
workspaceDir: "/tmp/agent-workspace",
},
);
const uploadCall = uploadMattermostFileCall();
expect(uploadCall?.[0]).toEqual({});
expect(uploadCall?.[1]?.channelId).toBe("town-square");
expect(uploadCall?.[1]?.fileName).toBe("photo.png");
expect(uploadCall?.[1]?.contentType).toBe("image/png");
});
it("fails instead of posting text-only when required media cannot be loaded", async () => {
mockState.loadOutboundMediaFromUrl.mockRejectedValueOnce(new Error("local root denied"));
mockState.resolveMattermostAccount.mockReturnValue({
accountId: "default",
botToken: "bot-token",
baseUrl: "https://mattermost.example.com",
config: {},
});
await expect(
sendMessageMattermost("channel:town-square", "hello", {
cfg: TEST_CFG,
mediaUrl: "file:///tmp/agent-workspace/photo.png",
mediaLocalRoots: ["/tmp/agent-workspace"],
requireMediaUpload: true,
}),
).rejects.toThrow("Mattermost media upload failed: local root denied");
expect(mockState.createMattermostPost).not.toHaveBeenCalled();
});
it("builds interactive button props when buttons are provided", async () => {
mockState.resolveMattermostAccount.mockReturnValue({
accountId: "default",
botToken: "bot-token",
baseUrl: "https://mattermost.example.com",
config: {},
});
await sendMessageMattermost("channel:town-square", "Pick a model", {
cfg: TEST_CFG,
buttons: [[{ callback_data: "mdlprov", text: "Browse providers" }]],
});
const postCall = createMattermostPostCall();
expect(postCall?.[0]).toEqual({});
expect(postCall?.[1]?.channelId).toBe("town-square");
expect(postCall?.[1]?.message).toBe("Pick a model");
const attachments = postCall?.[1]?.props?.attachments;
expect(Array.isArray(attachments)).toBe(true);
const actions = attachments?.[0]?.actions;
expect(Array.isArray(actions)).toBe(true);
expect(actions?.[0]?.id).toBe("mdlprov");
expect(actions?.[0]?.name).toBe("Browse providers");
});
it("resolves a bare Mattermost user id as a DM target before upload", async () => {
const userId = "dthcxgoxhifn3pwh65cut3ud3w";
mockState.resolveMattermostAccount.mockReturnValue({
accountId: "default",
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", {
cfg: TEST_CFG,
mediaUrl: "file:///tmp/agent-workspace/photo.png",
mediaLocalRoots: ["/tmp/agent-workspace"],
});
expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, userId);
const dmRetryCall = directChannelRetryCall();
expect(dmRetryCall?.[0]).toEqual({});
expect(dmRetryCall?.[1]).toEqual(["bot-user", userId]);
expect(Object.keys(dmRetryCall?.[2] ?? {})).toEqual(["onRetry"]);
expect(dmRetryCall?.[2]?.onRetry).toBeTypeOf("function");
const uploadCall = uploadMattermostFileCall();
expect(uploadCall?.[0]).toEqual({});
expect(uploadCall?.[1]?.channelId).toBe("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",
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", {
cfg: TEST_CFG,
mediaUrl: "file:///tmp/agent-workspace/photo.png",
mediaLocalRoots: ["/tmp/agent-workspace"],
});
expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, channelId);
expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled();
const uploadCall = uploadMattermostFileCall();
expect(uploadCall?.[0]).toEqual({});
expect(uploadCall?.[1]?.channelId).toBe(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", { cfg: TEST_CFG });
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1);
const params = createMattermostPostParams();
expect(params.channelId).toBe("dm-channel-id");
expect(res.channelId).toBe("dm-channel-id");
expect(res.messageId).toBe("post-id");
expect(res.receipt.primaryPlatformMessageId).toBe("post-id");
expect(res.receipt.platformMessageIds).toEqual(["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", { cfg: TEST_CFG });
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled();
const params = createMattermostPostParams();
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", { cfg: TEST_CFG });
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", { cfg: TEST_CFG });
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", { cfg: TEST_CFG });
expect(mockState.fetchMattermostUser).not.toHaveBeenCalled();
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1);
expect(res.channelId).toBe("dm-channel-id");
});
it("observes cache-miss DM resolution but not cached sends", async () => {
const userId = "iiiiii9999999999iiiiii9999"; // 26 chars
const onDmChannelResolution = vi.fn();
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-dm-observer-t9"));
await sendMessageMattermost(`user:${userId}`, "first", {
cfg: TEST_CFG,
onDmChannelResolution,
});
await sendMessageMattermost(`user:${userId}`, "second", {
cfg: TEST_CFG,
onDmChannelResolution,
});
expect(onDmChannelResolution).toHaveBeenCalledTimes(1);
expect(onDmChannelResolution).toHaveBeenCalledWith(expect.any(Promise));
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1);
});
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", { cfg: TEST_CFG });
expect(mockState.fetchMattermostUser).not.toHaveBeenCalled();
expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled();
const params = createMattermostPostParams();
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", {
cfg: TEST_CFG,
dmRetryOptions: retryOptions,
});
const retryCall = directChannelRetryCall();
expect(retryCall?.[0]).toEqual({});
expect(retryCall?.[1]).toEqual(["bot-id", userId]);
expect(retryCall?.[2]?.maxRetries).toBe(retryOptions.maxRetries);
expect(retryCall?.[2]?.initialDelayMs).toBe(retryOptions.initialDelayMs);
expect(retryCall?.[2]?.maxDelayMs).toBe(retryOptions.maxDelayMs);
expect(retryCall?.[2]?.timeoutMs).toBe(retryOptions.timeoutMs);
});
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", { cfg: TEST_CFG });
const retryCall = directChannelRetryCall();
expect(retryCall?.[0]).toEqual({});
expect(retryCall?.[1]).toEqual(["bot-id", userId]);
expect(retryCall?.[2]?.maxRetries).toBe(4);
expect(retryCall?.[2]?.initialDelayMs).toBe(2000);
expect(retryCall?.[2]?.maxDelayMs).toBe(8000);
expect(retryCall?.[2]?.timeoutMs).toBe(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", {
cfg: TEST_CFG,
dmRetryOptions: overrideOptions,
});
const retryCall = directChannelRetryCall();
expect(retryCall?.[0]).toEqual({});
expect(retryCall?.[1]).toEqual(["bot-id", userId]);
expect(retryCall?.[2]?.maxRetries).toBe(overrideOptions.maxRetries);
expect(retryCall?.[2]?.timeoutMs).toBe(overrideOptions.timeoutMs);
expect(retryCall?.[2]?.initialDelayMs).toBe(1000);
});
});

View File

@@ -0,0 +1,538 @@
// Mattermost plugin module implements send behavior.
import {
createMessageReceiptFromOutboundResults,
type MessageReceipt,
type MessageReceiptPartKind,
} from "openclaw/plugin-sdk/channel-outbound";
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
import { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import {
normalizeLowercaseStringOrEmpty,
normalizeOptionalString,
} from "openclaw/plugin-sdk/string-coerce-runtime";
import { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
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,
} 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>;
workspaceDir?: string;
/** Fail the send if media cannot be loaded/uploaded instead of posting text-only. */
requireMediaUpload?: boolean;
replyToId?: string;
props?: Record<string, unknown>;
buttons?: Array<unknown>;
attachmentText?: string;
/** Retry options for DM channel creation */
dmRetryOptions?: CreateDmChannelRetryOptions;
/** Observe the bounded cache-miss DM channel resolution lifecycle. */
onDmChannelResolution?: (resolution: PromiseLike<unknown>) => void;
};
export type MattermostSendResult = {
messageId: string;
channelId: string;
receipt: MessageReceipt;
};
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 createMattermostSendReceipt(params: {
messageId: string;
channelId: string;
kind: MessageReceiptPartKind;
replyToId?: string;
}): MessageReceipt {
const messageIds =
params.messageId.trim() && params.messageId !== "unknown" ? [params.messageId] : [];
return createMessageReceiptFromOutboundResults({
kind: params.kind,
...(params.replyToId ? { replyToId: params.replyToId } : {}),
results: messageIds.map((messageId) => ({
channel: "mattermost",
messageId,
channelId: params.channelId,
})),
});
}
function resolveMattermostReceiptKind(params: {
fileIds?: readonly string[];
buttons?: readonly unknown[];
props?: Record<string, unknown>;
}): MessageReceiptPartKind {
if (params.fileIds?.length) {
return "media";
}
if (params.buttons?.length || params.props) {
return "card";
}
return "text";
}
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 = normalizeOptionalString(text) ?? "";
const media = normalizeOptionalString(mediaUrl);
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 = normalizeLowercaseStringOrEmpty(trimmed);
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)}::${normalizeLowercaseStringOrEmpty(username)}`;
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::${normalizeLowercaseStringOrEmpty(name)}`;
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;
onDmChannelResolution?: (resolution: PromiseLike<unknown>) => void;
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 resolution = 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}`,
);
}
},
});
params.onDmChannelResolution?.(resolution);
const channel = await resolution;
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" });
if (!opts?.cfg) {
throw new Error(
"Mattermost send requires a resolved runtime config. Load and resolve config at the command or gateway boundary, then pass cfg through the runtime path.",
);
}
const cfg = requireRuntimeConfig(opts.cfg, "Mattermost send");
const account = resolveMattermostAccount({
cfg,
accountId: opts.accountId,
});
const token = normalizeOptionalString(opts.botToken) ?? normalizeOptionalString(account.botToken);
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 = normalizeOptionalString(to) ?? "";
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 = isPrivateNetworkOptInEnabled(account.config);
const channelId = await resolveTargetChannelId({
target,
baseUrl,
token,
allowPrivateNetwork,
dmRetryOptions,
onDmChannelResolution: opts.onDmChannelResolution,
logger: core.logging.shouldLogVerbose() ? logger : undefined,
});
return {
cfg,
accountId: account.accountId,
token,
baseUrl,
channelId,
allowPrivateNetwork,
};
}
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 = normalizeOptionalString(text) ?? "";
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,
workspaceDir: opts.workspaceDir,
});
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 (opts.requireMediaUpload) {
throw new Error(`Mattermost media upload failed: ${uploadError.message}`, {
cause: 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}`, {
cause: uploadError,
});
}
throw new Error("Mattermost message is empty");
}
const post = await createMattermostPost(client, {
channelId,
message,
rootId: opts.replyToId,
fileIds,
props,
});
recordMattermostOutboundActivity(accountId);
const messageId = post.id ?? "unknown";
return {
messageId,
channelId,
receipt: createMattermostSendReceipt({
messageId,
channelId,
kind: resolveMattermostReceiptKind({
fileIds,
buttons: opts.buttons,
props,
}),
replyToId: opts.replyToId,
}),
};
}

View File

@@ -0,0 +1,247 @@
// Mattermost tests cover slash commands plugin behavior.
import { describe, expect, it, vi } from "vitest";
import type { MattermostClient } from "./client.js";
import {
DEFAULT_COMMAND_SPECS,
MATTERMOST_SLASH_POST_METHOD,
parseSlashCommandPayload,
registerSlashCommands,
resolveCallbackUrl,
resolveCommandText,
resolveSlashCommandConfig,
} from "./slash-commands.js";
describe("slash-commands", () => {
async function registerSingleStatusCommand(
requestImpl: (path: string, init?: RequestInit) => Promise<unknown>,
) {
const client: MattermostClient = {
baseUrl: "https://chat.example.com",
apiBaseUrl: "https://chat.example.com/api/v4",
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).toEqual({
token: "t1",
team_id: "team",
team_domain: undefined,
channel_id: "ch1",
channel_name: undefined,
user_id: "u1",
user_name: undefined,
command: "/oc_status",
text: "now",
trigger_id: undefined,
response_url: undefined,
});
});
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).toEqual({
token: "t2",
team_id: "team",
team_domain: undefined,
channel_id: "ch2",
channel_name: undefined,
user_id: "u2",
user_name: undefined,
command: "/oc_model",
text: "gpt-5",
trigger_id: undefined,
response_url: undefined,
});
});
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("registers the queue command mapped to the core /queue directive", () => {
const queueSpec = DEFAULT_COMMAND_SPECS.find((spec) => spec.trigger === "oc_queue");
expect(queueSpec?.originalName).toBe("queue");
const triggerMap = new Map<string, string>([["oc_queue", "queue"]]);
expect(resolveCommandText("oc_queue", " collect drop:summarize ", triggerMap)).toBe(
"/queue collect drop:summarize",
);
});
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);
const firstCommand = result[0];
if (!firstCommand) {
throw new Error("expected Mattermost slash command result");
}
expect(firstCommand.managed).toBe(false);
expect(firstCommand.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);
});
it("updates owned commands when callback method drifts from POST", async () => {
const request = vi.fn(async (path: string, init?: RequestInit) => {
if (path.startsWith("/commands?team_id=")) {
return [
{
id: "cmd-1",
token: "tok-old",
team_id: "team-1",
creator_id: "bot-user",
trigger: "oc_status",
method: "G",
url: "http://gateway/callback",
auto_complete: true,
},
];
}
if (path === "/commands/cmd-1" && init?.method === "PUT") {
expect(JSON.parse(typeof init.body === "string" ? init.body : "{}")).toEqual({
id: "cmd-1",
team_id: "team-1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "http://gateway/callback",
description: "status",
auto_complete: true,
auto_complete_desc: "status",
auto_complete_hint: undefined,
});
return {
id: "cmd-1",
token: "tok-updated",
team_id: "team-1",
creator_id: "bot-user",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "http://gateway/callback",
auto_complete: true,
};
}
throw new Error(`unexpected request path: ${path}`);
});
const result = await registerSingleStatusCommand(request);
expect(result).toEqual([
{
id: "cmd-1",
trigger: "oc_status",
teamId: "team-1",
token: "tok-updated",
url: "http://gateway/callback",
managed: false,
},
]);
expect(request).toHaveBeenCalledTimes(2);
});
});

View File

@@ -0,0 +1,598 @@
// Mattermost plugin module implements slash commands behavior.
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
import type { MattermostClient } from "./client.js";
// ─── Types ───────────────────────────────────────────────────────────────────
export const MATTERMOST_SLASH_POST_METHOD = "P";
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;
url: 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: typeof MATTERMOST_SLASH_POST_METHOD | "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: typeof MATTERMOST_SLASH_POST_METHOD | "G";
url: string;
description?: string;
auto_complete: boolean;
auto_complete_desc?: string;
auto_complete_hint?: string;
};
export 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] [--runtime runtime]",
},
{
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]",
},
{
trigger: "oc_queue",
originalName: "queue",
description: "Adjust active-run queue behavior",
autoComplete: true,
autoCompleteHint: "[steer|followup|collect|interrupt] [debounce:2s] [cap:N] [drop:old|new|summarize]",
},
];
// ─── Command registration ────────────────────────────────────────────────────
/**
* List existing custom slash commands for a team.
*/
export async function listMattermostCommands(
client: MattermostClient,
teamId: string,
init?: Pick<RequestInit, "signal">,
): Promise<MattermostCommandResponse[]> {
return await client.request<MattermostCommandResponse[]>(
`/commands?team_id=${encodeURIComponent(teamId)}&custom_only=true`,
init,
);
}
/**
* Get a custom slash command by id.
*/
export async function getMattermostCommand(
client: MattermostClient,
commandId: string,
init?: Pick<RequestInit, "signal">,
): Promise<MattermostCommandResponse> {
return await client.request<MattermostCommandResponse>(
`/commands/${encodeURIComponent(commandId)}`,
init,
);
}
/**
* Create a custom slash command on a Mattermost team.
*/
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.
*/
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.
*/
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];
const existingNeedsUpdate = existingCmd
? existingCmd.url !== callbackUrl || existingCmd.method !== MATTERMOST_SLASH_POST_METHOD
: false;
// Already registered with the correct callback URL and method.
if (existingCmd && !existingNeedsUpdate) {
log?.(`mattermost: command /${spec.trigger} already registered (id=${existingCmd.id})`);
registered.push({
id: existingCmd.id,
trigger: spec.trigger,
teamId,
token: existingCmd.token,
url: callbackUrl,
managed: false,
});
continue;
}
// Exists but has drifted critical callback fields: attempt to reconcile by
// updating (useful during callback URL migrations or method drift).
if (existingCmd && existingNeedsUpdate) {
log?.(
`mattermost: command /${spec.trigger} exists with different callback settings; updating (id=${existingCmd.id})`,
);
try {
const updated = await updateMattermostCommand(client, {
id: existingCmd.id,
team_id: teamId,
trigger: spec.trigger,
method: MATTERMOST_SLASH_POST_METHOD,
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,
url: callbackUrl,
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: MATTERMOST_SLASH_POST_METHOD,
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,
url: callbackUrl,
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}`;
}
export function normalizeSlashCommandTrigger(command: string): string {
return command.replace(/^\//, "").trim();
}
// ─── 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: normalizeOptionalString(raw?.callbackUrl),
};
}
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;
}
/**
* 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}`;
}

View File

@@ -0,0 +1,395 @@
// Mattermost tests cover slash http.send config plugin behavior.
import { ServerResponse, type IncomingMessage } from "node:http";
import { PassThrough } from "node:stream";
import type { OpenClawConfig } from "openclaw/plugin-sdk/core";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
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),
getMattermostCommand: vi.fn(async () => ({
id: "cmd-1",
token: "valid-token",
team_id: "team-1",
trigger: "oc_models",
method: "P",
url: "https://gateway.example.com/slash",
delete_at: 0,
})),
listMattermostCommands: vi.fn(async () => []),
}));
vi.mock("./runtime-api.js", () => {
return {
buildModelsProviderData: mockState.buildModelsProviderData,
createChannelMessageReplyPipeline: 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) => (typeof value === "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", () => ({
MATTERMOST_SLASH_POST_METHOD: "P",
getMattermostCommand: mockState.getMattermostCommand,
listMattermostCommands: mockState.listMattermostCommands,
normalizeSlashCommandTrigger: (command: string) => command.replace(/^\//, "").trim(),
parseSlashCommandPayload: mockState.parseSlashCommandPayload,
resolveCommandText: mockState.resolveCommandText,
}));
let createSlashCommandHttpHandler: typeof import("./slash-http.js").createSlashCommandHttpHandler;
const callbackUrlFixture = "https://gateway.example.com/slash";
function createRequest(body = "token=valid-token"): IncomingMessage {
const req = new PassThrough();
const incoming = req as PassThrough & IncomingMessage;
incoming.method = "POST";
incoming.url = "/slash";
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,
botToken: "bot-token",
baseUrl: "https://chat.example.com",
botTokenSource: "config",
baseUrlSource: "config",
streamingMode: "partial",
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();
mockState.getMattermostCommand.mockClear();
mockState.listMattermostCommands.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,
registeredCommands: [
{
id: "cmd-1",
teamId: "team-1",
trigger: "oc_models",
token: "valid-token",
url: callbackUrlFixture,
managed: false,
},
],
});
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("rejects a callback when Mattermost reports a different current command token", async () => {
mockState.parseSlashCommandPayload.mockReturnValueOnce({
token: "old-token",
command: "/oc_models",
text: "models",
channel_id: "chan-1",
user_id: "user-1",
user_name: "alice",
team_id: "team-1",
});
mockState.getMattermostCommand.mockResolvedValueOnce({
id: "cmd-1",
token: "new-token",
team_id: "team-1",
trigger: "oc_models",
method: "P",
url: callbackUrlFixture,
delete_at: 0,
});
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
registeredCommands: [
{
id: "cmd-1",
teamId: "team-1",
trigger: "oc_models",
token: "old-token",
url: callbackUrlFixture,
managed: false,
},
],
});
const response = createResponse();
await handler(createRequest("token=old-token"), response.res);
expect(response.res.statusCode).toBe(401);
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
expect(mockState.fetchMattermostChannel).not.toHaveBeenCalled();
expect(mockState.sendMessageMattermost).not.toHaveBeenCalled();
});
it("rejects unknown tokens before calling Mattermost", async () => {
mockState.parseSlashCommandPayload.mockReturnValueOnce({
token: "unknown-token",
command: "/oc_models",
text: "models",
channel_id: "chan-1",
user_id: "user-1",
user_name: "alice",
team_id: "team-1",
});
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
registeredCommands: [
{
id: "cmd-1",
teamId: "team-1",
trigger: "oc_models",
token: "valid-token",
url: callbackUrlFixture,
managed: false,
},
],
});
const response = createResponse();
await handler(createRequest("token=unknown-token"), response.res);
expect(response.res.statusCode).toBe(401);
expect(mockState.getMattermostCommand).not.toHaveBeenCalled();
expect(mockState.fetchMattermostChannel).not.toHaveBeenCalled();
expect(mockState.sendMessageMattermost).not.toHaveBeenCalled();
});
it("rejects a refreshed callback token before Mattermost lookup until local state updates", async () => {
mockState.parseSlashCommandPayload.mockReturnValueOnce({
token: "new-token",
command: "/oc_models",
text: "models",
channel_id: "chan-1",
user_id: "user-1",
user_name: "alice",
team_id: "team-1",
});
mockState.getMattermostCommand.mockResolvedValueOnce({
id: "cmd-1",
token: "new-token",
team_id: "team-1",
trigger: "oc_models",
method: "P",
url: callbackUrlFixture,
delete_at: 0,
});
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
registeredCommands: [
{
id: "cmd-1",
teamId: "team-1",
trigger: "oc_models",
token: "old-token",
url: callbackUrlFixture,
managed: false,
},
],
});
const response = createResponse();
await handler(createRequest("token=new-token"), response.res);
expect(response.res.statusCode).toBe(401);
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
expect(mockState.getMattermostCommand).not.toHaveBeenCalled();
expect(mockState.fetchMattermostChannel).not.toHaveBeenCalled();
expect(mockState.sendMessageMattermost).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,926 @@
// Mattermost tests cover slash http plugin behavior.
import type { IncomingMessage, ServerResponse } from "node:http";
import { PassThrough } from "node:stream";
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, RuntimeEnv } from "../../runtime-api.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import type { MattermostClient } from "./client.js";
import {
MATTERMOST_SLASH_POST_METHOD,
type MattermostCommandResponse,
type MattermostRegisteredCommand,
} from "./slash-commands.js";
import {
createSlashCommandHttpHandler,
resetMattermostSlashCommandValidationCacheForTests,
validateMattermostSlashCommandToken,
} 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,
botToken: "bot-token",
baseUrl: "https://chat.example.com",
botTokenSource: "config",
baseUrlSource: "config",
streamingMode: "partial",
config: {},
};
function createRegisteredCommand(params?: {
token?: string;
teamId?: string;
trigger?: string;
url?: string;
}): MattermostRegisteredCommand {
return {
id: "cmd-1",
teamId: params?.teamId ?? "t1",
trigger: params?.trigger ?? "oc_status",
token: params?.token ?? "valid-token",
url: params?.url ?? "https://gateway.example.com/slash",
managed: false,
};
}
function createCommandLookupClient(params: {
command?: MattermostCommandResponse | null | (() => MattermostCommandResponse | null);
commandLookupError?: Error;
listLookupError?: Error;
listCommands?: MattermostCommandResponse[];
}): MattermostClient & { requests: string[] } {
const requests: string[] = [];
return {
baseUrl: "https://chat.example.com",
apiBaseUrl: "https://chat.example.com/api/v4",
token: "bot-token",
request: async <T>(path: string) => {
requests.push(path);
if (path === "/commands/cmd-1") {
if (params.commandLookupError) {
throw params.commandLookupError;
}
const command = typeof params.command === "function" ? params.command() : params.command;
if (command) {
return command as T;
}
throw new Error("not found");
}
if (path.startsWith("/commands?team_id=")) {
if (params.listLookupError) {
throw params.listLookupError;
}
const command = typeof params.command === "function" ? params.command() : params.command;
return (params.listCommands ?? (command ? [command] : [])) as T;
}
throw new Error(`unexpected request path: ${path}`);
},
fetchImpl: vi.fn<typeof fetch>(),
requests,
};
}
async function runSlashRequest(params: {
registeredCommands?: MattermostRegisteredCommand[];
body: string;
method?: string;
}) {
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
registeredCommands: params.registeredCommands ?? [],
});
const req = createRequest({ method: params.method, body: params.body });
const response = createResponse();
await handler(req, response.res);
return response;
}
function firstLogMessage(log: ReturnType<typeof vi.fn>): string {
const message = log.mock.calls[0]?.[0];
return typeof message === "string" ? message : "";
}
describe("slash-http", () => {
beforeEach(() => {
resetMattermostSlashCommandValidationCacheForTests();
});
it("rejects non-POST methods", async () => {
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
registeredCommands: [createRegisteredCommand()],
});
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,
registeredCommands: [createRegisteredCommand()],
});
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 commands are registered", async () => {
const response = await runSlashRequest({
registeredCommands: [],
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 slash commands before upstream validation", async () => {
const response = await runSlashRequest({
registeredCommands: [createRegisteredCommand({ token: "known-token" })],
body: "token=unknown&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_unknown&text=",
});
expect(response.res.statusCode).toBe(401);
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
});
it("rejects a token valid for one command when used against another command", async () => {
// Cross-command spray DoS guard: a payload pointing at command B with the
// token for command A must fail at the per-command startup gate, before
// upstream validation runs and could poison the failure cache for B.
const response = await runSlashRequest({
registeredCommands: [
createRegisteredCommand({ token: "token-status", trigger: "oc_status" }),
{
id: "cmd-2",
teamId: "t1",
trigger: "oc_help",
token: "token-help",
url: "https://gateway.example.com/slash",
managed: false,
},
],
body: "token=token-status&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_help&text=",
});
expect(response.res.statusCode).toBe(401);
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
});
it("returns 408 when the request body stalls", async () => {
const handler = createSlashCommandHttpHandler({
account: accountFixture,
cfg: {} as OpenClawConfig,
runtime: {} as RuntimeEnv,
registeredCommands: [createRegisteredCommand()],
bodyTimeoutMs: 1,
});
const req = createRequest({ autoEnd: false });
const response = createResponse();
await handler(req, response.res);
expect(response.res.statusCode).toBe(408);
expect(response.getBody()).toBe("Request body timeout");
});
it("rejects the startup token when Mattermost has rotated the current command token", async () => {
const registeredCommand = createRegisteredCommand({ token: "old-token" });
const client = createCommandLookupClient({
command: {
id: "cmd-1",
token: "new-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
});
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "old-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(false);
expect(registeredCommand.token).toBe("old-token");
});
it("accepts the startup token while the current Mattermost command still matches", async () => {
const registeredCommand = createRegisteredCommand({ token: "valid-token" });
const client = createCommandLookupClient({
command: {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
});
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(true);
});
it("rate-limits sequential current-command lookups without caching successes", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-27T00:00:00Z"));
try {
const registeredCommand = createRegisteredCommand({ token: "valid-token" });
const command = {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
};
const client = createCommandLookupClient({ command });
const payload = {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
};
const log = vi.fn();
for (let i = 0; i < 20; i += 1) {
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
log,
}),
).resolves.toBe(true);
}
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
log,
}),
).resolves.toBe(false);
expect(client.requests).toHaveLength(20);
expect(log).toHaveBeenCalledWith(
"mattermost: slash command validation lookup rate-limited for /oc_status",
);
} finally {
vi.useRealTimers();
}
});
it("rechecks matching current commands so startup tokens are not accepted after rotation", async () => {
const registeredCommand = createRegisteredCommand({ token: "valid-token" });
let command = {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
};
const client = createCommandLookupClient({
command: () => command,
});
const payload = {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
};
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(true);
command = {
...command,
token: "new-token",
};
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
expect(client.requests).toEqual(["/commands/cmd-1", "/commands/cmd-1"]);
});
it("briefly caches failed current command validation without accepting stale tokens", async () => {
const registeredCommand = createRegisteredCommand({ token: "old-token" });
const client = createCommandLookupClient({
command: {
id: "cmd-1",
token: "new-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
});
const payload = {
token: "old-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
};
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
expect(client.requests).toEqual(["/commands/cmd-1"]);
});
it("does not cache failed command validation when the expiry would exceed a valid Date", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date(8_640_000_000_000_000));
try {
const registeredCommand = createRegisteredCommand({ token: "old-token" });
const client = createCommandLookupClient({
command: {
id: "cmd-1",
token: "new-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
});
const payload = {
token: "old-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
};
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
expect(client.requests).toEqual(["/commands/cmd-1", "/commands/cmd-1"]);
} finally {
vi.useRealTimers();
}
});
it("drops exhausted validation lookup buckets when the current clock is invalid", async () => {
vi.useFakeTimers();
vi.setSystemTime(new Date("2026-04-27T00:00:00Z"));
try {
const registeredCommand = createRegisteredCommand({ token: "valid-token" });
const command = {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
};
const client = createCommandLookupClient({ command });
const payload = {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
};
for (let i = 0; i < 20; i += 1) {
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(true);
}
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(false);
const dateNow = vi.spyOn(Date, "now").mockReturnValue(Number.NaN);
try {
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload,
}),
).resolves.toBe(true);
} finally {
dateNow.mockRestore();
}
expect(client.requests).toHaveLength(21);
} finally {
vi.useRealTimers();
}
});
it("scopes validation cache entries by account", async () => {
const registeredCommand = createRegisteredCommand();
const clientA = createCommandLookupClient({
command: {
id: "cmd-1",
token: "token-a",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
});
const clientB = createCommandLookupClient({
command: {
id: "cmd-1",
token: "token-b",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
});
await expect(
validateMattermostSlashCommandToken({
accountId: "a1",
client: clientA,
registeredCommand,
payload: {
token: "token-a",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(true);
await expect(
validateMattermostSlashCommandToken({
accountId: "a2",
client: clientB,
registeredCommand,
payload: {
token: "token-b",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(true);
expect(clientA.requests).toEqual(["/commands/cmd-1"]);
expect(clientB.requests).toEqual(["/commands/cmd-1"]);
});
it("rejects a command that Mattermost reports as deleted", async () => {
const registeredCommand = createRegisteredCommand();
const client = createCommandLookupClient({
command: {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 123,
},
});
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(false);
});
it("rejects a regenerated command when the current command id changed", async () => {
const registeredCommand = createRegisteredCommand({ token: "old-token" });
const oldDeletedCommand = {
id: "cmd-1",
token: "old-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 123,
};
const newCommand = {
id: "cmd-2",
token: "new-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
};
const client = createCommandLookupClient({
command: oldDeletedCommand,
listCommands: [oldDeletedCommand, newCommand],
});
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "new-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(false);
expect(client.requests).toEqual(["/commands/cmd-1", "/commands?team_id=t1&custom_only=true"]);
});
it("logs when command lookup by id returns a deleted command before fallback", async () => {
const registeredCommand = createRegisteredCommand();
const command = {
id: "cmd-1\r\nspoofed",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 123,
};
const client = createCommandLookupClient({
command,
listCommands: [],
});
const log = vi.fn();
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
log,
}),
).resolves.toBe(false);
expect(log).toHaveBeenCalledTimes(1);
const message = firstLogMessage(log);
expect(message).not.toMatch(/[\r\n\t]/u);
expect(message).toContain("deleted command cmd-1 spoofed");
expect(message).toContain("using team list fallback");
});
it("rejects current commands with a mismatched method or callback URL", async () => {
const registeredCommand = createRegisteredCommand();
for (const command of [
{
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: "G",
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
},
{
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/other",
auto_complete: true,
delete_at: 0,
},
]) {
resetMattermostSlashCommandValidationCacheForTests();
const client = createCommandLookupClient({ command });
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(false);
}
});
it("falls back to the team command list when command lookup is unavailable", async () => {
const registeredCommand = createRegisteredCommand();
const command = {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
};
const client = createCommandLookupClient({
commandLookupError: new Error("not implemented"),
listCommands: [command],
});
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
}),
).resolves.toBe(true);
expect(client.requests).toEqual(["/commands/cmd-1", "/commands?team_id=t1&custom_only=true"]);
});
it("logs sanitized command lookup failures when falling back to the team command list", async () => {
const registeredCommand = createRegisteredCommand({ trigger: "oc_status\r\nspoofed" });
const command = {
id: "cmd-1",
token: "valid-token",
team_id: "t1",
trigger: "oc_status\r\nspoofed",
method: MATTERMOST_SLASH_POST_METHOD,
url: "https://gateway.example.com/slash",
auto_complete: true,
delete_at: 0,
};
const client = createCommandLookupClient({
commandLookupError: new Error(
"primary\ntoken=secret-token https://user:pass@chat.example.com/api?access_token=secret-access&client_secret=secret-client",
),
listCommands: [command],
});
const log = vi.fn();
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
log,
}),
).resolves.toBe(true);
expect(log).toHaveBeenCalledTimes(1);
const message = firstLogMessage(log);
expect(message).not.toMatch(/[\r\n\t]/u);
expect(message).toContain("/oc_status spoofed");
expect(message).toContain("primary token=[redacted]");
expect(message).toContain("https://redacted:redacted@chat.example.com/api");
expect(message).not.toContain("secret-token");
expect(message).not.toContain("secret-access");
expect(message).not.toContain("secret-client");
expect(message).not.toContain("user:pass");
});
it("sanitizes upstream lookup errors before logging fallback failures", async () => {
const registeredCommand = createRegisteredCommand();
const client = createCommandLookupClient({
commandLookupError: new Error('primary\ntoken=secret-token refresh_token="secret-refresh"'),
listLookupError: new Error(
"fallback\r\nsecond-line botToken: secret-bot https://user:pass@chat.example.com/hooks?token=secret-query",
),
});
const log = vi.fn();
await expect(
validateMattermostSlashCommandToken({
accountId: "default",
client,
registeredCommand,
payload: {
token: "valid-token",
team_id: "t1",
channel_id: "c1",
user_id: "u1",
command: "/oc_status",
text: "",
},
log,
}),
).resolves.toBe(false);
expect(log).toHaveBeenCalledTimes(1);
const message = firstLogMessage(log);
expect(message).not.toMatch(/[\r\n\t]/u);
expect(message).toContain("fallback second-line");
expect(message).toContain("botToken: [redacted]");
expect(message).toContain("https://redacted:redacted@chat.example.com/hooks");
expect(message).toContain("primary token=[redacted]");
expect(message).not.toContain("secret-token");
expect(message).not.toContain("secret-refresh");
expect(message).not.toContain("secret-bot");
expect(message).not.toContain("secret-query");
expect(message).not.toContain("user:pass");
});
});

View File

@@ -0,0 +1,947 @@
/**
* 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 {
asDateTimestampMs,
resolveExpiresAtMsFromDurationMs,
} from "openclaw/plugin-sdk/number-runtime";
import { safeEqualSecret } from "openclaw/plugin-sdk/security-runtime";
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import type { ResolvedMattermostAccount } from "../mattermost/accounts.js";
import { getMattermostRuntime } from "../runtime.js";
import {
createMattermostClient,
fetchMattermostChannel,
sendMattermostTyping,
type MattermostChannel,
} from "./client.js";
import {
renderMattermostModelSummaryView,
renderMattermostModelsPickerView,
renderMattermostProviderPickerView,
resolveMattermostModelPickerCurrentModel,
resolveMattermostModelPickerEntry,
} from "./model-picker.js";
import {
authorizeMattermostCommandInvocation,
normalizeMattermostAllowList,
} from "./monitor-auth.js";
import {
createMattermostReplyDeliveryBarrier,
deliverMattermostReplyPayload,
} from "./reply-delivery.js";
import {
buildModelsProviderData,
createChannelMessageReplyPipeline,
isRequestBodyLimitError,
logTypingFailure,
readRequestBodyWithLimit,
type OpenClawConfig,
type ReplyPayload,
type RuntimeEnv,
} from "./runtime-api.js";
import { sendMessageMattermost } from "./send.js";
import {
MATTERMOST_SLASH_POST_METHOD,
getMattermostCommand,
listMattermostCommands,
normalizeSlashCommandTrigger,
parseSlashCommandPayload,
resolveCommandText,
type MattermostRegisteredCommand,
type MattermostCommandResponse,
type MattermostSlashCommandResponse,
type MattermostSlashCommandPayload,
} from "./slash-commands.js";
type SlashHttpHandlerParams = {
account: ResolvedMattermostAccount;
cfg: OpenClawConfig;
runtime: RuntimeEnv;
/** Commands registered or reconciled during monitor startup. */
registeredCommands: readonly MattermostRegisteredCommand[];
/** Map from trigger to original command name (for skill commands that start with oc_). */
triggerMap?: ReadonlyMap<string, string>;
log?: (msg: string) => void;
bodyTimeoutMs?: number;
};
const MAX_BODY_BYTES = 64 * 1024;
const BODY_READ_TIMEOUT_MS = 5_000;
const COMMAND_LOOKUP_TIMEOUT_MS = 1_000;
const COMMAND_VALIDATION_FAILURE_CACHE_MS = 5_000;
const COMMAND_VALIDATION_FAILURE_CACHE_MAX_KEYS = 2_000;
const COMMAND_VALIDATION_LOOKUP_BURST = 20;
const COMMAND_VALIDATION_LOOKUP_REFILL_MS = 500;
const COMMAND_VALIDATION_LOOKUP_LIMIT_LOG_MS = 5_000;
const COMMAND_VALIDATION_LOOKUP_RATE_LIMIT_MAX_KEYS = 2_000;
type CommandLookupInflightEntry = {
accountId: string;
promise: Promise<MattermostCommandResponse | null>;
};
type CommandValidationRateLimitEntry = {
accountId: string;
tokens: number;
updatedAt: number;
lastLimitedLogAt: number;
};
const commandLookupInflight = new Map<string, CommandLookupInflightEntry>();
const commandValidationFailureCache = new Map<string, { accountId: string; expiresAt: number }>();
const commandValidationLookupRateLimit = new Map<string, CommandValidationRateLimitEntry>();
const SECRET_LOG_KEYS = new Set([
"access_token",
"authorization",
"bottoken",
"client_secret",
"refresh_token",
"token",
]);
/**
* Read the full request body as a string.
*/
function readBody(
req: IncomingMessage,
maxBytes: number,
timeoutMs = BODY_READ_TIMEOUT_MS,
): Promise<string> {
return readRequestBodyWithLimit(req, {
maxBytes,
timeoutMs,
});
}
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 findRegisteredCommandForPayload(params: {
registeredCommands: readonly MattermostRegisteredCommand[];
payload: MattermostSlashCommandPayload;
}): MattermostRegisteredCommand | undefined {
const trigger = normalizeSlashCommandTrigger(params.payload.command);
return params.registeredCommands.find(
(cmd) => cmd.teamId === params.payload.team_id && cmd.trigger === trigger,
);
}
function isDeletedMattermostCommand(command: { delete_at?: number }): boolean {
return typeof command.delete_at === "number" && command.delete_at > 0;
}
function sanitizeCommandLookupError(error: unknown): string {
const raw = error instanceof Error ? error.message : String(error);
return raw
.replace(/[\r\n\t]/gu, " ")
.replace(/https?:\/\/[^\s)\]}]+/giu, (urlText) => {
try {
const url = new URL(urlText);
if (url.username || url.password) {
url.username = "redacted";
url.password = "redacted";
}
for (const key of url.searchParams.keys()) {
if (SECRET_LOG_KEYS.has(key.toLowerCase())) {
url.searchParams.set(key, "redacted");
}
}
return url.toString();
} catch {
return urlText;
}
})
.replace(/(^|[^\w-])(Bearer|Token)\s+[A-Za-z0-9._~+/=-]+/giu, "$1$2 [redacted]")
.replace(
/\b(token|authorization|access_token|refresh_token|client_secret|botToken)\b(\s*["']?\s*(?:=|:)\s*["']?)[^"',\s;}]+/giu,
"$1$2[redacted]",
)
.slice(0, 300);
}
function sanitizeMattermostLogValue(value: string): string {
return value.replace(/[\r\n\t]/gu, " ").slice(0, 200);
}
async function withCommandLookupTimeout<T>(task: (signal: AbortSignal) => Promise<T>): Promise<T> {
const controller = new AbortController();
const timeout = setTimeout(() => controller.abort(), COMMAND_LOOKUP_TIMEOUT_MS);
try {
return await task(controller.signal);
} finally {
clearTimeout(timeout);
}
}
function commandLookupKey(
client: ReturnType<typeof createMattermostClient>,
registered: MattermostRegisteredCommand,
accountId: string,
): string {
return `${client.apiBaseUrl}:${accountId}:${registered.teamId}:${registered.id}`;
}
export function resetMattermostSlashCommandValidationCacheForTests(): void {
commandLookupInflight.clear();
commandValidationFailureCache.clear();
commandValidationLookupRateLimit.clear();
}
export function clearMattermostSlashCommandValidationCacheForAccount(accountId: string): void {
for (const [key, entry] of commandValidationFailureCache) {
if (entry.accountId === accountId) {
commandValidationFailureCache.delete(key);
}
}
for (const [key, entry] of commandLookupInflight) {
if (entry.accountId === accountId) {
commandLookupInflight.delete(key);
}
}
for (const [key, entry] of commandValidationLookupRateLimit) {
if (entry.accountId === accountId) {
commandValidationLookupRateLimit.delete(key);
}
}
}
function sweepCommandValidationFailureCache(now = Date.now()): void {
const validNow = asDateTimestampMs(now);
if (validNow === undefined) {
commandValidationFailureCache.clear();
return;
}
for (const [key, entry] of commandValidationFailureCache) {
const expiresAt = asDateTimestampMs(entry.expiresAt);
if (expiresAt === undefined || expiresAt <= validNow) {
commandValidationFailureCache.delete(key);
}
}
while (commandValidationFailureCache.size > COMMAND_VALIDATION_FAILURE_CACHE_MAX_KEYS) {
const oldestKey = commandValidationFailureCache.keys().next().value;
if (!oldestKey) {
break;
}
commandValidationFailureCache.delete(oldestKey);
}
}
function hasCachedCommandValidationFailure(key: string, now = Date.now()): boolean {
sweepCommandValidationFailureCache(now);
const validNow = asDateTimestampMs(now);
if (validNow === undefined) {
return false;
}
const cached = commandValidationFailureCache.get(key);
if (!cached) {
return false;
}
const expiresAt = asDateTimestampMs(cached.expiresAt);
if (expiresAt !== undefined && expiresAt > validNow) {
return true;
}
commandValidationFailureCache.delete(key);
return false;
}
function cacheCommandValidationFailure(key: string, accountId: string): void {
const now = Date.now();
sweepCommandValidationFailureCache(now);
const expiresAt = resolveExpiresAtMsFromDurationMs(COMMAND_VALIDATION_FAILURE_CACHE_MS, {
nowMs: now,
});
if (expiresAt === undefined) {
commandValidationFailureCache.delete(key);
return;
}
commandValidationFailureCache.set(key, {
accountId,
expiresAt,
});
}
function sweepCommandValidationLookupRateLimit(now = Date.now()): void {
const validNow = asDateTimestampMs(now);
if (validNow === undefined) {
commandValidationLookupRateLimit.clear();
return;
}
const staleAfterMs = COMMAND_VALIDATION_LOOKUP_REFILL_MS * COMMAND_VALIDATION_LOOKUP_BURST * 2;
for (const [key, entry] of commandValidationLookupRateLimit) {
const updatedAt = asDateTimestampMs(entry.updatedAt);
if (updatedAt === undefined || validNow - updatedAt > staleAfterMs) {
commandValidationLookupRateLimit.delete(key);
}
}
while (commandValidationLookupRateLimit.size > COMMAND_VALIDATION_LOOKUP_RATE_LIMIT_MAX_KEYS) {
const oldestKey = commandValidationLookupRateLimit.keys().next().value;
if (!oldestKey) {
break;
}
commandValidationLookupRateLimit.delete(oldestKey);
}
}
function reserveCommandValidationLookup(params: {
key: string;
accountId: string;
now?: number;
}): { allowed: true } | { allowed: false; shouldLog: boolean } {
const rawNow = params.now ?? Date.now();
const now = asDateTimestampMs(rawNow);
if (now === undefined) {
commandValidationLookupRateLimit.clear();
return { allowed: true };
}
sweepCommandValidationLookupRateLimit(now);
const existing = commandValidationLookupRateLimit.get(params.key);
if (!existing) {
commandValidationLookupRateLimit.set(params.key, {
accountId: params.accountId,
tokens: COMMAND_VALIDATION_LOOKUP_BURST - 1,
updatedAt: now,
lastLimitedLogAt: 0,
});
return { allowed: true };
}
const refill = Math.floor((now - existing.updatedAt) / COMMAND_VALIDATION_LOOKUP_REFILL_MS);
if (refill > 0) {
existing.tokens = Math.min(COMMAND_VALIDATION_LOOKUP_BURST, existing.tokens + refill);
existing.updatedAt += refill * COMMAND_VALIDATION_LOOKUP_REFILL_MS;
}
if (existing.tokens <= 0) {
const shouldLog = now - existing.lastLimitedLogAt >= COMMAND_VALIDATION_LOOKUP_LIMIT_LOG_MS;
if (shouldLog) {
existing.lastLimitedLogAt = now;
}
return { allowed: false, shouldLog };
}
existing.tokens -= 1;
return { allowed: true };
}
async function fetchCurrentMattermostCommandUncached(params: {
client: ReturnType<typeof createMattermostClient>;
registered: MattermostRegisteredCommand;
log?: (msg: string) => void;
}): Promise<MattermostCommandResponse | null> {
let commandLookupResult: MattermostCommandResponse | null = null;
let commandLookupError: unknown;
let commandLookupFallbackDetail: string | undefined;
try {
commandLookupResult = await withCommandLookupTimeout((signal) =>
getMattermostCommand(params.client, params.registered.id, { signal }),
);
if (!isDeletedMattermostCommand(commandLookupResult)) {
return commandLookupResult;
}
commandLookupFallbackDetail = `command lookup by id returned deleted command ${sanitizeMattermostLogValue(commandLookupResult.id)}`;
} catch (err) {
commandLookupError = err;
// Older Mattermost servers may not expose GET /commands/{id}; fall back to
// the team command list, which registration already requires.
}
try {
const currentCommands = await withCommandLookupTimeout((signal) =>
listMattermostCommands(params.client, params.registered.teamId, { signal }),
);
if (commandLookupError) {
params.log?.(
`mattermost: slash command lookup by id failed for /${sanitizeMattermostLogValue(params.registered.trigger)}; using team list fallback: ${sanitizeCommandLookupError(commandLookupError)}`,
);
} else if (commandLookupFallbackDetail) {
params.log?.(
`mattermost: slash ${commandLookupFallbackDetail} for /${sanitizeMattermostLogValue(params.registered.trigger)}; using team list fallback`,
);
}
return currentCommands.find((cmd) => cmd.id === params.registered.id) ?? commandLookupResult;
} catch (err) {
const primaryDetail = commandLookupError
? `; command lookup: ${sanitizeCommandLookupError(commandLookupError)}`
: commandLookupFallbackDetail
? `; command lookup: ${commandLookupFallbackDetail}`
: "";
params.log?.(
`mattermost: slash command registration check failed for /${sanitizeMattermostLogValue(params.registered.trigger)}: ${sanitizeCommandLookupError(err)}${primaryDetail}`,
);
return null;
}
}
async function fetchCurrentMattermostCommand(params: {
accountId: string;
client: ReturnType<typeof createMattermostClient>;
registered: MattermostRegisteredCommand;
log?: (msg: string) => void;
}): Promise<MattermostCommandResponse | null> {
const key = commandLookupKey(params.client, params.registered, params.accountId);
const existing = commandLookupInflight.get(key);
if (existing) {
return await existing.promise;
}
const lookup = fetchCurrentMattermostCommandUncached(params).finally(() => {
commandLookupInflight.delete(key);
});
commandLookupInflight.set(key, { accountId: params.accountId, promise: lookup });
return await lookup;
}
export async function validateMattermostSlashCommandToken(params: {
accountId: string;
client: ReturnType<typeof createMattermostClient>;
registeredCommand: MattermostRegisteredCommand;
payload: MattermostSlashCommandPayload;
log?: (msg: string) => void;
}): Promise<boolean> {
const lookupKey = commandLookupKey(params.client, params.registeredCommand, params.accountId);
if (hasCachedCommandValidationFailure(lookupKey)) {
return false;
}
if (!commandLookupInflight.has(lookupKey)) {
const reservation = reserveCommandValidationLookup({
key: lookupKey,
accountId: params.accountId,
});
if (!reservation.allowed) {
if (reservation.shouldLog) {
params.log?.(
`mattermost: slash command validation lookup rate-limited for /${sanitizeMattermostLogValue(params.registeredCommand.trigger)}`,
);
}
return false;
}
}
const current = await fetchCurrentMattermostCommand({
accountId: params.accountId,
client: params.client,
registered: params.registeredCommand,
log: params.log,
});
if (!current || isDeletedMattermostCommand(current)) {
cacheCommandValidationFailure(lookupKey, params.accountId);
return false;
}
if (
current.id !== params.registeredCommand.id ||
current.team_id !== params.registeredCommand.teamId ||
current.trigger !== params.registeredCommand.trigger ||
current.method !== MATTERMOST_SLASH_POST_METHOD ||
current.url !== params.registeredCommand.url
) {
cacheCommandValidationFailure(lookupKey, params.accountId);
return false;
}
if (!current.token || !safeEqualSecret(params.payload.token, current.token)) {
cacheCommandValidationFailure(lookupKey, params.accountId);
return false;
}
commandValidationFailureCache.delete(lookupKey);
return true;
}
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 ${sanitizeMattermostLogValue(channelId)}: ${sanitizeCommandLookupError(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 = await 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, registeredCommands, triggerMap, log, bodyTimeoutMs } = 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, bodyTimeoutMs);
} 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;
}
const registeredCommand = findRegisteredCommandForPayload({ registeredCommands, payload });
// Fail closed when no commands are registered, the payload doesn't map to
// a registered (team, trigger), or the payload token doesn't equal the
// resolved command's startup token. Comparing against the resolved
// command's token (rather than any token in the account) prevents a token
// valid for command A from advancing to upstream validation for command B,
// which would otherwise let an attacker poison the per-command failure
// cache and DoS legitimate invocations of command B.
if (
registeredCommands.length === 0 ||
!registeredCommand ||
!safeEqualSecret(payload.token, registeredCommand.token)
) {
sendJsonResponse(res, 401, {
response_type: "ephemeral",
text: "Unauthorized: invalid command token.",
});
return;
}
// Extract command info
const client = createMattermostClient({
baseUrl: account.baseUrl ?? "",
botToken: account.botToken ?? "",
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
});
const tokenIsCurrent = await validateMattermostSlashCommandToken({
accountId: account.accountId,
client,
registeredCommand,
payload,
log,
});
if (!tokenIsCurrent) {
sendJsonResponse(res, 401, {
response_type: "ephemeral",
text: "Unauthorized: invalid command token.",
});
return;
}
// Extract command info
const trigger = normalizeSlashCommandTrigger(payload.command);
const commandText = resolveCommandText(trigger, payload.text, triggerMap);
const channelId = payload.channel_id;
const senderId = payload.user_id;
const senderName = payload.user_name ?? senderId;
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 /${sanitizeMattermostLogValue(trigger)} from ${sanitizeMattermostLogValue(senderName)} in ${sanitizeMattermostLogValue(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: ${sanitizeCommandLookupError(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: _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 } = createChannelMessageReplyPipeline({
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 deliveryBarrier = createMattermostReplyDeliveryBarrier({
isDirect: kind === "direct",
dmRetryOptions: account.config.dmChannelRetry,
});
const { dispatcher, replyOptions, markDispatchIdle } =
core.channel.reply.createReplyDispatcherWithTyping({
...replyPipeline,
resolveFollowupAdmissionBarrierTimeoutPolicy: deliveryBarrier.resolveTimeoutPolicy,
onDeliverySettled: deliveryBarrier.markDeliverySettled,
humanDelay,
deliver: async (payload: ReplyPayload) => {
await deliverMattermostReplyPayload({
core,
cfg,
payload,
to,
accountId: account.accountId,
agentId: route.agentId,
textLimit,
tableMode,
sendMessage: sendMessageMattermost,
onDmChannelResolution: deliveryBarrier.trackDmChannelResolution,
});
runtime.log?.(`delivered slash reply to ${to}`);
},
onError: (err, info) => {
runtime.error?.(
`mattermost slash ${info.kind} reply failed: ${sanitizeCommandLookupError(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,
},
}),
});
}

View File

@@ -0,0 +1,188 @@
// Mattermost tests cover slash state plugin behavior.
import { afterEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import type { MattermostRegisteredCommand } from "./slash-commands.js";
import {
activateSlashCommands,
deactivateSlashCommands,
resolveSlashHandlerForCommand,
resolveSlashHandlerForToken,
} from "./slash-state.js";
function createResolvedMattermostAccount(accountId: string): ResolvedMattermostAccount {
return {
accountId,
enabled: true,
botTokenSource: "config",
baseUrlSource: "config",
streamingMode: "partial",
config: {},
};
}
function createRegisteredCommand(params?: {
id?: string;
teamId?: string;
trigger?: string;
}): MattermostRegisteredCommand {
return {
id: params?.id ?? "cmd-1",
teamId: params?.teamId ?? "team-1",
trigger: params?.trigger ?? "oc_status",
token: "token-1",
url: "https://gateway.example.com/slash",
managed: false,
};
}
const slashApi = {
cfg: {},
runtime: {
log: () => {},
error: () => {},
exit: () => {},
},
} satisfies {
cfg: OpenClawConfig;
runtime: RuntimeEnv;
};
const ACCOUNT_STATES_KEY = Symbol.for("openclaw.mattermost.slash-account-states");
describe("slash-state global singleton", () => {
afterEach(() => {
deactivateSlashCommands();
});
it("anchors accountStates on globalThis", () => {
deactivateSlashCommands();
activateSlashCommands({
account: createResolvedMattermostAccount("a1"),
commandTokens: ["tok-a"],
registeredCommands: [],
api: slashApi,
});
const globalStore = globalThis as Record<PropertyKey, unknown>;
const map = globalStore[ACCOUNT_STATES_KEY];
expect(map).toBeInstanceOf(Map);
expect((map as Map<string, unknown>).has("a1")).toBe(true);
});
it("preserves slash state across module reloads", async () => {
deactivateSlashCommands();
activateSlashCommands({
account: createResolvedMattermostAccount("a1"),
commandTokens: ["tok-reload"],
registeredCommands: [],
api: slashApi,
});
vi.resetModules();
const reloaded = await import("./slash-state.js");
const match = reloaded.resolveSlashHandlerForToken("tok-reload");
expect(match.kind).toBe("single");
if (match.kind !== "single") {
throw new Error("expected single match after module reload");
}
expect(match.accountIds).toEqual(["a1"]);
});
});
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");
if (match.kind !== "single") {
throw new Error("expected single match");
}
expect(match.source).toBe("token");
expect(match.accountIds).toEqual(["a1"]);
expect(typeof match.handler).toBe("function");
});
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");
if (match.kind !== "ambiguous") {
throw new Error("expected ambiguous match");
}
expect(match.source).toBe("token");
expect(match.accountIds.toSorted()).toEqual(["a1", "a2"]);
});
it("routes by registered team and command when token lookup misses", () => {
deactivateSlashCommands();
activateSlashCommands({
account: createResolvedMattermostAccount("a1"),
commandTokens: ["old-token"],
registeredCommands: [createRegisteredCommand()],
api: slashApi,
});
const match = resolveSlashHandlerForCommand({
teamId: "team-1",
command: "/oc_status",
});
expect(match.kind).toBe("single");
if (match.kind !== "single") {
throw new Error("expected single match");
}
expect(match.source).toBe("command");
expect(match.accountIds).toEqual(["a1"]);
expect(typeof match.handler).toBe("function");
});
it("returns ambiguous when registered team and command match multiple accounts", () => {
deactivateSlashCommands();
activateSlashCommands({
account: createResolvedMattermostAccount("a1"),
commandTokens: ["tok-a"],
registeredCommands: [createRegisteredCommand({ id: "cmd-a" })],
api: slashApi,
});
activateSlashCommands({
account: createResolvedMattermostAccount("a2"),
commandTokens: ["tok-b"],
registeredCommands: [createRegisteredCommand({ id: "cmd-b" })],
api: slashApi,
});
const match = resolveSlashHandlerForCommand({
teamId: "team-1",
command: "/oc_status",
});
expect(match.kind).toBe("ambiguous");
if (match.kind !== "ambiguous") {
throw new Error("expected ambiguous match");
}
expect(match.source).toBe("command");
expect(match.accountIds.toSorted()).toEqual(["a1", "a2"]);
});
});

View File

@@ -0,0 +1,423 @@
/**
* 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 known for fast-path routing, 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 { Readable } from "node:stream";
import type { MattermostConfig } from "../types.js";
import type { ResolvedMattermostAccount } from "./accounts.js";
import {
isRequestBodyLimitError,
readRequestBodyWithLimit,
type OpenClawPluginApi,
} from "./runtime-api.js";
import {
normalizeSlashCommandTrigger,
parseSlashCommandPayload,
resolveSlashCommandConfig,
type MattermostRegisteredCommand,
} from "./slash-commands.js";
import {
clearMattermostSlashCommandValidationCacheForAccount,
createSlashCommandHttpHandler,
} from "./slash-http.js";
const MULTI_ACCOUNT_BODY_MAX_BYTES = 64 * 1024;
const MULTI_ACCOUNT_BODY_TIMEOUT_MS = 5_000;
type SlashHandlerMatchSource = "token" | "command";
type SlashHandlerMatch =
| { kind: "none" }
| {
kind: "single";
source: SlashHandlerMatchSource;
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
accountIds: string[];
}
| {
kind: "ambiguous";
source: SlashHandlerMatchSource;
accountIds: string[];
};
// ─── Per-account state ───────────────────────────────────────────────────────
type SlashCommandAccountState = {
/** Tokens from registered/current commands, used for fast-path routing. */
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.
*
* Anchored to globalThis so that jiti-loaded (route registration) and
* native-ESM-loaded (monitor/activation) module instances share the
* same Map. Without this, each module loader creates its own copy of
* the module-level variable and the HTTP handler never sees the tokens
* populated by the monitor.
*/
const ACCOUNT_STATES_KEY = Symbol.for("openclaw.mattermost.slash-account-states");
function getSlashAccountStates(): Map<string, SlashCommandAccountState> {
const globalStore = globalThis as Record<PropertyKey, unknown>;
const existing = globalStore[ACCOUNT_STATES_KEY];
if (existing instanceof Map) {
return existing as Map<string, SlashCommandAccountState>;
}
const accountStates = new Map<string, SlashCommandAccountState>();
globalStore[ACCOUNT_STATES_KEY] = accountStates;
return accountStates;
}
const accountStates = getSlashAccountStates();
export function resolveSlashHandlerForToken(token: string): SlashHandlerMatch {
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",
source: "token",
handler: matches[0].handler,
accountIds: [matches[0].accountId],
};
}
return {
kind: "ambiguous",
source: "token",
accountIds: matches.map((entry) => entry.accountId),
};
}
export function resolveSlashHandlerForCommand(params: {
teamId: string;
command: string;
}): SlashHandlerMatch {
const trigger = normalizeSlashCommandTrigger(params.command);
if (!trigger) {
return { kind: "none" };
}
const matches: Array<{
accountId: string;
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
}> = [];
for (const [accountId, state] of accountStates) {
if (
state.handler &&
state.registeredCommands.some(
(cmd) => cmd.teamId === params.teamId && cmd.trigger === trigger,
)
) {
matches.push({ accountId, handler: state.handler });
}
}
if (matches.length === 0) {
return { kind: "none" };
}
if (matches.length === 1) {
return {
kind: "single",
source: "command",
handler: matches[0].handler,
accountIds: [matches[0].accountId],
};
}
return {
kind: "ambiguous",
source: "command",
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;
}
/**
* 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,
registeredCommands,
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;
clearMattermostSlashCommandValidationCacheForAccount(accountId);
accountStates.delete(accountId);
}
} else {
// Deactivate all accounts (full shutdown)
for (const [stateAccountId, state] of accountStates) {
state.commandTokens.clear();
state.registeredCommands = [];
state.handler = null;
clearMattermostSlashCommandValidationCacheForAccount(stateAccountId);
}
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 known tokens, falling back
* to registered team/trigger ownership so upstream validation can accept a
* rotated Mattermost token.
*/
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 body to route to the right account handler. Each
// account handler still performs upstream token validation before running a
// command.
// 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 or
// registered team/trigger, then replay the request to the correct handler.
// Use the bounded helper so a slow/never-finishing client cannot tie up the
// routing handler indefinitely (Slowloris).
let bodyStr: string;
try {
bodyStr = await readRequestBodyWithLimit(req, {
maxBytes: MULTI_ACCOUNT_BODY_MAX_BYTES,
timeoutMs: MULTI_ACCOUNT_BODY_TIMEOUT_MS,
});
} 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;
}
// Parse the token for the fast path; if it misses, parse the full slash
// payload so rotated tokens can still route by registered team/trigger.
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
}
let match: SlashHandlerMatch = token ? resolveSlashHandlerForToken(token) : { kind: "none" };
if (match.kind === "none") {
const payload = parseSlashCommandPayload(bodyStr, ct);
if (payload) {
match = resolveSlashHandlerForCommand({
teamId: payload.team_id,
command: payload.command,
});
}
}
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 matched multiple accounts via ${match.source} (${match.accountIds.join(", ")})`,
);
const conflictText =
match.source === "token"
? "Conflict: command token is not unique across accounts."
: "Conflict: slash command is not unique across accounts.";
res.statusCode = 409;
res.setHeader("Content-Type", "application/json; charset=utf-8");
res.end(
JSON.stringify({
response_type: "ephemeral",
text: conflictText,
}),
);
return;
}
const matchedHandler = match.handler;
// Replay: create a synthetic readable that re-emits the buffered body
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,
});
}
}

View File

@@ -0,0 +1,129 @@
// Mattermost tests cover target resolution plugin behavior.
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",
});
});
});

View File

@@ -0,0 +1,104 @@
// Mattermost plugin module implements target resolution behavior.
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
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 && typeof err.message === "string" ? err.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 = normalizeOptionalString(params.token) ?? normalizeOptionalString(account?.botToken);
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: isPrivateNetworkOptInEnabled(account?.config),
});
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();
}

View File

@@ -0,0 +1,115 @@
// Mattermost tests cover thread participation cache plugin behavior.
import type { OpenKeyedStoreOptions } from "openclaw/plugin-sdk/plugin-state-runtime";
import {
createPluginStateKeyedStoreForTests,
resetPluginStateStoreForTests,
} from "openclaw/plugin-sdk/plugin-state-test-runtime";
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
import { afterEach, beforeEach, describe, expect, it } from "vitest";
import { setMattermostRuntime } from "../runtime.js";
import {
clearMattermostThreadParticipationCache,
hasMattermostThreadParticipationWithPersistence,
recordMattermostThreadParticipation,
} from "./thread-participation.js";
// Drain microtasks + the immediate queue so the fire-and-forget persistent write
// in recordMattermostThreadParticipation has settled before we assert on it.
const flush = (): Promise<void> =>
new Promise((resolve) => {
setImmediate(resolve);
});
function setRuntime(openKeyedStore: (options: OpenKeyedStoreOptions) => unknown): void {
setMattermostRuntime({
state: { openKeyedStore },
logging: { getChildLogger: () => ({ warn() {} }) },
} as unknown as PluginRuntime);
}
function setPersistentRuntime(): void {
setRuntime((options) => createPluginStateKeyedStoreForTests("mattermost", options));
}
describe("mattermost thread participation", () => {
beforeEach(() => {
resetPluginStateStoreForTests();
clearMattermostThreadParticipationCache();
setPersistentRuntime();
});
afterEach(() => {
clearMattermostThreadParticipationCache();
resetPluginStateStoreForTests();
});
it("remembers a thread the bot replied in", async () => {
recordMattermostThreadParticipation("acct", "chan", "root-1");
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "acct",
channelId: "chan",
threadRootId: "root-1",
}),
).resolves.toBe(true);
});
it("isolates participation by account, channel, and thread", async () => {
recordMattermostThreadParticipation("acct", "chan", "root-1");
await flush();
for (const probe of [
{ accountId: "other", channelId: "chan", threadRootId: "root-1" },
{ accountId: "acct", channelId: "other", threadRootId: "root-1" },
{ accountId: "acct", channelId: "chan", threadRootId: "root-2" },
]) {
await expect(hasMattermostThreadParticipationWithPersistence(probe)).resolves.toBe(false);
}
});
it("ignores empty identifiers", async () => {
recordMattermostThreadParticipation("", "chan", "root-1");
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "",
channelId: "chan",
threadRootId: "root-1",
}),
).resolves.toBe(false);
});
it("recovers participation from the persistent store after the in-memory cache is lost", async () => {
recordMattermostThreadParticipation("acct", "chan", "root-1");
await flush();
// Simulate a restart: in-memory cache cleared, persistent SQLite store intact.
clearMattermostThreadParticipationCache();
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "acct",
channelId: "chan",
threadRootId: "root-1",
}),
).resolves.toBe(true);
});
it("degrades to in-memory only when the persistent store fails", async () => {
setRuntime(() => {
throw new Error("sqlite unavailable");
});
// record + read must not throw; the in-memory cache still answers.
recordMattermostThreadParticipation("acct", "chan", "root-1");
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "acct",
channelId: "chan",
threadRootId: "root-1",
}),
).resolves.toBe(true);
await expect(
hasMattermostThreadParticipationWithPersistence({
accountId: "acct",
channelId: "chan",
threadRootId: "missing",
}),
).resolves.toBe(false);
});
});

View File

@@ -0,0 +1,82 @@
// Mattermost plugin module implements thread participation cache behavior.
import { createPersistentDedupeCache } from "openclaw/plugin-sdk/dedupe-runtime";
import { getOptionalMattermostRuntime } from "../runtime.js";
/**
* Cache of Mattermost threads the bot has replied in. Lets the bot auto-respond
* to thread follow-ups without a re-mention after its first visible reply.
*/
const TTL_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
const MAX_ENTRIES = 5000;
const PERSISTENT_MAX_ENTRIES = 1000;
const PERSISTENT_NAMESPACE = "mattermost.thread-participation";
type MattermostThreadParticipationRecord = {
agentId?: string;
repliedAt: number;
};
/**
* Keep thread participation shared across bundled chunks so thread auto-reply
* gating does not diverge between the inbound-gate and reply-dispatch paths.
*/
const MATTERMOST_THREAD_PARTICIPATION_KEY = Symbol.for("openclaw.mattermostThreadParticipation");
const threadParticipation = createPersistentDedupeCache<MattermostThreadParticipationRecord>({
globalKey: MATTERMOST_THREAD_PARTICIPATION_KEY,
ttlMs: TTL_MS,
maxSize: MAX_ENTRIES,
persistent: {
namespace: PERSISTENT_NAMESPACE,
maxEntries: PERSISTENT_MAX_ENTRIES,
openStore: (options) => getOptionalMattermostRuntime()?.state.openKeyedStore(options),
logError: (error) => {
try {
getOptionalMattermostRuntime()
?.logging.getChildLogger({ plugin: "mattermost", feature: "thread-participation-state" })
.warn("Mattermost persistent thread participation state failed", {
error: String(error),
});
} catch {
// Best effort only: persistent state must never break Mattermost message handling.
}
},
},
});
function makeKey(accountId: string, channelId: string, threadRootId: string): string {
return `${accountId}:${channelId}:${threadRootId}`;
}
export function recordMattermostThreadParticipation(
accountId: string,
channelId: string,
threadRootId: string,
opts?: { agentId?: string },
): void {
if (!accountId || !channelId || !threadRootId) {
return;
}
void threadParticipation.register(makeKey(accountId, channelId, threadRootId), {
// Stored for future per-agent thread routing; current reads only need presence.
...(opts?.agentId ? { agentId: opts.agentId } : {}),
repliedAt: Date.now(),
});
}
export async function hasMattermostThreadParticipationWithPersistence(params: {
accountId: string;
channelId: string;
threadRootId: string;
}): Promise<boolean> {
if (!params.accountId || !params.channelId || !params.threadRootId) {
return false;
}
return await threadParticipation.lookup(
makeKey(params.accountId, params.channelId, params.threadRootId),
);
}
export function clearMattermostThreadParticipationCache(): void {
threadParticipation.clearForTest();
}

View File

@@ -0,0 +1,97 @@
// Mattermost tests cover normalize plugin behavior.
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
});
});

View File

@@ -0,0 +1,53 @@
// Mattermost helper module supports normalize behavior.
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
export function normalizeMattermostMessagingTarget(raw: string): string | undefined {
const trimmed = raw.trim();
if (!trimmed) {
return undefined;
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
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);
}

View File

@@ -0,0 +1,66 @@
// Legacy map-helper exports in this facade stay for older plugin consumers.
// New message-turn code should use createChannelHistoryWindow.
export {
applyAccountNameToChannelSection,
applySetupAccountConfigPatch,
type BaseProbeResult,
type BlockStreamingCoalesceConfig,
buildAgentMediaPayload,
buildChannelConfigSchema,
buildComputedAccountStatusSnapshot,
buildModelsProviderData,
buildPendingHistoryContextFromMap,
type ChannelAccountSnapshot,
type ChannelDirectoryEntry,
type ChannelGroupContext,
type ChannelMessageActionName,
type ChannelPlugin,
type ChatType,
chunkTextForOutbound,
clearHistoryEntriesIfEnabled,
createChannelHistoryWindow,
createAccountStatusSink,
createChannelPairingController,
createChannelMessageReplyPipeline,
createDedupeCache,
DEFAULT_ACCOUNT_ID,
DEFAULT_GROUP_HISTORY_LIMIT,
type DmPolicy,
formatInboundFromLabel,
getAgentScopedMediaLocalRoots,
GROUP_POLICY_BLOCKED_LABEL,
type GroupPolicy,
type HistoryEntry,
isDangerousNameMatchingEnabled,
isRequestBodyLimitError,
isTrustedProxyAddress,
listSkillCommandsForAgents,
loadOutboundMediaFromUrl,
logInboundDrop,
logTypingFailure,
migrateBaseNameToDefaultAccount,
type ModelsProviderData,
normalizeAccountId,
normalizeProviderId,
type OpenClawConfig,
type OpenClawPluginApi,
parseStrictPositiveInteger,
type PluginRuntime,
rawDataToString,
readRequestBodyWithLimit,
recordPendingHistoryEntryIfEnabled,
registerPluginHttpRoute,
type ReplyPayload,
resolveAllowlistMatchSimple,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveChannelMediaMaxBytes,
resolveClientIp,
resolveControlCommandGate,
resolveDefaultGroupPolicy,
resolveStoredModelOverride,
resolveStorePath,
resolveThreadSessionKeys,
type RuntimeEnv,
setMattermostRuntime,
warnMissingProviderGroupPolicyFallbackOnce,
} from "../runtime-api.js";

View File

@@ -0,0 +1,13 @@
// Mattermost plugin module implements runtime behavior.
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
const {
setRuntime: setMattermostRuntime,
getRuntime: getMattermostRuntime,
tryGetRuntime: getOptionalMattermostRuntime,
} = createPluginRuntimeStore<PluginRuntime>({
pluginId: "mattermost",
errorMessage: "Mattermost runtime not initialized",
});
export { getMattermostRuntime, getOptionalMattermostRuntime, setMattermostRuntime };

View File

@@ -0,0 +1,60 @@
// Mattermost plugin module implements secret contract behavior.
import {
collectSimpleChannelFieldAssignments,
getChannelSurface,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
export const secretTargetRegistryEntries: SecretTargetRegistryEntry[] = [
{
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,
},
];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults?: SecretDefaults;
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.",
});
}
export const channelSecrets = {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
};

View File

@@ -0,0 +1,8 @@
// Mattermost plugin module implements secret input behavior.
export type { SecretInput } from "openclaw/plugin-sdk/secret-input";
export {
buildSecretInputSchema,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";

View File

@@ -0,0 +1,105 @@
// Mattermost tests cover session route plugin behavior.
import { describe, expect, it } from "vitest";
import { resolveMattermostOutboundSessionRoute } from "./session-route.js";
function expectRoute(route: ReturnType<typeof resolveMattermostOutboundSessionRoute>) {
if (!route) {
throw new Error("Expected Mattermost route");
}
return route;
}
describe("mattermost session route", () => {
it("builds direct-message routes for user targets", () => {
const route = resolveMattermostOutboundSessionRoute({
cfg: {},
agentId: "main",
accountId: "acct-1",
target: "@user123",
});
const directRoute = expectRoute(route);
expect(directRoute.peer.kind).toBe("direct");
expect(directRoute.peer.id).toBe("user123");
expect(directRoute.from).toBe("mattermost:user123");
expect(directRoute.to).toBe("user:user123");
});
it("builds threaded channel routes for channel targets", () => {
const route = resolveMattermostOutboundSessionRoute({
cfg: {},
agentId: "main",
accountId: "acct-1",
target: "mattermost:channel:chan123",
threadId: "thread456",
});
const channelRoute = expectRoute(route);
expect(channelRoute.peer.kind).toBe("channel");
expect(channelRoute.peer.id).toBe("chan123");
expect(channelRoute.from).toBe("mattermost:channel:chan123");
expect(channelRoute.to).toBe("channel:chan123");
expect(channelRoute.threadId).toBe("thread456");
expect(channelRoute.sessionKey).toContain("thread456");
});
it("recovers channel thread routes from currentSessionKey", () => {
const route = resolveMattermostOutboundSessionRoute({
cfg: {},
agentId: "main",
accountId: "acct-1",
target: "mattermost:channel:chan123",
currentSessionKey: "agent:main:mattermost:channel:chan123:thread:root-post",
});
const recoveredRoute = expectRoute(route);
expect(recoveredRoute.sessionKey).toBe(
"agent:main:mattermost:channel:chan123:thread:root-post",
);
expect(recoveredRoute.baseSessionKey).toBe("agent:main:mattermost:channel:chan123");
expect(recoveredRoute.threadId).toBe("root-post");
});
it("keeps explicit replyToId ahead of recovered currentSessionKey thread", () => {
const route = resolveMattermostOutboundSessionRoute({
cfg: {},
agentId: "main",
accountId: "acct-1",
target: "mattermost:channel:chan123",
replyToId: "explicit-root",
currentSessionKey: "agent:main:mattermost:channel:chan123:thread:root-post",
});
const replyRoute = expectRoute(route);
expect(replyRoute.sessionKey).toBe(
"agent:main:mattermost:channel:chan123:thread:explicit-root",
);
expect(replyRoute.threadId).toBe("explicit-root");
});
it('does not recover currentSessionKey threads for shared dmScope "main" DMs', () => {
const route = resolveMattermostOutboundSessionRoute({
cfg: {},
agentId: "main",
accountId: "acct-1",
target: "@user123",
currentSessionKey: "agent:main:main:thread:root-post",
});
const dmRoute = expectRoute(route);
expect(dmRoute.sessionKey).toBe("agent:main:main");
expect(dmRoute.baseSessionKey).toBe("agent:main:main");
expect(dmRoute.threadId).toBeUndefined();
});
it("returns null when the target is empty after normalization", () => {
expect(
resolveMattermostOutboundSessionRoute({
cfg: {},
agentId: "main",
accountId: "acct-1",
target: "mattermost:",
}),
).toBeNull();
});
});

View File

@@ -0,0 +1,51 @@
// Mattermost plugin module implements session route behavior.
import {
buildChannelOutboundSessionRoute,
buildThreadAwareOutboundSessionRoute,
stripChannelTargetPrefix,
stripTargetKindPrefix,
type ChannelOutboundSessionRouteParams,
} from "openclaw/plugin-sdk/core";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
export function resolveMattermostOutboundSessionRoute(params: ChannelOutboundSessionRouteParams) {
let trimmed = stripChannelTargetPrefix(params.target, "mattermost");
if (!trimmed) {
return null;
}
const lower = normalizeLowercaseStringOrEmpty(trimmed);
const resolvedKind = params.resolvedTarget?.kind;
const isUser =
resolvedKind === "user" ||
(resolvedKind !== "channel" &&
resolvedKind !== "group" &&
(lower.startsWith("user:") || trimmed.startsWith("@")));
if (trimmed.startsWith("@")) {
trimmed = trimmed.slice(1).trim();
}
const rawId = stripTargetKindPrefix(trimmed);
if (!rawId) {
return null;
}
const baseRoute = buildChannelOutboundSessionRoute({
cfg: params.cfg,
agentId: params.agentId,
channel: "mattermost",
accountId: params.accountId,
peer: {
kind: isUser ? "direct" : "channel",
id: rawId,
},
chatType: isUser ? "direct" : "channel",
from: isUser ? `mattermost:${rawId}` : `mattermost:channel:${rawId}`,
to: isUser ? `user:${rawId}` : `channel:${rawId}`,
});
return buildThreadAwareOutboundSessionRoute({
route: baseRoute,
replyToId: params.replyToId,
threadId: params.threadId,
currentSessionKey: params.currentSessionKey,
canRecoverCurrentThread: ({ route }) =>
route.chatType !== "direct" || (params.cfg.session?.dmScope ?? "main") !== "main",
});
}

View File

@@ -0,0 +1,109 @@
// Mattermost plugin module implements setup core behavior.
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
applyAccountNameToChannelSection,
applySetupAccountConfigPatch,
migrateBaseNameToDefaultAccount,
} from "openclaw/plugin-sdk/setup";
import { createSetupInputPresenceValidator } from "openclaw/plugin-sdk/setup-runtime";
import {
resolveMattermostAccount,
type ResolvedMattermostAccount,
} from "./setup.accounts.runtime.js";
import { normalizeMattermostBaseUrl } from "./setup.client.runtime.js";
import { hasConfiguredSecretInput } from "./setup.secret-input.runtime.js";
const channel = "mattermost" as const;
export function isMattermostConfigured(account: ResolvedMattermostAccount): boolean {
const tokenConfigured =
Boolean(account.botToken?.trim()) || hasConfiguredSecretInput(account.config.botToken);
return tokenConfigured && Boolean(account.baseUrl);
}
export function resolveMattermostAccountWithSecrets(cfg: OpenClawConfig, accountId: string) {
return resolveMattermostAccount({
cfg,
accountId,
allowUnresolvedSecretRef: true,
});
}
export function applyMattermostSetupConfigPatch(params: {
cfg: OpenClawConfig;
accountId: string;
name?: string;
patch: Record<string, unknown>;
}): OpenClawConfig {
const namedConfig = applyAccountNameToChannelSection({
cfg: params.cfg,
channelKey: channel,
accountId: params.accountId,
name: params.name,
});
const next =
params.accountId !== DEFAULT_ACCOUNT_ID
? migrateBaseNameToDefaultAccount({
cfg: namedConfig,
channelKey: channel,
})
: namedConfig;
return applySetupAccountConfigPatch({
cfg: next,
channelKey: channel,
accountId: params.accountId,
patch: params.patch,
});
}
export const mattermostSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name,
}),
validateInput: createSetupInputPresenceValidator({
defaultAccountOnlyEnvError: "Mattermost env vars can only be used for the default account.",
whenNotUseEnv: [
{
someOf: ["botToken", "token"],
message: "Mattermost requires --bot-token and --http-url (or --use-env).",
},
{
someOf: ["httpUrl"],
message: "Mattermost requires --bot-token and --http-url (or --use-env).",
},
],
validate: ({ input }) => {
const token = input.botToken ?? input.token;
const baseUrl = normalizeMattermostBaseUrl(input.httpUrl);
if (!input.useEnv && (!token || !baseUrl)) {
return "Mattermost requires --bot-token and --http-url (or --use-env).";
}
if (input.httpUrl && !baseUrl) {
return "Mattermost --http-url must include a valid base URL.";
}
return null;
},
}),
applyAccountConfig: ({ cfg, accountId, input }) => {
const token = input.botToken ?? input.token;
const baseUrl = normalizeMattermostBaseUrl(input.httpUrl);
return applyMattermostSetupConfigPatch({
cfg,
accountId,
name: input.name,
patch: input.useEnv
? {}
: {
...(token ? { botToken: token } : {}),
...(baseUrl ? { baseUrl } : {}),
},
});
},
};

View File

@@ -0,0 +1,141 @@
// Mattermost plugin module implements setup surface behavior.
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
import {
applySetupAccountConfigPatch,
createStandardChannelSetupStatus,
formatDocsLink,
createSetupTranslator,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
import {
applyMattermostSetupConfigPatch,
isMattermostConfigured,
resolveMattermostAccountWithSecrets,
} from "./setup-core.js";
import { normalizeMattermostBaseUrl } from "./setup.client.runtime.js";
import { hasConfiguredSecretInput } from "./setup.secret-input.runtime.js";
const t = createSetupTranslator();
const channel = "mattermost" as const;
export { mattermostSetupAdapter } from "./setup-core.js";
export const mattermostSetupWizard: ChannelSetupWizard = {
channel,
status: createStandardChannelSetupStatus({
channelLabel: "Mattermost",
configuredLabel: t("wizard.channels.statusConfigured"),
unconfiguredLabel: t("wizard.channels.statusNeedsTokenUrl"),
configuredHint: t("wizard.channels.statusConfigured"),
unconfiguredHint: t("wizard.channels.statusNeedsSetup"),
configuredScore: 2,
unconfiguredScore: 1,
resolveConfigured: ({ cfg, accountId }) =>
isMattermostConfigured(
resolveMattermostAccountWithSecrets(cfg, accountId ?? DEFAULT_ACCOUNT_ID),
),
}),
introNote: {
title: t("wizard.mattermost.botTokenTitle"),
lines: [
t("wizard.mattermost.helpOpenConsole"),
t("wizard.mattermost.helpCreateBot"),
t("wizard.mattermost.helpBaseUrl"),
t("wizard.mattermost.helpBotMember"),
t("wizard.channels.docs", { link: formatDocsLink("/mattermost", "mattermost") }),
],
shouldShow: ({ cfg, accountId }) =>
!isMattermostConfigured(resolveMattermostAccountWithSecrets(cfg, accountId)),
},
envShortcut: {
prompt: t("wizard.mattermost.envPrompt"),
preferredEnvVar: "MATTERMOST_BOT_TOKEN",
isAvailable: ({ cfg, accountId }) => {
if (accountId !== DEFAULT_ACCOUNT_ID) {
return false;
}
const resolvedAccount = resolveMattermostAccountWithSecrets(cfg, accountId);
const hasConfigValues =
hasConfiguredSecretInput(resolvedAccount.config.botToken) ||
Boolean(resolvedAccount.config.baseUrl?.trim());
return Boolean(
process.env.MATTERMOST_BOT_TOKEN?.trim() &&
process.env.MATTERMOST_URL?.trim() &&
!hasConfigValues,
);
},
apply: ({ cfg, accountId }) =>
applySetupAccountConfigPatch({
cfg,
channelKey: channel,
accountId,
patch: {},
}),
},
credentials: [
{
inputKey: "botToken",
providerHint: channel,
credentialLabel: t("wizard.mattermost.botToken"),
preferredEnvVar: "MATTERMOST_BOT_TOKEN",
envPrompt: t("wizard.mattermost.envPrompt"),
keepPrompt: t("wizard.mattermost.botTokenKeep"),
inputPrompt: t("wizard.mattermost.botTokenInput"),
inspect: ({ cfg, accountId }) => {
const resolvedAccount = resolveMattermostAccountWithSecrets(cfg, accountId);
return {
accountConfigured: isMattermostConfigured(resolvedAccount),
hasConfiguredValue: hasConfiguredSecretInput(resolvedAccount.config.botToken),
};
},
applySet: async ({ cfg, accountId, value }) =>
applyMattermostSetupConfigPatch({
cfg,
accountId,
patch: { botToken: value },
}),
},
],
textInputs: [
{
inputKey: "httpUrl",
message: t("wizard.mattermost.baseUrlPrompt"),
confirmCurrentValue: false,
currentValue: ({ cfg, accountId }) =>
resolveMattermostAccountWithSecrets(cfg, accountId).baseUrl ??
process.env.MATTERMOST_URL?.trim(),
initialValue: ({ cfg, accountId }) =>
resolveMattermostAccountWithSecrets(cfg, accountId).baseUrl ??
process.env.MATTERMOST_URL?.trim(),
shouldPrompt: ({ cfg, accountId, credentialValues, currentValue }) => {
const resolvedAccount = resolveMattermostAccountWithSecrets(cfg, accountId);
const tokenConfigured =
Boolean(resolvedAccount.botToken?.trim()) ||
hasConfiguredSecretInput(resolvedAccount.config.botToken);
return Boolean(credentialValues.botToken) || !tokenConfigured || !currentValue;
},
validate: ({ value }) =>
normalizeMattermostBaseUrl(value)
? undefined
: "Mattermost base URL must include a valid base URL.",
normalizeValue: ({ value }) => normalizeMattermostBaseUrl(value) ?? value.trim(),
applySet: async ({ cfg, accountId, value }) =>
applyMattermostSetupConfigPatch({
cfg,
accountId,
patch: { baseUrl: value },
}),
},
],
disable: (cfg: OpenClawConfig) => ({
...cfg,
channels: {
...cfg.channels,
mattermost: {
...cfg.channels?.mattermost,
enabled: false,
},
},
}),
};

View File

@@ -0,0 +1,2 @@
// Mattermost plugin module implements setup.accounts behavior.
export { resolveMattermostAccount, type ResolvedMattermostAccount } from "./mattermost/accounts.js";

View File

@@ -0,0 +1,2 @@
// Mattermost plugin module implements setup.client behavior.
export { normalizeMattermostBaseUrl } from "./mattermost/client.js";

View File

@@ -0,0 +1,2 @@
// Mattermost plugin module implements setup.secret input behavior.
export { hasConfiguredSecretInput } from "./secret-input.js";

View File

@@ -0,0 +1,408 @@
// Mattermost tests cover setup plugin behavior.
import { createTestPluginApi } from "openclaw/plugin-sdk/plugin-test-api";
import {
createSetupWizardAdapter,
createQueuedWizardPrompter,
runSetupWizardConfigure,
} from "openclaw/plugin-sdk/plugin-test-runtime";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
import type { OpenClawConfig, OpenClawPluginApi } from "../runtime-api.js";
const resolveMattermostAccount = vi.hoisted(() => vi.fn());
const normalizeMattermostBaseUrl = vi.hoisted(() => vi.fn((value: string | undefined) => value));
const hasConfiguredSecretInput = vi.hoisted(() => vi.fn((value: unknown) => Boolean(value)));
vi.mock("./setup.accounts.runtime.js", () => ({
listMattermostAccountIds: vi.fn((cfg: OpenClawConfig) => {
const accounts = cfg.channels?.mattermost?.accounts;
const ids = accounts ? Object.keys(accounts) : [];
return ids.length > 0 ? ids : [DEFAULT_ACCOUNT_ID];
}),
resolveMattermostAccount: (params: Parameters<typeof resolveMattermostAccount>[0]) => {
const mocked = resolveMattermostAccount(params);
return (
mocked ?? {
accountId: params.accountId ?? DEFAULT_ACCOUNT_ID,
enabled: params.cfg.channels?.mattermost?.enabled !== false,
botToken:
typeof params.cfg.channels?.mattermost?.botToken === "string"
? params.cfg.channels.mattermost.botToken
: undefined,
baseUrl: normalizeMattermostBaseUrl(params.cfg.channels?.mattermost?.baseUrl),
botTokenSource:
typeof params.cfg.channels?.mattermost?.botToken === "string" ? "config" : "none",
baseUrlSource: params.cfg.channels?.mattermost?.baseUrl ? "config" : "none",
config: params.cfg.channels?.mattermost ?? {},
}
);
},
}));
vi.mock("./setup.client.runtime.js", () => ({
normalizeMattermostBaseUrl,
}));
vi.mock("./setup.secret-input.runtime.js", () => ({
hasConfiguredSecretInput,
}));
function createApi(
registrationMode: OpenClawPluginApi["registrationMode"],
registerHttpRoute = vi.fn(),
): OpenClawPluginApi {
return createTestPluginApi({
id: "mattermost",
name: "Mattermost",
source: "test",
config: {},
runtime: {} as OpenClawPluginApi["runtime"],
registrationMode,
registerHttpRoute,
});
}
let plugin: typeof import("../index.js").default;
let mattermostSetupWizard: typeof import("./setup-surface.js").mattermostSetupWizard;
let isMattermostConfigured: typeof import("./setup-core.js").isMattermostConfigured;
let resolveMattermostAccountWithSecrets: typeof import("./setup-core.js").resolveMattermostAccountWithSecrets;
let mattermostSetupAdapter: typeof import("./setup-core.js").mattermostSetupAdapter;
describe("mattermost setup", () => {
beforeAll(async () => {
({ mattermostSetupWizard } = await import("./setup-surface.js"));
({ isMattermostConfigured, resolveMattermostAccountWithSecrets, mattermostSetupAdapter } =
await import("./setup-core.js"));
plugin = {
register(api: OpenClawPluginApi) {
if (api.registrationMode === "full") {
api.registerHttpRoute({
path: "/api/channels/mattermost/command",
auth: "plugin",
handler: async () => true,
});
}
},
} as typeof plugin;
});
beforeEach(() => {
registerEnvDefaults();
});
afterEach(() => {
resolveMattermostAccount.mockReset();
normalizeMattermostBaseUrl.mockReset();
normalizeMattermostBaseUrl.mockImplementation((value: string | undefined) => value);
hasConfiguredSecretInput.mockReset();
hasConfiguredSecretInput.mockImplementation((value: unknown) => Boolean(value));
vi.unstubAllEnvs();
});
it("reports configuration only when token and base url are both present", () => {
expect(
isMattermostConfigured({
botToken: "bot-token",
baseUrl: "https://chat.example.com",
config: {},
} as never),
).toBe(true);
expect(
isMattermostConfigured({
botToken: "",
baseUrl: "https://chat.example.com",
config: { botToken: "secret-ref" },
} as never),
).toBe(true);
expect(
isMattermostConfigured({
botToken: "",
baseUrl: "",
config: {},
} as never),
).toBe(false);
});
it("resolves accounts with unresolved secret refs allowed", () => {
resolveMattermostAccount.mockReturnValue({ accountId: "default" });
const cfg = { channels: { mattermost: {} } };
expect(resolveMattermostAccountWithSecrets(cfg as never, "default")).toEqual({
accountId: "default",
});
expect(resolveMattermostAccount).toHaveBeenCalledWith({
cfg,
accountId: "default",
allowUnresolvedSecretRef: true,
});
});
it("validates env and explicit credential requirements", () => {
const validateInput = mattermostSetupAdapter.validateInput;
expect(validateInput).toBeTypeOf("function");
if (!validateInput) {
throw new Error("Expected Mattermost setup validateInput");
}
expect(
validateInput({
accountId: "secondary",
input: { useEnv: true },
} as never),
).toBe("Mattermost env vars can only be used for the default account.");
normalizeMattermostBaseUrl.mockReturnValue(undefined);
expect(
validateInput({
accountId: DEFAULT_ACCOUNT_ID,
input: { useEnv: false, botToken: "tok", httpUrl: "not-a-url" },
} as never),
).toBe("Mattermost requires --bot-token and --http-url (or --use-env).");
normalizeMattermostBaseUrl.mockReturnValue("https://chat.example.com");
expect(
validateInput({
accountId: DEFAULT_ACCOUNT_ID,
input: { useEnv: false, botToken: "tok", httpUrl: "https://chat.example.com" },
} as never),
).toBeNull();
});
it("applies normalized config for default and named accounts", () => {
normalizeMattermostBaseUrl.mockReturnValue("https://chat.example.com");
const applyAccountConfig = mattermostSetupAdapter.applyAccountConfig;
expect(applyAccountConfig).toBeTypeOf("function");
expect(
applyAccountConfig({
cfg: { channels: { mattermost: {} } },
accountId: DEFAULT_ACCOUNT_ID,
input: {
name: "Default",
botToken: "tok",
httpUrl: "https://chat.example.com",
},
} as never),
).toEqual({
channels: {
mattermost: {
enabled: true,
name: "Default",
botToken: "tok",
baseUrl: "https://chat.example.com",
},
},
});
expect(
applyAccountConfig({
cfg: {
channels: {
mattermost: {
name: "Legacy",
},
},
},
accountId: "Work Team",
input: {
name: "Work",
botToken: "tok2",
httpUrl: "https://chat.example.com",
},
} as never),
).toEqual({
channels: {
mattermost: {
enabled: true,
accounts: {
default: {
name: "Legacy",
},
"work-team": {
enabled: true,
name: "Work",
botToken: "tok2",
baseUrl: "https://chat.example.com",
},
},
},
},
});
});
it.each([
{ name: "skips slash callback registration in setup-only mode", mode: "setup-only" as const },
{ name: "registers slash callback routes in full mode", mode: "full" as const },
])("$name", ({ mode }) => {
const registerHttpRoute = vi.fn();
plugin.register(createApi(mode, registerHttpRoute));
if (mode === "setup-only") {
expect(registerHttpRoute).not.toHaveBeenCalled();
return;
}
expect(registerHttpRoute).toHaveBeenCalledTimes(1);
const [route] = registerHttpRoute.mock.calls[0] ?? [];
expect(route?.path).toBe("/api/channels/mattermost/command");
expect(route?.auth).toBe("plugin");
expect(typeof route?.handler).toBe("function");
});
it("treats secret-ref tokens plus base url as configured", async () => {
const configured = await mattermostSetupWizard.status.resolveConfigured({
cfg: {
channels: {
mattermost: {
baseUrl: "https://chat.example.com",
botToken: {
source: "env",
provider: "default",
id: "MATTERMOST_BOT_TOKEN",
},
},
},
} as OpenClawConfig,
});
expect(configured).toBe(true);
});
it("does not inherit configured state from a sibling when defaultAccount is named", async () => {
const configured = await mattermostSetupWizard.status.resolveConfigured({
cfg: {
channels: {
mattermost: {
defaultAccount: "work",
accounts: {
alerts: {
baseUrl: "https://chat.example.com",
botToken: {
source: "env",
provider: "default",
id: "MATTERMOST_BOT_TOKEN",
},
},
work: {},
},
},
},
} as OpenClawConfig,
accountId: undefined,
});
expect(configured).toBe(false);
});
it("shows intro note only when the target account is not configured", () => {
expect(
mattermostSetupWizard.introNote?.shouldShow?.({
cfg: {
channels: {
mattermost: {},
},
} as OpenClawConfig,
accountId: "default",
} as never),
).toBe(true);
expect(
mattermostSetupWizard.introNote?.shouldShow?.({
cfg: {
channels: {
mattermost: {
baseUrl: "https://chat.example.com",
botToken: {
source: "env",
provider: "default",
id: "MATTERMOST_BOT_TOKEN",
},
},
},
} as OpenClawConfig,
accountId: "default",
} as never),
).toBe(false);
});
it("offers env shortcut only for the default account when env is present and config is empty", () => {
vi.stubEnv("MATTERMOST_BOT_TOKEN", "bot-token");
vi.stubEnv("MATTERMOST_URL", "https://chat.example.com");
expect(
mattermostSetupWizard.envShortcut?.isAvailable?.({
cfg: { channels: { mattermost: {} } } as OpenClawConfig,
accountId: "default",
} as never),
).toBe(true);
expect(
mattermostSetupWizard.envShortcut?.isAvailable?.({
cfg: { channels: { mattermost: {} } } as OpenClawConfig,
accountId: "work",
} as never),
).toBe(false);
});
it("keeps env shortcut as a no-op patch for the selected account", () => {
expect(
mattermostSetupWizard.envShortcut?.apply?.({
cfg: { channels: { mattermost: { enabled: false } } } as OpenClawConfig,
accountId: "default",
} as never),
).toEqual({
channels: {
mattermost: {
enabled: true,
},
},
});
});
it("prompts for bot token and server URL before validating wizard setup", async () => {
normalizeMattermostBaseUrl.mockImplementation((value: string | undefined) =>
value?.startsWith("http") ? value : undefined,
);
const queued = createQueuedWizardPrompter({
textValues: ["bot-token", "https://chat.example.com"],
});
const adapter = createSetupWizardAdapter({
plugin: {
id: "mattermost",
meta: { label: "Mattermost" },
config: {
listAccountIds: () => [DEFAULT_ACCOUNT_ID],
},
setup: mattermostSetupAdapter,
} as never,
wizard: mattermostSetupWizard,
});
const result = await runSetupWizardConfigure({
configure: adapter.configure,
cfg: { channels: { mattermost: {} } } as OpenClawConfig,
prompter: queued.prompter,
options: { secretInputMode: "plaintext" as const },
});
const textMessages = queued.text.mock.calls.map(
([params]) => (params as { message: string }).message,
);
expect(textMessages).toEqual(["Enter Mattermost bot token", "Enter Mattermost base URL"]);
const mattermostConfig = result.cfg.channels?.mattermost;
if (!mattermostConfig) {
throw new Error("expected Mattermost config");
}
expect(mattermostConfig.botToken).toBe("bot-token");
expect(mattermostConfig.baseUrl).toBe("https://chat.example.com");
expect(result.accountId).toBe(DEFAULT_ACCOUNT_ID);
});
});
function registerEnvDefaults() {
vi.unstubAllEnvs();
}

View File

@@ -0,0 +1,120 @@
// Mattermost type declarations define plugin contracts.
import type {
ChannelPreviewStreamingConfig,
StreamingMode,
} from "openclaw/plugin-sdk/channel-outbound";
import type { BlockStreamingCoalesceConfig, DmPolicy, GroupPolicy } from "./runtime-api.js";
import type { SecretInput } from "./secret-input.js";
export type MattermostReplyToMode = "off" | "first" | "all" | "batched";
export type MattermostChatTypeKey = "direct" | "channel" | "group";
export type MattermostChatMode = "oncall" | "onmessage" | "onchar";
type MattermostNetworkConfig = {
/** Dangerous opt-in for self-hosted Mattermost on trusted private/internal hosts. */
dangerouslyAllowPrivateNetwork?: boolean;
};
export type MattermostAccountConfig = {
/** Optional display name for this account (used in CLI/UI lists). */
name?: string;
/** Optional provider capability tags used for agent/runtime guidance. */
capabilities?: string[];
/**
* Break-glass override: allow mutable identity matching (@username/display name) in allowlists.
* Default behavior is ID-only matching.
*/
dangerouslyAllowNameMatching?: boolean;
/** Allow channel-initiated config writes (default: true). */
configWrites?: boolean;
/** If false, do not start this Mattermost account. Default: true. */
enabled?: boolean;
/** Bot token for Mattermost. */
botToken?: SecretInput;
/** Base URL for the Mattermost server (e.g., https://chat.example.com). */
baseUrl?: string;
/**
* Controls when channel messages trigger replies.
* - "oncall": only respond when mentioned
* - "onmessage": respond to every channel message
* - "onchar": respond when a trigger character prefixes the message
*/
chatmode?: MattermostChatMode;
/** Prefix characters that trigger onchar mode (default: [">", "!"]). */
oncharPrefixes?: string[];
/** Require @mention to respond in channels. Default: true. */
requireMention?: boolean;
/** Direct message policy (pairing/allowlist/open/disabled). */
dmPolicy?: DmPolicy;
/** Allowlist for direct messages (user ids or @usernames). */
allowFrom?: Array<string | number>;
/** Allowlist for group messages (user ids or @usernames). */
groupAllowFrom?: Array<string | number>;
/** Group message policy (allowlist/open/disabled). */
groupPolicy?: GroupPolicy;
/** Outbound text chunk size (chars). Default: 4000. */
textChunkLimit?: number;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Preview streaming mode/config. */
streaming?: StreamingMode | boolean | ChannelPreviewStreamingConfig;
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
/**
* Controls whether channel and group replies are sent as thread replies.
* - "off" (default): only thread-reply when incoming message is already a thread reply
* - "first": reply in a thread under the triggering message
* - "all": always reply in a thread; uses existing thread root or starts a new thread under the message
* Direct messages always behave as "off".
*/
replyToMode?: MattermostReplyToMode;
/** Action toggles for this account. */
actions?: {
/** Enable message reaction actions. Default: true. */
reactions?: boolean;
};
/** Native slash command configuration. */
commands?: {
/** Enable native slash commands. "auto" resolves to false (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 (e.g. behind reverse proxy). */
callbackUrl?: string;
};
interactions?: {
/** External base URL used for Mattermost interaction callbacks. */
callbackBaseUrl?: string;
/**
* IP/CIDR allowlist for callback request sources when Mattermost reaches the gateway
* over a non-loopback path. Keep this narrow to the Mattermost server or trusted ingress.
*/
allowedSourceIps?: string[];
};
/** Network policy overrides for self-hosted Mattermost on trusted private/internal hosts. */
network?: MattermostNetworkConfig;
/** Retry configuration for DM channel creation */
dmChannelRetry?: {
/** Maximum number of retry attempts (default: 3) */
maxRetries?: number;
/** Initial delay in milliseconds before first retry (default: 1000) */
initialDelayMs?: number;
/** Maximum delay in milliseconds between retries (default: 10000) */
maxDelayMs?: number;
/** Timeout for each individual request in milliseconds (default: 30000) */
timeoutMs?: number;
};
};
export type MattermostConfig = {
/** Optional per-account Mattermost configuration (multi-account). */
accounts?: Record<string, MattermostAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & MattermostAccountConfig;