mirror of
https://github.com/hansjone/oclaw.git
synced 2026-10-12 08:00:48 +08:00
重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。
同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。 Made-with: Cursor
This commit is contained in:
parent
4a23b715a2
commit
dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions
28
openclaw/extensions/mattermost/src/approval-auth.test.ts
Normal file
28
openclaw/extensions/mattermost/src/approval-auth.test.ts
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { mattermostApprovalAuth } from "./approval-auth.js";
|
||||
|
||||
describe("mattermostApprovalAuth", () => {
|
||||
it("authorizes stable Mattermost user ids and ignores usernames", () => {
|
||||
expect(
|
||||
mattermostApprovalAuth.authorizeActorAction({
|
||||
cfg: {
|
||||
channels: { mattermost: { allowFrom: ["user:abcdefghijklmnopqrstuvwxyz"] } },
|
||||
},
|
||||
senderId: "abcdefghijklmnopqrstuvwxyz",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
|
||||
expect(
|
||||
mattermostApprovalAuth.authorizeActorAction({
|
||||
cfg: {
|
||||
channels: { mattermost: { allowFrom: ["@owner"] } },
|
||||
},
|
||||
senderId: "attacker-user-id",
|
||||
action: "approve",
|
||||
approvalKind: "exec",
|
||||
}),
|
||||
).toEqual({ authorized: true });
|
||||
});
|
||||
});
|
||||
30
openclaw/extensions/mattermost/src/approval-auth.ts
Normal file
30
openclaw/extensions/mattermost/src/approval-auth.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
import {
|
||||
createResolvedApproverActionAuthAdapter,
|
||||
resolveApprovalApprovers,
|
||||
} from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-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),
|
||||
});
|
||||
8
openclaw/extensions/mattermost/src/channel-api.ts
Normal file
8
openclaw/extensions/mattermost/src/channel-api.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
export { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
export type { ChannelPlugin } from "openclaw/plugin-sdk/core";
|
||||
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/core";
|
||||
export {
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
resolveDefaultGroupPolicy,
|
||||
} from "openclaw/plugin-sdk/config-runtime";
|
||||
export { chunkTextForOutbound } from "openclaw/plugin-sdk/text-chunking";
|
||||
82
openclaw/extensions/mattermost/src/channel-config-shared.ts
Normal file
82
openclaw/extensions/mattermost/src/channel-config-shared.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
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/text-runtime";
|
||||
import {
|
||||
collectMattermostSlashCallbackPaths,
|
||||
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(/^@/, ""),
|
||||
);
|
||||
}
|
||||
|
||||
export 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 { collectMattermostSlashCallbackPaths, 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,
|
||||
},
|
||||
});
|
||||
}
|
||||
9
openclaw/extensions/mattermost/src/channel.runtime.ts
Normal file
9
openclaw/extensions/mattermost/src/channel.runtime.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
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";
|
||||
38
openclaw/extensions/mattermost/src/channel.setup.ts
Normal file
38
openclaw/extensions/mattermost/src/channel.setup.ts
Normal file
|
|
@ -0,0 +1,38 @@
|
|||
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,
|
||||
};
|
||||
605
openclaw/extensions/mattermost/src/channel.test.ts
Normal file
605
openclaw/extensions/mattermost/src/channel.test.ts
Normal file
|
|
@ -0,0 +1,605 @@
|
|||
import { Type } from "@sinclair/typebox";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { createChannelReplyPipeline } from "../runtime-api.js";
|
||||
|
||||
vi.mock("../../../test/helpers/config/bundled-channel-config-runtime.js", () => ({
|
||||
getBundledChannelRuntimeMap: () => new Map(),
|
||||
getBundledChannelConfigSchemaMap: () => new Map(),
|
||||
}));
|
||||
|
||||
const { sendMessageMattermostMock, mockFetchGuard } = vi.hoisted(() => ({
|
||||
sendMessageMattermostMock: vi.fn(),
|
||||
mockFetchGuard: vi.fn(async (p: { url: string; init?: RequestInit }) => {
|
||||
const response = await globalThis.fetch(p.url, p.init);
|
||||
return { response, release: async () => {}, finalUrl: p.url };
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock("./mattermost/send.js", () => ({
|
||||
sendMessageMattermost: sendMessageMattermostMock,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
|
||||
const original = (await vi.importActual("openclaw/plugin-sdk/ssrf-runtime")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return { ...original, fetchWithSsrFGuard: mockFetchGuard };
|
||||
});
|
||||
|
||||
import { mattermostPlugin } from "./channel.js";
|
||||
import { resetMattermostReactionBotUserCacheForTests } from "./mattermost/reactions.js";
|
||||
import {
|
||||
createMattermostReactionFetchMock,
|
||||
createMattermostTestConfig,
|
||||
withMockedGlobalFetch,
|
||||
} from "./mattermost/reactions.test-helpers.js";
|
||||
|
||||
type MattermostHandleAction = NonNullable<
|
||||
NonNullable<typeof mattermostPlugin.actions>["handleAction"]
|
||||
>;
|
||||
type MattermostActionContext = Parameters<MattermostHandleAction>[0];
|
||||
type MattermostSendText = NonNullable<NonNullable<typeof mattermostPlugin.outbound>["sendText"]>;
|
||||
type MattermostSendTextParams = Parameters<MattermostSendText>[0];
|
||||
type MattermostSendMedia = NonNullable<NonNullable<typeof mattermostPlugin.outbound>["sendMedia"]>;
|
||||
type MattermostSendMediaParams = Parameters<MattermostSendMedia>[0];
|
||||
|
||||
function getDescribedActions(cfg: OpenClawConfig, accountId?: string): string[] {
|
||||
return [...(mattermostPlugin.actions?.describeMessageTool?.({ cfg, accountId })?.actions ?? [])];
|
||||
}
|
||||
|
||||
function requireMattermostNormalizeTarget() {
|
||||
const normalize = mattermostPlugin.messaging?.normalizeTarget;
|
||||
if (!normalize) {
|
||||
throw new Error("mattermost messaging.normalizeTarget missing");
|
||||
}
|
||||
return normalize;
|
||||
}
|
||||
|
||||
function requireMattermostPairingNormalizer() {
|
||||
const normalize = mattermostPlugin.pairing?.normalizeAllowEntry;
|
||||
if (!normalize) {
|
||||
throw new Error("mattermost pairing.normalizeAllowEntry missing");
|
||||
}
|
||||
return normalize;
|
||||
}
|
||||
|
||||
function requireMattermostReplyToModeResolver() {
|
||||
const resolveReplyToMode = mattermostPlugin.threading?.resolveReplyToMode;
|
||||
if (!resolveReplyToMode) {
|
||||
throw new Error("mattermost threading.resolveReplyToMode missing");
|
||||
}
|
||||
return resolveReplyToMode;
|
||||
}
|
||||
|
||||
function requireMattermostSendText() {
|
||||
const sendText = mattermostPlugin.outbound?.sendText;
|
||||
if (!sendText) {
|
||||
throw new Error("mattermost outbound.sendText missing");
|
||||
}
|
||||
return sendText;
|
||||
}
|
||||
|
||||
function requireMattermostSendMedia() {
|
||||
const sendMedia = mattermostPlugin.outbound?.sendMedia;
|
||||
if (!sendMedia) {
|
||||
throw new Error("mattermost outbound.sendMedia missing");
|
||||
}
|
||||
return sendMedia;
|
||||
}
|
||||
|
||||
function requireMattermostChunker() {
|
||||
const chunker = mattermostPlugin.outbound?.chunker;
|
||||
if (!chunker) {
|
||||
throw new Error("mattermost outbound.chunker missing");
|
||||
}
|
||||
return chunker;
|
||||
}
|
||||
|
||||
function createMattermostActionContext(
|
||||
overrides: Partial<MattermostActionContext>,
|
||||
): MattermostActionContext {
|
||||
return {
|
||||
channel: "mattermost",
|
||||
action: "send",
|
||||
params: {},
|
||||
cfg: createMattermostTestConfig(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe("mattermostPlugin", () => {
|
||||
beforeEach(() => {
|
||||
sendMessageMattermostMock.mockReset();
|
||||
sendMessageMattermostMock.mockResolvedValue({
|
||||
messageId: "post-1",
|
||||
channelId: "channel-1",
|
||||
});
|
||||
});
|
||||
|
||||
describe("messaging", () => {
|
||||
it("keeps @username targets", () => {
|
||||
const normalize = requireMattermostNormalizeTarget();
|
||||
|
||||
expect(normalize("@Alice")).toBe("@Alice");
|
||||
expect(normalize("@alice")).toBe("@alice");
|
||||
});
|
||||
|
||||
it("normalizes spaced mattermost prefixes to user targets", () => {
|
||||
const normalize = requireMattermostNormalizeTarget();
|
||||
|
||||
expect(normalize("mattermost:USER123")).toBe("user:USER123");
|
||||
expect(normalize(" mattermost:USER123 ")).toBe("user:USER123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("pairing", () => {
|
||||
it("normalizes allowlist entries", () => {
|
||||
const normalize = requireMattermostPairingNormalizer();
|
||||
|
||||
expect(normalize("@Alice")).toBe("alice");
|
||||
expect(normalize("user:USER123")).toBe("user123");
|
||||
expect(normalize(" @Alice ")).toBe("alice");
|
||||
expect(normalize(" mattermost:USER123 ")).toBe("user123");
|
||||
});
|
||||
});
|
||||
|
||||
describe("threading", () => {
|
||||
it("uses replyToMode for channel messages and keeps direct messages off", () => {
|
||||
const resolveReplyToMode = requireMattermostReplyToModeResolver();
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveReplyToMode({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
chatType: "channel",
|
||||
}),
|
||||
).toBe("all");
|
||||
expect(
|
||||
resolveReplyToMode({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
chatType: "direct",
|
||||
}),
|
||||
).toBe("off");
|
||||
});
|
||||
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const resolveReplyToMode = requireMattermostReplyToModeResolver();
|
||||
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
defaultAccount: "alerts",
|
||||
replyToMode: "off",
|
||||
accounts: {
|
||||
alerts: {
|
||||
replyToMode: "all",
|
||||
botToken: "alerts-token",
|
||||
baseUrl: "https://alerts.example.com",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveReplyToMode({
|
||||
cfg,
|
||||
chatType: "channel",
|
||||
}),
|
||||
).toBe("all");
|
||||
});
|
||||
});
|
||||
|
||||
describe("messageActions", () => {
|
||||
beforeEach(() => {
|
||||
resetMattermostReactionBotUserCacheForTests();
|
||||
});
|
||||
|
||||
const runReactAction = async (params: Record<string, unknown>, fetchMode: "add" | "remove") => {
|
||||
const cfg = createMattermostTestConfig();
|
||||
const fetchImpl = createMattermostReactionFetchMock({
|
||||
mode: fetchMode,
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
});
|
||||
|
||||
return await withMockedGlobalFetch(fetchImpl, async () => {
|
||||
return await mattermostPlugin.actions?.handleAction?.(
|
||||
createMattermostActionContext({
|
||||
action: "react",
|
||||
params,
|
||||
cfg,
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
it("exposes react when mattermost is configured", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const actions = getDescribedActions(cfg);
|
||||
expect(actions).toContain("react");
|
||||
expect(actions).toContain("send");
|
||||
expect(mattermostPlugin.actions?.supportsAction?.({ action: "react" })).toBe(true);
|
||||
expect(mattermostPlugin.actions?.supportsAction?.({ action: "send" })).toBe(true);
|
||||
});
|
||||
|
||||
it("hides react when mattermost is not configured", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const actions = getDescribedActions(cfg);
|
||||
expect(actions).toEqual([]);
|
||||
});
|
||||
|
||||
it("keeps buttons optional in message tool schema", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const discovery = mattermostPlugin.actions?.describeMessageTool?.({ cfg });
|
||||
const schema = discovery?.schema;
|
||||
if (!schema || Array.isArray(schema)) {
|
||||
throw new Error("expected mattermost message-tool schema");
|
||||
}
|
||||
|
||||
expect(Type.Object(schema.properties).required).toBeUndefined();
|
||||
});
|
||||
|
||||
it("hides react when actions.reactions is false", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
actions: { reactions: false },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const actions = getDescribedActions(cfg);
|
||||
expect(actions).not.toContain("react");
|
||||
expect(actions).toContain("send");
|
||||
});
|
||||
|
||||
it("respects per-account actions.reactions in message discovery", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
actions: { reactions: false },
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
actions: { reactions: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const actions = getDescribedActions(cfg);
|
||||
expect(actions).toContain("react");
|
||||
});
|
||||
|
||||
it("honors the selected Mattermost account during discovery", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
actions: { reactions: false },
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
actions: { reactions: false },
|
||||
},
|
||||
work: {
|
||||
enabled: true,
|
||||
botToken: "work-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
actions: { reactions: true },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(getDescribedActions(cfg, "default")).toEqual(["send"]);
|
||||
expect(getDescribedActions(cfg, "work")).toEqual(["send", "react"]);
|
||||
});
|
||||
|
||||
it("blocks react when default account disables reactions and accountId is omitted", async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
enabled: true,
|
||||
actions: { reactions: true },
|
||||
accounts: {
|
||||
default: {
|
||||
enabled: true,
|
||||
botToken: "test-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
actions: { reactions: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await expect(
|
||||
mattermostPlugin.actions?.handleAction?.(
|
||||
createMattermostActionContext({
|
||||
action: "react",
|
||||
params: { messageId: "POST1", emoji: "thumbsup" },
|
||||
cfg,
|
||||
}),
|
||||
),
|
||||
).rejects.toThrow("Mattermost reactions are disabled in config");
|
||||
});
|
||||
|
||||
it("handles react by calling Mattermost reactions API", async () => {
|
||||
const result = await runReactAction({ messageId: "POST1", emoji: "thumbsup" }, "add");
|
||||
|
||||
expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]);
|
||||
expect(result?.details).toEqual({});
|
||||
});
|
||||
|
||||
it("only treats boolean remove flag as removal", async () => {
|
||||
const result = await runReactAction(
|
||||
{ messageId: "POST1", emoji: "thumbsup", remove: "true" },
|
||||
"add",
|
||||
);
|
||||
|
||||
expect(result?.content).toEqual([{ type: "text", text: "Reacted with :thumbsup: on POST1" }]);
|
||||
});
|
||||
|
||||
it("removes reaction when remove flag is boolean true", async () => {
|
||||
const result = await runReactAction(
|
||||
{ messageId: "POST1", emoji: "thumbsup", remove: true },
|
||||
"remove",
|
||||
);
|
||||
|
||||
expect(result?.content).toEqual([
|
||||
{ type: "text", text: "Removed reaction :thumbsup: from POST1" },
|
||||
]);
|
||||
expect(result?.details).toEqual({});
|
||||
});
|
||||
|
||||
it("maps replyTo to replyToId for send actions", async () => {
|
||||
const cfg = createMattermostTestConfig();
|
||||
|
||||
await mattermostPlugin.actions?.handleAction?.(
|
||||
createMattermostActionContext({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "channel:CHAN1",
|
||||
message: "hello",
|
||||
replyTo: "post-root",
|
||||
},
|
||||
cfg,
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
|
||||
"channel:CHAN1",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
replyToId: "post-root",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("falls back to trimmed replyTo when replyToId is blank", async () => {
|
||||
const cfg = createMattermostTestConfig();
|
||||
|
||||
await mattermostPlugin.actions?.handleAction?.(
|
||||
createMattermostActionContext({
|
||||
action: "send",
|
||||
params: {
|
||||
to: "channel:CHAN1",
|
||||
message: "hello",
|
||||
replyToId: " ",
|
||||
replyTo: " post-root ",
|
||||
},
|
||||
cfg,
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
|
||||
"channel:CHAN1",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
replyToId: "post-root",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("outbound", () => {
|
||||
it("chunks outbound text without requiring Mattermost runtime initialization", () => {
|
||||
const chunker = requireMattermostChunker();
|
||||
|
||||
expect(() => chunker("hello world", 5)).not.toThrow();
|
||||
expect(chunker("hello world", 5)).toEqual(["hello", "world"]);
|
||||
});
|
||||
|
||||
it("forwards mediaLocalRoots on sendMedia", async () => {
|
||||
const sendMedia = requireMattermostSendMedia();
|
||||
const cfg = createMattermostTestConfig();
|
||||
|
||||
const params: MattermostSendMediaParams = {
|
||||
cfg,
|
||||
to: "channel:CHAN1",
|
||||
text: "hello",
|
||||
mediaUrl: "/tmp/workspace/image.png",
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
accountId: "default",
|
||||
replyToId: "post-root",
|
||||
};
|
||||
|
||||
await sendMedia(params);
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
|
||||
"channel:CHAN1",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
mediaUrl: "/tmp/workspace/image.png",
|
||||
mediaLocalRoots: ["/tmp/workspace"],
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("threads resolved cfg on sendText", async () => {
|
||||
const sendText = requireMattermostSendText();
|
||||
const cfg = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
botToken: "resolved-bot-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
|
||||
const params: MattermostSendTextParams = {
|
||||
cfg,
|
||||
to: "channel:CHAN1",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
};
|
||||
|
||||
await sendText(params);
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
|
||||
"channel:CHAN1",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses threadId as fallback when replyToId is absent (sendText)", async () => {
|
||||
const sendText = requireMattermostSendText();
|
||||
const cfg = createMattermostTestConfig();
|
||||
|
||||
const params: MattermostSendTextParams = {
|
||||
cfg,
|
||||
to: "channel:CHAN1",
|
||||
text: "hello",
|
||||
accountId: "default",
|
||||
threadId: "post-root",
|
||||
};
|
||||
|
||||
await sendText(params);
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
|
||||
"channel:CHAN1",
|
||||
"hello",
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
replyToId: "post-root",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses threadId as fallback when replyToId is absent (sendMedia)", async () => {
|
||||
const sendMedia = requireMattermostSendMedia();
|
||||
const cfg = createMattermostTestConfig();
|
||||
|
||||
const params: MattermostSendMediaParams = {
|
||||
cfg,
|
||||
to: "channel:CHAN1",
|
||||
text: "caption",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
accountId: "default",
|
||||
threadId: "post-root",
|
||||
};
|
||||
|
||||
await sendMedia(params);
|
||||
|
||||
expect(sendMessageMattermostMock).toHaveBeenCalledWith(
|
||||
"channel:CHAN1",
|
||||
"caption",
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
replyToId: "post-root",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("config", () => {
|
||||
it("formats allowFrom entries", () => {
|
||||
const formatAllowFrom = mattermostPlugin.config.formatAllowFrom!;
|
||||
|
||||
const formatted = formatAllowFrom({
|
||||
cfg: {} as OpenClawConfig,
|
||||
allowFrom: [" @Alice ", " user:USER123 ", " mattermost:BOT999 "],
|
||||
});
|
||||
expect(formatted).toEqual(["@alice", "user123", "bot999"]);
|
||||
});
|
||||
|
||||
it("uses account responsePrefix overrides", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
responsePrefix: "[Channel]",
|
||||
accounts: {
|
||||
default: { responsePrefix: "[Account]" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const prefixContext = createChannelReplyPipeline({
|
||||
cfg,
|
||||
agentId: "main",
|
||||
channel: "mattermost",
|
||||
accountId: "default",
|
||||
});
|
||||
|
||||
expect(prefixContext.responsePrefix).toBe("[Account]");
|
||||
});
|
||||
});
|
||||
});
|
||||
463
openclaw/extensions/mattermost/src/channel.ts
Normal file
463
openclaw/extensions/mattermost/src/channel.ts
Normal file
|
|
@ -0,0 +1,463 @@
|
|||
import { Type } from "@sinclair/typebox";
|
||||
import { createMessageToolButtonsSchema } from "openclaw/plugin-sdk/channel-actions";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
ChannelMessageToolDiscovery,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
||||
import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel-pairing";
|
||||
import { createRestrictSendersChannelSecurity } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { createChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
|
||||
import { buildPassiveProbedChannelStatusSummary } from "openclaw/plugin-sdk/extension-shared";
|
||||
import { createLazyRuntimeModule } from "openclaw/plugin-sdk/lazy-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
createComputedAccountStatusAdapter,
|
||||
createDefaultChannelRuntimeState,
|
||||
} from "openclaw/plugin-sdk/status-helpers";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-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"));
|
||||
|
||||
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 ? ["buttons"] : [],
|
||||
schema:
|
||||
enabledAccounts.length > 0
|
||||
? {
|
||||
properties: {
|
||||
buttons: Type.Optional(createMessageToolButtonsSchema()),
|
||||
},
|
||||
}
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
const mattermostMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: describeMattermostMessageTool,
|
||||
supportsAction: ({ action }) => {
|
||||
return action === "send" || action === "react";
|
||||
},
|
||||
handleAction: async ({ action, params, cfg, accountId }) => {
|
||||
if (action === "react") {
|
||||
const resolvedAccountId = accountId ?? resolveDefaultMattermostAccountId(cfg);
|
||||
const mattermostConfig = cfg.channels?.mattermost as MattermostConfig | undefined;
|
||||
const account = resolveMattermostAccount({ cfg, accountId: resolvedAccountId });
|
||||
const reactionsEnabled =
|
||||
account.config.actions?.reactions ?? mattermostConfig?.actions?.reactions ?? true;
|
||||
if (!reactionsEnabled) {
|
||||
throw new Error("Mattermost reactions are disabled in config");
|
||||
}
|
||||
|
||||
const { postId, emojiName, remove } = parseMattermostReactActionParams(params);
|
||||
if (remove) {
|
||||
const result = await (
|
||||
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 message = typeof params.message === "string" ? params.message : "";
|
||||
// Match the shared runner semantics: trim empty reply IDs away before
|
||||
// falling back from replyToId to replyTo on direct plugin calls.
|
||||
const replyToId =
|
||||
normalizeOptionalString(params.replyToId) ?? normalizeOptionalString(params.replyTo);
|
||||
const resolvedAccountId = accountId || undefined;
|
||||
|
||||
const mediaUrl =
|
||||
typeof params.media === "string" ? params.media.trim() || undefined : undefined;
|
||||
|
||||
const result = await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).sendMessageMattermost(to, message, {
|
||||
accountId: resolvedAccountId,
|
||||
replyToId,
|
||||
buttons: Array.isArray(params.buttons) ? params.buttons : undefined,
|
||||
attachmentText: typeof params.attachmentText === "string" ? params.attachmentText : undefined,
|
||||
mediaUrl,
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify({
|
||||
ok: true,
|
||||
channel: "mattermost",
|
||||
messageId: result.messageId,
|
||||
channelId: result.channelId,
|
||||
}),
|
||||
},
|
||||
],
|
||||
details: {},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
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,
|
||||
};
|
||||
}
|
||||
|
||||
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,
|
||||
secrets: {
|
||||
secretTargetRegistryEntries,
|
||||
collectRuntimeConfigAssignments,
|
||||
},
|
||||
directory: createChannelDirectoryAdapter({
|
||||
listGroups: async (params) =>
|
||||
(await loadMattermostChannelRuntime()).listMattermostDirectoryGroups(params),
|
||||
listGroupsLive: async (params) =>
|
||||
(await loadMattermostChannelRuntime()).listMattermostDirectoryGroups(params),
|
||||
listPeers: async (params) =>
|
||||
(await loadMattermostChannelRuntime()).listMattermostDirectoryPeers(params),
|
||||
listPeersLive: async (params) =>
|
||||
(await loadMattermostChannelRuntime()).listMattermostDirectoryPeers(params),
|
||||
}),
|
||||
messaging: {
|
||||
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,
|
||||
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: {
|
||||
scopedAccountReplyToMode: {
|
||||
resolveAccount: (cfg, accountId) =>
|
||||
resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: accountId ?? resolveDefaultMattermostAccountId(cfg),
|
||||
}),
|
||||
resolveReplyToMode: (account, chatType) =>
|
||||
resolveMattermostReplyToMode(
|
||||
account,
|
||||
chatType === "direct" || chatType === "group" || chatType === "channel"
|
||||
? chatType
|
||||
: "channel",
|
||||
),
|
||||
},
|
||||
resolveReplyTransport: ({ threadId, replyToId }) => ({
|
||||
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
|
||||
threadId,
|
||||
}),
|
||||
},
|
||||
security: mattermostSecurityAdapter,
|
||||
outbound: {
|
||||
base: {
|
||||
deliveryMode: "direct",
|
||||
chunker: chunkTextForOutbound,
|
||||
chunkerMode: "markdown",
|
||||
textChunkLimit: 4000,
|
||||
resolveTarget: ({ to }) => {
|
||||
const trimmed = to?.trim();
|
||||
if (!trimmed) {
|
||||
return {
|
||||
ok: false,
|
||||
error: new Error(
|
||||
"Delivering to Mattermost requires --to <channelId|@username|user:ID|channel:ID>",
|
||||
),
|
||||
};
|
||||
}
|
||||
return { ok: true, to: trimmed };
|
||||
},
|
||||
},
|
||||
attachedResults: {
|
||||
channel: "mattermost",
|
||||
sendText: async ({ cfg, to, text, accountId, replyToId, threadId }) =>
|
||||
await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).sendMessageMattermost(to, text, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
|
||||
}),
|
||||
sendMedia: async ({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
accountId,
|
||||
replyToId,
|
||||
threadId,
|
||||
}) =>
|
||||
await (
|
||||
await loadMattermostChannelRuntime()
|
||||
).sendMessageMattermost(to, text, {
|
||||
cfg,
|
||||
accountId: accountId ?? undefined,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
replyToId: replyToId ?? (threadId != null ? String(threadId) : undefined),
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
142
openclaw/extensions/mattermost/src/config-schema-core.ts
Normal file
142
openclaw/extensions/mattermost/src/config-schema-core.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import {
|
||||
BlockStreamingCoalesceSchema,
|
||||
DmPolicySchema,
|
||||
GroupPolicySchema,
|
||||
MarkdownConfigSchema,
|
||||
requireOpenAllowFrom,
|
||||
} from "openclaw/plugin-sdk/channel-config-primitives";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
import { buildSecretInputSchema } from "./secret-input.js";
|
||||
|
||||
const MattermostGroupSchema = z
|
||||
.object({
|
||||
/** Whether mentions are required to trigger the bot in this group. */
|
||||
requireMention: z.boolean().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
function requireMattermostOpenAllowFrom(params: {
|
||||
policy?: string;
|
||||
allowFrom?: Array<string | number>;
|
||||
ctx: z.RefinementCtx;
|
||||
}) {
|
||||
requireOpenAllowFrom({
|
||||
policy: params.policy,
|
||||
allowFrom: params.allowFrom,
|
||||
ctx: params.ctx,
|
||||
path: ["allowFrom"],
|
||||
message:
|
||||
'channels.mattermost.dmPolicy="open" requires channels.mattermost.allowFrom to include "*"',
|
||||
});
|
||||
}
|
||||
|
||||
const DmChannelRetrySchema = z
|
||||
.object({
|
||||
/** Maximum number of retry attempts for DM channel creation (default: 3) */
|
||||
maxRetries: z.number().int().min(0).max(10).optional(),
|
||||
/** Initial delay in milliseconds before first retry (default: 1000) */
|
||||
initialDelayMs: z.number().int().min(100).max(60000).optional(),
|
||||
/** Maximum delay in milliseconds between retries (default: 10000) */
|
||||
maxDelayMs: z.number().int().min(1000).max(60000).optional(),
|
||||
/** Timeout for each individual DM channel creation request in milliseconds (default: 30000) */
|
||||
timeoutMs: z.number().int().min(5000).max(120000).optional(),
|
||||
})
|
||||
.strict()
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.initialDelayMs !== undefined && data.maxDelayMs !== undefined) {
|
||||
return data.initialDelayMs <= data.maxDelayMs;
|
||||
}
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: "initialDelayMs must be less than or equal to maxDelayMs",
|
||||
path: ["initialDelayMs"],
|
||||
},
|
||||
)
|
||||
.optional();
|
||||
|
||||
const MattermostSlashCommandsSchema = z
|
||||
.object({
|
||||
/** Enable native slash commands. "auto" resolves to false (opt-in). */
|
||||
native: z.union([z.boolean(), z.literal("auto")]).optional(),
|
||||
/** Also register skill-based commands. */
|
||||
nativeSkills: z.union([z.boolean(), z.literal("auto")]).optional(),
|
||||
/** Path for the callback endpoint on the gateway HTTP server. */
|
||||
callbackPath: z.string().optional(),
|
||||
/** Explicit callback URL (e.g. behind reverse proxy). */
|
||||
callbackUrl: z.string().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const MattermostNetworkSchema = z
|
||||
.object({
|
||||
/** Dangerous opt-in for self-hosted Mattermost on trusted private/internal hosts. */
|
||||
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
|
||||
})
|
||||
.strict()
|
||||
.optional();
|
||||
|
||||
const MattermostAccountSchemaBase = z
|
||||
.object({
|
||||
name: z.string().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
dangerouslyAllowNameMatching: z.boolean().optional(),
|
||||
markdown: MarkdownConfigSchema,
|
||||
enabled: z.boolean().optional(),
|
||||
configWrites: z.boolean().optional(),
|
||||
botToken: buildSecretInputSchema().optional(),
|
||||
baseUrl: z.string().optional(),
|
||||
chatmode: z.enum(["oncall", "onmessage", "onchar"]).optional(),
|
||||
oncharPrefixes: z.array(z.string()).optional(),
|
||||
requireMention: z.boolean().optional(),
|
||||
dmPolicy: DmPolicySchema.optional().default("pairing"),
|
||||
allowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupAllowFrom: z.array(z.union([z.string(), z.number()])).optional(),
|
||||
groupPolicy: GroupPolicySchema.optional().default("allowlist"),
|
||||
textChunkLimit: z.number().int().positive().optional(),
|
||||
chunkMode: z.enum(["length", "newline"]).optional(),
|
||||
blockStreaming: z.boolean().optional(),
|
||||
blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(),
|
||||
replyToMode: z.enum(["off", "first", "all", "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,
|
||||
});
|
||||
});
|
||||
82
openclaw/extensions/mattermost/src/config-schema.test.ts
Normal file
82
openclaw/extensions/mattermost/src/config-schema.test.ts
Normal file
|
|
@ -0,0 +1,82 @@
|
|||
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("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);
|
||||
});
|
||||
});
|
||||
1
openclaw/extensions/mattermost/src/config-schema.ts
Normal file
1
openclaw/extensions/mattermost/src/config-schema.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { MattermostChannelConfigSchema } from "./config-surface.js";
|
||||
4
openclaw/extensions/mattermost/src/config-surface.ts
Normal file
4
openclaw/extensions/mattermost/src/config-surface.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-primitives";
|
||||
import { MattermostConfigSchema } from "./config-schema-core.js";
|
||||
|
||||
export const MattermostChannelConfigSchema = buildChannelConfigSchema(MattermostConfigSchema);
|
||||
9
openclaw/extensions/mattermost/src/doctor-contract.ts
Normal file
9
openclaw/extensions/mattermost/src/doctor-contract.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
const contract = createLegacyPrivateNetworkDoctorContract({
|
||||
channelKey: "mattermost",
|
||||
});
|
||||
|
||||
export const legacyConfigRules = contract.legacyConfigRules;
|
||||
|
||||
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;
|
||||
40
openclaw/extensions/mattermost/src/doctor.test.ts
Normal file
40
openclaw/extensions/mattermost/src/doctor.test.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { mattermostDoctor } from "./doctor.js";
|
||||
|
||||
describe("mattermost doctor", () => {
|
||||
it("normalizes legacy private-network aliases", () => {
|
||||
const normalize = mattermostDoctor.normalizeCompatibilityConfig;
|
||||
expect(normalize).toBeDefined();
|
||||
if (!normalize) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = normalize({
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
allowPrivateNetwork: true,
|
||||
accounts: {
|
||||
work: {
|
||||
allowPrivateNetwork: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
});
|
||||
|
||||
expect(result.config.channels?.mattermost?.network).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: true,
|
||||
});
|
||||
expect(
|
||||
(
|
||||
result.config.channels?.mattermost?.accounts?.work as
|
||||
| { network?: Record<string, unknown> }
|
||||
| undefined
|
||||
)?.network,
|
||||
).toEqual({
|
||||
dangerouslyAllowPrivateNetwork: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
48
openclaw/extensions/mattermost/src/doctor.ts
Normal file
48
openclaw/extensions/mattermost/src/doctor.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { createDangerousNameMatchingMutableAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-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;
|
||||
}
|
||||
|
||||
export 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,
|
||||
};
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
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"]);
|
||||
});
|
||||
});
|
||||
83
openclaw/extensions/mattermost/src/gateway-auth-bypass.ts
Normal file
83
openclaw/extensions/mattermost/src/gateway-auth-bypass.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
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];
|
||||
}
|
||||
46
openclaw/extensions/mattermost/src/group-mentions.test.ts
Normal file
46
openclaw/extensions/mattermost/src/group-mentions.test.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../runtime-api.js";
|
||||
import { resolveMattermostGroupRequireMention } from "./group-mentions.js";
|
||||
|
||||
describe("resolveMattermostGroupRequireMention", () => {
|
||||
it("defaults to requiring mention when no override is configured", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {},
|
||||
},
|
||||
};
|
||||
|
||||
const requireMention = resolveMattermostGroupRequireMention({ cfg, accountId: "default" });
|
||||
expect(requireMention).toBe(true);
|
||||
});
|
||||
|
||||
it("respects chatmode-derived account override", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
chatmode: "onmessage",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const requireMention = resolveMattermostGroupRequireMention({ cfg, accountId: "default" });
|
||||
expect(requireMention).toBe(false);
|
||||
});
|
||||
|
||||
it("prefers an explicit runtime override when provided", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
chatmode: "oncall",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const requireMention = resolveMattermostGroupRequireMention({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
requireMentionOverride: false,
|
||||
});
|
||||
expect(requireMention).toBe(false);
|
||||
});
|
||||
});
|
||||
23
openclaw/extensions/mattermost/src/group-mentions.ts
Normal file
23
openclaw/extensions/mattermost/src/group-mentions.ts
Normal file
|
|
@ -0,0 +1,23 @@
|
|||
import { resolveChannelGroupRequireMention } from "openclaw/plugin-sdk/channel-policy";
|
||||
import { resolveMattermostAccount } from "./mattermost/accounts.js";
|
||||
import type { ChannelGroupContext } from "./runtime-api.js";
|
||||
|
||||
export function resolveMattermostGroupRequireMention(
|
||||
params: ChannelGroupContext & { requireMentionOverride?: boolean },
|
||||
): boolean | undefined {
|
||||
const account = resolveMattermostAccount({
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
});
|
||||
const requireMentionOverride =
|
||||
typeof params.requireMentionOverride === "boolean"
|
||||
? params.requireMentionOverride
|
||||
: account.requireMention;
|
||||
return resolveChannelGroupRequireMention({
|
||||
cfg: params.cfg,
|
||||
channel: "mattermost",
|
||||
groupId: params.groupId,
|
||||
accountId: params.accountId,
|
||||
requireMentionOverride,
|
||||
});
|
||||
}
|
||||
138
openclaw/extensions/mattermost/src/mattermost/accounts.test.ts
Normal file
138
openclaw/extensions/mattermost/src/mattermost/accounts.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../runtime-api.js";
|
||||
import {
|
||||
resolveDefaultMattermostAccountId,
|
||||
resolveMattermostAccount,
|
||||
resolveMattermostReplyToMode,
|
||||
} from "./accounts.js";
|
||||
|
||||
describe("resolveDefaultMattermostAccountId", () => {
|
||||
it("prefers channels.mattermost.defaultAccount when it matches a configured account", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
defaultAccount: "alerts",
|
||||
accounts: {
|
||||
default: { botToken: "tok-default", baseUrl: "https://chat.example.com" },
|
||||
alerts: { botToken: "tok-alerts", baseUrl: "https://alerts.example.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultMattermostAccountId(cfg)).toBe("alerts");
|
||||
});
|
||||
|
||||
it("normalizes channels.mattermost.defaultAccount before lookup", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
defaultAccount: "Ops Team",
|
||||
accounts: {
|
||||
"ops-team": { botToken: "tok-ops", baseUrl: "https://chat.example.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultMattermostAccountId(cfg)).toBe("ops-team");
|
||||
});
|
||||
|
||||
it("falls back when channels.mattermost.defaultAccount is missing", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
defaultAccount: "missing",
|
||||
accounts: {
|
||||
default: { botToken: "tok-default", baseUrl: "https://chat.example.com" },
|
||||
alerts: { botToken: "tok-alerts", baseUrl: "https://alerts.example.com" },
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
expect(resolveDefaultMattermostAccountId(cfg)).toBe("default");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMattermostReplyToMode", () => {
|
||||
it("uses configured defaultAccount when accountId is omitted", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
defaultAccount: "alerts",
|
||||
accounts: {
|
||||
alerts: {
|
||||
botToken: "tok-alerts",
|
||||
baseUrl: "https://alerts.example.com",
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMattermostAccount({ cfg });
|
||||
expect(account.accountId).toBe("alerts");
|
||||
expect(resolveMattermostReplyToMode(account, "channel")).toBe("all");
|
||||
});
|
||||
|
||||
it("uses the configured mode for channel and group messages", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMattermostAccount({ cfg, accountId: "default" });
|
||||
expect(resolveMattermostReplyToMode(account, "channel")).toBe("all");
|
||||
expect(resolveMattermostReplyToMode(account, "group")).toBe("all");
|
||||
});
|
||||
|
||||
it("keeps direct messages off even when replyToMode is enabled", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
replyToMode: "all",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const account = resolveMattermostAccount({ cfg, accountId: "default" });
|
||||
expect(resolveMattermostReplyToMode(account, "direct")).toBe("off");
|
||||
});
|
||||
|
||||
it("defaults to off when replyToMode is unset", () => {
|
||||
const account = resolveMattermostAccount({ cfg: {}, accountId: "default" });
|
||||
expect(resolveMattermostReplyToMode(account, "channel")).toBe("off");
|
||||
});
|
||||
|
||||
it("preserves shared commands config when an account overrides one commands field", () => {
|
||||
const account = resolveMattermostAccount({
|
||||
cfg: {
|
||||
channels: {
|
||||
mattermost: {
|
||||
commands: {
|
||||
native: true,
|
||||
},
|
||||
accounts: {
|
||||
work: {
|
||||
commands: {
|
||||
callbackPath: "/hooks/work",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(account.config.commands).toEqual({
|
||||
native: true,
|
||||
callbackPath: "/hooks/work",
|
||||
});
|
||||
});
|
||||
});
|
||||
147
openclaw/extensions/mattermost/src/mattermost/accounts.ts
Normal file
147
openclaw/extensions/mattermost/src/mattermost/accounts.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { createAccountListHelpers } from "openclaw/plugin-sdk/account-helpers";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
|
||||
import { resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
|
||||
import {
|
||||
resolveChannelStreamingBlockCoalesce,
|
||||
resolveChannelStreamingBlockEnabled,
|
||||
resolveChannelStreamingChunkMode,
|
||||
} from "openclaw/plugin-sdk/channel-streaming";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-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";
|
||||
|
||||
export type MattermostTokenSource = "env" | "config" | "none";
|
||||
export type MattermostBaseUrlSource = "env" | "config" | "none";
|
||||
|
||||
export type ResolvedMattermostAccount = {
|
||||
accountId: string;
|
||||
enabled: boolean;
|
||||
name?: string;
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
botTokenSource: MattermostTokenSource;
|
||||
baseUrlSource: MattermostBaseUrlSource;
|
||||
config: MattermostAccountConfig;
|
||||
chatmode?: MattermostChatMode;
|
||||
oncharPrefixes?: string[];
|
||||
requireMention?: boolean;
|
||||
textChunkLimit?: number;
|
||||
chunkMode?: MattermostAccountConfig["chunkMode"];
|
||||
blockStreaming?: boolean;
|
||||
blockStreamingCoalesce?: MattermostAccountConfig["blockStreamingCoalesce"];
|
||||
};
|
||||
|
||||
const mattermostAccountHelpers = createAccountListHelpers("mattermost");
|
||||
|
||||
export function listMattermostAccountIds(cfg: OpenClawConfig): string[] {
|
||||
return mattermostAccountHelpers.listAccountIds(cfg);
|
||||
}
|
||||
|
||||
export function resolveDefaultMattermostAccountId(cfg: OpenClawConfig): string {
|
||||
return mattermostAccountHelpers.resolveDefaultAccountId(cfg);
|
||||
}
|
||||
|
||||
function mergeMattermostAccountConfig(
|
||||
cfg: OpenClawConfig,
|
||||
accountId: string,
|
||||
): MattermostAccountConfig {
|
||||
return resolveMergedAccountConfig<MattermostAccountConfig>({
|
||||
channelConfig: cfg.channels?.mattermost as MattermostAccountConfig | undefined,
|
||||
accounts: cfg.channels?.mattermost?.accounts as
|
||||
| Record<string, Partial<MattermostAccountConfig>>
|
||||
| undefined,
|
||||
accountId,
|
||||
omitKeys: ["defaultAccount"],
|
||||
nestedObjectKeys: ["commands"],
|
||||
});
|
||||
}
|
||||
|
||||
function resolveMattermostRequireMention(config: MattermostAccountConfig): boolean | undefined {
|
||||
if (config.chatmode === "oncall") {
|
||||
return true;
|
||||
}
|
||||
if (config.chatmode === "onmessage") {
|
||||
return false;
|
||||
}
|
||||
if (config.chatmode === "onchar") {
|
||||
return true;
|
||||
}
|
||||
return config.requireMention;
|
||||
}
|
||||
|
||||
export function resolveMattermostAccount(params: {
|
||||
cfg: OpenClawConfig;
|
||||
accountId?: string | null;
|
||||
allowUnresolvedSecretRef?: boolean;
|
||||
}): ResolvedMattermostAccount {
|
||||
const accountId = normalizeAccountId(
|
||||
params.accountId ?? resolveDefaultMattermostAccountId(params.cfg),
|
||||
);
|
||||
const baseEnabled = params.cfg.channels?.mattermost?.enabled !== false;
|
||||
const merged = mergeMattermostAccountConfig(params.cfg, accountId);
|
||||
const accountEnabled = merged.enabled !== false;
|
||||
const enabled = baseEnabled && accountEnabled;
|
||||
|
||||
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
||||
const envToken = allowEnv ? process.env.MATTERMOST_BOT_TOKEN?.trim() : undefined;
|
||||
const 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,
|
||||
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";
|
||||
}
|
||||
|
||||
export function listEnabledMattermostAccounts(cfg: OpenClawConfig): ResolvedMattermostAccount[] {
|
||||
return listMattermostAccountIds(cfg)
|
||||
.map((accountId) => resolveMattermostAccount({ cfg, accountId }))
|
||||
.filter((account) => account.enabled);
|
||||
}
|
||||
|
|
@ -0,0 +1,511 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createMattermostClient, createMattermostDirectChannelWithRetry } from "./client.js";
|
||||
|
||||
describe("createMattermostDirectChannelWithRetry", () => {
|
||||
const mockFetch = vi.fn<typeof fetch>();
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.clearAllMocks();
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await vi.runOnlyPendingTimersAsync();
|
||||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function createMockClient() {
|
||||
return createMattermostClient({
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
botToken: "test-token",
|
||||
fetchImpl: mockFetch,
|
||||
});
|
||||
}
|
||||
|
||||
function createFetchFailedError(params: { message: string; code?: string }): TypeError {
|
||||
const cause = Object.assign(new Error(params.message), {
|
||||
code: params.code,
|
||||
});
|
||||
return Object.assign(new TypeError("fetch failed"), { cause });
|
||||
}
|
||||
|
||||
async function resolveRetryRun<T>(run: Promise<T>): Promise<T> {
|
||||
await vi.runAllTimersAsync();
|
||||
return await run;
|
||||
}
|
||||
|
||||
function suppressUnhandled<T>(run: Promise<T>): Promise<T> {
|
||||
run.catch(() => {});
|
||||
return run;
|
||||
}
|
||||
|
||||
it("succeeds on first attempt without retries", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ id: "dm-channel-123" }),
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
const onRetry = vi.fn();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
onRetry,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.id).toBe("dm-channel-123");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(onRetry).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("retries on 429 rate limit error and succeeds", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 429,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ message: "Too many requests" }),
|
||||
text: async () => "Too many requests",
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ id: "dm-channel-456" }),
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
const onRetry = vi.fn();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
onRetry,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.id).toBe("dm-channel-456");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(onRetry).toHaveBeenCalledTimes(1);
|
||||
expect(onRetry).toHaveBeenCalledWith(
|
||||
1,
|
||||
expect.any(Number),
|
||||
expect.objectContaining({ message: expect.stringContaining("429") }),
|
||||
);
|
||||
});
|
||||
|
||||
it("retries on port 443 connection errors (not misclassified as 4xx)", async () => {
|
||||
// This tests that port numbers like :443 don't trigger false 4xx classification
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error("connect ECONNRESET 104.18.32.10:443"))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ id: "dm-channel-port" }),
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
// Should retry and succeed on second attempt (port 443 should NOT be treated as 4xx)
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(result.id).toBe("dm-channel-port");
|
||||
});
|
||||
|
||||
it("does not retry on 400 even if error message contains '429' text", async () => {
|
||||
// This tests that "429" in error detail doesn't trigger false rate-limit retry
|
||||
// e.g., "Invalid user ID: 4294967295" should NOT be retried
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ message: "Invalid user ID: 4294967295" }),
|
||||
text: async () => "Invalid user ID: 4294967295",
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow();
|
||||
|
||||
// Should not retry - only called once (400 is a client error, even though message contains "429")
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("retries on 5xx server errors", async () => {
|
||||
mockFetch
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 503,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ message: "Service unavailable" }),
|
||||
text: async () => "Service unavailable",
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 502,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ message: "Bad gateway" }),
|
||||
text: async () => "Bad gateway",
|
||||
} as Response)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ id: "dm-channel-789" }),
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.id).toBe("dm-channel-789");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("retries on network errors", async () => {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error("Network error: connection refused"))
|
||||
.mockRejectedValueOnce(new Error("ECONNRESET"))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ id: "dm-channel-abc" }),
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.id).toBe("dm-channel-abc");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("retries on fetch failed errors when the cause carries a transient code", async () => {
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(
|
||||
createFetchFailedError({
|
||||
message: "connect ECONNREFUSED 127.0.0.1:81",
|
||||
code: "ECONNREFUSED",
|
||||
}),
|
||||
)
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ id: "dm-channel-fetch-failed" }),
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(result.id).toBe("dm-channel-fetch-failed");
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("does not retry on 4xx client errors (except 429)", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ message: "Bad request" }),
|
||||
text: async () => "Bad request",
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("400");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry on 404 not found", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 404,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ message: "User not found" }),
|
||||
text: async () => "User not found",
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("404");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("throws after exhausting all retries", async () => {
|
||||
mockFetch.mockResolvedValue({
|
||||
ok: false,
|
||||
status: 503,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ message: "Service unavailable" }),
|
||||
text: async () => "Service unavailable",
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 2,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(3); // initial + 2 retries
|
||||
});
|
||||
|
||||
it("respects custom timeout option and aborts fetch", async () => {
|
||||
let abortSignal: AbortSignal | undefined;
|
||||
let abortListenerCalled = false;
|
||||
|
||||
mockFetch.mockImplementationOnce((url, init) => {
|
||||
abortSignal = init?.signal ?? undefined;
|
||||
if (abortSignal) {
|
||||
abortSignal.addEventListener("abort", () => {
|
||||
abortListenerCalled = true;
|
||||
});
|
||||
}
|
||||
// Return a promise that rejects when aborted, otherwise never resolves
|
||||
return new Promise((_, reject) => {
|
||||
if (abortSignal) {
|
||||
const checkAbort = () => {
|
||||
if (abortSignal?.aborted) {
|
||||
reject(new Error("AbortError"));
|
||||
} else {
|
||||
setTimeout(checkAbort, 10);
|
||||
}
|
||||
};
|
||||
setTimeout(checkAbort, 10);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
timeoutMs: 50,
|
||||
maxRetries: 0,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow();
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
expect(abortSignal).toBeDefined();
|
||||
expect(abortListenerCalled).toBe(true);
|
||||
});
|
||||
|
||||
it("uses exponential backoff with jitter between retries", async () => {
|
||||
const delays: number[] = [];
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error("Mattermost API 503 Service Unavailable"))
|
||||
.mockRejectedValueOnce(new Error("Mattermost API 503 Service Unavailable"))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ id: "dm-channel-delay" }),
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 100,
|
||||
maxDelayMs: 1000,
|
||||
onRetry: (attempt, delayMs) => {
|
||||
delays.push(delayMs);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(delays).toHaveLength(2);
|
||||
// First retry: exponentialDelay = 100ms, jitter = 0-100ms, total = 100-200ms
|
||||
expect(delays[0]).toBeGreaterThanOrEqual(100);
|
||||
expect(delays[0]).toBeLessThanOrEqual(200);
|
||||
// Second retry: exponentialDelay = 200ms, jitter = 0-200ms, total = 200-400ms
|
||||
expect(delays[1]).toBeGreaterThanOrEqual(200);
|
||||
expect(delays[1]).toBeLessThanOrEqual(400);
|
||||
});
|
||||
|
||||
it("respects maxDelayMs cap", async () => {
|
||||
const delays: number[] = [];
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error("Mattermost API 503"))
|
||||
.mockRejectedValueOnce(new Error("Mattermost API 503"))
|
||||
.mockRejectedValueOnce(new Error("Mattermost API 503"))
|
||||
.mockRejectedValueOnce(new Error("Mattermost API 503"))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ id: "dm-channel-max" }),
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 4,
|
||||
initialDelayMs: 1000,
|
||||
maxDelayMs: 2500,
|
||||
onRetry: (attempt, delayMs) => {
|
||||
delays.push(delayMs);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
||||
expect(delays).toHaveLength(4);
|
||||
// All delays should be capped at maxDelayMs
|
||||
delays.forEach((delay) => {
|
||||
expect(delay).toBeLessThanOrEqual(2500);
|
||||
});
|
||||
});
|
||||
|
||||
it("does not retry on 4xx errors even if message contains retryable keywords", async () => {
|
||||
// This tests the fix for false positives where a 400 error with "timeout" in the message
|
||||
// would incorrectly be retried
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 400,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ message: "Request timeout: connection timed out" }),
|
||||
text: async () => "Request timeout: connection timed out",
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("400");
|
||||
|
||||
// Should not retry - only called once
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does not retry on 403 Forbidden even with 'abort' in message", async () => {
|
||||
mockFetch.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
status: 403,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ message: "Request aborted: forbidden" }),
|
||||
text: async () => "Request aborted: forbidden",
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const run = suppressUnhandled(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
await expect(resolveRetryRun(run)).rejects.toThrow("403");
|
||||
|
||||
expect(mockFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("passes AbortSignal to fetch for timeout support", async () => {
|
||||
let capturedSignal: AbortSignal | undefined;
|
||||
mockFetch.mockImplementationOnce((url, init) => {
|
||||
capturedSignal = init?.signal ?? undefined;
|
||||
return Promise.resolve({
|
||||
ok: true,
|
||||
status: 201,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ id: "dm-channel-signal" }),
|
||||
} as Response);
|
||||
});
|
||||
|
||||
const client = createMockClient();
|
||||
await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
timeoutMs: 5000,
|
||||
}),
|
||||
);
|
||||
|
||||
expect(capturedSignal).toBeDefined();
|
||||
expect(capturedSignal).toBeInstanceOf(AbortSignal);
|
||||
});
|
||||
|
||||
it("retries on 5xx even if error message contains 4xx substring", async () => {
|
||||
// This tests the fix for the ordering bug: 503 with "upstream 404" should be retried
|
||||
mockFetch
|
||||
.mockRejectedValueOnce(new Error("Mattermost API 503: upstream returned 404 Not Found"))
|
||||
.mockResolvedValueOnce({
|
||||
ok: true,
|
||||
status: 201,
|
||||
headers: new Headers({ "content-type": "application/json" }),
|
||||
json: async () => ({ id: "dm-channel-5xx-with-404" }),
|
||||
} as Response);
|
||||
|
||||
const client = createMockClient();
|
||||
|
||||
const result = await resolveRetryRun(
|
||||
createMattermostDirectChannelWithRetry(client, ["user-1", "user-2"], {
|
||||
maxRetries: 3,
|
||||
initialDelayMs: 10,
|
||||
}),
|
||||
);
|
||||
|
||||
// Should retry and succeed on second attempt
|
||||
expect(mockFetch).toHaveBeenCalledTimes(2);
|
||||
expect(result.id).toBe("dm-channel-5xx-with-404");
|
||||
});
|
||||
});
|
||||
313
openclaw/extensions/mattermost/src/mattermost/client.test.ts
Normal file
313
openclaw/extensions/mattermost/src/mattermost/client.test.ts
Normal file
|
|
@ -0,0 +1,313 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMattermostClient,
|
||||
createMattermostPost,
|
||||
normalizeMattermostBaseUrl,
|
||||
updateMattermostPost,
|
||||
} from "./client.js";
|
||||
|
||||
// ── Helper: mock fetch that captures requests ────────────────────────
|
||||
|
||||
function createMockFetch(response?: { status?: number; body?: unknown; contentType?: string }) {
|
||||
const status = response?.status ?? 200;
|
||||
const body = response?.body ?? {};
|
||||
const contentType = response?.contentType ?? "application/json";
|
||||
|
||||
const calls: Array<{ url: string; init?: RequestInit }> = [];
|
||||
|
||||
const mockFetch = vi.fn(async (url: string | URL | Request, init?: RequestInit) => {
|
||||
const urlStr = 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 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");
|
||||
});
|
||||
});
|
||||
|
||||
// ── createMattermostClient ───────────────────────────────────────────
|
||||
|
||||
describe("createMattermostClient", () => {
|
||||
it("creates a client with normalized baseUrl", () => {
|
||||
const { mockFetch } = createMockFetch();
|
||||
const client = createMattermostClient({
|
||||
baseUrl: "http://localhost:8065/",
|
||||
botToken: "tok",
|
||||
fetchImpl: mockFetch,
|
||||
});
|
||||
expect(client.baseUrl).toBe("http://localhost:8065");
|
||||
expect(client.apiBaseUrl).toBe("http://localhost:8065/api/v4");
|
||||
});
|
||||
|
||||
it("throws on empty baseUrl", () => {
|
||||
expect(() => createMattermostClient({ baseUrl: "", botToken: "tok" })).toThrow(
|
||||
"baseUrl is required",
|
||||
);
|
||||
});
|
||||
|
||||
it("sends Authorization header with Bearer token", async () => {
|
||||
const { mockFetch, calls } = createMockFetch({ body: { id: "u1" } });
|
||||
const client = createMattermostClient({
|
||||
baseUrl: "http://localhost:8065",
|
||||
botToken: "my-secret-token",
|
||||
fetchImpl: mockFetch,
|
||||
});
|
||||
await client.request("/users/me");
|
||||
const headers = new Headers(calls[0].init?.headers);
|
||||
expect(headers.get("Authorization")).toBe("Bearer my-secret-token");
|
||||
});
|
||||
|
||||
it("sets Content-Type for string bodies", async () => {
|
||||
const { mockFetch, calls } = createMockFetch({ body: { id: "p1" } });
|
||||
const client = createMattermostClient({
|
||||
baseUrl: "http://localhost:8065",
|
||||
botToken: "tok",
|
||||
fetchImpl: mockFetch,
|
||||
});
|
||||
await client.request("/posts", { method: "POST", body: JSON.stringify({ message: "hi" }) });
|
||||
const headers = new Headers(calls[0].init?.headers);
|
||||
expect(headers.get("Content-Type")).toBe("application/json");
|
||||
});
|
||||
|
||||
it("throws on non-ok responses", async () => {
|
||||
const { mockFetch } = createMockFetch({
|
||||
status: 404,
|
||||
body: { message: "Not Found" },
|
||||
});
|
||||
const client = createMattermostClient({
|
||||
baseUrl: "http://localhost:8065",
|
||||
botToken: "tok",
|
||||
fetchImpl: mockFetch,
|
||||
});
|
||||
await expect(client.request("/missing")).rejects.toThrow("Mattermost API 404");
|
||||
});
|
||||
|
||||
it("returns undefined on 204 responses", async () => {
|
||||
const fetchImpl = vi.fn<typeof fetch>(async () => {
|
||||
return new Response(null, { status: 204 });
|
||||
});
|
||||
const client = createMattermostClient({
|
||||
baseUrl: "https://chat.example.com",
|
||||
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.props).toEqual(props);
|
||||
expect(body.props).toMatchObject({
|
||||
attachments: [{ actions: [{ type: "button" }] }],
|
||||
});
|
||||
});
|
||||
|
||||
it("omits props when not provided", async () => {
|
||||
const { mockFetch, calls } = createMockFetch({ body: { id: "post5" } });
|
||||
const client = createMattermostClient({
|
||||
baseUrl: "http://localhost:8065",
|
||||
botToken: "tok",
|
||||
fetchImpl: mockFetch,
|
||||
});
|
||||
|
||||
await createMattermostPost(client, {
|
||||
channelId: "ch123",
|
||||
message: "No props",
|
||||
});
|
||||
|
||||
const body = parseRequestJson(calls[0].init);
|
||||
expect(body.props).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
// ── updateMattermostPost ─────────────────────────────────────────────
|
||||
|
||||
describe("updateMattermostPost", () => {
|
||||
it("sends PUT to /posts/{id}", async () => {
|
||||
const { calls } = await updatePostAndCapture({ message: "Updated" });
|
||||
|
||||
expect(calls[0].url).toContain("/posts/post1");
|
||||
expect(calls[0].init?.method).toBe("PUT");
|
||||
});
|
||||
|
||||
it("includes post id in the body", async () => {
|
||||
const { body } = await updatePostAndCapture({ message: "Updated" });
|
||||
expect(body.id).toBe("post1");
|
||||
expect(body.message).toBe("Updated");
|
||||
});
|
||||
|
||||
it("includes props for button completion updates", async () => {
|
||||
const { body } = await updatePostAndCapture({
|
||||
message: "Original message",
|
||||
props: {
|
||||
attachments: [{ text: "✓ **do_now** selected by @tony" }],
|
||||
},
|
||||
});
|
||||
expect(body.message).toBe("Original message");
|
||||
expect(body.props).toMatchObject({
|
||||
attachments: [{ text: expect.stringContaining("✓") }],
|
||||
});
|
||||
expect(body.props).toMatchObject({
|
||||
attachments: [{ text: expect.stringContaining("do_now") }],
|
||||
});
|
||||
});
|
||||
|
||||
it("omits message when not provided", async () => {
|
||||
const { body } = await updatePostAndCapture({
|
||||
props: { attachments: [] },
|
||||
});
|
||||
expect(body.id).toBe("post1");
|
||||
expect(body.message).toBeUndefined();
|
||||
expect(body.props).toEqual({ attachments: [] });
|
||||
});
|
||||
});
|
||||
594
openclaw/extensions/mattermost/src/mattermost/client.ts
Normal file
594
openclaw/extensions/mattermost/src/mattermost/client.ts
Normal file
|
|
@ -0,0 +1,594 @@
|
|||
import {
|
||||
fetchWithSsrFGuard,
|
||||
ssrfPolicyFromPrivateNetworkOptIn,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { z } from "openclaw/plugin-sdk/zod";
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
export async function readMattermostError(res: Response): Promise<string> {
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
const data = (await res.json()) as { message?: string } | undefined;
|
||||
if (data?.message) {
|
||||
return data.message;
|
||||
}
|
||||
return JSON.stringify(data);
|
||||
}
|
||||
return await res.text();
|
||||
}
|
||||
|
||||
export function createMattermostClient(params: {
|
||||
baseUrl: string;
|
||||
botToken: string;
|
||||
fetchImpl?: 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;
|
||||
|
||||
// Guarded fetch adapter: calls fetchWithSsrFGuard and returns a plain Response.
|
||||
// Body is buffered before releasing the dispatcher so callers get a complete Response.
|
||||
// Null-body status codes per Fetch spec — Response constructor rejects a body for these.
|
||||
const NULL_BODY_STATUSES = new Set([101, 204, 205, 304]);
|
||||
|
||||
const guardedFetchImpl: MattermostFetch = async (input, init) => {
|
||||
const url =
|
||||
typeof input === "string" ? input : input instanceof URL ? input.toString() : input.url;
|
||||
const { response, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init,
|
||||
auditContext: "mattermost-api",
|
||||
policy: ssrfPolicyFromPrivateNetworkOptIn(params.allowPrivateNetwork),
|
||||
});
|
||||
try {
|
||||
const bodyBytes = NULL_BODY_STATUSES.has(response.status)
|
||||
? null
|
||||
: await response.arrayBuffer();
|
||||
return new Response(bodyBytes, { status: response.status, headers: response.headers });
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
};
|
||||
|
||||
const fetchImpl = externalFetchImpl ?? guardedFetchImpl;
|
||||
|
||||
const request = async <T>(path: string, init?: RequestInit): Promise<T> => {
|
||||
const url = buildMattermostApiUrl(baseUrl, path);
|
||||
const headers = new Headers(init?.headers);
|
||||
headers.set("Authorization", `Bearer ${token}`);
|
||||
if (typeof init?.body === "string" && !headers.has("Content-Type")) {
|
||||
headers.set("Content-Type", "application/json");
|
||||
}
|
||||
const res = await fetchImpl(url, { ...init, headers });
|
||||
if (!res.ok) {
|
||||
const detail = await readMattermostError(res);
|
||||
throw new Error(
|
||||
`Mattermost API ${res.status} ${res.statusText}: ${detail || "unknown error"}`,
|
||||
);
|
||||
}
|
||||
|
||||
if (res.status === 204) {
|
||||
return undefined as T;
|
||||
}
|
||||
|
||||
const contentType = res.headers.get("content-type") ?? "";
|
||||
if (contentType.includes("application/json")) {
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
return (await res.text()) as T;
|
||||
};
|
||||
|
||||
return { baseUrl, apiBaseUrl, token, request, 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 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 = 30000,
|
||||
onRetry,
|
||||
} = options;
|
||||
|
||||
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 * Math.pow(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 = 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 = candidates
|
||||
.map((candidate) => readErrorCode(candidate))
|
||||
.filter((code): code is string => Boolean(code));
|
||||
if (codes.some((code) => RETRYABLE_NETWORK_ERROR_CODES.has(code))) {
|
||||
return true;
|
||||
}
|
||||
|
||||
const names = candidates
|
||||
.map((candidate) => readErrorName(candidate))
|
||||
.filter((name): name is string => Boolean(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];
|
||||
const seen = new Set<unknown>();
|
||||
const candidates: unknown[] = [];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const current = queue.shift();
|
||||
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;
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
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 res.json()) as { file_infos?: MattermostFileInfo[] };
|
||||
const info = data.file_infos?.[0];
|
||||
if (!info?.id) {
|
||||
throw new Error("Mattermost file upload failed");
|
||||
}
|
||||
return info;
|
||||
}
|
||||
172
openclaw/extensions/mattermost/src/mattermost/directory.test.ts
Normal file
172
openclaw/extensions/mattermost/src/mattermost/directory.test.ts
Normal file
|
|
@ -0,0 +1,172 @@
|
|||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const {
|
||||
listMattermostAccountIdsMock,
|
||||
resolveMattermostAccountMock,
|
||||
createMattermostClientMock,
|
||||
fetchMattermostMeMock,
|
||||
} = vi.hoisted(() => {
|
||||
return {
|
||||
listMattermostAccountIdsMock: vi.fn(),
|
||||
resolveMattermostAccountMock: vi.fn(),
|
||||
createMattermostClientMock: vi.fn(),
|
||||
fetchMattermostMeMock: vi.fn(),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./accounts.js", () => {
|
||||
return {
|
||||
listMattermostAccountIds: listMattermostAccountIdsMock,
|
||||
resolveMattermostAccount: resolveMattermostAccountMock,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./client.js", () => {
|
||||
return {
|
||||
createMattermostClient: createMattermostClientMock,
|
||||
fetchMattermostMe: fetchMattermostMeMock,
|
||||
};
|
||||
});
|
||||
|
||||
let listMattermostDirectoryGroups: typeof import("./directory.js").listMattermostDirectoryGroups;
|
||||
let listMattermostDirectoryPeers: typeof import("./directory.js").listMattermostDirectoryPeers;
|
||||
|
||||
describe("mattermost directory", () => {
|
||||
beforeAll(async () => {
|
||||
({ listMattermostDirectoryGroups, listMattermostDirectoryPeers } =
|
||||
await import("./directory.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it("deduplicates channels across enabled accounts and skips failing accounts", async () => {
|
||||
const clientA = {
|
||||
token: "token-a",
|
||||
request: vi.fn().mockResolvedValueOnce([
|
||||
{ id: "chan-1", type: "O", name: "alerts", display_name: "Alerts" },
|
||||
{ id: "chan-2", type: "P", name: "ops", display_name: "Ops" },
|
||||
{ id: "chan-3", type: "D", name: "dm", display_name: "Direct" },
|
||||
]),
|
||||
};
|
||||
const clientB = {
|
||||
token: "token-b",
|
||||
request: vi.fn().mockRejectedValue(new Error("expired token")),
|
||||
};
|
||||
const clientC = {
|
||||
token: "token-c",
|
||||
request: vi.fn().mockResolvedValueOnce([
|
||||
{ id: "chan-2", type: "P", name: "ops", display_name: "Ops" },
|
||||
{ id: "chan-4", type: "O", name: "infra", display_name: "Infra" },
|
||||
]),
|
||||
};
|
||||
|
||||
listMattermostAccountIdsMock.mockReturnValue(["default", "alerts", "infra"]);
|
||||
resolveMattermostAccountMock.mockImplementation(({ accountId }) => {
|
||||
if (accountId === "disabled") {
|
||||
return { enabled: false };
|
||||
}
|
||||
return { enabled: true, botToken: `token-${accountId}`, baseUrl: "https://chat.example.com" };
|
||||
});
|
||||
createMattermostClientMock
|
||||
.mockReturnValueOnce(clientA)
|
||||
.mockReturnValueOnce(clientB)
|
||||
.mockReturnValueOnce(clientC);
|
||||
fetchMattermostMeMock.mockResolvedValue({ id: "me-1" });
|
||||
|
||||
await expect(
|
||||
listMattermostDirectoryGroups({
|
||||
cfg: {} as never,
|
||||
runtime: {} as never,
|
||||
query: " op ",
|
||||
}),
|
||||
).resolves.toEqual([{ kind: "group", id: "channel:chan-2", name: "ops", handle: "Ops" }]);
|
||||
});
|
||||
|
||||
it("uses the first healthy client for peers and filters self and blanks", async () => {
|
||||
const client = {
|
||||
token: "token-default",
|
||||
request: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: "team-1" }])
|
||||
.mockResolvedValueOnce([{ user_id: "me-1" }, { user_id: "user-1" }, { user_id: "user-2" }])
|
||||
.mockResolvedValueOnce([
|
||||
{
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
first_name: "Alice",
|
||||
last_name: "Ng",
|
||||
},
|
||||
{
|
||||
id: "user-2",
|
||||
username: "bob",
|
||||
nickname: "Bobby",
|
||||
},
|
||||
{
|
||||
id: "me-1",
|
||||
username: "self",
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
listMattermostAccountIdsMock.mockReturnValue(["default"]);
|
||||
resolveMattermostAccountMock.mockReturnValue({
|
||||
enabled: true,
|
||||
botToken: "token-default",
|
||||
baseUrl: "https://chat.example.com",
|
||||
});
|
||||
createMattermostClientMock.mockReturnValue(client);
|
||||
fetchMattermostMeMock.mockResolvedValue({ id: "me-1" });
|
||||
|
||||
await expect(
|
||||
listMattermostDirectoryPeers({
|
||||
cfg: {} as never,
|
||||
runtime: {} as never,
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
{ kind: "user", id: "user:user-1", name: "alice", handle: "Alice Ng" },
|
||||
{ kind: "user", id: "user:user-2", name: "bob", handle: "Bobby" },
|
||||
]);
|
||||
});
|
||||
|
||||
it("uses user search when a query is present and applies limits", async () => {
|
||||
const client = {
|
||||
token: "token-default",
|
||||
request: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce([{ id: "team-1" }])
|
||||
.mockResolvedValueOnce([
|
||||
{ id: "user-1", username: "alice", first_name: "Alice", last_name: "Ng" },
|
||||
{ id: "user-2", username: "alex", nickname: "Lex" },
|
||||
]),
|
||||
};
|
||||
|
||||
listMattermostAccountIdsMock.mockReturnValue(["default"]);
|
||||
resolveMattermostAccountMock.mockReturnValue({
|
||||
enabled: true,
|
||||
botToken: "token-default",
|
||||
baseUrl: "https://chat.example.com",
|
||||
});
|
||||
createMattermostClientMock.mockReturnValue(client);
|
||||
fetchMattermostMeMock.mockResolvedValue({ id: "me-1" });
|
||||
|
||||
await expect(
|
||||
listMattermostDirectoryPeers({
|
||||
cfg: {} as never,
|
||||
runtime: {} as never,
|
||||
query: " ali ",
|
||||
limit: 1,
|
||||
}),
|
||||
).resolves.toEqual([{ kind: "user", id: "user:user-1", name: "alice", handle: "Alice Ng" }]);
|
||||
|
||||
expect(client.request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
"/users/search",
|
||||
expect.objectContaining({
|
||||
method: "POST",
|
||||
body: JSON.stringify({ term: "ali", team_id: "team-1" }),
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
180
openclaw/extensions/mattermost/src/mattermost/directory.ts
Normal file
180
openclaw/extensions/mattermost/src/mattermost/directory.ts
Normal file
|
|
@ -0,0 +1,180 @@
|
|||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-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.
|
||||
*
|
||||
* NOTE: per_page=200 for member listing; same pagination caveat as groups.
|
||||
*/
|
||||
export async function listMattermostDirectoryPeers(
|
||||
params: MattermostDirectoryParams,
|
||||
): Promise<ChannelDirectoryEntry[]> {
|
||||
const clients = buildClients(params);
|
||||
if (!clients.length) {
|
||||
return [];
|
||||
}
|
||||
// All bots see the same user list, so one client suffices (unlike channels
|
||||
// where private channel membership varies per bot).
|
||||
const client = clients[0];
|
||||
try {
|
||||
const me = await fetchMattermostMe(client);
|
||||
const teams = await client.request<{ id: string }[]>("/users/me/teams");
|
||||
if (!teams.length) {
|
||||
return [];
|
||||
}
|
||||
// Uses first team — multi-team setups may need iteration in the future
|
||||
const teamId = teams[0].id;
|
||||
const q = 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 members = await client.request<{ user_id: string }[]>(
|
||||
`/teams/${teamId}/members?per_page=200`,
|
||||
);
|
||||
const userIds = members.map((m) => m.user_id).filter((id) => id !== me.id);
|
||||
if (!userIds.length) {
|
||||
return [];
|
||||
}
|
||||
users = await client.request<MattermostUser[]>("/users/ids", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(userIds),
|
||||
});
|
||||
}
|
||||
|
||||
const entries = users
|
||||
.filter((u) => u.id !== me.id)
|
||||
.map((u) => ({
|
||||
kind: "user" as const,
|
||||
id: `user:${u.id}`,
|
||||
name: u.username ?? undefined,
|
||||
handle:
|
||||
[u.first_name, u.last_name].filter(Boolean).join(" ").trim() || u.nickname || undefined,
|
||||
}));
|
||||
return params.limit && params.limit > 0 ? entries.slice(0, params.limit) : entries;
|
||||
} catch (err) {
|
||||
console.debug?.("[mattermost-directory] listPeers failed:", (err as Error)?.message);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MattermostClient } from "./client.js";
|
||||
import { buildMattermostToolStatusText, 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 };
|
||||
}
|
||||
|
||||
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");
|
||||
|
||||
const createBody = JSON.parse((calls[0]?.init?.body as string | undefined) ?? "{}");
|
||||
expect(createBody).toMatchObject({
|
||||
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("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(JSON.parse((calls[1]?.init?.body as string | undefined) ?? "{}")).toMatchObject({
|
||||
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("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");
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMattermostToolStatusText", () => {
|
||||
it("renders a status with the tool name", () => {
|
||||
expect(buildMattermostToolStatusText({ name: "read" })).toBe("Running `read`…");
|
||||
});
|
||||
|
||||
it("falls back to a generic running tool status", () => {
|
||||
expect(buildMattermostToolStatusText({ name: "exec" })).toBe("Running `exec`…");
|
||||
});
|
||||
});
|
||||
131
openclaw/extensions/mattermost/src/mattermost/draft-stream.ts
Normal file
131
openclaw/extensions/mattermost/src/mattermost/draft-stream.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { createFinalizableDraftLifecycle } from "openclaw/plugin-sdk/channel-lifecycle";
|
||||
import {
|
||||
createMattermostPost,
|
||||
deleteMattermostPost,
|
||||
updateMattermostPost,
|
||||
type MattermostClient,
|
||||
} from "./client.js";
|
||||
|
||||
const MATTERMOST_STREAM_MAX_CHARS = 4000;
|
||||
const DEFAULT_THROTTLE_MS = 1000;
|
||||
|
||||
export type MattermostDraftStream = {
|
||||
update: (text: string) => void;
|
||||
flush: () => Promise<void>;
|
||||
postId: () => string | undefined;
|
||||
clear: () => Promise<void>;
|
||||
stop: () => Promise<void>;
|
||||
forceNewMessage: () => void;
|
||||
};
|
||||
|
||||
export function normalizeMattermostDraftText(text: string, maxChars: number): string {
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed.length <= maxChars) {
|
||||
return trimmed;
|
||||
}
|
||||
return `${trimmed.slice(0, Math.max(0, maxChars - 3)).trimEnd()}...`;
|
||||
}
|
||||
|
||||
export function buildMattermostToolStatusText(params: { name?: string; phase?: string }): string {
|
||||
const tool = params.name?.trim() ? ` \`${params.name.trim()}\`` : " tool";
|
||||
return `Running${tool}…`;
|
||||
}
|
||||
|
||||
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 } = 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,
|
||||
stop,
|
||||
forceNewMessage,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,880 @@
|
|||
import { type IncomingMessage, type ServerResponse } from "node:http";
|
||||
import { describe, expect, it, beforeEach, afterEach, vi } from "vitest";
|
||||
import type { PluginRuntime } from "../../runtime-api.js";
|
||||
import { setMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import type { MattermostClient, MattermostPost } from "./client.js";
|
||||
import {
|
||||
buildButtonAttachments,
|
||||
computeInteractionCallbackUrl,
|
||||
createMattermostInteractionHandler,
|
||||
generateInteractionToken,
|
||||
getInteractionCallbackUrl,
|
||||
getInteractionSecret,
|
||||
resolveInteractionCallbackPath,
|
||||
resolveInteractionCallbackUrl,
|
||||
setInteractionCallbackUrl,
|
||||
setInteractionSecret,
|
||||
verifyInteractionToken,
|
||||
} from "./interactions.js";
|
||||
|
||||
// ── HMAC token management ────────────────────────────────────────────
|
||||
|
||||
describe("setInteractionSecret / getInteractionSecret", () => {
|
||||
beforeEach(() => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
// ── Callback URL registry ────────────────────────────────────────────
|
||||
|
||||
describe("callback URL registry", () => {
|
||||
it("stores and retrieves callback URLs", () => {
|
||||
setInteractionCallbackUrl("acct1", "http://localhost:18789/mattermost/interactions/acct1");
|
||||
expect(getInteractionCallbackUrl("acct1")).toBe(
|
||||
"http://localhost:18789/mattermost/interactions/acct1",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns undefined for unknown account", () => {
|
||||
expect(getInteractionCallbackUrl("nonexistent-account-id")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveInteractionCallbackUrl", () => {
|
||||
afterEach(() => {
|
||||
for (const accountId of ["cached", "default", "acct", "myaccount"]) {
|
||||
setInteractionCallbackUrl(accountId, "");
|
||||
}
|
||||
});
|
||||
|
||||
it("prefers cached URL from registry", () => {
|
||||
setInteractionCallbackUrl("cached", "http://cached:1234/path");
|
||||
expect(resolveInteractionCallbackUrl("cached")).toBe("http://cached:1234/path");
|
||||
});
|
||||
|
||||
it("recomputes from config when bypassing the cache explicitly", () => {
|
||||
setInteractionCallbackUrl("acct", "http://cached:1234/path");
|
||||
const url = computeInteractionCallbackUrl("acct", {
|
||||
gateway: { port: 9999, customBindHost: "gateway.internal" },
|
||||
});
|
||||
expect(url).toBe("http://gateway.internal:9999/mattermost/interactions/acct");
|
||||
});
|
||||
|
||||
it("uses interactions.callbackBaseUrl when configured", () => {
|
||||
const url = resolveInteractionCallbackUrl("default", {
|
||||
channels: {
|
||||
mattermost: {
|
||||
interactions: {
|
||||
callbackBaseUrl: "https://gateway.example.com/openclaw",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(url).toBe("https://gateway.example.com/openclaw/mattermost/interactions/default");
|
||||
});
|
||||
|
||||
it("trims trailing slashes from callbackBaseUrl", () => {
|
||||
const url = resolveInteractionCallbackUrl("acct", {
|
||||
channels: {
|
||||
mattermost: {
|
||||
interactions: {
|
||||
callbackBaseUrl: "https://gateway.example.com/root///",
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(url).toBe("https://gateway.example.com/root/mattermost/interactions/acct");
|
||||
});
|
||||
|
||||
it("uses merged per-account interactions.callbackBaseUrl", () => {
|
||||
const cfg = {
|
||||
gateway: { port: 9999 },
|
||||
channels: {
|
||||
mattermost: {
|
||||
accounts: {
|
||||
acct: {
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
interactions: {
|
||||
callbackBaseUrl: "https://gateway.example.com/root",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
const account = resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: "acct",
|
||||
allowUnresolvedSecretRef: true,
|
||||
});
|
||||
const url = resolveInteractionCallbackUrl(account.accountId, {
|
||||
gateway: cfg.gateway,
|
||||
interactions: account.config.interactions,
|
||||
});
|
||||
expect(url).toBe("https://gateway.example.com/root/mattermost/interactions/acct");
|
||||
});
|
||||
|
||||
it("falls back to gateway.customBindHost when configured", () => {
|
||||
const url = resolveInteractionCallbackUrl("default", {
|
||||
gateway: { port: 9999, customBindHost: "gateway.internal" },
|
||||
});
|
||||
expect(url).toBe("http://gateway.internal:9999/mattermost/interactions/default");
|
||||
});
|
||||
|
||||
it("falls back to localhost when customBindHost is a wildcard bind address", () => {
|
||||
const url = resolveInteractionCallbackUrl("default", {
|
||||
gateway: { port: 9999, customBindHost: "0.0.0.0" },
|
||||
});
|
||||
expect(url).toBe("http://localhost:9999/mattermost/interactions/default");
|
||||
});
|
||||
|
||||
it("brackets IPv6 custom bind hosts", () => {
|
||||
const url = resolveInteractionCallbackUrl("acct", {
|
||||
gateway: { port: 9999, customBindHost: "::1" },
|
||||
});
|
||||
expect(url).toBe("http://[::1]:9999/mattermost/interactions/acct");
|
||||
});
|
||||
|
||||
it("uses default port 18789 when no config provided", () => {
|
||||
const url = resolveInteractionCallbackUrl("myaccount");
|
||||
expect(url).toBe("http://localhost:18789/mattermost/interactions/myaccount");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveInteractionCallbackPath", () => {
|
||||
it("builds the per-account callback path", () => {
|
||||
expect(resolveInteractionCallbackPath("acct")).toBe("/mattermost/interactions/acct");
|
||||
});
|
||||
});
|
||||
|
||||
// ── buildButtonAttachments ───────────────────────────────────────────
|
||||
|
||||
describe("buildButtonAttachments", () => {
|
||||
beforeEach(() => {
|
||||
setInteractionSecret("test-bot-token");
|
||||
});
|
||||
|
||||
it("returns an array with one attachment containing all buttons", () => {
|
||||
const result = buildButtonAttachments({
|
||||
callbackUrl: "http://localhost:18789/mattermost/interactions/default",
|
||||
buttons: [
|
||||
{ id: "btn1", name: "Click Me" },
|
||||
{ id: "btn2", name: "Skip", style: "danger" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].actions).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("sets type to 'button' on every action", () => {
|
||||
const result = buildButtonAttachments({
|
||||
callbackUrl: "http://localhost:18789/cb",
|
||||
buttons: [{ id: "a", name: "A" }],
|
||||
});
|
||||
|
||||
expect(result[0].actions![0].type).toBe("button");
|
||||
});
|
||||
|
||||
it("includes HMAC _token in integration context", () => {
|
||||
const result = buildButtonAttachments({
|
||||
callbackUrl: "http://localhost:18789/cb",
|
||||
buttons: [{ id: "test", name: "Test" }],
|
||||
});
|
||||
|
||||
const action = result[0].actions![0];
|
||||
expect(action.integration.context._token).toMatch(/^[0-9a-f]{64}$/);
|
||||
});
|
||||
|
||||
it("includes sanitized action_id in integration context", () => {
|
||||
const result = buildButtonAttachments({
|
||||
callbackUrl: "http://localhost:18789/cb",
|
||||
buttons: [{ id: "my_action", name: "Do It" }],
|
||||
});
|
||||
|
||||
const action = result[0].actions![0];
|
||||
// sanitizeActionId strips hyphens and underscores (Mattermost routing bug #25747)
|
||||
expect(action.integration.context.action_id).toBe("myaction");
|
||||
expect(action.id).toBe("myaction");
|
||||
});
|
||||
|
||||
it("merges custom context into integration context", () => {
|
||||
const result = buildButtonAttachments({
|
||||
callbackUrl: "http://localhost:18789/cb",
|
||||
buttons: [{ id: "btn", name: "Go", context: { tweet_id: "123", batch: true } }],
|
||||
});
|
||||
|
||||
const ctx = result[0].actions![0].integration.context;
|
||||
expect(ctx.tweet_id).toBe("123");
|
||||
expect(ctx.batch).toBe(true);
|
||||
expect(ctx.action_id).toBe("btn");
|
||||
expect(ctx._token).toBeDefined();
|
||||
});
|
||||
|
||||
it("passes callback URL to each button integration", () => {
|
||||
const url = "http://localhost:18789/mattermost/interactions/default";
|
||||
const result = buildButtonAttachments({
|
||||
callbackUrl: url,
|
||||
buttons: [
|
||||
{ id: "a", name: "A" },
|
||||
{ id: "b", name: "B" },
|
||||
],
|
||||
});
|
||||
|
||||
for (const action of result[0].actions!) {
|
||||
expect(action.integration.url).toBe(url);
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves button style", () => {
|
||||
const result = buildButtonAttachments({
|
||||
callbackUrl: "http://localhost/cb",
|
||||
buttons: [
|
||||
{ id: "ok", name: "OK", style: "primary" },
|
||||
{ id: "no", name: "No", style: "danger" },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result[0].actions![0].style).toBe("primary");
|
||||
expect(result[0].actions![1].style).toBe("danger");
|
||||
});
|
||||
|
||||
it("uses provided text for the attachment", () => {
|
||||
const result = buildButtonAttachments({
|
||||
callbackUrl: "http://localhost/cb",
|
||||
buttons: [{ id: "x", name: "X" }],
|
||||
text: "Choose an action:",
|
||||
});
|
||||
|
||||
expect(result[0].text).toBe("Choose an action:");
|
||||
});
|
||||
|
||||
it("defaults to empty string text when not provided", () => {
|
||||
const result = buildButtonAttachments({
|
||||
callbackUrl: "http://localhost/cb",
|
||||
buttons: [{ id: "x", name: "X" }],
|
||||
});
|
||||
|
||||
expect(result[0].text).toBe("");
|
||||
});
|
||||
|
||||
it("generates verifiable tokens", () => {
|
||||
const result = buildButtonAttachments({
|
||||
callbackUrl: "http://localhost/cb",
|
||||
buttons: [{ id: "verify_me", name: "V", context: { extra: "data" } }],
|
||||
});
|
||||
|
||||
const ctx = result[0].actions![0].integration.context;
|
||||
const token = ctx._token as string;
|
||||
const { _token, ...contextWithoutToken } = ctx;
|
||||
expect(verifyInteractionToken(contextWithoutToken, token)).toBe(true);
|
||||
});
|
||||
|
||||
it("generates tokens that verify even when Mattermost reorders context keys", () => {
|
||||
const result = buildButtonAttachments({
|
||||
callbackUrl: "http://localhost/cb",
|
||||
buttons: [{ id: "do_action", name: "Do", context: { tweet_id: "42", category: "ai" } }],
|
||||
});
|
||||
|
||||
const ctx = result[0].actions![0].integration.context;
|
||||
const token = ctx._token as string;
|
||||
|
||||
// Simulate Mattermost returning context with keys in a different order
|
||||
const reordered: Record<string, unknown> = {};
|
||||
const keys = Object.keys(ctx).filter((k) => k !== "_token");
|
||||
// Reverse the key order to simulate reordering
|
||||
for (const key of keys.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 ? "" : JSON.stringify(params.body);
|
||||
const listeners = new Map<string, Array<(...args: unknown[]) => void>>();
|
||||
|
||||
const req = {
|
||||
method: params.method ?? "POST",
|
||||
headers: params.headers ?? {},
|
||||
socket: { remoteAddress: params.remoteAddress ?? "203.0.113.10" },
|
||||
on(event: string, handler: (...args: unknown[]) => void) {
|
||||
const existing = listeners.get(event) ?? [];
|
||||
existing.push(handler);
|
||||
listeners.set(event, existing);
|
||||
return this;
|
||||
},
|
||||
} as IncomingMessage & { emitTest: (event: string, ...args: unknown[]) => void };
|
||||
|
||||
req.emitTest = (event: string, ...args: unknown[]) => {
|
||||
const handlers = listeners.get(event) ?? [];
|
||||
for (const handler of handlers) {
|
||||
handler(...args);
|
||||
}
|
||||
};
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (body) {
|
||||
req.emitTest("data", Buffer.from(body));
|
||||
}
|
||||
req.emitTest("end");
|
||||
});
|
||||
|
||||
return req;
|
||||
}
|
||||
|
||||
function createRes(): ServerResponse & { headers: Record<string, string>; body: string } {
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
headers: {},
|
||||
body: "",
|
||||
setHeader(name: string, value: string | number | readonly string[]) {
|
||||
res.headers[name] = Array.isArray(value) ? value.join(",") : String(value);
|
||||
return res;
|
||||
},
|
||||
end(
|
||||
chunk?: string | Buffer | Uint8Array,
|
||||
_encoding?: BufferEncoding | (() => void),
|
||||
cb?: () => void,
|
||||
) {
|
||||
res.body = chunk ? String(chunk) : "";
|
||||
cb?.();
|
||||
return res;
|
||||
},
|
||||
} as ServerResponse & { headers: Record<string, string>; body: string };
|
||||
return res;
|
||||
}
|
||||
|
||||
function createActionContext(actionId = "approve", channelId = "chan-1") {
|
||||
const context = { action_id: actionId, __openclaw_channel_id: channelId };
|
||||
return { context, token: generateInteractionToken(context, "acct") };
|
||||
}
|
||||
|
||||
function createInteractionBody(params: {
|
||||
context: Record<string, unknown>;
|
||||
token: string;
|
||||
channelId?: string;
|
||||
postId?: string;
|
||||
userId?: string;
|
||||
userName?: string;
|
||||
}) {
|
||||
return {
|
||||
user_id: params.userId ?? "user-1",
|
||||
...(params.userName ? { user_name: params.userName } : {}),
|
||||
channel_id: params.channelId ?? "chan-1",
|
||||
post_id: params.postId ?? "post-1",
|
||||
context: { ...params.context, _token: params.token },
|
||||
};
|
||||
}
|
||||
|
||||
async function runHandler(
|
||||
handler: ReturnType<typeof createMattermostInteractionHandler>,
|
||||
params: {
|
||||
body: unknown;
|
||||
remoteAddress?: string;
|
||||
headers?: Record<string, string>;
|
||||
},
|
||||
) {
|
||||
const req = createReq({
|
||||
remoteAddress: params.remoteAddress,
|
||||
headers: params.headers,
|
||||
body: params.body,
|
||||
});
|
||||
const res = createRes();
|
||||
await handler(req, res);
|
||||
return res;
|
||||
}
|
||||
|
||||
function expectForbiddenResponse(
|
||||
res: ServerResponse & { body: string },
|
||||
expectedMessage: string,
|
||||
) {
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toContain(expectedMessage);
|
||||
}
|
||||
|
||||
function expectSuccessfulApprovalUpdate(
|
||||
res: ServerResponse & { body: string },
|
||||
requestLog?: Array<{ path: string; method?: string }>,
|
||||
) {
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("{}");
|
||||
if (requestLog) {
|
||||
expect(requestLog).toEqual([
|
||||
{ path: "/posts/post-1", method: undefined },
|
||||
{ path: "/posts/post-1", method: "PUT" },
|
||||
]);
|
||||
}
|
||||
}
|
||||
|
||||
function createActionPost(params?: {
|
||||
actionId?: string;
|
||||
actionName?: string;
|
||||
channelId?: string;
|
||||
rootId?: string;
|
||||
}): MattermostPost {
|
||||
return {
|
||||
id: "post-1",
|
||||
channel_id: params?.channelId ?? "chan-1",
|
||||
...(params?.rootId ? { root_id: params.rootId } : {}),
|
||||
message: "Choose",
|
||||
props: {
|
||||
attachments: [
|
||||
{
|
||||
actions: [
|
||||
{
|
||||
id: params?.actionId ?? "approve",
|
||||
name: params?.actionName ?? "Approve",
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function createUnusedInteractionHandler() {
|
||||
return createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async () => ({ message: "unused" })),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
});
|
||||
}
|
||||
|
||||
async function runApproveInteraction(params?: {
|
||||
actionName?: string;
|
||||
allowedSourceIps?: string[];
|
||||
trustedProxies?: string[];
|
||||
remoteAddress?: string;
|
||||
headers?: Record<string, string>;
|
||||
}) {
|
||||
const { context, token } = createActionContext();
|
||||
const requestLog: Array<{ path: string; method?: string }> = [];
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async (path: string, init?: { method?: string }) => {
|
||||
requestLog.push({ path, method: init?.method });
|
||||
if (init?.method === "PUT") {
|
||||
return { id: "post-1" };
|
||||
}
|
||||
return createActionPost({ actionName: params?.actionName });
|
||||
}),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
allowedSourceIps: params?.allowedSourceIps,
|
||||
trustedProxies: params?.trustedProxies,
|
||||
});
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
remoteAddress: params?.remoteAddress,
|
||||
headers: params?.headers,
|
||||
body: createInteractionBody({ context, token, userName: "alice" }),
|
||||
});
|
||||
return { res, requestLog };
|
||||
}
|
||||
|
||||
async function runInvalidActionRequest(actionId: string) {
|
||||
const { context, token } = createActionContext();
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async () =>
|
||||
createActionPost({ actionId, actionName: actionId }),
|
||||
),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
});
|
||||
|
||||
return await runHandler(handler, {
|
||||
body: createInteractionBody({ context, token }),
|
||||
});
|
||||
}
|
||||
|
||||
it("accepts callback requests from an allowlisted source IP", async () => {
|
||||
const { res, requestLog } = await runApproveInteraction({
|
||||
allowedSourceIps: ["198.51.100.8"],
|
||||
remoteAddress: "198.51.100.8",
|
||||
});
|
||||
|
||||
expectSuccessfulApprovalUpdate(res, requestLog);
|
||||
});
|
||||
|
||||
it("accepts forwarded Mattermost source IPs from a trusted proxy", async () => {
|
||||
const { res } = await runApproveInteraction({
|
||||
allowedSourceIps: ["198.51.100.8"],
|
||||
trustedProxies: ["127.0.0.1"],
|
||||
remoteAddress: "127.0.0.1",
|
||||
headers: { "x-forwarded-for": "198.51.100.8" },
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe("{}");
|
||||
});
|
||||
|
||||
it("rejects callback requests from non-allowlisted source IPs", async () => {
|
||||
const { context, token } = createActionContext();
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async () => {
|
||||
throw new Error("should not fetch post for rejected origins");
|
||||
}),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
allowedSourceIps: ["127.0.0.1"],
|
||||
});
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
remoteAddress: "198.51.100.8",
|
||||
body: createInteractionBody({ context, token }),
|
||||
});
|
||||
expectForbiddenResponse(res, "Forbidden origin");
|
||||
});
|
||||
|
||||
it("rejects requests with an invalid interaction token", async () => {
|
||||
const handler = createUnusedInteractionHandler();
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
body: {
|
||||
user_id: "user-1",
|
||||
channel_id: "chan-1",
|
||||
post_id: "post-1",
|
||||
context: { action_id: "approve", _token: "deadbeef" },
|
||||
},
|
||||
});
|
||||
expectForbiddenResponse(res, "Invalid token");
|
||||
});
|
||||
|
||||
it("rejects requests when the signed channel does not match the callback payload", async () => {
|
||||
const { context, token } = createActionContext();
|
||||
const handler = createUnusedInteractionHandler();
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
body: createInteractionBody({ context, token, channelId: "chan-2" }),
|
||||
});
|
||||
expectForbiddenResponse(res, "Channel mismatch");
|
||||
});
|
||||
|
||||
it("rejects requests when the fetched post does not belong to the callback channel", async () => {
|
||||
const { context, token } = createActionContext();
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async () => createActionPost({ channelId: "chan-9" })),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
});
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
body: createInteractionBody({ context, token }),
|
||||
});
|
||||
expectForbiddenResponse(res, "Post/channel mismatch");
|
||||
});
|
||||
|
||||
it("rejects requests when the action is not present on the fetched post", async () => {
|
||||
const res = await runInvalidActionRequest("reject");
|
||||
|
||||
expect(res.statusCode).toBe(403);
|
||||
expect(res.body).toContain("Unknown action");
|
||||
});
|
||||
|
||||
it("accepts actions when the button name matches the action id", async () => {
|
||||
const { res, requestLog } = await runApproveInteraction({
|
||||
actionName: "approve",
|
||||
});
|
||||
|
||||
expectSuccessfulApprovalUpdate(res, requestLog);
|
||||
});
|
||||
|
||||
it("blocks button dispatch when the sender is not allowed for the action", async () => {
|
||||
const { context, token } = createActionContext();
|
||||
const dispatchButtonClick = vi.fn();
|
||||
const handleInteraction = vi.fn();
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async (_path: string, init?: { method?: string }) =>
|
||||
init?.method === "PUT" ? { id: "post-1" } : createActionPost(),
|
||||
),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
authorizeButtonClick: async () => ({
|
||||
ok: false,
|
||||
response: {
|
||||
ephemeral_text: "blocked",
|
||||
},
|
||||
}),
|
||||
handleInteraction,
|
||||
dispatchButtonClick,
|
||||
});
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
body: createInteractionBody({ context, token }),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toContain("blocked");
|
||||
expect(handleInteraction).not.toHaveBeenCalled();
|
||||
expect(dispatchButtonClick).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("forwards fetched post threading metadata to session and button callbacks", async () => {
|
||||
const enqueueSystemEvent = vi.fn();
|
||||
setInteractionRuntime(enqueueSystemEvent);
|
||||
const { context, token } = createActionContext();
|
||||
const resolveSessionKey = vi.fn().mockResolvedValue("session:thread:root-9");
|
||||
const dispatchButtonClick = vi.fn();
|
||||
const fetchedPost = createActionPost({ rootId: "root-9" });
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async (_path: string, init?: { method?: string }) =>
|
||||
init?.method === "PUT" ? { id: "post-1" } : fetchedPost,
|
||||
),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
resolveSessionKey,
|
||||
dispatchButtonClick,
|
||||
});
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
body: createInteractionBody({ context, token, userName: "alice" }),
|
||||
});
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(resolveSessionKey).toHaveBeenCalledWith({
|
||||
channelId: "chan-1",
|
||||
userId: "user-1",
|
||||
post: fetchedPost,
|
||||
});
|
||||
expect(enqueueSystemEvent).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Mattermost button click: action="approve"'),
|
||||
expect.objectContaining({ sessionKey: "session:thread:root-9" }),
|
||||
);
|
||||
expect(dispatchButtonClick).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
channelId: "chan-1",
|
||||
userId: "user-1",
|
||||
postId: "post-1",
|
||||
post: fetchedPost,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("lets a custom interaction handler short-circuit generic completion updates", async () => {
|
||||
const { context, token } = createActionContext("mdlprov");
|
||||
const requestLog: Array<{ path: string; method?: string }> = [];
|
||||
const handleInteraction = vi.fn().mockResolvedValue({
|
||||
ephemeral_text: "Only the original requester can use this picker.",
|
||||
});
|
||||
const dispatchButtonClick = vi.fn();
|
||||
const handler = createMattermostInteractionHandler({
|
||||
client: createMattermostClientMock(async (path: string, init?: { method?: string }) => {
|
||||
requestLog.push({ path, method: init?.method });
|
||||
return createActionPost({
|
||||
actionId: "mdlprov",
|
||||
actionName: "Browse providers",
|
||||
});
|
||||
}),
|
||||
botUserId: "bot",
|
||||
accountId: "acct",
|
||||
handleInteraction,
|
||||
dispatchButtonClick,
|
||||
});
|
||||
|
||||
const res = await runHandler(handler, {
|
||||
body: createInteractionBody({
|
||||
context,
|
||||
token,
|
||||
userId: "user-2",
|
||||
userName: "alice",
|
||||
}),
|
||||
});
|
||||
|
||||
expect(res.statusCode).toBe(200);
|
||||
expect(res.body).toBe(
|
||||
JSON.stringify({
|
||||
ephemeral_text: "Only the original requester can use this picker.",
|
||||
}),
|
||||
);
|
||||
expect(requestLog).toEqual([{ path: "/posts/post-1", method: undefined }]);
|
||||
expect(handleInteraction).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actionId: "mdlprov",
|
||||
actionName: "Browse providers",
|
||||
originalMessage: "Choose",
|
||||
post: expect.objectContaining({ id: "post-1" }),
|
||||
userName: "alice",
|
||||
}),
|
||||
);
|
||||
expect(dispatchButtonClick).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
693
openclaw/extensions/mattermost/src/mattermost/interactions.ts
Normal file
693
openclaw/extensions/mattermost/src/mattermost/interactions.ts
Normal file
|
|
@ -0,0 +1,693 @@
|
|||
import { createHmac } from "node:crypto";
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-security-runtime";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringifiedOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { updateMattermostPost, type MattermostClient, type MattermostPost } from "./client.js";
|
||||
import { isTrustedProxyAddress, resolveClientIp, type OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
const INTERACTION_MAX_BODY_BYTES = 64 * 1024;
|
||||
const INTERACTION_BODY_TIMEOUT_MS = 10_000;
|
||||
const SIGNED_CHANNEL_ID_CONTEXT_KEY = "__openclaw_channel_id";
|
||||
|
||||
/**
|
||||
* Mattermost interactive message callback payload.
|
||||
* Sent by Mattermost when a user clicks an action button.
|
||||
* See: https://developers.mattermost.com/integrate/plugins/interactive-messages/
|
||||
*/
|
||||
export type MattermostInteractionPayload = {
|
||||
user_id: string;
|
||||
user_name?: string;
|
||||
channel_id: string;
|
||||
team_id?: string;
|
||||
post_id: string;
|
||||
trigger_id?: string;
|
||||
type?: string;
|
||||
data_source?: string;
|
||||
context?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type MattermostInteractionResponse = {
|
||||
update?: {
|
||||
message: string;
|
||||
props?: Record<string, unknown>;
|
||||
};
|
||||
ephemeral_text?: string;
|
||||
};
|
||||
|
||||
export type MattermostInteractionAuthorizationResult =
|
||||
| { ok: true }
|
||||
| { ok: false; statusCode?: number; response?: MattermostInteractionResponse };
|
||||
|
||||
export type MattermostInteractiveButtonInput = {
|
||||
id?: string;
|
||||
callback_data?: string;
|
||||
text?: string;
|
||||
name?: string;
|
||||
label?: string;
|
||||
style?: "default" | "primary" | "danger";
|
||||
context?: Record<string, unknown>;
|
||||
};
|
||||
|
||||
// ── Callback URL registry ──────────────────────────────────────────────
|
||||
|
||||
const callbackUrls = new Map<string, string>();
|
||||
|
||||
export function setInteractionCallbackUrl(accountId: string, url: string): void {
|
||||
callbackUrls.set(accountId, url);
|
||||
}
|
||||
|
||||
export function getInteractionCallbackUrl(accountId: string): string | undefined {
|
||||
return callbackUrls.get(accountId);
|
||||
}
|
||||
|
||||
type InteractionCallbackConfig = Pick<OpenClawConfig, "gateway" | "channels"> & {
|
||||
interactions?: {
|
||||
callbackBaseUrl?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export function resolveInteractionCallbackPath(accountId: string): string {
|
||||
return `/mattermost/interactions/${accountId}`;
|
||||
}
|
||||
|
||||
function isWildcardBindHost(rawHost: string): boolean {
|
||||
const trimmed = rawHost.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const host = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
|
||||
return host === "0.0.0.0" || host === "::" || host === "0:0:0:0:0:0:0:0" || host === "::0";
|
||||
}
|
||||
|
||||
function normalizeCallbackBaseUrl(baseUrl: string): string {
|
||||
return baseUrl.trim().replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function headerValue(value: string | string[] | undefined): string | undefined {
|
||||
if (Array.isArray(value)) {
|
||||
return 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 ─────────────────────────────────────────────
|
||||
|
||||
export type MattermostButton = {
|
||||
id: string;
|
||||
type: "button" | "select";
|
||||
name: string;
|
||||
style?: "default" | "primary" | "danger";
|
||||
integration: {
|
||||
url: string;
|
||||
context: Record<string, unknown>;
|
||||
};
|
||||
};
|
||||
|
||||
export type MattermostAttachment = {
|
||||
text?: string;
|
||||
actions?: MattermostButton[];
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
/**
|
||||
* Build Mattermost `props.attachments` with interactive buttons.
|
||||
*
|
||||
* Each button includes an HMAC token in its integration context so the
|
||||
* callback handler can verify the request originated from a legitimate
|
||||
* button click (Mattermost's recommended security pattern).
|
||||
*/
|
||||
/**
|
||||
* Sanitize a button ID so Mattermost's action router can match it.
|
||||
* Mattermost uses the action ID in the URL path `/api/v4/posts/{id}/actions/{actionId}`
|
||||
* and IDs containing hyphens or underscores break the server-side routing.
|
||||
* See: https://github.com/mattermost/mattermost/issues/25747
|
||||
*/
|
||||
function sanitizeActionId(id: string): string {
|
||||
return id.replace(/[-_]/g, "");
|
||||
}
|
||||
|
||||
export function buildButtonAttachments(params: {
|
||||
callbackUrl: string;
|
||||
accountId?: string;
|
||||
buttons: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
style?: "default" | "primary" | "danger";
|
||||
context?: Record<string, unknown>;
|
||||
}>;
|
||||
text?: string;
|
||||
}): MattermostAttachment[] {
|
||||
const actions: MattermostButton[] = params.buttons.map((btn) => {
|
||||
const safeId = sanitizeActionId(btn.id);
|
||||
const context: Record<string, unknown> = {
|
||||
action_id: safeId,
|
||||
...btn.context,
|
||||
};
|
||||
const token = generateInteractionToken(context, params.accountId);
|
||||
return {
|
||||
id: safeId,
|
||||
type: "button" as const,
|
||||
name: btn.name,
|
||||
style: btn.style,
|
||||
integration: {
|
||||
url: params.callbackUrl,
|
||||
context: {
|
||||
...context,
|
||||
_token: token,
|
||||
},
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
return [
|
||||
{
|
||||
text: params.text ?? "",
|
||||
actions,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function buildButtonProps(params: {
|
||||
callbackUrl: string;
|
||||
accountId?: string;
|
||||
channelId: string;
|
||||
buttons: Array<unknown>;
|
||||
text?: string;
|
||||
}): Record<string, unknown> | undefined {
|
||||
const rawButtons = params.buttons.flatMap((item) =>
|
||||
Array.isArray(item) ? item : [item],
|
||||
) as MattermostInteractiveButtonInput[];
|
||||
|
||||
const buttons = rawButtons
|
||||
.map((btn) => ({
|
||||
id: 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 new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let totalBytes = 0;
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
req.destroy();
|
||||
reject(new Error("Request body read timeout"));
|
||||
}, INTERACTION_BODY_TIMEOUT_MS);
|
||||
|
||||
req.on("data", (chunk: Buffer) => {
|
||||
totalBytes += chunk.length;
|
||||
if (totalBytes > INTERACTION_MAX_BODY_BYTES) {
|
||||
req.destroy();
|
||||
clearTimeout(timer);
|
||||
reject(new Error("Request body too large"));
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk);
|
||||
});
|
||||
|
||||
req.on("end", () => {
|
||||
clearTimeout(timer);
|
||||
resolve(Buffer.concat(chunks).toString("utf8"));
|
||||
});
|
||||
|
||||
req.on("error", (err) => {
|
||||
clearTimeout(timer);
|
||||
reject(err);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ── HTTP handler ───────────────────────────────────────────────────────
|
||||
|
||||
export function createMattermostInteractionHandler(params: {
|
||||
client: MattermostClient;
|
||||
botUserId: string;
|
||||
accountId: string;
|
||||
allowedSourceIps?: string[];
|
||||
trustedProxies?: string[];
|
||||
allowRealIpFallback?: boolean;
|
||||
resolveSessionKey?: (params: {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
post: MattermostPost;
|
||||
}) => Promise<string>;
|
||||
handleInteraction?: (opts: {
|
||||
payload: MattermostInteractionPayload;
|
||||
userName: string;
|
||||
actionId: string;
|
||||
actionName: string;
|
||||
originalMessage: string;
|
||||
context: Record<string, unknown>;
|
||||
post: MattermostPost;
|
||||
}) => Promise<MattermostInteractionResponse | null>;
|
||||
authorizeButtonClick?: (opts: {
|
||||
payload: MattermostInteractionPayload;
|
||||
post: MattermostPost;
|
||||
}) => Promise<MattermostInteractionAuthorizationResult>;
|
||||
dispatchButtonClick?: (opts: {
|
||||
channelId: string;
|
||||
userId: string;
|
||||
userName: string;
|
||||
actionId: string;
|
||||
actionName: string;
|
||||
postId: string;
|
||||
post: MattermostPost;
|
||||
}) => Promise<void>;
|
||||
log?: (message: string) => void;
|
||||
}): (req: IncomingMessage, res: ServerResponse) => Promise<void> {
|
||||
const { client, accountId, log } = params;
|
||||
const core = getMattermostRuntime();
|
||||
|
||||
return async (req: IncomingMessage, res: ServerResponse) => {
|
||||
// Only accept POST
|
||||
if (req.method !== "POST") {
|
||||
res.statusCode = 405;
|
||||
res.setHeader("Allow", "POST");
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Method Not Allowed" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!isAllowedInteractionSource({
|
||||
req,
|
||||
allowedSourceIps: params.allowedSourceIps,
|
||||
trustedProxies: params.trustedProxies,
|
||||
allowRealIpFallback: params.allowRealIpFallback,
|
||||
})
|
||||
) {
|
||||
log?.(
|
||||
`mattermost interaction: rejected callback source remote=${req.socket?.remoteAddress ?? "?"}`,
|
||||
);
|
||||
res.statusCode = 403;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Forbidden origin" }));
|
||||
return;
|
||||
}
|
||||
|
||||
let payload: MattermostInteractionPayload;
|
||||
try {
|
||||
const raw = await readInteractionBody(req);
|
||||
payload = JSON.parse(raw) as MattermostInteractionPayload;
|
||||
} catch (err) {
|
||||
log?.(`mattermost interaction: failed to parse body: ${String(err)}`);
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Invalid request body" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const context = payload.context;
|
||||
if (!context) {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Missing context" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Verify HMAC token
|
||||
const token = context._token;
|
||||
if (typeof token !== "string") {
|
||||
log?.("mattermost interaction: missing _token in context");
|
||||
res.statusCode = 403;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Missing token" }));
|
||||
return;
|
||||
}
|
||||
|
||||
// Strip _token before verification (it wasn't in the original context)
|
||||
const { _token, ...contextWithoutToken } = context;
|
||||
if (!verifyInteractionToken(contextWithoutToken, token, accountId)) {
|
||||
log?.("mattermost interaction: invalid _token");
|
||||
res.statusCode = 403;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Invalid token" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const actionId = context.action_id;
|
||||
if (typeof actionId !== "string") {
|
||||
res.statusCode = 400;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Missing action_id in context" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const signedChannelId =
|
||||
typeof contextWithoutToken[SIGNED_CHANNEL_ID_CONTEXT_KEY] === "string"
|
||||
? contextWithoutToken[SIGNED_CHANNEL_ID_CONTEXT_KEY].trim()
|
||||
: "";
|
||||
if (signedChannelId && signedChannelId !== payload.channel_id) {
|
||||
log?.(
|
||||
`mattermost interaction: signed channel mismatch payload=${payload.channel_id} signed=${signedChannelId}`,
|
||||
);
|
||||
res.statusCode = 403;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Channel mismatch" }));
|
||||
return;
|
||||
}
|
||||
|
||||
const userName = payload.user_name ?? payload.user_id;
|
||||
let originalMessage = "";
|
||||
let originalPost: MattermostPost | null = null;
|
||||
let clickedButtonName: string | null = null;
|
||||
try {
|
||||
originalPost = await client.request<MattermostPost>(`/posts/${payload.post_id}`);
|
||||
const postChannelId = originalPost.channel_id?.trim();
|
||||
if (!postChannelId || postChannelId !== payload.channel_id) {
|
||||
log?.(
|
||||
`mattermost interaction: post channel mismatch payload=${payload.channel_id} post=${postChannelId ?? "<missing>"}`,
|
||||
);
|
||||
res.statusCode = 403;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Post/channel mismatch" }));
|
||||
return;
|
||||
}
|
||||
originalMessage = originalPost.message ?? "";
|
||||
|
||||
// Ensure the callback can only target an action that exists on the original post.
|
||||
const postAttachments = Array.isArray(originalPost?.props?.attachments)
|
||||
? (originalPost.props.attachments as Array<{
|
||||
actions?: Array<{ id?: string; name?: string }>;
|
||||
}>)
|
||||
: [];
|
||||
for (const att of postAttachments) {
|
||||
const match = att.actions?.find((a) => a.id === actionId);
|
||||
if (match?.name) {
|
||||
clickedButtonName = match.name;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (clickedButtonName === null) {
|
||||
log?.(`mattermost interaction: action ${actionId} not found in post ${payload.post_id}`);
|
||||
res.statusCode = 403;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Unknown action" }));
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
log?.(`mattermost interaction: failed to validate post ${payload.post_id}: ${String(err)}`);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Failed to validate interaction" }));
|
||||
return;
|
||||
}
|
||||
|
||||
if (!originalPost) {
|
||||
log?.(`mattermost interaction: missing fetched post ${payload.post_id}`);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Failed to load interaction post" }));
|
||||
return;
|
||||
}
|
||||
|
||||
log?.(
|
||||
`mattermost interaction: action=${actionId} user=${payload.user_name ?? payload.user_id} ` +
|
||||
`post=${payload.post_id} channel=${payload.channel_id}`,
|
||||
);
|
||||
|
||||
if (params.authorizeButtonClick) {
|
||||
try {
|
||||
const authorization = await params.authorizeButtonClick({
|
||||
payload,
|
||||
post: originalPost,
|
||||
});
|
||||
if (!authorization.ok) {
|
||||
res.statusCode = authorization.statusCode ?? 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(
|
||||
JSON.stringify(
|
||||
authorization.response ?? {
|
||||
ephemeral_text: "You are not allowed to use this action here.",
|
||||
},
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
log?.(`mattermost interaction: authorization failed: ${String(err)}`);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Interaction authorization failed" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (params.handleInteraction) {
|
||||
try {
|
||||
const response = await params.handleInteraction({
|
||||
payload,
|
||||
userName,
|
||||
actionId,
|
||||
actionName: clickedButtonName,
|
||||
originalMessage,
|
||||
context: contextWithoutToken,
|
||||
post: originalPost,
|
||||
});
|
||||
if (response !== null) {
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify(response));
|
||||
return;
|
||||
}
|
||||
} catch (err) {
|
||||
log?.(`mattermost interaction: custom handler failed: ${String(err)}`);
|
||||
res.statusCode = 500;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end(JSON.stringify({ error: "Interaction handler failed" }));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Dispatch as system event so the agent can handle it.
|
||||
// Wrapped in try/catch — the post update below must still run even if
|
||||
// system event dispatch fails (e.g. missing sessionKey or channel lookup).
|
||||
try {
|
||||
const eventLabel =
|
||||
`Mattermost button click: action="${actionId}" ` +
|
||||
`by ${payload.user_name ?? payload.user_id} ` +
|
||||
`in channel ${payload.channel_id}`;
|
||||
|
||||
const sessionKey = params.resolveSessionKey
|
||||
? await params.resolveSessionKey({
|
||||
channelId: payload.channel_id,
|
||||
userId: payload.user_id,
|
||||
post: originalPost,
|
||||
})
|
||||
: `agent:main:mattermost:${accountId}:${payload.channel_id}`;
|
||||
|
||||
core.system.enqueueSystemEvent(eventLabel, {
|
||||
sessionKey,
|
||||
contextKey: `mattermost:interaction:${payload.post_id}:${actionId}`,
|
||||
});
|
||||
} catch (err) {
|
||||
log?.(`mattermost interaction: system event dispatch failed: ${String(err)}`);
|
||||
}
|
||||
|
||||
// Update the post via API to replace buttons with a completion indicator.
|
||||
try {
|
||||
await updateMattermostPost(client, payload.post_id, {
|
||||
message: originalMessage,
|
||||
props: {
|
||||
attachments: [
|
||||
{
|
||||
text: `✓ **${clickedButtonName}** selected by @${userName}`,
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
log?.(`mattermost interaction: failed to update post ${payload.post_id}: ${String(err)}`);
|
||||
}
|
||||
|
||||
// Respond with empty JSON — the post update is handled above
|
||||
res.statusCode = 200;
|
||||
res.setHeader("Content-Type", "application/json");
|
||||
res.end("{}");
|
||||
|
||||
// Dispatch a synthetic inbound message so the agent responds to the button click.
|
||||
if (params.dispatchButtonClick) {
|
||||
try {
|
||||
await params.dispatchButtonClick({
|
||||
channelId: payload.channel_id,
|
||||
userId: payload.user_id,
|
||||
userName,
|
||||
actionId,
|
||||
actionName: clickedButtonName,
|
||||
postId: payload.post_id,
|
||||
post: originalPost,
|
||||
});
|
||||
} catch (err) {
|
||||
log?.(`mattermost interaction: dispatchButtonClick failed: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,175 @@
|
|||
import fs from "node:fs";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../../runtime-api.js";
|
||||
import {
|
||||
buildMattermostAllowedModelRefs,
|
||||
parseMattermostModelPickerContext,
|
||||
renderMattermostModelSummaryView,
|
||||
renderMattermostModelsPickerView,
|
||||
renderMattermostProviderPickerView,
|
||||
resolveMattermostModelPickerCurrentModel,
|
||||
resolveMattermostModelPickerEntry,
|
||||
} from "./model-picker.js";
|
||||
|
||||
const data = {
|
||||
byProvider: new Map<string, Set<string>>([
|
||||
["anthropic", new Set(["claude-opus-4-5", "claude-sonnet-4-5"])],
|
||||
["openai", new Set(["gpt-4.1", "gpt-5"])],
|
||||
]),
|
||||
providers: ["anthropic", "openai"],
|
||||
resolvedDefault: {
|
||||
provider: "anthropic",
|
||||
model: "claude-opus-4-5",
|
||||
},
|
||||
modelNames: new Map<string, string>(),
|
||||
};
|
||||
|
||||
describe("Mattermost model picker", () => {
|
||||
it("resolves bare /model and /models entry points", () => {
|
||||
expect(resolveMattermostModelPickerEntry("/model")).toEqual({ kind: "summary" });
|
||||
expect(resolveMattermostModelPickerEntry("/models")).toEqual({ kind: "providers" });
|
||||
expect(resolveMattermostModelPickerEntry("/models OpenAI")).toEqual({
|
||||
kind: "models",
|
||||
provider: "openai",
|
||||
});
|
||||
expect(resolveMattermostModelPickerEntry("/model openai/gpt-5")).toBeNull();
|
||||
});
|
||||
|
||||
it("builds the allowed model refs set", () => {
|
||||
expect(buildMattermostAllowedModelRefs(data)).toEqual(
|
||||
new Set([
|
||||
"anthropic/claude-opus-4-5",
|
||||
"anthropic/claude-sonnet-4-5",
|
||||
"openai/gpt-4.1",
|
||||
"openai/gpt-5",
|
||||
]),
|
||||
);
|
||||
});
|
||||
|
||||
it("renders the summary view with a browse button", () => {
|
||||
const view = renderMattermostModelSummaryView({
|
||||
ownerUserId: "user-1",
|
||||
currentModel: "openai/gpt-5",
|
||||
});
|
||||
|
||||
expect(view.text).toContain("Current: openai/gpt-5");
|
||||
expect(view.text).toContain("Tap below to browse models");
|
||||
expect(view.text).toContain("/oc_model <provider/model> to switch");
|
||||
expect(view.buttons[0]?.[0]?.text).toBe("Browse providers");
|
||||
});
|
||||
|
||||
it("trims accidental model spacing in Mattermost current-model text", () => {
|
||||
const view = renderMattermostModelSummaryView({
|
||||
ownerUserId: "user-1",
|
||||
currentModel: " OpenAI/ gpt-5 ",
|
||||
});
|
||||
|
||||
expect(view.text).toContain("Current: openai/gpt-5");
|
||||
});
|
||||
|
||||
it("renders providers and models with Telegram-style navigation", () => {
|
||||
const providersView = renderMattermostProviderPickerView({
|
||||
ownerUserId: "user-1",
|
||||
data,
|
||||
currentModel: "openai/gpt-5",
|
||||
});
|
||||
const providerTexts = providersView.buttons.flat().map((button) => button.text);
|
||||
expect(providerTexts).toContain("anthropic (2)");
|
||||
expect(providerTexts).toContain("openai (2)");
|
||||
|
||||
const modelsView = renderMattermostModelsPickerView({
|
||||
ownerUserId: "user-1",
|
||||
data,
|
||||
provider: "openai",
|
||||
page: 1,
|
||||
currentModel: "openai/gpt-5",
|
||||
});
|
||||
const modelTexts = modelsView.buttons.flat().map((button) => button.text);
|
||||
expect(modelsView.text).toContain("Models (openai) - 2 available");
|
||||
expect(modelTexts).toContain("gpt-5 [current]");
|
||||
expect(modelTexts).toContain("Back to providers");
|
||||
});
|
||||
|
||||
it("renders unique alphanumeric action ids per button", () => {
|
||||
const modelsView = renderMattermostModelsPickerView({
|
||||
ownerUserId: "user-1",
|
||||
data,
|
||||
provider: "openai",
|
||||
page: 1,
|
||||
currentModel: "openai/gpt-5",
|
||||
});
|
||||
|
||||
const ids = modelsView.buttons.flat().map((button) => button.id);
|
||||
expect(ids.every((id) => typeof id === "string" && /^[a-z0-9]+$/.test(id))).toBe(true);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
});
|
||||
|
||||
it("parses signed picker contexts", () => {
|
||||
expect(
|
||||
parseMattermostModelPickerContext({
|
||||
oc_model_picker: true,
|
||||
action: "select",
|
||||
ownerUserId: "user-1",
|
||||
provider: "openai",
|
||||
page: 2,
|
||||
model: "gpt-5",
|
||||
}),
|
||||
).toEqual({
|
||||
action: "select",
|
||||
ownerUserId: "user-1",
|
||||
provider: "openai",
|
||||
page: 2,
|
||||
model: "gpt-5",
|
||||
});
|
||||
expect(parseMattermostModelPickerContext({ action: "select" })).toBeNull();
|
||||
});
|
||||
|
||||
it("falls back to the routed agent default model when no override is stored", async () => {
|
||||
const testDir = fs.mkdtempSync(path.join(os.tmpdir(), "mm-model-picker-"));
|
||||
try {
|
||||
const cfg: OpenClawConfig = {
|
||||
session: {
|
||||
store: path.join(testDir, "{agentId}.json"),
|
||||
},
|
||||
agents: {
|
||||
defaults: {
|
||||
model: "anthropic/claude-opus-4-5",
|
||||
},
|
||||
list: [
|
||||
{
|
||||
id: "support",
|
||||
model: "openai/gpt-5",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
const providerData = {
|
||||
byProvider: new Map<string, Set<string>>([
|
||||
["anthropic", new Set(["claude-opus-4-5"])],
|
||||
["openai", new Set(["gpt-5"])],
|
||||
]),
|
||||
providers: ["anthropic", "openai"],
|
||||
resolvedDefault: {
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
},
|
||||
modelNames: new Map<string, string>(),
|
||||
};
|
||||
|
||||
expect(
|
||||
resolveMattermostModelPickerCurrentModel({
|
||||
cfg,
|
||||
route: {
|
||||
agentId: "support",
|
||||
sessionKey: "agent:support:main",
|
||||
},
|
||||
data: providerData,
|
||||
}),
|
||||
).toBe("openai/gpt-5");
|
||||
} finally {
|
||||
fs.rmSync(testDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
405
openclaw/extensions/mattermost/src/mattermost/model-picker.ts
Normal file
405
openclaw/extensions/mattermost/src/mattermost/model-picker.ts
Normal file
|
|
@ -0,0 +1,405 @@
|
|||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringifiedOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { MattermostInteractiveButtonInput } from "./interactions.js";
|
||||
import {
|
||||
loadSessionStore,
|
||||
normalizeProviderId,
|
||||
resolveStorePath,
|
||||
resolveStoredModelOverride,
|
||||
type ModelsProviderData,
|
||||
type OpenClawConfig,
|
||||
} from "./runtime-api.js";
|
||||
|
||||
const MATTERMOST_MODEL_PICKER_CONTEXT_KEY = "oc_model_picker";
|
||||
const MODELS_PAGE_SIZE = 8;
|
||||
const ACTION_IDS = {
|
||||
providers: "mdlprov",
|
||||
list: "mdllist",
|
||||
select: "mdlsel",
|
||||
back: "mdlback",
|
||||
} as const;
|
||||
|
||||
export type MattermostModelPickerEntry =
|
||||
| { kind: "summary" }
|
||||
| { kind: "providers" }
|
||||
| { kind: "models"; provider: string };
|
||||
|
||||
export type MattermostModelPickerState =
|
||||
| { action: "providers"; ownerUserId: string }
|
||||
| { action: "back"; ownerUserId: string }
|
||||
| { action: "list"; ownerUserId: string; provider: string; page: number }
|
||||
| { action: "select"; ownerUserId: string; provider: string; page: number; model: string };
|
||||
|
||||
export type MattermostModelPickerRenderedView = {
|
||||
text: string;
|
||||
buttons: MattermostInteractiveButtonInput[][];
|
||||
};
|
||||
|
||||
function splitModelRef(modelRef?: string | null): { provider: string; model: string } | null {
|
||||
const trimmed = 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") {
|
||||
const parsed = Number.parseInt(value.trim(), 10);
|
||||
if (Number.isFinite(parsed)) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
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;
|
||||
skipCache?: boolean;
|
||||
}): string {
|
||||
const fallback = `${params.data.resolvedDefault.provider}/${params.data.resolvedDefault.model}`;
|
||||
try {
|
||||
const storePath = resolveStorePath(params.cfg.session?.store, {
|
||||
agentId: params.route.agentId,
|
||||
});
|
||||
const sessionStore = params.skipCache
|
||||
? loadSessionStore(storePath, { skipCache: true })
|
||||
: loadSessionStore(storePath);
|
||||
const sessionEntry = sessionStore[params.route.sessionKey];
|
||||
const override = resolveStoredModelOverride({
|
||||
sessionEntry,
|
||||
sessionStore,
|
||||
sessionKey: params.route.sessionKey,
|
||||
defaultProvider: params.data.resolvedDefault.provider,
|
||||
});
|
||||
if (!override?.model) {
|
||||
return fallback;
|
||||
}
|
||||
const provider = (override.provider || params.data.resolvedDefault.provider).trim();
|
||||
return provider ? `${provider}/${override.model}` : fallback;
|
||||
} catch {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
export function renderMattermostModelSummaryView(params: {
|
||||
ownerUserId: string;
|
||||
currentModel?: string;
|
||||
}): MattermostModelPickerRenderedView {
|
||||
return {
|
||||
text: [
|
||||
formatCurrentModelLine(params.currentModel),
|
||||
"",
|
||||
"Tap below to browse models, or use:",
|
||||
"/oc_model <provider/model> to switch",
|
||||
"/oc_model status for details",
|
||||
].join("\n"),
|
||||
buttons: [
|
||||
[
|
||||
buildButton({
|
||||
action: "providers",
|
||||
ownerUserId: params.ownerUserId,
|
||||
text: "Browse providers",
|
||||
style: "primary",
|
||||
}),
|
||||
],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
export function renderMattermostProviderPickerView(params: {
|
||||
ownerUserId: string;
|
||||
data: ModelsProviderData;
|
||||
currentModel?: string;
|
||||
}): MattermostModelPickerRenderedView {
|
||||
const currentProvider = splitModelRef(params.currentModel)?.provider;
|
||||
const rows = params.data.providers.map((provider) => [
|
||||
buildButton({
|
||||
action: "list",
|
||||
ownerUserId: params.ownerUserId,
|
||||
text: `${provider} (${params.data.byProvider.get(provider)?.size ?? 0})`,
|
||||
provider,
|
||||
page: 1,
|
||||
style: provider === currentProvider ? "primary" : "default",
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
text: [formatCurrentModelLine(params.currentModel), "", "Select a provider:"].join("\n"),
|
||||
buttons: rows,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderMattermostModelsPickerView(params: {
|
||||
ownerUserId: string;
|
||||
data: ModelsProviderData;
|
||||
provider: string;
|
||||
page?: number;
|
||||
currentModel?: string;
|
||||
}): MattermostModelPickerRenderedView {
|
||||
const provider = normalizeProviderId(params.provider);
|
||||
const models = getProviderModels(params.data, provider);
|
||||
const current = splitModelRef(params.currentModel);
|
||||
|
||||
if (models.length === 0) {
|
||||
return {
|
||||
text: [formatCurrentModelLine(params.currentModel), "", `Unknown provider: ${provider}`].join(
|
||||
"\n",
|
||||
),
|
||||
buttons: [
|
||||
[
|
||||
buildButton({
|
||||
action: "back",
|
||||
ownerUserId: params.ownerUserId,
|
||||
text: "Back to providers",
|
||||
}),
|
||||
],
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const page = paginateItems(models, params.page);
|
||||
const rows: MattermostInteractiveButtonInput[][] = page.items.map((model) => {
|
||||
const isCurrent = current?.provider === provider && current.model === model;
|
||||
return [
|
||||
buildButton({
|
||||
action: "select",
|
||||
ownerUserId: params.ownerUserId,
|
||||
text: isCurrent ? `${model} [current]` : model,
|
||||
provider,
|
||||
model,
|
||||
page: page.page,
|
||||
style: isCurrent ? "primary" : "default",
|
||||
}),
|
||||
];
|
||||
});
|
||||
|
||||
const navRow: MattermostInteractiveButtonInput[] = [];
|
||||
if (page.hasPrev) {
|
||||
navRow.push(
|
||||
buildButton({
|
||||
action: "list",
|
||||
ownerUserId: params.ownerUserId,
|
||||
text: "Prev",
|
||||
provider,
|
||||
page: page.page - 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (page.hasNext) {
|
||||
navRow.push(
|
||||
buildButton({
|
||||
action: "list",
|
||||
ownerUserId: params.ownerUserId,
|
||||
text: "Next",
|
||||
provider,
|
||||
page: page.page + 1,
|
||||
}),
|
||||
);
|
||||
}
|
||||
if (navRow.length > 0) {
|
||||
rows.push(navRow);
|
||||
}
|
||||
|
||||
rows.push([
|
||||
buildButton({
|
||||
action: "back",
|
||||
ownerUserId: params.ownerUserId,
|
||||
text: "Back to providers",
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
text: [
|
||||
`Models (${provider}) - ${page.totalItems} available`,
|
||||
formatCurrentModelLine(params.currentModel),
|
||||
`Page ${page.page}/${page.totalPages}`,
|
||||
"Select a model to switch immediately.",
|
||||
].join("\n"),
|
||||
buttons: rows,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,165 @@
|
|||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const evaluateSenderGroupAccessForPolicy = vi.hoisted(() => vi.fn());
|
||||
const isDangerousNameMatchingEnabled = vi.hoisted(() => vi.fn());
|
||||
const resolveAllowlistMatchSimple = vi.hoisted(() => vi.fn());
|
||||
const resolveControlCommandGate = vi.hoisted(() => vi.fn());
|
||||
const resolveEffectiveAllowFromLists = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./runtime-api.js", () => ({
|
||||
evaluateSenderGroupAccessForPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistMatchSimple,
|
||||
resolveControlCommandGate,
|
||||
resolveEffectiveAllowFromLists,
|
||||
}));
|
||||
|
||||
describe("mattermost monitor auth", () => {
|
||||
let authorizeMattermostCommandInvocation: typeof import("./monitor-auth.js").authorizeMattermostCommandInvocation;
|
||||
let isMattermostSenderAllowed: typeof import("./monitor-auth.js").isMattermostSenderAllowed;
|
||||
let normalizeMattermostAllowEntry: typeof import("./monitor-auth.js").normalizeMattermostAllowEntry;
|
||||
let normalizeMattermostAllowList: typeof import("./monitor-auth.js").normalizeMattermostAllowList;
|
||||
let resolveMattermostEffectiveAllowFromLists: typeof import("./monitor-auth.js").resolveMattermostEffectiveAllowFromLists;
|
||||
|
||||
beforeAll(async () => {
|
||||
({
|
||||
authorizeMattermostCommandInvocation,
|
||||
isMattermostSenderAllowed,
|
||||
normalizeMattermostAllowEntry,
|
||||
normalizeMattermostAllowList,
|
||||
resolveMattermostEffectiveAllowFromLists,
|
||||
} = await import("./monitor-auth.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
evaluateSenderGroupAccessForPolicy.mockReset();
|
||||
isDangerousNameMatchingEnabled.mockReset();
|
||||
resolveAllowlistMatchSimple.mockReset();
|
||||
resolveControlCommandGate.mockReset();
|
||||
resolveEffectiveAllowFromLists.mockReset();
|
||||
});
|
||||
|
||||
it("normalizes allowlist entries and resolves effective lists", () => {
|
||||
resolveEffectiveAllowFromLists.mockReturnValue({
|
||||
effectiveAllowFrom: ["alice"],
|
||||
effectiveGroupAllowFrom: ["team"],
|
||||
});
|
||||
|
||||
expect(normalizeMattermostAllowEntry(" @Alice ")).toBe("alice");
|
||||
expect(normalizeMattermostAllowEntry("mattermost:Bob")).toBe("bob");
|
||||
expect(normalizeMattermostAllowEntry("*")).toBe("*");
|
||||
expect(normalizeMattermostAllowList([" Alice ", "user:alice", "ALICE", "*"])).toEqual([
|
||||
"alice",
|
||||
"*",
|
||||
]);
|
||||
expect(
|
||||
resolveMattermostEffectiveAllowFromLists({
|
||||
allowFrom: [" Alice "],
|
||||
groupAllowFrom: [" Team "],
|
||||
storeAllowFrom: ["Store"],
|
||||
dmPolicy: "pairing",
|
||||
}),
|
||||
).toEqual({
|
||||
effectiveAllowFrom: ["alice"],
|
||||
effectiveGroupAllowFrom: ["team"],
|
||||
});
|
||||
expect(resolveEffectiveAllowFromLists).toHaveBeenCalledWith({
|
||||
allowFrom: ["alice"],
|
||||
groupAllowFrom: ["team"],
|
||||
storeAllowFrom: ["store"],
|
||||
dmPolicy: "pairing",
|
||||
});
|
||||
});
|
||||
|
||||
it("checks sender allowlists against normalized ids and names", () => {
|
||||
resolveAllowlistMatchSimple.mockReturnValue({ allowed: true });
|
||||
expect(
|
||||
isMattermostSenderAllowed({
|
||||
senderId: "@Alice",
|
||||
senderName: "Alice",
|
||||
allowFrom: [" mattermost:alice "],
|
||||
allowNameMatching: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
expect(resolveAllowlistMatchSimple).toHaveBeenCalledWith({
|
||||
allowFrom: ["alice"],
|
||||
senderId: "alice",
|
||||
senderName: "alice",
|
||||
allowNameMatching: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("authorizes direct messages in open mode and blocks disabled/group-restricted channels", async () => {
|
||||
isDangerousNameMatchingEnabled.mockReturnValue(false);
|
||||
resolveEffectiveAllowFromLists.mockReturnValue({
|
||||
effectiveAllowFrom: [],
|
||||
effectiveGroupAllowFrom: [],
|
||||
});
|
||||
resolveControlCommandGate.mockReturnValue({
|
||||
commandAuthorized: false,
|
||||
shouldBlock: false,
|
||||
});
|
||||
evaluateSenderGroupAccessForPolicy.mockReturnValue({
|
||||
allowed: false,
|
||||
reason: "empty_allowlist",
|
||||
});
|
||||
resolveAllowlistMatchSimple.mockReturnValue({ allowed: false });
|
||||
|
||||
expect(
|
||||
authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
config: { dmPolicy: "open" },
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
channelId: "dm-1",
|
||||
channelInfo: { type: "D", name: "alice", display_name: "Alice" } as never,
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: true,
|
||||
commandAuthorized: true,
|
||||
kind: "direct",
|
||||
roomLabel: "#alice",
|
||||
});
|
||||
|
||||
expect(
|
||||
authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
config: { dmPolicy: "disabled" },
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
channelId: "dm-1",
|
||||
channelInfo: { type: "D", name: "alice", display_name: "Alice" } as never,
|
||||
allowTextCommands: false,
|
||||
hasControlCommand: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
denyReason: "dm-disabled",
|
||||
});
|
||||
|
||||
expect(
|
||||
authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
config: { groupPolicy: "allowlist" },
|
||||
} as never,
|
||||
cfg: {} as never,
|
||||
senderId: "alice",
|
||||
senderName: "Alice",
|
||||
channelId: "chan-1",
|
||||
channelInfo: { type: "O", name: "town-square", display_name: "Town Square" } as never,
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: false,
|
||||
}),
|
||||
).toMatchObject({
|
||||
ok: false,
|
||||
denyReason: "channel-no-allowlist",
|
||||
kind: "channel",
|
||||
});
|
||||
});
|
||||
});
|
||||
318
openclaw/extensions/mattermost/src/mattermost/monitor-auth.ts
Normal file
318
openclaw/extensions/mattermost/src/mattermost/monitor-auth.ts
Normal file
|
|
@ -0,0 +1,318 @@
|
|||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import type { MattermostChannel } from "./client.js";
|
||||
import type { OpenClawConfig } from "./runtime-api.js";
|
||||
import {
|
||||
evaluateSenderGroupAccessForPolicy,
|
||||
isDangerousNameMatchingEnabled,
|
||||
resolveAllowlistMatchSimple,
|
||||
resolveControlCommandGate,
|
||||
resolveEffectiveAllowFromLists,
|
||||
} from "./runtime-api.js";
|
||||
|
||||
export function normalizeMattermostAllowEntry(entry: string): string {
|
||||
const trimmed = entry.trim();
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
if (trimmed === "*") {
|
||||
return "*";
|
||||
}
|
||||
return trimmed
|
||||
.replace(/^(mattermost|user):/i, "")
|
||||
.replace(/^@/, "")
|
||||
.trim()
|
||||
? normalizeLowercaseStringOrEmpty(trimmed.replace(/^(mattermost|user):/i, "").replace(/^@/, ""))
|
||||
: "";
|
||||
}
|
||||
|
||||
export function normalizeMattermostAllowList(entries: Array<string | number>): string[] {
|
||||
const normalized = entries
|
||||
.map((entry) => normalizeMattermostAllowEntry(String(entry)))
|
||||
.filter(Boolean);
|
||||
return Array.from(new Set(normalized));
|
||||
}
|
||||
|
||||
export function resolveMattermostEffectiveAllowFromLists(params: {
|
||||
allowFrom?: Array<string | number> | null;
|
||||
groupAllowFrom?: Array<string | number> | null;
|
||||
storeAllowFrom?: Array<string | number> | null;
|
||||
dmPolicy?: string | null;
|
||||
}): {
|
||||
effectiveAllowFrom: string[];
|
||||
effectiveGroupAllowFrom: string[];
|
||||
} {
|
||||
return resolveEffectiveAllowFromLists({
|
||||
allowFrom: normalizeMattermostAllowList(params.allowFrom ?? []),
|
||||
groupAllowFrom: normalizeMattermostAllowList(params.groupAllowFrom ?? []),
|
||||
storeAllowFrom: normalizeMattermostAllowList(params.storeAllowFrom ?? []),
|
||||
dmPolicy: params.dmPolicy,
|
||||
});
|
||||
}
|
||||
|
||||
export function isMattermostSenderAllowed(params: {
|
||||
senderId: string;
|
||||
senderName?: string;
|
||||
allowFrom: string[];
|
||||
allowNameMatching?: boolean;
|
||||
}): boolean {
|
||||
const allowFrom = normalizeMattermostAllowList(params.allowFrom);
|
||||
if (allowFrom.length === 0) {
|
||||
return false;
|
||||
}
|
||||
const match = resolveAllowlistMatchSimple({
|
||||
allowFrom,
|
||||
senderId: normalizeMattermostAllowEntry(params.senderId),
|
||||
senderName: params.senderName ? normalizeMattermostAllowEntry(params.senderName) : undefined,
|
||||
allowNameMatching: params.allowNameMatching,
|
||||
});
|
||||
return match.allowed;
|
||||
}
|
||||
|
||||
function mapMattermostChannelKind(channelType?: string | null): "direct" | "group" | "channel" {
|
||||
const normalized = channelType?.trim().toUpperCase();
|
||||
if (normalized === "D") {
|
||||
return "direct";
|
||||
}
|
||||
if (normalized === "G" || normalized === "P") {
|
||||
return "group";
|
||||
}
|
||||
return "channel";
|
||||
}
|
||||
|
||||
export type MattermostCommandAuthDecision =
|
||||
| {
|
||||
ok: true;
|
||||
commandAuthorized: boolean;
|
||||
channelInfo: MattermostChannel;
|
||||
kind: "direct" | "group" | "channel";
|
||||
chatType: "direct" | "group" | "channel";
|
||||
channelName: string;
|
||||
channelDisplay: string;
|
||||
roomLabel: string;
|
||||
}
|
||||
| {
|
||||
ok: false;
|
||||
denyReason:
|
||||
| "unknown-channel"
|
||||
| "dm-disabled"
|
||||
| "dm-pairing"
|
||||
| "unauthorized"
|
||||
| "channels-disabled"
|
||||
| "channel-no-allowlist";
|
||||
commandAuthorized: false;
|
||||
channelInfo: MattermostChannel | null;
|
||||
kind: "direct" | "group" | "channel";
|
||||
chatType: "direct" | "group" | "channel";
|
||||
channelName: string;
|
||||
channelDisplay: string;
|
||||
roomLabel: string;
|
||||
};
|
||||
|
||||
export function authorizeMattermostCommandInvocation(params: {
|
||||
account: ResolvedMattermostAccount;
|
||||
cfg: OpenClawConfig;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
channelId: string;
|
||||
channelInfo: MattermostChannel | null;
|
||||
storeAllowFrom?: Array<string | number> | null;
|
||||
allowTextCommands: boolean;
|
||||
hasControlCommand: boolean;
|
||||
}): MattermostCommandAuthDecision {
|
||||
const {
|
||||
account,
|
||||
cfg,
|
||||
senderId,
|
||||
senderName,
|
||||
channelId,
|
||||
channelInfo,
|
||||
storeAllowFrom,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
} = params;
|
||||
|
||||
if (!channelInfo) {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason: "unknown-channel",
|
||||
commandAuthorized: false,
|
||||
channelInfo: null,
|
||||
kind: "channel",
|
||||
chatType: "channel",
|
||||
channelName: "",
|
||||
channelDisplay: "",
|
||||
roomLabel: `#${channelId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const kind = mapMattermostChannelKind(channelInfo.type);
|
||||
const chatType = kind;
|
||||
const channelName = channelInfo.name ?? "";
|
||||
const channelDisplay = channelInfo.display_name ?? channelName;
|
||||
const roomLabel = channelName ? `#${channelName}` : channelDisplay || `#${channelId}`;
|
||||
|
||||
const dmPolicy = account.config.dmPolicy ?? "pairing";
|
||||
const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy;
|
||||
const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist";
|
||||
const allowNameMatching = isDangerousNameMatchingEnabled(account.config);
|
||||
const configAllowFrom = normalizeMattermostAllowList(account.config.allowFrom ?? []);
|
||||
const configGroupAllowFrom = normalizeMattermostAllowList(account.config.groupAllowFrom ?? []);
|
||||
const normalizedStoreAllowFrom = normalizeMattermostAllowList(storeAllowFrom ?? []);
|
||||
const { effectiveAllowFrom, effectiveGroupAllowFrom } = resolveMattermostEffectiveAllowFromLists({
|
||||
allowFrom: configAllowFrom,
|
||||
groupAllowFrom: configGroupAllowFrom,
|
||||
storeAllowFrom: normalizedStoreAllowFrom,
|
||||
dmPolicy,
|
||||
});
|
||||
|
||||
const useAccessGroups = cfg.commands?.useAccessGroups !== false;
|
||||
const commandDmAllowFrom = kind === "direct" ? effectiveAllowFrom : configAllowFrom;
|
||||
const commandGroupAllowFrom =
|
||||
kind === "direct"
|
||||
? effectiveGroupAllowFrom
|
||||
: configGroupAllowFrom.length > 0
|
||||
? configGroupAllowFrom
|
||||
: configAllowFrom;
|
||||
|
||||
const senderAllowedForCommands = isMattermostSenderAllowed({
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom: commandDmAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
const groupAllowedForCommands = isMattermostSenderAllowed({
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom: commandGroupAllowFrom,
|
||||
allowNameMatching,
|
||||
});
|
||||
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups,
|
||||
authorizers: [
|
||||
{ configured: commandDmAllowFrom.length > 0, allowed: senderAllowedForCommands },
|
||||
{
|
||||
configured: commandGroupAllowFrom.length > 0,
|
||||
allowed: groupAllowedForCommands,
|
||||
},
|
||||
],
|
||||
allowTextCommands,
|
||||
hasControlCommand: allowTextCommands && hasControlCommand,
|
||||
});
|
||||
|
||||
const commandAuthorized =
|
||||
kind === "direct"
|
||||
? dmPolicy === "open" || senderAllowedForCommands
|
||||
: commandGate.commandAuthorized;
|
||||
|
||||
if (kind === "direct") {
|
||||
if (dmPolicy === "disabled") {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason: "dm-disabled",
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (dmPolicy !== "open" && !senderAllowedForCommands) {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason: dmPolicy === "pairing" ? "dm-pairing" : "unauthorized",
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
} else {
|
||||
const senderGroupAccess = evaluateSenderGroupAccessForPolicy({
|
||||
groupPolicy,
|
||||
groupAllowFrom: effectiveGroupAllowFrom,
|
||||
senderId,
|
||||
isSenderAllowed: (_senderId, allowFrom) =>
|
||||
isMattermostSenderAllowed({
|
||||
senderId,
|
||||
senderName,
|
||||
allowFrom,
|
||||
allowNameMatching,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "disabled") {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason: "channels-disabled",
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "empty_allowlist") {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason: "channel-no-allowlist",
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (!senderGroupAccess.allowed && senderGroupAccess.reason === "sender_not_allowlisted") {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason: "unauthorized",
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
||||
if (commandGate.shouldBlock) {
|
||||
return {
|
||||
ok: false,
|
||||
denyReason: "unauthorized",
|
||||
commandAuthorized: false,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
commandAuthorized,
|
||||
channelInfo,
|
||||
kind,
|
||||
chatType,
|
||||
channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,84 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
evaluateMattermostMentionGate,
|
||||
mapMattermostChannelTypeToChatType,
|
||||
} from "./monitor-gating.js";
|
||||
|
||||
describe("mattermost monitor gating", () => {
|
||||
it("maps mattermost channel types to chat types", () => {
|
||||
expect(mapMattermostChannelTypeToChatType("D")).toBe("direct");
|
||||
expect(mapMattermostChannelTypeToChatType("G")).toBe("group");
|
||||
expect(mapMattermostChannelTypeToChatType("P")).toBe("group");
|
||||
expect(mapMattermostChannelTypeToChatType("O")).toBe("channel");
|
||||
expect(mapMattermostChannelTypeToChatType(undefined)).toBe("channel");
|
||||
});
|
||||
|
||||
it("drops non-mentioned traffic when onchar is enabled but not triggered", () => {
|
||||
const resolveRequireMention = vi.fn(() => true);
|
||||
|
||||
expect(
|
||||
evaluateMattermostMentionGate({
|
||||
kind: "channel",
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelId: "chan-1",
|
||||
resolveRequireMention,
|
||||
wasMentioned: false,
|
||||
isControlCommand: false,
|
||||
commandAuthorized: false,
|
||||
oncharEnabled: true,
|
||||
oncharTriggered: false,
|
||||
canDetectMention: true,
|
||||
}),
|
||||
).toEqual({
|
||||
shouldRequireMention: true,
|
||||
shouldBypassMention: false,
|
||||
effectiveWasMentioned: false,
|
||||
dropReason: "onchar-not-triggered",
|
||||
});
|
||||
});
|
||||
|
||||
it("bypasses mention for authorized control commands and allows direct chats", () => {
|
||||
const resolveRequireMention = vi.fn(() => true);
|
||||
|
||||
expect(
|
||||
evaluateMattermostMentionGate({
|
||||
kind: "channel",
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelId: "chan-1",
|
||||
resolveRequireMention,
|
||||
wasMentioned: false,
|
||||
isControlCommand: true,
|
||||
commandAuthorized: true,
|
||||
oncharEnabled: false,
|
||||
oncharTriggered: false,
|
||||
canDetectMention: true,
|
||||
}),
|
||||
).toEqual({
|
||||
shouldRequireMention: true,
|
||||
shouldBypassMention: true,
|
||||
effectiveWasMentioned: true,
|
||||
dropReason: null,
|
||||
});
|
||||
|
||||
expect(
|
||||
evaluateMattermostMentionGate({
|
||||
kind: "direct",
|
||||
cfg: {} as never,
|
||||
accountId: "default",
|
||||
channelId: "chan-1",
|
||||
resolveRequireMention,
|
||||
wasMentioned: false,
|
||||
isControlCommand: false,
|
||||
commandAuthorized: false,
|
||||
oncharEnabled: false,
|
||||
oncharTriggered: false,
|
||||
canDetectMention: true,
|
||||
}),
|
||||
).toMatchObject({
|
||||
shouldRequireMention: false,
|
||||
dropReason: null,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
import type { ChatType, OpenClawConfig } from "./runtime-api.js";
|
||||
|
||||
export function mapMattermostChannelTypeToChatType(channelType?: string | null): ChatType {
|
||||
if (!channelType) {
|
||||
return "channel";
|
||||
}
|
||||
const normalized = channelType.trim().toUpperCase();
|
||||
if (normalized === "D") {
|
||||
return "direct";
|
||||
}
|
||||
if (normalized === "G" || normalized === "P") {
|
||||
return "group";
|
||||
}
|
||||
return "channel";
|
||||
}
|
||||
|
||||
export type MattermostRequireMentionResolverInput = {
|
||||
cfg: OpenClawConfig;
|
||||
channel: "mattermost";
|
||||
accountId: string;
|
||||
groupId: string;
|
||||
requireMentionOverride?: boolean;
|
||||
};
|
||||
|
||||
export type MattermostMentionGateInput = {
|
||||
kind: ChatType;
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
channelId: string;
|
||||
threadRootId?: string;
|
||||
requireMentionOverride?: boolean;
|
||||
resolveRequireMention: (params: MattermostRequireMentionResolverInput) => boolean;
|
||||
wasMentioned: boolean;
|
||||
isControlCommand: boolean;
|
||||
commandAuthorized: boolean;
|
||||
oncharEnabled: boolean;
|
||||
oncharTriggered: boolean;
|
||||
canDetectMention: boolean;
|
||||
};
|
||||
|
||||
type MattermostMentionGateDecision = {
|
||||
shouldRequireMention: boolean;
|
||||
shouldBypassMention: boolean;
|
||||
effectiveWasMentioned: boolean;
|
||||
dropReason: "onchar-not-triggered" | "missing-mention" | null;
|
||||
};
|
||||
|
||||
export function evaluateMattermostMentionGate(
|
||||
params: MattermostMentionGateInput,
|
||||
): MattermostMentionGateDecision {
|
||||
const shouldRequireMention =
|
||||
params.kind !== "direct" &&
|
||||
params.resolveRequireMention({
|
||||
cfg: params.cfg,
|
||||
channel: "mattermost",
|
||||
accountId: params.accountId,
|
||||
groupId: params.channelId,
|
||||
requireMentionOverride: params.requireMentionOverride,
|
||||
});
|
||||
const shouldBypassMention =
|
||||
params.isControlCommand &&
|
||||
shouldRequireMention &&
|
||||
!params.wasMentioned &&
|
||||
params.commandAuthorized;
|
||||
const effectiveWasMentioned =
|
||||
params.wasMentioned || shouldBypassMention || params.oncharTriggered;
|
||||
if (
|
||||
params.oncharEnabled &&
|
||||
!params.oncharTriggered &&
|
||||
!params.wasMentioned &&
|
||||
!params.isControlCommand
|
||||
) {
|
||||
return {
|
||||
shouldRequireMention,
|
||||
shouldBypassMention,
|
||||
effectiveWasMentioned,
|
||||
dropReason: "onchar-not-triggered",
|
||||
};
|
||||
}
|
||||
if (
|
||||
params.kind !== "direct" &&
|
||||
shouldRequireMention &&
|
||||
params.canDetectMention &&
|
||||
!effectiveWasMentioned
|
||||
) {
|
||||
return {
|
||||
shouldRequireMention,
|
||||
shouldBypassMention,
|
||||
effectiveWasMentioned,
|
||||
dropReason: "missing-mention",
|
||||
};
|
||||
}
|
||||
return {
|
||||
shouldRequireMention,
|
||||
shouldBypassMention,
|
||||
effectiveWasMentioned,
|
||||
dropReason: null,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { normalizeMention } from "./monitor-helpers.js";
|
||||
|
||||
describe("normalizeMention", () => {
|
||||
it("returns trimmed text when no mention provided", () => {
|
||||
expect(normalizeMention(" hello world ", undefined)).toBe("hello world");
|
||||
});
|
||||
|
||||
it("strips bot mention from text", () => {
|
||||
expect(normalizeMention("@echobot hello", "echobot")).toBe("hello");
|
||||
});
|
||||
|
||||
it("strips mention case-insensitively", () => {
|
||||
expect(normalizeMention("@EchoBot hello", "echobot")).toBe("hello");
|
||||
});
|
||||
|
||||
it("preserves newlines in multi-line messages", () => {
|
||||
const input = "@echobot\nline1\nline2\nline3";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toBe("line1\nline2\nline3");
|
||||
});
|
||||
|
||||
it("preserves Markdown headings", () => {
|
||||
const input = "@echobot\n# Heading\n\nSome text";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain("# Heading");
|
||||
expect(result).toContain("\n");
|
||||
});
|
||||
|
||||
it("preserves Markdown blockquotes", () => {
|
||||
const input = "@echobot\n> quoted line\n> second line";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain("> quoted line");
|
||||
expect(result).toContain("> second line");
|
||||
});
|
||||
|
||||
it("preserves Markdown lists", () => {
|
||||
const input = "@echobot\n- item A\n- item B\n - sub B1";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain("- item A");
|
||||
expect(result).toContain("- item B");
|
||||
});
|
||||
|
||||
it("preserves task lists", () => {
|
||||
const input = "@echobot\n- [ ] todo\n- [x] done";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain("- [ ] todo");
|
||||
expect(result).toContain("- [x] done");
|
||||
});
|
||||
|
||||
it("handles mention in middle of text", () => {
|
||||
const input = "hey @echobot check this\nout";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toBe("hey check this\nout");
|
||||
});
|
||||
|
||||
it("preserves leading indentation for nested lists", () => {
|
||||
const input = "@echobot\n- item\n - nested\n - deep";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain(" - nested");
|
||||
expect(result).toContain(" - deep");
|
||||
});
|
||||
|
||||
it("preserves first-line indentation for nested list items", () => {
|
||||
const input = "@echobot\n - nested\n - deep";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toBe(" - nested\n - deep");
|
||||
});
|
||||
|
||||
it("preserves indented code blocks", () => {
|
||||
const input = "@echobot\ntext\n code line 1\n code line 2";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toContain(" code line 1");
|
||||
expect(result).toContain(" code line 2");
|
||||
});
|
||||
|
||||
it("preserves first-line indentation for indented code blocks", () => {
|
||||
const input = "@echobot\n code line 1\n code line 2";
|
||||
const result = normalizeMention(input, "echobot");
|
||||
expect(result).toBe(" code line 1\n code line 2");
|
||||
});
|
||||
});
|
||||
113
openclaw/extensions/mattermost/src/mattermost/monitor-helpers.ts
Normal file
113
openclaw/extensions/mattermost/src/mattermost/monitor-helpers.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import {
|
||||
createDedupeCache,
|
||||
formatInboundFromLabel as formatInboundFromLabelShared,
|
||||
rawDataToString,
|
||||
resolveThreadSessionKeys as resolveThreadSessionKeysShared,
|
||||
type OpenClawConfig,
|
||||
} from "./runtime-api.js";
|
||||
|
||||
export { createDedupeCache, rawDataToString };
|
||||
|
||||
export type ResponsePrefixContext = {
|
||||
model?: string;
|
||||
modelFull?: string;
|
||||
provider?: string;
|
||||
thinkingLevel?: string;
|
||||
identityName?: string;
|
||||
};
|
||||
|
||||
export function extractShortModelName(fullModel: string): string {
|
||||
const slash = fullModel.lastIndexOf("/");
|
||||
const modelPart = slash >= 0 ? fullModel.slice(slash + 1) : fullModel;
|
||||
return modelPart.replace(/-\d{8}$/, "").replace(/-latest$/, "");
|
||||
}
|
||||
|
||||
export const formatInboundFromLabel = formatInboundFromLabelShared;
|
||||
|
||||
function normalizeAgentId(value: string | undefined | null): string {
|
||||
const trimmed = (value ?? "").trim();
|
||||
if (!trimmed) {
|
||||
return "main";
|
||||
}
|
||||
if (/^[a-z0-9][a-z0-9_-]{0,63}$/i.test(trimmed)) {
|
||||
return trimmed;
|
||||
}
|
||||
return (
|
||||
normalizeLowercaseStringOrEmpty(trimmed)
|
||||
.replace(/[^a-z0-9_-]+/g, "-")
|
||||
.replace(/^-+/, "")
|
||||
.replace(/-+$/, "")
|
||||
.slice(0, 64) || "main"
|
||||
);
|
||||
}
|
||||
|
||||
type AgentEntry = NonNullable<NonNullable<OpenClawConfig["agents"]>["list"]>[number];
|
||||
|
||||
function isAgentEntry(entry: unknown): entry is AgentEntry {
|
||||
return Boolean(entry && typeof entry === "object");
|
||||
}
|
||||
|
||||
function listAgents(cfg: OpenClawConfig): AgentEntry[] {
|
||||
return Array.isArray(cfg.agents?.list) ? cfg.agents.list.filter(isAgentEntry) : [];
|
||||
}
|
||||
|
||||
function resolveAgentEntry(cfg: OpenClawConfig, agentId: string): AgentEntry | undefined {
|
||||
const id = normalizeAgentId(agentId);
|
||||
return listAgents(cfg).find((entry) => normalizeAgentId(entry.id) === id);
|
||||
}
|
||||
|
||||
export function resolveIdentityName(cfg: OpenClawConfig, agentId: string): string | undefined {
|
||||
const entry = resolveAgentEntry(cfg, agentId);
|
||||
return normalizeOptionalString(entry?.identity?.name);
|
||||
}
|
||||
|
||||
export function resolveThreadSessionKeys(params: {
|
||||
baseSessionKey: string;
|
||||
threadId?: string | null;
|
||||
parentSessionKey?: string;
|
||||
useSuffix?: boolean;
|
||||
}): { sessionKey: string; parentSessionKey?: string } {
|
||||
return resolveThreadSessionKeysShared({
|
||||
...params,
|
||||
normalizeThreadId: (threadId) => threadId,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip bot mention from message text while preserving newlines and
|
||||
* block-level Markdown formatting (headings, lists, blockquotes).
|
||||
*/
|
||||
export function normalizeMention(text: string, mention: string | undefined): string {
|
||||
if (!mention) {
|
||||
return text.trim();
|
||||
}
|
||||
const escaped = mention.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
const hasMentionRe = new RegExp(`@${escaped}\\b`, "i");
|
||||
const leadingMentionRe = new RegExp(`^([\\t ]*)@${escaped}\\b[\\t ]*`, "i");
|
||||
const trailingMentionRe = new RegExp(`[\\t ]*@${escaped}\\b[\\t ]*$`, "i");
|
||||
const normalizedLines = text.split("\n").map((line) => {
|
||||
const hadMention = hasMentionRe.test(line);
|
||||
const normalizedLine = line
|
||||
.replace(leadingMentionRe, "$1")
|
||||
.replace(trailingMentionRe, "")
|
||||
.replace(new RegExp(`@${escaped}\\b`, "gi"), "")
|
||||
.replace(/(\S)[ \t]{2,}/g, "$1 ");
|
||||
return {
|
||||
text: normalizedLine,
|
||||
mentionOnlyBlank: hadMention && normalizedLine.trim() === "",
|
||||
};
|
||||
});
|
||||
|
||||
while (normalizedLines[0]?.mentionOnlyBlank) {
|
||||
normalizedLines.shift();
|
||||
}
|
||||
while (normalizedLines.at(-1)?.text.trim() === "") {
|
||||
normalizedLines.pop();
|
||||
}
|
||||
|
||||
return normalizedLines.map((line) => line.text).join("\n");
|
||||
}
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveOncharPrefixes, stripOncharPrefix } from "./monitor-onchar.js";
|
||||
|
||||
describe("mattermost monitor onchar", () => {
|
||||
it("uses defaults when prefixes are missing or empty after trimming", () => {
|
||||
expect(resolveOncharPrefixes(undefined)).toEqual([">", "!"]);
|
||||
expect(resolveOncharPrefixes([" ", ""])).toEqual([">", "!"]);
|
||||
});
|
||||
|
||||
it("trims configured prefixes and preserves order", () => {
|
||||
expect(resolveOncharPrefixes([" ?? ", " !", " /bot "])).toEqual(["??", "!", "/bot"]);
|
||||
});
|
||||
|
||||
it("strips the first matching prefix after leading whitespace", () => {
|
||||
expect(stripOncharPrefix(" ! hello world", ["!", ">"])).toEqual({
|
||||
triggered: true,
|
||||
stripped: "hello world",
|
||||
});
|
||||
|
||||
expect(stripOncharPrefix("??multi prefix", ["??", "?"])).toEqual({
|
||||
triggered: true,
|
||||
stripped: "multi prefix",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns the original text when no prefix matches", () => {
|
||||
expect(stripOncharPrefix("hello world", ["!", ">"])).toEqual({
|
||||
triggered: false,
|
||||
stripped: "hello world",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
const DEFAULT_ONCHAR_PREFIXES = [">", "!"];
|
||||
|
||||
export function resolveOncharPrefixes(prefixes: string[] | undefined): string[] {
|
||||
const cleaned = prefixes?.map((entry) => entry.trim()).filter(Boolean) ?? DEFAULT_ONCHAR_PREFIXES;
|
||||
return cleaned.length > 0 ? cleaned : DEFAULT_ONCHAR_PREFIXES;
|
||||
}
|
||||
|
||||
export function stripOncharPrefix(
|
||||
text: string,
|
||||
prefixes: string[],
|
||||
): { triggered: boolean; stripped: string } {
|
||||
const trimmed = text.trimStart();
|
||||
for (const prefix of prefixes) {
|
||||
if (!prefix) {
|
||||
continue;
|
||||
}
|
||||
if (trimmed.startsWith(prefix)) {
|
||||
return {
|
||||
triggered: true,
|
||||
stripped: trimmed.slice(prefix.length).trimStart(),
|
||||
};
|
||||
}
|
||||
}
|
||||
return { triggered: false, stripped: text };
|
||||
}
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
import { beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const fetchMattermostChannel = vi.hoisted(() => vi.fn());
|
||||
const fetchMattermostUser = vi.hoisted(() => vi.fn());
|
||||
const sendMattermostTyping = vi.hoisted(() => vi.fn());
|
||||
const updateMattermostPost = vi.hoisted(() => vi.fn());
|
||||
const buildButtonProps = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
fetchMattermostChannel,
|
||||
fetchMattermostUser,
|
||||
sendMattermostTyping,
|
||||
updateMattermostPost,
|
||||
}));
|
||||
|
||||
vi.mock("./interactions.js", () => ({
|
||||
buildButtonProps,
|
||||
}));
|
||||
|
||||
describe("mattermost monitor resources", () => {
|
||||
let createMattermostMonitorResources: typeof import("./monitor-resources.js").createMattermostMonitorResources;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ createMattermostMonitorResources } = await import("./monitor-resources.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
fetchMattermostChannel.mockReset();
|
||||
fetchMattermostUser.mockReset();
|
||||
sendMattermostTyping.mockReset();
|
||||
updateMattermostPost.mockReset();
|
||||
buildButtonProps.mockReset();
|
||||
});
|
||||
|
||||
it("downloads media, preserves auth headers, and infers media kind", async () => {
|
||||
const fetchRemoteMedia = vi.fn(async () => ({
|
||||
buffer: new Uint8Array([1, 2, 3]),
|
||||
contentType: "image/png",
|
||||
}));
|
||||
const saveMediaBuffer = vi.fn(async () => ({
|
||||
path: "/tmp/file.png",
|
||||
contentType: "image/png",
|
||||
}));
|
||||
|
||||
const resources = createMattermostMonitorResources({
|
||||
accountId: "default",
|
||||
callbackUrl: "https://openclaw.test/callback",
|
||||
client: {
|
||||
apiBaseUrl: "https://chat.example.com/api/v4",
|
||||
baseUrl: "https://chat.example.com",
|
||||
token: "bot-token",
|
||||
} as never,
|
||||
logger: {},
|
||||
mediaMaxBytes: 1024,
|
||||
fetchRemoteMedia,
|
||||
saveMediaBuffer,
|
||||
mediaKindFromMime: () => "image",
|
||||
});
|
||||
|
||||
await expect(resources.resolveMattermostMedia([" file-1 "])).resolves.toEqual([
|
||||
{
|
||||
path: "/tmp/file.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
},
|
||||
]);
|
||||
|
||||
expect(fetchRemoteMedia).toHaveBeenCalledWith({
|
||||
url: "https://chat.example.com/api/v4/files/file-1",
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: "Bearer bot-token",
|
||||
},
|
||||
},
|
||||
filePathHint: "file-1",
|
||||
maxBytes: 1024,
|
||||
ssrfPolicy: { allowedHostnames: ["chat.example.com"] },
|
||||
});
|
||||
});
|
||||
|
||||
it("caches channel and user lookups and falls back to empty picker props", async () => {
|
||||
fetchMattermostChannel.mockResolvedValue({ id: "chan-1", name: "town-square" });
|
||||
fetchMattermostUser.mockResolvedValue({ id: "user-1", username: "alice" });
|
||||
buildButtonProps.mockReturnValue(undefined);
|
||||
|
||||
const resources = createMattermostMonitorResources({
|
||||
accountId: "default",
|
||||
callbackUrl: "https://openclaw.test/callback",
|
||||
client: {} as never,
|
||||
logger: {},
|
||||
mediaMaxBytes: 1024,
|
||||
fetchRemoteMedia: vi.fn(),
|
||||
saveMediaBuffer: vi.fn(),
|
||||
mediaKindFromMime: () => "document",
|
||||
});
|
||||
|
||||
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
|
||||
id: "chan-1",
|
||||
name: "town-square",
|
||||
});
|
||||
await expect(resources.resolveChannelInfo("chan-1")).resolves.toEqual({
|
||||
id: "chan-1",
|
||||
name: "town-square",
|
||||
});
|
||||
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
});
|
||||
await expect(resources.resolveUserInfo("user-1")).resolves.toEqual({
|
||||
id: "user-1",
|
||||
username: "alice",
|
||||
});
|
||||
|
||||
expect(fetchMattermostChannel).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
|
||||
await resources.updateModelPickerPost({
|
||||
channelId: "chan-1",
|
||||
postId: "post-1",
|
||||
message: "Pick a model",
|
||||
});
|
||||
|
||||
expect(updateMattermostPost).toHaveBeenCalledWith(
|
||||
{},
|
||||
"post-1",
|
||||
expect.objectContaining({
|
||||
message: "Pick a model",
|
||||
props: { attachments: [] },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("proxies typing indicators to the mattermost client helper", async () => {
|
||||
const client = {} as never;
|
||||
|
||||
const resources = createMattermostMonitorResources({
|
||||
accountId: "default",
|
||||
callbackUrl: "https://openclaw.test/callback",
|
||||
client,
|
||||
logger: {},
|
||||
mediaMaxBytes: 1024,
|
||||
fetchRemoteMedia: vi.fn(),
|
||||
saveMediaBuffer: vi.fn(),
|
||||
mediaKindFromMime: () => "document",
|
||||
});
|
||||
|
||||
await resources.sendTypingIndicator("chan-1", "root-1");
|
||||
expect(sendMattermostTyping).toHaveBeenCalledWith(client, {
|
||||
channelId: "chan-1",
|
||||
parentId: "root-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import {
|
||||
fetchMattermostChannel,
|
||||
fetchMattermostUser,
|
||||
sendMattermostTyping,
|
||||
updateMattermostPost,
|
||||
type MattermostChannel,
|
||||
type MattermostClient,
|
||||
type MattermostUser,
|
||||
} from "./client.js";
|
||||
import { buildButtonProps, type MattermostInteractionResponse } from "./interactions.js";
|
||||
|
||||
export type MattermostMediaKind = "image" | "audio" | "video" | "document" | "unknown";
|
||||
|
||||
export type MattermostMediaInfo = {
|
||||
path: string;
|
||||
contentType?: string;
|
||||
kind: MattermostMediaKind;
|
||||
};
|
||||
|
||||
const CHANNEL_CACHE_TTL_MS = 5 * 60_000;
|
||||
const USER_CACHE_TTL_MS = 10 * 60_000;
|
||||
|
||||
type FetchRemoteMedia = (params: {
|
||||
url: string;
|
||||
requestInit?: RequestInit;
|
||||
filePathHint?: string;
|
||||
maxBytes: number;
|
||||
ssrfPolicy?: { allowedHostnames?: string[] };
|
||||
}) => Promise<{ buffer: Uint8Array; contentType?: string | null }>;
|
||||
|
||||
type SaveMediaBuffer = (
|
||||
buffer: Uint8Array,
|
||||
contentType: string | undefined,
|
||||
direction: "inbound" | "outbound",
|
||||
maxBytes: number,
|
||||
) => Promise<{ path: string; contentType?: string | null }>;
|
||||
|
||||
export function createMattermostMonitorResources(params: {
|
||||
accountId: string;
|
||||
callbackUrl: string;
|
||||
client: MattermostClient;
|
||||
logger: { debug?: (...args: unknown[]) => void };
|
||||
mediaMaxBytes: number;
|
||||
fetchRemoteMedia: FetchRemoteMedia;
|
||||
saveMediaBuffer: SaveMediaBuffer;
|
||||
mediaKindFromMime: (contentType?: string) => MattermostMediaKind | null | undefined;
|
||||
}) {
|
||||
const {
|
||||
accountId,
|
||||
callbackUrl,
|
||||
client,
|
||||
logger,
|
||||
mediaMaxBytes,
|
||||
fetchRemoteMedia,
|
||||
saveMediaBuffer,
|
||||
mediaKindFromMime,
|
||||
} = params;
|
||||
const channelCache = new Map<string, { value: MattermostChannel | null; expiresAt: number }>();
|
||||
const userCache = new Map<string, { value: MattermostUser | null; expiresAt: number }>();
|
||||
|
||||
const resolveMattermostMedia = async (
|
||||
fileIds?: string[] | null,
|
||||
): Promise<MattermostMediaInfo[]> => {
|
||||
const ids = (fileIds ?? []).map((id) => id?.trim()).filter(Boolean);
|
||||
if (ids.length === 0) {
|
||||
return [];
|
||||
}
|
||||
const out: MattermostMediaInfo[] = [];
|
||||
for (const fileId of ids) {
|
||||
try {
|
||||
const fetched = await fetchRemoteMedia({
|
||||
url: `${client.apiBaseUrl}/files/${fileId}`,
|
||||
requestInit: {
|
||||
headers: {
|
||||
Authorization: `Bearer ${client.token}`,
|
||||
},
|
||||
},
|
||||
filePathHint: fileId,
|
||||
maxBytes: mediaMaxBytes,
|
||||
ssrfPolicy: { allowedHostnames: [new URL(client.baseUrl).hostname] },
|
||||
});
|
||||
const saved = await saveMediaBuffer(
|
||||
Buffer.from(fetched.buffer),
|
||||
fetched.contentType ?? undefined,
|
||||
"inbound",
|
||||
mediaMaxBytes,
|
||||
);
|
||||
const contentType = saved.contentType ?? fetched.contentType ?? undefined;
|
||||
out.push({
|
||||
path: saved.path,
|
||||
contentType,
|
||||
kind: mediaKindFromMime(contentType) ?? "unknown",
|
||||
});
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: failed to download file ${fileId}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
};
|
||||
|
||||
const sendTypingIndicator = async (channelId: string, parentId?: string) => {
|
||||
await sendMattermostTyping(client, { channelId, parentId });
|
||||
};
|
||||
|
||||
const resolveChannelInfo = async (channelId: string): Promise<MattermostChannel | null> => {
|
||||
const cached = channelCache.get(channelId);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
}
|
||||
try {
|
||||
const info = await fetchMattermostChannel(client, channelId);
|
||||
channelCache.set(channelId, {
|
||||
value: info,
|
||||
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
|
||||
});
|
||||
return info;
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: channel lookup failed: ${String(err)}`);
|
||||
channelCache.set(channelId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + CHANNEL_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const resolveUserInfo = async (userId: string): Promise<MattermostUser | null> => {
|
||||
const cached = userCache.get(userId);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.value;
|
||||
}
|
||||
try {
|
||||
const info = await fetchMattermostUser(client, userId);
|
||||
userCache.set(userId, {
|
||||
value: info,
|
||||
expiresAt: Date.now() + USER_CACHE_TTL_MS,
|
||||
});
|
||||
return info;
|
||||
} catch (err) {
|
||||
logger.debug?.(`mattermost: user lookup failed: ${String(err)}`);
|
||||
userCache.set(userId, {
|
||||
value: null,
|
||||
expiresAt: Date.now() + USER_CACHE_TTL_MS,
|
||||
});
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const buildModelPickerProps = (
|
||||
channelId: string,
|
||||
buttons: Array<unknown>,
|
||||
): Record<string, unknown> | undefined =>
|
||||
buildButtonProps({
|
||||
callbackUrl,
|
||||
accountId,
|
||||
channelId,
|
||||
buttons,
|
||||
});
|
||||
|
||||
const updateModelPickerPost = async (params: {
|
||||
channelId: string;
|
||||
postId: string;
|
||||
message: string;
|
||||
buttons?: Array<unknown>;
|
||||
}): Promise<MattermostInteractionResponse> => {
|
||||
const props = buildModelPickerProps(params.channelId, params.buttons ?? []) ?? {
|
||||
attachments: [],
|
||||
};
|
||||
await updateMattermostPost(client, params.postId, {
|
||||
message: params.message,
|
||||
props,
|
||||
});
|
||||
return {};
|
||||
};
|
||||
|
||||
return {
|
||||
resolveMattermostMedia,
|
||||
sendTypingIndicator,
|
||||
resolveChannelInfo,
|
||||
resolveUserInfo,
|
||||
updateModelPickerPost,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,183 @@
|
|||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const listSkillCommandsForAgents = vi.hoisted(() => vi.fn());
|
||||
const parseStrictPositiveInteger = vi.hoisted(() => vi.fn());
|
||||
const fetchMattermostUserTeams = vi.hoisted(() => vi.fn());
|
||||
const normalizeMattermostBaseUrl = vi.hoisted(() => vi.fn((value: string | undefined) => value));
|
||||
const isSlashCommandsEnabled = vi.hoisted(() => vi.fn());
|
||||
const registerSlashCommands = vi.hoisted(() => vi.fn());
|
||||
const resolveCallbackUrl = vi.hoisted(() => vi.fn());
|
||||
const resolveSlashCommandConfig = vi.hoisted(() => vi.fn());
|
||||
const activateSlashCommands = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("./runtime-api.js", () => ({
|
||||
listSkillCommandsForAgents,
|
||||
parseStrictPositiveInteger,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", async () => {
|
||||
const actual = await vi.importActual<typeof import("./client.js")>("./client.js");
|
||||
return {
|
||||
...actual,
|
||||
fetchMattermostUserTeams,
|
||||
normalizeMattermostBaseUrl,
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock("./slash-commands.js", () => ({
|
||||
DEFAULT_COMMAND_SPECS: [
|
||||
{ trigger: "ping", description: "ping" },
|
||||
{ trigger: "ping", description: "duplicate" },
|
||||
],
|
||||
isSlashCommandsEnabled,
|
||||
registerSlashCommands,
|
||||
resolveCallbackUrl,
|
||||
resolveSlashCommandConfig,
|
||||
}));
|
||||
|
||||
vi.mock("./slash-state.js", () => ({
|
||||
activateSlashCommands,
|
||||
}));
|
||||
|
||||
describe("mattermost monitor slash", () => {
|
||||
let registerMattermostMonitorSlashCommands: typeof import("./monitor-slash.js").registerMattermostMonitorSlashCommands;
|
||||
|
||||
beforeAll(async () => {
|
||||
({ registerMattermostMonitorSlashCommands } = await import("./monitor-slash.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
listSkillCommandsForAgents.mockReset();
|
||||
parseStrictPositiveInteger.mockReset();
|
||||
fetchMattermostUserTeams.mockReset();
|
||||
normalizeMattermostBaseUrl.mockClear();
|
||||
isSlashCommandsEnabled.mockReset();
|
||||
registerSlashCommands.mockReset();
|
||||
resolveCallbackUrl.mockReset();
|
||||
resolveSlashCommandConfig.mockReset();
|
||||
activateSlashCommands.mockReset();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllEnvs();
|
||||
});
|
||||
|
||||
it("returns early when slash commands are disabled", async () => {
|
||||
resolveSlashCommandConfig.mockReturnValue({ enabled: false });
|
||||
isSlashCommandsEnabled.mockReturnValue(false);
|
||||
|
||||
await registerMattermostMonitorSlashCommands({
|
||||
client: {} as never,
|
||||
cfg: {} as never,
|
||||
runtime: {} as never,
|
||||
account: { config: {} } as never,
|
||||
baseUrl: "https://chat.example.com",
|
||||
botUserId: "bot-user",
|
||||
});
|
||||
|
||||
expect(fetchMattermostUserTeams).not.toHaveBeenCalled();
|
||||
expect(activateSlashCommands).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("registers deduped default and native skill commands across teams", async () => {
|
||||
vi.stubEnv("OPENCLAW_GATEWAY_PORT", "18888");
|
||||
resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: true });
|
||||
isSlashCommandsEnabled.mockReturnValue(true);
|
||||
parseStrictPositiveInteger.mockReturnValue(18888);
|
||||
fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }, { id: "team-2" }]);
|
||||
resolveCallbackUrl.mockReturnValue("https://openclaw.test/slash");
|
||||
listSkillCommandsForAgents.mockReturnValue([
|
||||
{ name: "skill", description: "Skill run" },
|
||||
{ name: "oc_ping", description: "Already prefixed" },
|
||||
{ name: " ", description: "ignored" },
|
||||
]);
|
||||
registerSlashCommands
|
||||
.mockResolvedValueOnce([{ token: "token-1", trigger: "ping" }])
|
||||
.mockResolvedValueOnce([{ token: "token-2", trigger: "oc_skill" }]);
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
await registerMattermostMonitorSlashCommands({
|
||||
client: {} as never,
|
||||
cfg: { gateway: { port: 18789 } } as never,
|
||||
runtime: runtime as never,
|
||||
account: { config: { commands: {} }, accountId: "default" } as never,
|
||||
baseUrl: "https://chat.example.com",
|
||||
botUserId: "bot-user",
|
||||
});
|
||||
|
||||
expect(registerSlashCommands).toHaveBeenCalledTimes(2);
|
||||
expect(registerSlashCommands.mock.calls[0]?.[0]).toMatchObject({
|
||||
teamId: "team-1",
|
||||
creatorUserId: "bot-user",
|
||||
callbackUrl: "https://openclaw.test/slash",
|
||||
});
|
||||
expect(registerSlashCommands.mock.calls[0]?.[0].commands).toEqual([
|
||||
{ trigger: "ping", description: "ping" },
|
||||
{
|
||||
trigger: "oc_skill",
|
||||
description: "Skill run",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[args]",
|
||||
originalName: "skill",
|
||||
},
|
||||
{
|
||||
trigger: "oc_ping",
|
||||
description: "Already prefixed",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[args]",
|
||||
originalName: "oc_ping",
|
||||
},
|
||||
]);
|
||||
expect(activateSlashCommands).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
commandTokens: ["token-1", "token-2"],
|
||||
triggerMap: new Map([
|
||||
["oc_skill", "skill"],
|
||||
["oc_ping", "oc_ping"],
|
||||
]),
|
||||
}),
|
||||
);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"mattermost: slash commands registered (2 commands across 2 teams, callback=https://openclaw.test/slash)",
|
||||
);
|
||||
});
|
||||
|
||||
it("warns on loopback callback urls and reports partial team failures", async () => {
|
||||
resolveSlashCommandConfig.mockReturnValue({ enabled: true, nativeSkills: false });
|
||||
isSlashCommandsEnabled.mockReturnValue(true);
|
||||
parseStrictPositiveInteger.mockReturnValue(undefined);
|
||||
fetchMattermostUserTeams.mockResolvedValue([{ id: "team-1" }, { id: "team-2" }]);
|
||||
resolveCallbackUrl.mockReturnValue("http://127.0.0.1:18789/slash");
|
||||
registerSlashCommands
|
||||
.mockResolvedValueOnce([{ token: "token-1", trigger: "ping" }])
|
||||
.mockRejectedValueOnce(new Error("boom"));
|
||||
const runtime = {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
};
|
||||
|
||||
await registerMattermostMonitorSlashCommands({
|
||||
client: {} as never,
|
||||
cfg: { gateway: { customBindHost: "loopback" } } as never,
|
||||
runtime: runtime as never,
|
||||
account: { config: { commands: {} }, accountId: "default" } as never,
|
||||
baseUrl: "https://chat.example.com",
|
||||
botUserId: "bot-user",
|
||||
});
|
||||
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
expect.stringContaining(
|
||||
"slash commands callbackUrl resolved to http://127.0.0.1:18789/slash",
|
||||
),
|
||||
);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"mattermost: failed to register slash commands for team team-2: Error: boom",
|
||||
);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"mattermost: slash command registration completed with 1 team error(s)",
|
||||
);
|
||||
});
|
||||
});
|
||||
213
openclaw/extensions/mattermost/src/mattermost/monitor-slash.ts
Normal file
213
openclaw/extensions/mattermost/src/mattermost/monitor-slash.ts
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
fetchMattermostUserTeams,
|
||||
normalizeMattermostBaseUrl,
|
||||
type MattermostClient,
|
||||
} from "./client.js";
|
||||
import {
|
||||
listSkillCommandsForAgents,
|
||||
parseStrictPositiveInteger,
|
||||
type OpenClawConfig,
|
||||
type RuntimeEnv,
|
||||
} from "./runtime-api.js";
|
||||
import {
|
||||
DEFAULT_COMMAND_SPECS,
|
||||
isSlashCommandsEnabled,
|
||||
registerSlashCommands,
|
||||
resolveCallbackUrl,
|
||||
resolveSlashCommandConfig,
|
||||
type MattermostCommandSpec,
|
||||
type MattermostRegisteredCommand,
|
||||
type MattermostSlashCommandConfig,
|
||||
} from "./slash-commands.js";
|
||||
import { activateSlashCommands } from "./slash-state.js";
|
||||
|
||||
function isLoopbackHost(hostname: string): boolean {
|
||||
return hostname === "localhost" || hostname === "127.0.0.1" || hostname === "::1";
|
||||
}
|
||||
|
||||
function buildSlashCommands(params: {
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
nativeSkills: boolean;
|
||||
}): MattermostCommandSpec[] {
|
||||
const commandsToRegister: MattermostCommandSpec[] = [...DEFAULT_COMMAND_SPECS];
|
||||
if (!params.nativeSkills) {
|
||||
return commandsToRegister;
|
||||
}
|
||||
try {
|
||||
const skillCommands = listSkillCommandsForAgents({ cfg: params.cfg });
|
||||
for (const spec of skillCommands) {
|
||||
const name = typeof spec.name === "string" ? spec.name.trim() : "";
|
||||
if (!name) {
|
||||
continue;
|
||||
}
|
||||
const trigger = name.startsWith("oc_") ? name : `oc_${name}`;
|
||||
commandsToRegister.push({
|
||||
trigger,
|
||||
description: spec.description || `Run skill ${name}`,
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[args]",
|
||||
originalName: name,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
params.runtime.error?.(`mattermost: failed to list skill commands: ${String(err)}`);
|
||||
}
|
||||
return commandsToRegister;
|
||||
}
|
||||
|
||||
function dedupeSlashCommands(commands: MattermostCommandSpec[]): MattermostCommandSpec[] {
|
||||
const seen = new Set<string>();
|
||||
return commands.filter((cmd) => {
|
||||
const key = cmd.trigger.trim();
|
||||
if (!key || seen.has(key)) {
|
||||
return false;
|
||||
}
|
||||
seen.add(key);
|
||||
return true;
|
||||
});
|
||||
}
|
||||
|
||||
function buildTriggerMap(commands: MattermostCommandSpec[]): Map<string, string> {
|
||||
const triggerMap = new Map<string, string>();
|
||||
for (const cmd of commands) {
|
||||
if (cmd.originalName) {
|
||||
triggerMap.set(cmd.trigger, cmd.originalName);
|
||||
}
|
||||
}
|
||||
return triggerMap;
|
||||
}
|
||||
|
||||
function warnOnSuspiciousCallbackUrl(params: {
|
||||
runtime: RuntimeEnv;
|
||||
baseUrl: string;
|
||||
callbackUrl: string;
|
||||
}) {
|
||||
try {
|
||||
const mmHost = new URL(normalizeMattermostBaseUrl(params.baseUrl) ?? params.baseUrl).hostname;
|
||||
const callbackHost = new URL(params.callbackUrl).hostname;
|
||||
|
||||
if (isLoopbackHost(callbackHost) && !isLoopbackHost(mmHost)) {
|
||||
params.runtime.error?.(
|
||||
`mattermost: slash commands callbackUrl resolved to ${params.callbackUrl} (loopback) while baseUrl is ${params.baseUrl}. This MAY be unreachable depending on your deployment. If native slash commands don't work, set channels.mattermost.commands.callbackUrl to a URL reachable from the Mattermost server (e.g. your public reverse proxy URL).`,
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
// Ignore malformed URLs and let the downstream registration fail naturally.
|
||||
}
|
||||
}
|
||||
|
||||
async function registerSlashCommandsAcrossTeams(params: {
|
||||
client: MattermostClient;
|
||||
teams: Array<{ id: string }>;
|
||||
botUserId: string;
|
||||
callbackUrl: string;
|
||||
commands: MattermostCommandSpec[];
|
||||
runtime: RuntimeEnv;
|
||||
}): Promise<{
|
||||
registered: MattermostRegisteredCommand[];
|
||||
teamRegistrationFailures: number;
|
||||
}> {
|
||||
const registered: MattermostRegisteredCommand[] = [];
|
||||
let teamRegistrationFailures = 0;
|
||||
|
||||
for (const team of params.teams) {
|
||||
try {
|
||||
const created = await registerSlashCommands({
|
||||
client: params.client,
|
||||
teamId: team.id,
|
||||
creatorUserId: params.botUserId,
|
||||
callbackUrl: params.callbackUrl,
|
||||
commands: params.commands,
|
||||
log: (msg) => params.runtime.log?.(msg),
|
||||
});
|
||||
registered.push(...created);
|
||||
} catch (err) {
|
||||
teamRegistrationFailures += 1;
|
||||
params.runtime.error?.(
|
||||
`mattermost: failed to register slash commands for team ${team.id}: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { registered, teamRegistrationFailures };
|
||||
}
|
||||
|
||||
export async function registerMattermostMonitorSlashCommands(params: {
|
||||
client: MattermostClient;
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
account: ResolvedMattermostAccount;
|
||||
baseUrl: string;
|
||||
botUserId: string;
|
||||
}) {
|
||||
const commandsRaw = params.account.config.commands as
|
||||
| Partial<MattermostSlashCommandConfig>
|
||||
| undefined;
|
||||
const slashConfig = resolveSlashCommandConfig(commandsRaw);
|
||||
if (!isSlashCommandsEnabled(slashConfig)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const teams = await fetchMattermostUserTeams(params.client, params.botUserId);
|
||||
const envPort = parseStrictPositiveInteger(process.env.OPENCLAW_GATEWAY_PORT?.trim());
|
||||
const slashGatewayPort = envPort ?? params.cfg.gateway?.port ?? 18789;
|
||||
const slashCallbackUrl = resolveCallbackUrl({
|
||||
config: slashConfig,
|
||||
gatewayPort: slashGatewayPort,
|
||||
gatewayHost: params.cfg.gateway?.customBindHost ?? undefined,
|
||||
});
|
||||
|
||||
warnOnSuspiciousCallbackUrl({
|
||||
runtime: params.runtime,
|
||||
baseUrl: params.baseUrl,
|
||||
callbackUrl: slashCallbackUrl,
|
||||
});
|
||||
|
||||
const dedupedCommands = dedupeSlashCommands(
|
||||
buildSlashCommands({
|
||||
cfg: params.cfg,
|
||||
runtime: params.runtime,
|
||||
nativeSkills: slashConfig.nativeSkills === true,
|
||||
}),
|
||||
);
|
||||
const { registered, teamRegistrationFailures } = await registerSlashCommandsAcrossTeams({
|
||||
client: params.client,
|
||||
teams,
|
||||
botUserId: params.botUserId,
|
||||
callbackUrl: slashCallbackUrl,
|
||||
commands: dedupedCommands,
|
||||
runtime: params.runtime,
|
||||
});
|
||||
|
||||
if (registered.length === 0) {
|
||||
params.runtime.error?.(
|
||||
"mattermost: native slash commands enabled but no commands could be registered; keeping slash callbacks inactive",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (teamRegistrationFailures > 0) {
|
||||
params.runtime.error?.(
|
||||
`mattermost: slash command registration completed with ${teamRegistrationFailures} team error(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
activateSlashCommands({
|
||||
account: params.account,
|
||||
commandTokens: registered.map((cmd) => cmd.token).filter(Boolean),
|
||||
registeredCommands: registered,
|
||||
triggerMap: buildTriggerMap(dedupedCommands),
|
||||
api: { cfg: params.cfg, runtime: params.runtime },
|
||||
log: (msg) => params.runtime.log?.(msg),
|
||||
});
|
||||
|
||||
params.runtime.log?.(
|
||||
`mattermost: slash commands registered (${registered.length} commands across ${teams.length} teams, callback=${slashCallbackUrl})`,
|
||||
);
|
||||
} catch (err) {
|
||||
params.runtime.error?.(`mattermost: failed to register slash commands: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { RuntimeEnv } from "../../runtime-api.js";
|
||||
import {
|
||||
createMattermostConnectOnce,
|
||||
type MattermostWebSocketLike,
|
||||
WebSocketClosedBeforeOpenError,
|
||||
} from "./monitor-websocket.js";
|
||||
|
||||
class FakeWebSocket implements MattermostWebSocketLike {
|
||||
public readonly sent: string[] = [];
|
||||
public closeCalls = 0;
|
||||
public terminateCalls = 0;
|
||||
private openListeners: Array<() => void> = [];
|
||||
private messageListeners: Array<(data: Buffer) => void | Promise<void>> = [];
|
||||
private closeListeners: Array<(code: number, reason: Buffer) => void> = [];
|
||||
private errorListeners: Array<(err: unknown) => void> = [];
|
||||
|
||||
on(event: "open", listener: () => void): void;
|
||||
on(event: "message", listener: (data: Buffer) => void | Promise<void>): void;
|
||||
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
|
||||
on(event: "error", listener: (err: unknown) => void): void;
|
||||
on(event: "open" | "message" | "close" | "error", listener: unknown): void {
|
||||
if (event === "open") {
|
||||
this.openListeners.push(listener as () => void);
|
||||
return;
|
||||
}
|
||||
if (event === "message") {
|
||||
this.messageListeners.push(listener as (data: Buffer) => void | Promise<void>);
|
||||
return;
|
||||
}
|
||||
if (event === "close") {
|
||||
this.closeListeners.push(listener as (code: number, reason: Buffer) => void);
|
||||
return;
|
||||
}
|
||||
this.errorListeners.push(listener as (err: unknown) => void);
|
||||
}
|
||||
|
||||
send(data: string): void {
|
||||
this.sent.push(data);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.closeCalls++;
|
||||
}
|
||||
|
||||
terminate(): void {
|
||||
this.terminateCalls++;
|
||||
}
|
||||
|
||||
emitOpen(): void {
|
||||
for (const listener of this.openListeners) {
|
||||
listener();
|
||||
}
|
||||
}
|
||||
|
||||
emitMessage(data: Buffer): void {
|
||||
for (const listener of this.messageListeners) {
|
||||
void listener(data);
|
||||
}
|
||||
}
|
||||
|
||||
emitClose(code: number, reason = ""): void {
|
||||
const buffer = Buffer.from(reason, "utf8");
|
||||
for (const listener of this.closeListeners) {
|
||||
listener(code, buffer);
|
||||
}
|
||||
}
|
||||
|
||||
emitError(err: unknown): void {
|
||||
for (const listener of this.errorListeners) {
|
||||
listener(err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const testRuntime = (): RuntimeEnv =>
|
||||
({
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
exit: ((code: number): never => {
|
||||
throw new Error(`exit ${code}`);
|
||||
}) as RuntimeEnv["exit"],
|
||||
}) as RuntimeEnv;
|
||||
|
||||
describe("mattermost websocket monitor", () => {
|
||||
beforeEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("rejects when websocket closes before open", async () => {
|
||||
const socket = new FakeWebSocket();
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime: testRuntime(),
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
});
|
||||
|
||||
queueMicrotask(() => {
|
||||
socket.emitClose(1006, "connection refused");
|
||||
});
|
||||
|
||||
const failure = connectOnce();
|
||||
await expect(failure).rejects.toBeInstanceOf(WebSocketClosedBeforeOpenError);
|
||||
await expect(failure).rejects.toMatchObject({
|
||||
message: "websocket closed before open (code 1006)",
|
||||
});
|
||||
});
|
||||
|
||||
it("retries when first attempt errors before open and next attempt succeeds", async () => {
|
||||
const patches: Array<Record<string, unknown>> = [];
|
||||
const sockets: FakeWebSocket[] = [];
|
||||
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime: testRuntime(),
|
||||
nextSeq: (() => {
|
||||
let seq = 1;
|
||||
return () => seq++;
|
||||
})(),
|
||||
onPosted: async () => {},
|
||||
statusSink: (patch) => {
|
||||
patches.push(patch as Record<string, unknown>);
|
||||
},
|
||||
webSocketFactory: () => {
|
||||
const socket = new FakeWebSocket();
|
||||
const attempt = sockets.length;
|
||||
sockets.push(socket);
|
||||
queueMicrotask(() => {
|
||||
if (attempt === 0) {
|
||||
socket.emitError(new Error("boom"));
|
||||
socket.emitClose(1006, "connection refused");
|
||||
return;
|
||||
}
|
||||
socket.emitOpen();
|
||||
socket.emitClose(1000);
|
||||
});
|
||||
return socket;
|
||||
},
|
||||
});
|
||||
|
||||
const firstAttempt = connectOnce();
|
||||
await expect(firstAttempt).rejects.toBeInstanceOf(WebSocketClosedBeforeOpenError);
|
||||
|
||||
await connectOnce();
|
||||
|
||||
expect(sockets).toHaveLength(2);
|
||||
expect(sockets[0].closeCalls).toBe(1);
|
||||
expect(sockets[1].sent).toHaveLength(1);
|
||||
expect(JSON.parse(sockets[1].sent[0])).toMatchObject({
|
||||
action: "authentication_challenge",
|
||||
data: { token: "token" },
|
||||
seq: 1,
|
||||
});
|
||||
expect(patches.some((patch) => patch.connected === true)).toBe(true);
|
||||
expect(patches.filter((patch) => patch.connected === false)).toHaveLength(2);
|
||||
});
|
||||
|
||||
it("dispatches reaction events to the reaction handler", async () => {
|
||||
const socket = new FakeWebSocket();
|
||||
const onPosted = vi.fn(async () => {});
|
||||
const onReaction = vi.fn(async (payload) => payload);
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime: testRuntime(),
|
||||
nextSeq: () => 1,
|
||||
onPosted,
|
||||
onReaction,
|
||||
webSocketFactory: () => socket,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
queueMicrotask(() => {
|
||||
socket.emitOpen();
|
||||
socket.emitMessage(
|
||||
Buffer.from(
|
||||
JSON.stringify({
|
||||
event: "reaction_added",
|
||||
data: {
|
||||
reaction: JSON.stringify({
|
||||
user_id: "user-1",
|
||||
post_id: "post-1",
|
||||
emoji_name: "thumbsup",
|
||||
}),
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
socket.emitClose(1000);
|
||||
});
|
||||
|
||||
await connected;
|
||||
|
||||
expect(onReaction).toHaveBeenCalledTimes(1);
|
||||
expect(onPosted).not.toHaveBeenCalled();
|
||||
const payload = onReaction.mock.calls[0]?.[0];
|
||||
expect(payload).toMatchObject({
|
||||
event: "reaction_added",
|
||||
data: {
|
||||
reaction: JSON.stringify({
|
||||
user_id: "user-1",
|
||||
post_id: "post-1",
|
||||
emoji_name: "thumbsup",
|
||||
}),
|
||||
},
|
||||
});
|
||||
expect(payload.data?.reaction).toBe(
|
||||
JSON.stringify({
|
||||
user_id: "user-1",
|
||||
post_id: "post-1",
|
||||
emoji_name: "thumbsup",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("terminates when bot update_at changes (disable/enable cycle)", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = new FakeWebSocket();
|
||||
const runtime = testRuntime();
|
||||
let updateAt = 1000;
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime,
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
getBotUpdateAt: async () => updateAt,
|
||||
healthCheckIntervalMs: 100,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
socket.emitOpen();
|
||||
|
||||
// Let initial getBotUpdateAt resolve
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// update_at unchanged — no terminate
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(socket.terminateCalls).toBe(0);
|
||||
|
||||
// Simulate disable/enable — update_at changes
|
||||
updateAt = 2000;
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(socket.terminateCalls).toBe(1);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"mattermost: bot account updated (update_at changed: 1000 → 2000) — reconnecting",
|
||||
);
|
||||
|
||||
socket.emitClose(1006);
|
||||
await connected;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps connection alive when update_at stays the same", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = new FakeWebSocket();
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime: testRuntime(),
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
getBotUpdateAt: async () => 1000,
|
||||
healthCheckIntervalMs: 100,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
socket.emitOpen();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(socket.terminateCalls).toBe(0);
|
||||
|
||||
socket.emitClose(1000);
|
||||
await connected;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not terminate when getBotUpdateAt throws", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = new FakeWebSocket();
|
||||
const runtime = testRuntime();
|
||||
let shouldThrow = false;
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime,
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
getBotUpdateAt: async () => {
|
||||
if (shouldThrow) {
|
||||
throw new Error("network error");
|
||||
}
|
||||
return 1000;
|
||||
},
|
||||
healthCheckIntervalMs: 100,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
socket.emitOpen();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
// API error — should log but not terminate
|
||||
shouldThrow = true;
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(socket.terminateCalls).toBe(0);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"mattermost: health check error: Error: network error",
|
||||
);
|
||||
|
||||
socket.emitClose(1000);
|
||||
await connected;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("keeps polling when the initial getBotUpdateAt call fails", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = new FakeWebSocket();
|
||||
const runtime = testRuntime();
|
||||
const responses: Array<number | Error> = [new Error("network error"), 1000, 2000];
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime,
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
getBotUpdateAt: async () => {
|
||||
const next = responses.shift();
|
||||
if (next instanceof Error) {
|
||||
throw next;
|
||||
}
|
||||
return next ?? 2000;
|
||||
},
|
||||
healthCheckIntervalMs: 100,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
socket.emitOpen();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(runtime.error).toHaveBeenCalledWith(
|
||||
"mattermost: failed to get initial update_at: Error: network error",
|
||||
);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(socket.terminateCalls).toBe(0);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(socket.terminateCalls).toBe(1);
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"mattermost: bot account updated (update_at changed: 1000 → 2000) — reconnecting",
|
||||
);
|
||||
|
||||
socket.emitClose(1006);
|
||||
await connected;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it("does not overlap health checks when a prior poll is still running", async () => {
|
||||
vi.useFakeTimers();
|
||||
const socket = new FakeWebSocket();
|
||||
const resolvers: Array<(value: number) => void> = [];
|
||||
let pollCount = 0;
|
||||
const connectOnce = createMattermostConnectOnce({
|
||||
wsUrl: "wss://example.invalid/api/v4/websocket",
|
||||
botToken: "token",
|
||||
runtime: testRuntime(),
|
||||
nextSeq: () => 1,
|
||||
onPosted: async () => {},
|
||||
webSocketFactory: () => socket,
|
||||
getBotUpdateAt: async () => {
|
||||
pollCount++;
|
||||
return await new Promise<number>((resolve) => {
|
||||
resolvers.push(resolve);
|
||||
});
|
||||
},
|
||||
healthCheckIntervalMs: 100,
|
||||
});
|
||||
|
||||
const connected = connectOnce();
|
||||
socket.emitOpen();
|
||||
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(pollCount).toBe(1);
|
||||
|
||||
await vi.advanceTimersByTimeAsync(300);
|
||||
expect(pollCount).toBe(1);
|
||||
|
||||
resolvers[0]?.(1000);
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
await vi.advanceTimersByTimeAsync(100);
|
||||
expect(pollCount).toBe(2);
|
||||
|
||||
socket.emitClose(1000);
|
||||
await connected;
|
||||
vi.useRealTimers();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,377 @@
|
|||
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 { z } from "openclaw/plugin-sdk/zod";
|
||||
import WebSocket from "ws";
|
||||
import { MattermostPostSchema, type MattermostPost } from "./client.js";
|
||||
import { rawDataToString } from "./monitor-helpers.js";
|
||||
import type { ChannelAccountSnapshot, RuntimeEnv } from "./runtime-api.js";
|
||||
|
||||
export type MattermostEventPayload = {
|
||||
event?: string;
|
||||
data?: {
|
||||
post?: string | MattermostPost;
|
||||
reaction?: string | Record<string, unknown>;
|
||||
channel_id?: string;
|
||||
channel_name?: string;
|
||||
channel_display_name?: string;
|
||||
channel_type?: string;
|
||||
sender_name?: string;
|
||||
team_id?: string;
|
||||
};
|
||||
broadcast?: {
|
||||
channel_id?: string;
|
||||
team_id?: string;
|
||||
user_id?: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type MattermostWebSocketLike = {
|
||||
on(event: "open", listener: () => void): void;
|
||||
on(event: "message", listener: (data: WebSocket.RawData) => void | Promise<void>): void;
|
||||
on(event: "close", listener: (code: number, reason: Buffer) => void): void;
|
||||
on(event: "error", listener: (err: unknown) => void): void;
|
||||
send(data: string): void;
|
||||
close(): void;
|
||||
terminate(): void;
|
||||
};
|
||||
|
||||
export type MattermostWebSocketFactory = (url: string) => MattermostWebSocketLike;
|
||||
const MattermostEventPayloadSchema = z.object({
|
||||
event: z.string().optional(),
|
||||
data: z
|
||||
.object({
|
||||
post: z.union([z.string(), MattermostPostSchema]).optional(),
|
||||
reaction: z.union([z.string(), z.record(z.string(), z.unknown())]).optional(),
|
||||
channel_id: z.string().optional(),
|
||||
channel_name: z.string().optional(),
|
||||
channel_display_name: z.string().optional(),
|
||||
channel_type: z.string().optional(),
|
||||
sender_name: z.string().optional(),
|
||||
team_id: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
broadcast: z
|
||||
.object({
|
||||
channel_id: z.string().optional(),
|
||||
team_id: z.string().optional(),
|
||||
user_id: z.string().optional(),
|
||||
})
|
||||
.optional(),
|
||||
}) as z.ZodType<MattermostEventPayload>;
|
||||
|
||||
function parseMattermostEventPayload(raw: string): MattermostEventPayload | null {
|
||||
return safeParseJsonWithSchema(MattermostEventPayloadSchema, raw);
|
||||
}
|
||||
|
||||
function parseMattermostPost(value: unknown): MattermostPost | null {
|
||||
if (typeof value === "string") {
|
||||
return safeParseJsonWithSchema(MattermostPostSchema, value);
|
||||
}
|
||||
return safeParseWithSchema(MattermostPostSchema, value);
|
||||
}
|
||||
|
||||
export class WebSocketClosedBeforeOpenError extends Error {
|
||||
constructor(
|
||||
public readonly code: number,
|
||||
public readonly reason?: string,
|
||||
) {
|
||||
super(`websocket closed before open (code ${code})`);
|
||||
this.name = "WebSocketClosedBeforeOpenError";
|
||||
}
|
||||
}
|
||||
|
||||
type CreateMattermostConnectOnceOpts = {
|
||||
wsUrl: string;
|
||||
botToken: string;
|
||||
abortSignal?: AbortSignal;
|
||||
statusSink?: (patch: Partial<ChannelAccountSnapshot>) => void;
|
||||
runtime: RuntimeEnv;
|
||||
nextSeq: () => number;
|
||||
onPosted: (post: MattermostPost, payload: MattermostEventPayload) => Promise<void>;
|
||||
onReaction?: (payload: MattermostEventPayload) => Promise<void>;
|
||||
webSocketFactory?: MattermostWebSocketFactory;
|
||||
/**
|
||||
* Called periodically to check whether the bot account has been modified
|
||||
* (e.g. disabled then re-enabled) since the WebSocket was opened.
|
||||
* Returns the bot's current `update_at` timestamp. When it differs from
|
||||
* the value recorded at connect time, the connection is terminated so the
|
||||
* reconnect loop can establish a fresh one.
|
||||
*/
|
||||
getBotUpdateAt?: () => Promise<number>;
|
||||
healthCheckIntervalMs?: number;
|
||||
};
|
||||
|
||||
export const defaultMattermostWebSocketFactory: MattermostWebSocketFactory = (url) => {
|
||||
const agent = createDebugProxyWebSocketAgent(resolveDebugProxySettings());
|
||||
return new WebSocket(url, agent ? { agent } : undefined) as MattermostWebSocketLike;
|
||||
};
|
||||
|
||||
export function parsePostedPayload(
|
||||
payload: MattermostEventPayload,
|
||||
): { payload: MattermostEventPayload; post: MattermostPost } | null {
|
||||
if (payload.event !== "posted") {
|
||||
return null;
|
||||
}
|
||||
const postData = payload.data?.post;
|
||||
if (!postData) {
|
||||
return null;
|
||||
}
|
||||
const post = parseMattermostPost(postData);
|
||||
if (!post) {
|
||||
return null;
|
||||
}
|
||||
return { payload, post };
|
||||
}
|
||||
|
||||
export function parsePostedEvent(
|
||||
data: WebSocket.RawData,
|
||||
): { payload: MattermostEventPayload; post: MattermostPost } | null {
|
||||
const raw = rawDataToString(data);
|
||||
const payload = parseMattermostEventPayload(raw);
|
||||
if (!payload) {
|
||||
return null;
|
||||
}
|
||||
return parsePostedPayload(payload);
|
||||
}
|
||||
|
||||
export function createMattermostConnectOnce(
|
||||
opts: CreateMattermostConnectOnceOpts,
|
||||
): () => Promise<void> {
|
||||
const webSocketFactory = opts.webSocketFactory ?? defaultMattermostWebSocketFactory;
|
||||
const healthCheckIntervalMs = opts.healthCheckIntervalMs ?? 30_000;
|
||||
return async () => {
|
||||
const 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 initialUpdateAt: number | undefined;
|
||||
|
||||
const clearTimers = () => {
|
||||
if (healthCheckTimer !== undefined) {
|
||||
clearTimeout(healthCheckTimer);
|
||||
healthCheckTimer = undefined;
|
||||
}
|
||||
};
|
||||
|
||||
const stopHealthChecks = () => {
|
||||
healthCheckEnabled = false;
|
||||
clearTimers();
|
||||
};
|
||||
|
||||
const scheduleHealthCheck = () => {
|
||||
if (!getBotUpdateAt || !healthCheckEnabled || settled || healthCheckInFlight) {
|
||||
return;
|
||||
}
|
||||
healthCheckTimer = setTimeout(() => {
|
||||
healthCheckTimer = undefined;
|
||||
void runHealthCheck();
|
||||
}, healthCheckIntervalMs);
|
||||
};
|
||||
|
||||
const runHealthCheck = async () => {
|
||||
if (!getBotUpdateAt || !healthCheckEnabled || settled || healthCheckInFlight) {
|
||||
return;
|
||||
}
|
||||
healthCheckInFlight = true;
|
||||
try {
|
||||
const current = await getBotUpdateAt();
|
||||
if (!healthCheckEnabled || settled) {
|
||||
return;
|
||||
}
|
||||
if (initialUpdateAt === undefined) {
|
||||
initialUpdateAt = current;
|
||||
return;
|
||||
}
|
||||
if (current !== initialUpdateAt) {
|
||||
opts.runtime.log?.(
|
||||
`mattermost: bot account updated (update_at changed: ${initialUpdateAt} → ${current}) — reconnecting`,
|
||||
);
|
||||
stopHealthChecks();
|
||||
ws.terminate();
|
||||
}
|
||||
} catch (err) {
|
||||
if (!healthCheckEnabled || settled) {
|
||||
return;
|
||||
}
|
||||
const label =
|
||||
initialUpdateAt === undefined
|
||||
? "mattermost: failed to get initial update_at"
|
||||
: "mattermost: health check error";
|
||||
opts.runtime.error?.(`${label}: ${String(err)}`);
|
||||
} finally {
|
||||
healthCheckInFlight = false;
|
||||
scheduleHealthCheck();
|
||||
}
|
||||
};
|
||||
|
||||
const resolveOnce = () => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
stopHealthChecks();
|
||||
resolve();
|
||||
};
|
||||
const rejectOnce = (error: Error) => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
settled = true;
|
||||
stopHealthChecks();
|
||||
reject(error);
|
||||
};
|
||||
|
||||
ws.on("open", () => {
|
||||
opened = true;
|
||||
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);
|
||||
|
||||
// Periodically check if the bot account was modified (e.g. disable/enable).
|
||||
// After such a cycle the WebSocket silently stops delivering events even
|
||||
// though the connection itself stays alive. Comparing update_at detects
|
||||
// this reliably regardless of how quickly the cycle happens.
|
||||
if (getBotUpdateAt) {
|
||||
// Use a recursive timeout so only one REST poll can be in flight at a time.
|
||||
void runHealthCheck();
|
||||
}
|
||||
});
|
||||
|
||||
ws.on("message", async (data) => {
|
||||
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") : "";
|
||||
}
|
||||
|
|
@ -0,0 +1,122 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveControlCommandGate } from "../../runtime-api.js";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
authorizeMattermostCommandInvocation,
|
||||
resolveMattermostEffectiveAllowFromLists,
|
||||
} from "./monitor-auth.js";
|
||||
|
||||
const accountFixture: ResolvedMattermostAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
botTokenSource: "config",
|
||||
baseUrlSource: "config",
|
||||
config: {},
|
||||
};
|
||||
|
||||
function authorizeGroupCommand(senderId: string) {
|
||||
return authorizeMattermostCommandInvocation({
|
||||
account: {
|
||||
...accountFixture,
|
||||
config: {
|
||||
groupPolicy: "allowlist",
|
||||
allowFrom: ["trusted-user"],
|
||||
},
|
||||
},
|
||||
cfg: {
|
||||
commands: {
|
||||
useAccessGroups: true,
|
||||
},
|
||||
},
|
||||
senderId,
|
||||
senderName: senderId,
|
||||
channelId: "chan-1",
|
||||
channelInfo: {
|
||||
id: "chan-1",
|
||||
type: "O",
|
||||
name: "general",
|
||||
display_name: "General",
|
||||
},
|
||||
storeAllowFrom: [],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
});
|
||||
}
|
||||
|
||||
describe("mattermost monitor authz", () => {
|
||||
it("keeps DM allowlist merged with pairing-store entries", () => {
|
||||
const resolved = resolveMattermostEffectiveAllowFromLists({
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["@trusted-user"],
|
||||
groupAllowFrom: ["@group-owner"],
|
||||
storeAllowFrom: ["user:attacker"],
|
||||
});
|
||||
|
||||
expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
|
||||
});
|
||||
|
||||
it("uses explicit groupAllowFrom without pairing-store inheritance", () => {
|
||||
const resolved = resolveMattermostEffectiveAllowFromLists({
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["@trusted-user"],
|
||||
groupAllowFrom: ["@group-owner"],
|
||||
storeAllowFrom: ["user:attacker"],
|
||||
});
|
||||
|
||||
expect(resolved.effectiveGroupAllowFrom).toEqual(["group-owner"]);
|
||||
});
|
||||
|
||||
it("does not inherit pairing-store entries into group allowlist", () => {
|
||||
const resolved = resolveMattermostEffectiveAllowFromLists({
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: ["@trusted-user"],
|
||||
storeAllowFrom: ["user:attacker"],
|
||||
});
|
||||
|
||||
expect(resolved.effectiveAllowFrom).toEqual(["trusted-user", "attacker"]);
|
||||
expect(resolved.effectiveGroupAllowFrom).toEqual(["trusted-user"]);
|
||||
});
|
||||
|
||||
it("does not auto-authorize DM commands in open mode without allowlists", () => {
|
||||
const resolved = resolveMattermostEffectiveAllowFromLists({
|
||||
dmPolicy: "open",
|
||||
allowFrom: [],
|
||||
groupAllowFrom: [],
|
||||
storeAllowFrom: [],
|
||||
});
|
||||
|
||||
const commandGate = resolveControlCommandGate({
|
||||
useAccessGroups: true,
|
||||
authorizers: [
|
||||
{ configured: resolved.effectiveAllowFrom.length > 0, allowed: false },
|
||||
{ configured: resolved.effectiveGroupAllowFrom.length > 0, allowed: false },
|
||||
],
|
||||
allowTextCommands: true,
|
||||
hasControlCommand: true,
|
||||
});
|
||||
|
||||
expect(commandGate.commandAuthorized).toBe(false);
|
||||
});
|
||||
|
||||
it("denies group control commands when the sender is outside the allowlist", () => {
|
||||
const decision = authorizeGroupCommand("attacker");
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
ok: false,
|
||||
denyReason: "unauthorized",
|
||||
kind: "channel",
|
||||
});
|
||||
});
|
||||
|
||||
it("authorizes group control commands for allowlisted senders", () => {
|
||||
const decision = authorizeGroupCommand("trusted-user");
|
||||
|
||||
expect(decision).toMatchObject({
|
||||
ok: true,
|
||||
commandAuthorized: true,
|
||||
kind: "channel",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { mapMattermostChannelTypeToChatType } from "./monitor.js";
|
||||
|
||||
describe("mapMattermostChannelTypeToChatType", () => {
|
||||
it("maps direct and group dm channel types", () => {
|
||||
expect(mapMattermostChannelTypeToChatType("D")).toBe("direct");
|
||||
expect(mapMattermostChannelTypeToChatType("g")).toBe("group");
|
||||
});
|
||||
|
||||
it("maps private channels to group", () => {
|
||||
expect(mapMattermostChannelTypeToChatType("P")).toBe("group");
|
||||
expect(mapMattermostChannelTypeToChatType(" p ")).toBe("group");
|
||||
});
|
||||
|
||||
it("keeps public channels and unknown values as channel", () => {
|
||||
expect(mapMattermostChannelTypeToChatType("O")).toBe("channel");
|
||||
expect(mapMattermostChannelTypeToChatType("x")).toBe("channel");
|
||||
expect(mapMattermostChannelTypeToChatType(undefined)).toBe("channel");
|
||||
});
|
||||
});
|
||||
652
openclaw/extensions/mattermost/src/mattermost/monitor.test.ts
Normal file
652
openclaw/extensions/mattermost/src/mattermost/monitor.test.ts
Normal file
|
|
@ -0,0 +1,652 @@
|
|||
import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { OpenClawConfig } from "../../runtime-api.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import * as clientModule from "./client.js";
|
||||
import type { MattermostClient } from "./client.js";
|
||||
import {
|
||||
buildMattermostModelPickerSelectMessageSid,
|
||||
canFinalizeMattermostPreviewInPlace,
|
||||
deliverMattermostReplyWithDraftPreview,
|
||||
evaluateMattermostMentionGate,
|
||||
MattermostRetryableInboundError,
|
||||
processMattermostReplayGuardedPost,
|
||||
resolveMattermostReactionChannelId,
|
||||
resolveMattermostEffectiveReplyToId,
|
||||
resolveMattermostReplyRootId,
|
||||
resolveMattermostThreadSessionContext,
|
||||
shouldFinalizeMattermostPreviewAfterDispatch,
|
||||
shouldClearMattermostDraftPreview,
|
||||
type MattermostMentionGateInput,
|
||||
type MattermostRequireMentionResolverInput,
|
||||
} from "./monitor.js";
|
||||
|
||||
function resolveRequireMentionForTest(params: MattermostRequireMentionResolverInput): boolean {
|
||||
const root = params.cfg.channels?.mattermost;
|
||||
const accountGroups = (
|
||||
root?.accounts?.[params.accountId] as
|
||||
| { groups?: Record<string, { requireMention?: boolean }> }
|
||||
| undefined
|
||||
)?.groups;
|
||||
const groups = accountGroups ?? root?.groups;
|
||||
const typedGroups = groups as Record<string, { requireMention?: boolean }> | undefined;
|
||||
const groupConfig = params.groupId ? typedGroups?.[params.groupId] : undefined;
|
||||
const defaultGroupConfig = typedGroups?.["*"];
|
||||
const configMention =
|
||||
typeof groupConfig?.requireMention === "boolean"
|
||||
? groupConfig.requireMention
|
||||
: typeof defaultGroupConfig?.requireMention === "boolean"
|
||||
? defaultGroupConfig.requireMention
|
||||
: undefined;
|
||||
if (typeof configMention === "boolean") {
|
||||
return configMention;
|
||||
}
|
||||
if (typeof params.requireMentionOverride === "boolean") {
|
||||
return params.requireMentionOverride;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
const updateMattermostPostSpy = vi.spyOn(clientModule, "updateMattermostPost");
|
||||
|
||||
function createMattermostClientMock(): MattermostClient {
|
||||
return {
|
||||
baseUrl: "https://chat.example.com",
|
||||
apiBaseUrl: "https://chat.example.com/api/v4",
|
||||
token: "token",
|
||||
request: vi.fn(async () => ({})) as MattermostClient["request"],
|
||||
fetchImpl: vi.fn(
|
||||
async () => new Response(null, { status: 200 }),
|
||||
) as MattermostClient["fetchImpl"],
|
||||
};
|
||||
}
|
||||
|
||||
function createDraftStreamMock(postId: string | undefined = "preview-post-1") {
|
||||
return {
|
||||
flush: vi.fn(async () => {}),
|
||||
postId: vi.fn(() => postId),
|
||||
clear: vi.fn(async () => {}),
|
||||
stop: vi.fn(async () => {}),
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
updateMattermostPostSpy.mockResolvedValue({ id: "patched" } as never);
|
||||
});
|
||||
|
||||
function evaluateMentionGateForMessage(params: { cfg: OpenClawConfig; threadRootId?: string }) {
|
||||
const account = resolveMattermostAccount({ cfg: params.cfg, accountId: "default" });
|
||||
const resolver = vi.fn(resolveRequireMentionForTest);
|
||||
const input: MattermostMentionGateInput = {
|
||||
kind: "channel",
|
||||
cfg: params.cfg,
|
||||
accountId: account.accountId,
|
||||
channelId: "chan-1",
|
||||
threadRootId: params.threadRootId,
|
||||
requireMentionOverride: account.requireMention,
|
||||
resolveRequireMention: resolver,
|
||||
wasMentioned: false,
|
||||
isControlCommand: false,
|
||||
commandAuthorized: false,
|
||||
oncharEnabled: false,
|
||||
oncharTriggered: false,
|
||||
canDetectMention: true,
|
||||
};
|
||||
const decision = evaluateMattermostMentionGate(input);
|
||||
return { account, resolver, decision };
|
||||
}
|
||||
|
||||
describe("mattermost mention gating", () => {
|
||||
it("accepts unmentioned root channel posts in onmessage mode", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
chatmode: "onmessage",
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
};
|
||||
const { resolver, decision } = evaluateMentionGateForMessage({ cfg });
|
||||
expect(decision.dropReason).toBeNull();
|
||||
expect(decision.shouldRequireMention).toBe(false);
|
||||
expect(resolver).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
accountId: "default",
|
||||
groupId: "chan-1",
|
||||
requireMentionOverride: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("accepts unmentioned thread replies in onmessage mode", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
chatmode: "onmessage",
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
};
|
||||
const { resolver, decision } = evaluateMentionGateForMessage({
|
||||
cfg,
|
||||
threadRootId: "thread-root-1",
|
||||
});
|
||||
expect(decision.dropReason).toBeNull();
|
||||
expect(decision.shouldRequireMention).toBe(false);
|
||||
const resolverCall = resolver.mock.calls.at(-1)?.[0];
|
||||
expect(resolverCall?.groupId).toBe("chan-1");
|
||||
expect(resolverCall?.groupId).not.toBe("thread-root-1");
|
||||
});
|
||||
|
||||
it("rejects unmentioned channel posts in oncall mode", () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
chatmode: "oncall",
|
||||
groupPolicy: "open",
|
||||
},
|
||||
},
|
||||
};
|
||||
const { decision, account } = evaluateMentionGateForMessage({ cfg });
|
||||
expect(account.requireMention).toBe(true);
|
||||
expect(decision.shouldRequireMention).toBe(true);
|
||||
expect(decision.dropReason).toBe("missing-mention");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMattermostReplyRootId with block streaming payloads", () => {
|
||||
it("uses threadRootId for block-streamed payloads with replyToId", () => {
|
||||
// When block streaming sends a payload with replyToId from the threading
|
||||
// mode, the deliver callback should still use the existing threadRootId.
|
||||
expect(
|
||||
resolveMattermostReplyRootId({
|
||||
threadRootId: "thread-root-1",
|
||||
replyToId: "streamed-reply-id",
|
||||
}),
|
||||
).toBe("thread-root-1");
|
||||
});
|
||||
|
||||
it("falls back to payload replyToId when no threadRootId in block streaming", () => {
|
||||
// Top-level channel message: no threadRootId, payload carries the
|
||||
// inbound post id as replyToId from the "all" threading mode.
|
||||
expect(
|
||||
resolveMattermostReplyRootId({
|
||||
replyToId: "inbound-post-for-threading",
|
||||
}),
|
||||
).toBe("inbound-post-for-threading");
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMattermostReplyRootId", () => {
|
||||
it("uses replyToId for top-level replies", () => {
|
||||
expect(
|
||||
resolveMattermostReplyRootId({
|
||||
replyToId: "inbound-post-123",
|
||||
}),
|
||||
).toBe("inbound-post-123");
|
||||
});
|
||||
|
||||
it("keeps the thread root when replying inside an existing thread", () => {
|
||||
expect(
|
||||
resolveMattermostReplyRootId({
|
||||
threadRootId: "thread-root-456",
|
||||
replyToId: "child-post-789",
|
||||
}),
|
||||
).toBe("thread-root-456");
|
||||
});
|
||||
|
||||
it("falls back to undefined when neither reply target is available", () => {
|
||||
expect(resolveMattermostReplyRootId({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("canFinalizeMattermostPreviewInPlace", () => {
|
||||
it("allows in-place finalization when the final reply target matches the preview thread", () => {
|
||||
expect(
|
||||
canFinalizeMattermostPreviewInPlace({
|
||||
previewRootId: "thread-root-456",
|
||||
threadRootId: "thread-root-456",
|
||||
replyToId: "child-post-789",
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("prevents in-place finalization when a top-level preview would become a threaded reply", () => {
|
||||
expect(
|
||||
canFinalizeMattermostPreviewInPlace({
|
||||
replyToId: "child-post-789",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldClearMattermostDraftPreview", () => {
|
||||
it("deletes the preview after successful normal final delivery", () => {
|
||||
expect(
|
||||
shouldClearMattermostDraftPreview({
|
||||
finalizedViaPreviewPost: false,
|
||||
finalReplyDelivered: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps the preview when final delivery failed", () => {
|
||||
expect(
|
||||
shouldClearMattermostDraftPreview({
|
||||
finalizedViaPreviewPost: false,
|
||||
finalReplyDelivered: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps the preview when it already became the final reply", () => {
|
||||
expect(
|
||||
shouldClearMattermostDraftPreview({
|
||||
finalizedViaPreviewPost: true,
|
||||
finalReplyDelivered: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("deliverMattermostReplyWithDraftPreview", () => {
|
||||
it("deletes the preview after a successful normal final send", async () => {
|
||||
const draftStream = createDraftStreamMock();
|
||||
const deliverFinal = vi.fn(async () => {});
|
||||
|
||||
await deliverMattermostReplyWithDraftPreview({
|
||||
payload: { text: "All good", replyToId: "reply-1" } as never,
|
||||
info: { kind: "final" },
|
||||
client: createMattermostClientMock(),
|
||||
draftStream,
|
||||
resolvePreviewFinalText: (text) => text?.trim(),
|
||||
previewState: { finalizedViaPreviewPost: false },
|
||||
logVerboseMessage: vi.fn(),
|
||||
deliverFinal,
|
||||
});
|
||||
|
||||
expect(deliverFinal).toHaveBeenCalledTimes(1);
|
||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||
expect(updateMattermostPostSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("deletes the preview after a successful non-finalizable media final", async () => {
|
||||
const draftStream = createDraftStreamMock();
|
||||
const deliverFinal = vi.fn(async () => {});
|
||||
|
||||
await deliverMattermostReplyWithDraftPreview({
|
||||
payload: {
|
||||
text: "Photo",
|
||||
replyToId: "reply-1",
|
||||
mediaUrl: "https://example.com/a.png",
|
||||
} as never,
|
||||
info: { kind: "final" },
|
||||
client: createMattermostClientMock(),
|
||||
draftStream,
|
||||
effectiveReplyToId: "thread-root-1",
|
||||
resolvePreviewFinalText: (text) => text?.trim(),
|
||||
previewState: { finalizedViaPreviewPost: false },
|
||||
logVerboseMessage: vi.fn(),
|
||||
deliverFinal,
|
||||
});
|
||||
|
||||
expect(deliverFinal).toHaveBeenCalledTimes(1);
|
||||
expect(draftStream.clear).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("finalizes the preview in place when the final targets the same thread", async () => {
|
||||
const draftStream = createDraftStreamMock();
|
||||
const deliverFinal = vi.fn(async () => {});
|
||||
|
||||
await deliverMattermostReplyWithDraftPreview({
|
||||
payload: { text: "Final answer", replyToId: "child-post-789" } as never,
|
||||
info: { kind: "final" },
|
||||
client: createMattermostClientMock(),
|
||||
draftStream,
|
||||
effectiveReplyToId: "thread-root-456",
|
||||
resolvePreviewFinalText: (text) => text?.trim(),
|
||||
previewState: { finalizedViaPreviewPost: false },
|
||||
logVerboseMessage: vi.fn(),
|
||||
deliverFinal,
|
||||
});
|
||||
|
||||
expect(updateMattermostPostSpy).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"preview-post-1",
|
||||
expect.objectContaining({ message: "Final answer" }),
|
||||
);
|
||||
expect(draftStream.stop).toHaveBeenCalledTimes(1);
|
||||
expect(draftStream.stop.mock.invocationCallOrder[0]).toBeLessThan(
|
||||
updateMattermostPostSpy.mock.invocationCallOrder[0] ?? Number.POSITIVE_INFINITY,
|
||||
);
|
||||
expect(deliverFinal).not.toHaveBeenCalled();
|
||||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the existing preview unchanged when final delivery fails", async () => {
|
||||
const draftStream = createDraftStreamMock();
|
||||
const deliverFinal = vi.fn(async () => {
|
||||
throw new Error("send failed");
|
||||
});
|
||||
|
||||
await expect(
|
||||
deliverMattermostReplyWithDraftPreview({
|
||||
payload: { text: "Broken", replyToId: "reply-1" } as never,
|
||||
info: { kind: "final" },
|
||||
client: createMattermostClientMock(),
|
||||
draftStream,
|
||||
resolvePreviewFinalText: (text) => text?.trim(),
|
||||
previewState: { finalizedViaPreviewPost: false },
|
||||
logVerboseMessage: vi.fn(),
|
||||
deliverFinal,
|
||||
}),
|
||||
).rejects.toThrow("send failed");
|
||||
|
||||
expect(draftStream.clear).not.toHaveBeenCalled();
|
||||
expect(updateMattermostPostSpy).not.toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
"preview-post-1",
|
||||
expect.objectContaining({ message: "↓ See below." }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("shouldFinalizeMattermostPreviewAfterDispatch", () => {
|
||||
it("reuses the preview only for a single eligible final payload", () => {
|
||||
expect(
|
||||
shouldFinalizeMattermostPreviewAfterDispatch({
|
||||
finalCount: 1,
|
||||
canFinalizeInPlace: true,
|
||||
}),
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("falls back to normal sends for multi-payload finals", () => {
|
||||
expect(
|
||||
shouldFinalizeMattermostPreviewAfterDispatch({
|
||||
finalCount: 2,
|
||||
canFinalizeInPlace: true,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("falls back to normal sends when the final cannot be edited into the preview", () => {
|
||||
expect(
|
||||
shouldFinalizeMattermostPreviewAfterDispatch({
|
||||
finalCount: 1,
|
||||
canFinalizeInPlace: false,
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMattermostEffectiveReplyToId", () => {
|
||||
it("keeps an existing thread root", () => {
|
||||
expect(
|
||||
resolveMattermostEffectiveReplyToId({
|
||||
kind: "channel",
|
||||
postId: "post-123",
|
||||
replyToMode: "all",
|
||||
threadRootId: "thread-root-456",
|
||||
}),
|
||||
).toBe("thread-root-456");
|
||||
});
|
||||
|
||||
it("suppresses existing thread roots when replyToMode is off", () => {
|
||||
expect(
|
||||
resolveMattermostEffectiveReplyToId({
|
||||
kind: "channel",
|
||||
postId: "post-123",
|
||||
replyToMode: "off",
|
||||
threadRootId: "thread-root-456",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
it("starts a thread for top-level channel messages when replyToMode is all", () => {
|
||||
expect(
|
||||
resolveMattermostEffectiveReplyToId({
|
||||
kind: "channel",
|
||||
postId: "post-123",
|
||||
replyToMode: "all",
|
||||
}),
|
||||
).toBe("post-123");
|
||||
});
|
||||
|
||||
it("starts a thread for top-level group messages when replyToMode is first", () => {
|
||||
expect(
|
||||
resolveMattermostEffectiveReplyToId({
|
||||
kind: "group",
|
||||
postId: "post-123",
|
||||
replyToMode: "first",
|
||||
}),
|
||||
).toBe("post-123");
|
||||
});
|
||||
|
||||
it("keeps direct messages non-threaded", () => {
|
||||
expect(
|
||||
resolveMattermostEffectiveReplyToId({
|
||||
kind: "direct",
|
||||
postId: "post-123",
|
||||
replyToMode: "all",
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMattermostThreadSessionContext", () => {
|
||||
it("forks channel sessions by top-level post when replyToMode is all", () => {
|
||||
expect(
|
||||
resolveMattermostThreadSessionContext({
|
||||
baseSessionKey: "agent:main:mattermost:default:chan-1",
|
||||
kind: "channel",
|
||||
postId: "post-123",
|
||||
replyToMode: "all",
|
||||
}),
|
||||
).toEqual({
|
||||
effectiveReplyToId: "post-123",
|
||||
sessionKey: "agent:main:mattermost:default:chan-1:thread:post-123",
|
||||
parentSessionKey: "agent:main:mattermost:default:chan-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps existing thread roots for threaded follow-ups", () => {
|
||||
expect(
|
||||
resolveMattermostThreadSessionContext({
|
||||
baseSessionKey: "agent:main:mattermost:default:chan-1",
|
||||
kind: "group",
|
||||
postId: "post-123",
|
||||
replyToMode: "first",
|
||||
threadRootId: "root-456",
|
||||
}),
|
||||
).toEqual({
|
||||
effectiveReplyToId: "root-456",
|
||||
sessionKey: "agent:main:mattermost:default:chan-1:thread:root-456",
|
||||
parentSessionKey: "agent:main:mattermost:default:chan-1",
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps threaded messages top-level when replyToMode is off", () => {
|
||||
expect(
|
||||
resolveMattermostThreadSessionContext({
|
||||
baseSessionKey: "agent:main:mattermost:default:chan-1",
|
||||
kind: "group",
|
||||
postId: "post-123",
|
||||
replyToMode: "off",
|
||||
threadRootId: "root-456",
|
||||
}),
|
||||
).toEqual({
|
||||
effectiveReplyToId: undefined,
|
||||
sessionKey: "agent:main:mattermost:default:chan-1",
|
||||
parentSessionKey: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps direct-message sessions linear", () => {
|
||||
expect(
|
||||
resolveMattermostThreadSessionContext({
|
||||
baseSessionKey: "agent:main:mattermost:default:user-1",
|
||||
kind: "direct",
|
||||
postId: "post-123",
|
||||
replyToMode: "all",
|
||||
}),
|
||||
).toEqual({
|
||||
effectiveReplyToId: undefined,
|
||||
sessionKey: "agent:main:mattermost:default:user-1",
|
||||
parentSessionKey: undefined,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("processMattermostReplayGuardedPost", () => {
|
||||
it("skips duplicate message batches after a successful commit", async () => {
|
||||
const replayGuard = createClaimableDedupe({
|
||||
ttlMs: 10_000,
|
||||
memoryMaxSize: 100,
|
||||
});
|
||||
const handlePost = vi.fn(async () => undefined);
|
||||
|
||||
await expect(
|
||||
processMattermostReplayGuardedPost({
|
||||
replayGuard,
|
||||
accountId: "acct",
|
||||
messageIds: ["post-1"],
|
||||
handlePost,
|
||||
}),
|
||||
).resolves.toBe("processed");
|
||||
await expect(
|
||||
processMattermostReplayGuardedPost({
|
||||
replayGuard,
|
||||
accountId: "acct",
|
||||
messageIds: ["post-1"],
|
||||
handlePost,
|
||||
}),
|
||||
).resolves.toBe("duplicate");
|
||||
|
||||
expect(handlePost).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("releases claims for explicit retryable failures", async () => {
|
||||
const replayGuard = createClaimableDedupe({
|
||||
ttlMs: 10_000,
|
||||
memoryMaxSize: 100,
|
||||
});
|
||||
let attempts = 0;
|
||||
const handlePost = vi.fn(async () => {
|
||||
attempts += 1;
|
||||
if (attempts === 1) {
|
||||
throw new MattermostRetryableInboundError("retry me");
|
||||
}
|
||||
});
|
||||
|
||||
await expect(
|
||||
processMattermostReplayGuardedPost({
|
||||
replayGuard,
|
||||
accountId: "acct",
|
||||
messageIds: ["post-2"],
|
||||
handlePost,
|
||||
}),
|
||||
).rejects.toThrow("retry me");
|
||||
await expect(
|
||||
processMattermostReplayGuardedPost({
|
||||
replayGuard,
|
||||
accountId: "acct",
|
||||
messageIds: ["post-2"],
|
||||
handlePost,
|
||||
}),
|
||||
).resolves.toBe("processed");
|
||||
|
||||
expect(handlePost).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("keeps replay committed after a non-retryable failure", async () => {
|
||||
const replayGuard = createClaimableDedupe({
|
||||
ttlMs: 10_000,
|
||||
memoryMaxSize: 100,
|
||||
});
|
||||
const visibleSideEffect = vi.fn();
|
||||
const handlePost = vi.fn(async () => {
|
||||
visibleSideEffect();
|
||||
throw new Error("post-send failure");
|
||||
});
|
||||
|
||||
await expect(
|
||||
processMattermostReplayGuardedPost({
|
||||
replayGuard,
|
||||
accountId: "acct",
|
||||
messageIds: ["post-3"],
|
||||
handlePost,
|
||||
}),
|
||||
).rejects.toThrow("post-send failure");
|
||||
await expect(
|
||||
processMattermostReplayGuardedPost({
|
||||
replayGuard,
|
||||
accountId: "acct",
|
||||
messageIds: ["post-3"],
|
||||
handlePost,
|
||||
}),
|
||||
).resolves.toBe("duplicate");
|
||||
|
||||
expect(handlePost).toHaveBeenCalledTimes(1);
|
||||
expect(visibleSideEffect).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("buildMattermostModelPickerSelectMessageSid", () => {
|
||||
it("stays stable for the same picker selection", () => {
|
||||
expect(
|
||||
buildMattermostModelPickerSelectMessageSid({
|
||||
postId: "post-1",
|
||||
provider: "OpenAI",
|
||||
model: " GPT-5 ",
|
||||
}),
|
||||
).toBe("interaction:post-1:select:openai/gpt-5");
|
||||
expect(
|
||||
buildMattermostModelPickerSelectMessageSid({
|
||||
postId: "post-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
}),
|
||||
).toBe("interaction:post-1:select:openai/gpt-5");
|
||||
});
|
||||
|
||||
it("keeps different model selections distinct", () => {
|
||||
expect(
|
||||
buildMattermostModelPickerSelectMessageSid({
|
||||
postId: "post-1",
|
||||
provider: "openai",
|
||||
model: "gpt-5",
|
||||
}),
|
||||
).not.toBe(
|
||||
buildMattermostModelPickerSelectMessageSid({
|
||||
postId: "post-1",
|
||||
provider: "openai",
|
||||
model: "gpt-4.1",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveMattermostReactionChannelId", () => {
|
||||
it("prefers broadcast channel_id when present", () => {
|
||||
expect(
|
||||
resolveMattermostReactionChannelId({
|
||||
broadcast: { channel_id: "chan-broadcast" },
|
||||
data: { channel_id: "chan-data" },
|
||||
}),
|
||||
).toBe("chan-broadcast");
|
||||
});
|
||||
|
||||
it("falls back to data.channel_id when broadcast channel_id is missing", () => {
|
||||
expect(
|
||||
resolveMattermostReactionChannelId({
|
||||
data: { channel_id: "chan-data" },
|
||||
}),
|
||||
).toBe("chan-data");
|
||||
});
|
||||
|
||||
it("returns undefined when neither payload location includes channel_id", () => {
|
||||
expect(resolveMattermostReactionChannelId({})).toBeUndefined();
|
||||
});
|
||||
});
|
||||
2050
openclaw/extensions/mattermost/src/mattermost/monitor.ts
Normal file
2050
openclaw/extensions/mattermost/src/mattermost/monitor.ts
Normal file
File diff suppressed because it is too large
Load diff
134
openclaw/extensions/mattermost/src/mattermost/probe.test.ts
Normal file
134
openclaw/extensions/mattermost/src/mattermost/probe.test.ts
Normal file
|
|
@ -0,0 +1,134 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { probeMattermost } from "./probe.js";
|
||||
|
||||
const { mockFetchGuard, mockRelease } = vi.hoisted(() => ({
|
||||
mockFetchGuard: vi.fn(),
|
||||
mockRelease: vi.fn(async () => {}),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/ssrf-runtime", async () => {
|
||||
const original = (await vi.importActual("openclaw/plugin-sdk/ssrf-runtime")) as Record<
|
||||
string,
|
||||
unknown
|
||||
>;
|
||||
return { ...original, fetchWithSsrFGuard: mockFetchGuard };
|
||||
});
|
||||
|
||||
describe("probeMattermost", () => {
|
||||
beforeEach(() => {
|
||||
mockFetchGuard.mockReset();
|
||||
mockRelease.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it("returns baseUrl missing for empty base URL", async () => {
|
||||
await expect(probeMattermost(" ", "token")).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "baseUrl missing",
|
||||
});
|
||||
expect(mockFetchGuard).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("normalizes base URL and returns bot info", async () => {
|
||||
mockFetchGuard.mockResolvedValueOnce({
|
||||
response: new Response(JSON.stringify({ id: "bot-1", username: "clawbot" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release: mockRelease,
|
||||
});
|
||||
|
||||
const result = await probeMattermost("https://mm.example.com/api/v4/", "bot-token");
|
||||
|
||||
expect(mockFetchGuard).toHaveBeenCalledWith({
|
||||
url: "https://mm.example.com/api/v4/users/me",
|
||||
init: expect.objectContaining({
|
||||
headers: { Authorization: "Bearer bot-token" },
|
||||
}),
|
||||
auditContext: "mattermost-probe",
|
||||
policy: undefined,
|
||||
});
|
||||
expect(result).toEqual(
|
||||
expect.objectContaining({
|
||||
ok: true,
|
||||
status: 200,
|
||||
bot: { id: "bot-1", username: "clawbot" },
|
||||
}),
|
||||
);
|
||||
expect(result.elapsedMs).toBeGreaterThanOrEqual(0);
|
||||
expect(mockRelease).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("forwards allowPrivateNetwork to the SSRF guard policy", async () => {
|
||||
mockFetchGuard.mockResolvedValueOnce({
|
||||
response: new Response(JSON.stringify({ id: "bot-1" }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release: mockRelease,
|
||||
});
|
||||
|
||||
await probeMattermost("https://mm.example.com", "bot-token", 2500, true);
|
||||
|
||||
expect(mockFetchGuard).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
policy: { allowPrivateNetwork: true },
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("returns API error details from JSON response", async () => {
|
||||
mockFetchGuard.mockResolvedValueOnce({
|
||||
response: new Response(JSON.stringify({ message: "invalid auth token" }), {
|
||||
status: 401,
|
||||
statusText: "Unauthorized",
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release: mockRelease,
|
||||
});
|
||||
|
||||
await expect(probeMattermost("https://mm.example.com", "bad-token")).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: false,
|
||||
status: 401,
|
||||
error: "invalid auth token",
|
||||
}),
|
||||
);
|
||||
expect(mockRelease).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to statusText when error body is empty", async () => {
|
||||
mockFetchGuard.mockResolvedValueOnce({
|
||||
response: new Response("", {
|
||||
status: 403,
|
||||
statusText: "Forbidden",
|
||||
headers: { "content-type": "text/plain" },
|
||||
}),
|
||||
release: mockRelease,
|
||||
});
|
||||
|
||||
await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: false,
|
||||
status: 403,
|
||||
error: "Forbidden",
|
||||
}),
|
||||
);
|
||||
expect(mockRelease).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns fetch error when request throws", async () => {
|
||||
mockFetchGuard.mockRejectedValueOnce(new Error("network down"));
|
||||
|
||||
await expect(probeMattermost("https://mm.example.com", "token")).resolves.toEqual(
|
||||
expect.objectContaining({
|
||||
ok: false,
|
||||
status: null,
|
||||
error: "network down",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
76
openclaw/extensions/mattermost/src/mattermost/probe.ts
Normal file
76
openclaw/extensions/mattermost/src/mattermost/probe.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import {
|
||||
fetchWithSsrFGuard,
|
||||
ssrfPolicyFromPrivateNetworkOptIn,
|
||||
} from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeMattermostBaseUrl, readMattermostError, type MattermostUser } from "./client.js";
|
||||
import type { BaseProbeResult } from "./runtime-api.js";
|
||||
|
||||
export type MattermostProbe = BaseProbeResult & {
|
||||
status?: number | null;
|
||||
elapsedMs?: number | null;
|
||||
bot?: MattermostUser;
|
||||
};
|
||||
|
||||
export async function probeMattermost(
|
||||
baseUrl: string,
|
||||
botToken: string,
|
||||
timeoutMs = 2500,
|
||||
allowPrivateNetwork = false,
|
||||
): Promise<MattermostProbe> {
|
||||
const normalized = normalizeMattermostBaseUrl(baseUrl);
|
||||
if (!normalized) {
|
||||
return { ok: false, error: "baseUrl missing" };
|
||||
}
|
||||
const url = `${normalized}/api/v4/users/me`;
|
||||
const start = Date.now();
|
||||
const controller = timeoutMs > 0 ? new AbortController() : undefined;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
if (controller) {
|
||||
timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
}
|
||||
try {
|
||||
const { response: res, release } = await fetchWithSsrFGuard({
|
||||
url,
|
||||
init: {
|
||||
headers: { Authorization: `Bearer ${botToken}` },
|
||||
signal: controller?.signal,
|
||||
},
|
||||
auditContext: "mattermost-probe",
|
||||
policy: 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 res.json()) as MattermostUser;
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,99 @@
|
|||
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;
|
||||
}
|
||||
}
|
||||
107
openclaw/extensions/mattermost/src/mattermost/reactions.test.ts
Normal file
107
openclaw/extensions/mattermost/src/mattermost/reactions.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { beforeEach, describe, expect, it } from "vitest";
|
||||
import {
|
||||
addMattermostReaction,
|
||||
removeMattermostReaction,
|
||||
resetMattermostReactionBotUserCacheForTests,
|
||||
} from "./reactions.js";
|
||||
import {
|
||||
createMattermostReactionFetchMock,
|
||||
createMattermostTestConfig,
|
||||
requestUrl,
|
||||
} from "./reactions.test-helpers.js";
|
||||
|
||||
describe("mattermost reactions", () => {
|
||||
beforeEach(() => {
|
||||
resetMattermostReactionBotUserCacheForTests();
|
||||
});
|
||||
|
||||
async function addReactionWithFetch(fetchMock: typeof fetch) {
|
||||
return addMattermostReaction({
|
||||
cfg: createMattermostTestConfig(),
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
}
|
||||
|
||||
async function removeReactionWithFetch(fetchMock: typeof fetch) {
|
||||
return removeMattermostReaction({
|
||||
cfg: createMattermostTestConfig(),
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
}
|
||||
|
||||
it("adds reactions by calling /users/me then POST /reactions", async () => {
|
||||
const fetchMock = createMattermostReactionFetchMock({
|
||||
mode: "add",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
});
|
||||
|
||||
const result = await addReactionWithFetch(fetchMock);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("returns a Result error when add reaction API call fails", async () => {
|
||||
const fetchMock = createMattermostReactionFetchMock({
|
||||
mode: "add",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
status: 500,
|
||||
body: { id: "err", message: "boom" },
|
||||
});
|
||||
|
||||
const result = await addReactionWithFetch(fetchMock);
|
||||
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) {
|
||||
expect(result.error).toContain("Mattermost add reaction failed");
|
||||
}
|
||||
});
|
||||
|
||||
it("removes reactions by calling /users/me then DELETE /users/:id/posts/:postId/reactions/:emoji", async () => {
|
||||
const fetchMock = createMattermostReactionFetchMock({
|
||||
mode: "remove",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
});
|
||||
|
||||
const result = await removeReactionWithFetch(fetchMock);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
expect(fetchMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("caches the bot user id across reaction mutations", async () => {
|
||||
const fetchMock = createMattermostReactionFetchMock({
|
||||
mode: "both",
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
});
|
||||
|
||||
const cfg = createMattermostTestConfig();
|
||||
const addResult = await addMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
const removeResult = await removeMattermostReaction({
|
||||
cfg,
|
||||
postId: "POST1",
|
||||
emojiName: "thumbsup",
|
||||
fetchImpl: fetchMock,
|
||||
});
|
||||
|
||||
const usersMeCalls = fetchMock.mock.calls.filter((call) =>
|
||||
requestUrl(call[0]).endsWith("/api/v4/users/me"),
|
||||
);
|
||||
expect(addResult).toEqual({ ok: true });
|
||||
expect(removeResult).toEqual({ ok: true });
|
||||
expect(usersMeCalls).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
131
openclaw/extensions/mattermost/src/mattermost/reactions.ts
Normal file
131
openclaw/extensions/mattermost/src/mattermost/reactions.ts
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
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 cached = botUserIdCache.get(cacheKey);
|
||||
if (cached && cached.expiresAt > Date.now()) {
|
||||
return cached.userId;
|
||||
}
|
||||
const me = await fetchMattermostMe(client);
|
||||
const userId = me?.id?.trim();
|
||||
if (!userId) {
|
||||
return null;
|
||||
}
|
||||
botUserIdCache.set(cacheKey, { userId, expiresAt: Date.now() + BOT_USER_CACHE_TTL_MS });
|
||||
return userId;
|
||||
}
|
||||
|
||||
export async function addMattermostReaction(params: {
|
||||
cfg: OpenClawConfig;
|
||||
postId: string;
|
||||
emojiName: string;
|
||||
accountId?: string | null;
|
||||
fetchImpl?: MattermostFetch;
|
||||
}): Promise<Result> {
|
||||
return runMattermostReaction(params, {
|
||||
action: "add",
|
||||
mutation: createReaction,
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeMattermostReaction(params: {
|
||||
cfg: OpenClawConfig;
|
||||
postId: string;
|
||||
emojiName: string;
|
||||
accountId?: string | null;
|
||||
fetchImpl?: MattermostFetch;
|
||||
}): Promise<Result> {
|
||||
return runMattermostReaction(params, {
|
||||
action: "remove",
|
||||
mutation: deleteReaction,
|
||||
});
|
||||
}
|
||||
|
||||
export function resetMattermostReactionBotUserCacheForTests(): void {
|
||||
botUserIdCache.clear();
|
||||
}
|
||||
|
||||
async function runMattermostReaction(
|
||||
params: ReactionParams,
|
||||
options: {
|
||||
action: "add" | "remove";
|
||||
mutation: ReactionMutation;
|
||||
},
|
||||
): Promise<Result> {
|
||||
const resolved = resolveMattermostAccount({ cfg: params.cfg, accountId: params.accountId });
|
||||
const baseUrl = resolved.baseUrl?.trim();
|
||||
const botToken = resolved.botToken?.trim();
|
||||
if (!baseUrl || !botToken) {
|
||||
return { ok: false, error: "Mattermost botToken/baseUrl missing." };
|
||||
}
|
||||
|
||||
const client = createMattermostClient({
|
||||
baseUrl,
|
||||
botToken,
|
||||
fetchImpl: params.fetchImpl,
|
||||
allowPrivateNetwork: 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",
|
||||
},
|
||||
);
|
||||
}
|
||||
198
openclaw/extensions/mattermost/src/mattermost/reconnect.test.ts
Normal file
198
openclaw/extensions/mattermost/src/mattermost/reconnect.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { runWithReconnect } from "./reconnect.js";
|
||||
|
||||
beforeEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useFakeTimers();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
async function resolveReconnectRun(promise: Promise<void>): Promise<void> {
|
||||
await vi.runAllTimersAsync();
|
||||
await promise;
|
||||
}
|
||||
|
||||
describe("runWithReconnect", () => {
|
||||
it("retries after connectFn resolves (normal close)", async () => {
|
||||
let callCount = 0;
|
||||
const abort = new AbortController();
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount >= 3) {
|
||||
abort.abort();
|
||||
}
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
initialDelayMs: 1,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(3);
|
||||
});
|
||||
|
||||
it("retries after connectFn throws (connection error)", async () => {
|
||||
let callCount = 0;
|
||||
const abort = new AbortController();
|
||||
const onError = vi.fn();
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount < 3) {
|
||||
throw new Error("fetch failed");
|
||||
}
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onError,
|
||||
initialDelayMs: 1,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(3);
|
||||
expect(onError).toHaveBeenCalledTimes(2);
|
||||
expect(onError).toHaveBeenCalledWith(expect.objectContaining({ message: "fetch failed" }));
|
||||
});
|
||||
|
||||
it("uses exponential backoff on consecutive errors, capped at maxDelayMs", async () => {
|
||||
const abort = new AbortController();
|
||||
const delays: number[] = [];
|
||||
let callCount = 0;
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount >= 6) {
|
||||
abort.abort();
|
||||
return;
|
||||
}
|
||||
throw new Error("connection refused");
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onReconnect: (delayMs) => delays.push(delayMs),
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 10,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(6);
|
||||
expect(delays).toEqual([1, 2, 4, 8, 10]);
|
||||
});
|
||||
|
||||
it("resets backoff after successful connection", async () => {
|
||||
const abort = new AbortController();
|
||||
const delays: number[] = [];
|
||||
let callCount = 0;
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error("first failure");
|
||||
}
|
||||
if (callCount === 2) {
|
||||
return;
|
||||
}
|
||||
if (callCount === 3) {
|
||||
throw new Error("second failure");
|
||||
}
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onReconnect: (delayMs) => delays.push(delayMs),
|
||||
initialDelayMs: 1,
|
||||
maxDelayMs: 60_000,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(4);
|
||||
expect(delays).toEqual([1, 1, 1]);
|
||||
});
|
||||
|
||||
it("stops immediately when abort signal is pre-fired", async () => {
|
||||
const abort = new AbortController();
|
||||
abort.abort();
|
||||
const connectFn = vi.fn(async () => {});
|
||||
|
||||
await runWithReconnect(connectFn, { abortSignal: abort.signal });
|
||||
|
||||
expect(connectFn).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("stops after current connection when abort fires mid-connection", async () => {
|
||||
const abort = new AbortController();
|
||||
const connectFn = vi.fn(async () => {
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
await runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
initialDelayMs: 1,
|
||||
});
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("abort signal interrupts backoff sleep immediately", async () => {
|
||||
const abort = new AbortController();
|
||||
const connectFn = vi.fn(async () => {
|
||||
setTimeout(() => abort.abort(), 10);
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
initialDelayMs: 60_000,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("applies jitter to reconnect delay when configured", async () => {
|
||||
const abort = new AbortController();
|
||||
const delays: number[] = [];
|
||||
let callCount = 0;
|
||||
const connectFn = vi.fn(async () => {
|
||||
callCount++;
|
||||
if (callCount === 1) {
|
||||
throw new Error("connection refused");
|
||||
}
|
||||
abort.abort();
|
||||
});
|
||||
|
||||
const run = runWithReconnect(connectFn, {
|
||||
abortSignal: abort.signal,
|
||||
onReconnect: (delayMs) => delays.push(delayMs),
|
||||
initialDelayMs: 10,
|
||||
jitterRatio: 0.5,
|
||||
random: () => 1,
|
||||
});
|
||||
await resolveReconnectRun(run);
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(2);
|
||||
expect(delays).toEqual([15]);
|
||||
});
|
||||
|
||||
it("supports strategy hook to stop reconnecting after failure", async () => {
|
||||
const onReconnect = vi.fn();
|
||||
const connectFn = vi.fn(async () => {
|
||||
throw new Error("fatal");
|
||||
});
|
||||
|
||||
await runWithReconnect(connectFn, {
|
||||
initialDelayMs: 1,
|
||||
onReconnect,
|
||||
shouldReconnect: (params) => params.outcome !== "rejected",
|
||||
});
|
||||
|
||||
expect(connectFn).toHaveBeenCalledTimes(1);
|
||||
expect(onReconnect).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
103
openclaw/extensions/mattermost/src/mattermost/reconnect.ts
Normal file
103
openclaw/extensions/mattermost/src/mattermost/reconnect.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
export type ReconnectOutcome = "resolved" | "rejected";
|
||||
|
||||
export type ShouldReconnectParams = {
|
||||
attempt: number;
|
||||
delayMs: number;
|
||||
outcome: ReconnectOutcome;
|
||||
error?: unknown;
|
||||
};
|
||||
|
||||
export type RunWithReconnectOpts = {
|
||||
abortSignal?: AbortSignal;
|
||||
onError?: (err: unknown) => void;
|
||||
onReconnect?: (delayMs: number) => void;
|
||||
initialDelayMs?: number;
|
||||
maxDelayMs?: number;
|
||||
jitterRatio?: number;
|
||||
random?: () => number;
|
||||
shouldReconnect?: (params: ShouldReconnectParams) => boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Reconnection loop with exponential backoff.
|
||||
*
|
||||
* Calls `connectFn` in a while loop. On normal resolve (connection closed),
|
||||
* the backoff resets. On thrown error (connection failed), the current delay is
|
||||
* used, then doubled for the next retry.
|
||||
* The loop exits when `abortSignal` fires.
|
||||
*/
|
||||
export async function runWithReconnect(
|
||||
connectFn: () => Promise<void>,
|
||||
opts: RunWithReconnectOpts = {},
|
||||
): Promise<void> {
|
||||
const { initialDelayMs = 2000, maxDelayMs = 60_000 } = opts;
|
||||
const jitterRatio = Math.max(0, opts.jitterRatio ?? 0);
|
||||
const random = opts.random ?? Math.random;
|
||||
let retryDelay = initialDelayMs;
|
||||
let attempt = 0;
|
||||
|
||||
while (!opts.abortSignal?.aborted) {
|
||||
let shouldIncreaseDelay = false;
|
||||
let outcome: ReconnectOutcome = "resolved";
|
||||
let error: unknown;
|
||||
try {
|
||||
await connectFn();
|
||||
retryDelay = initialDelayMs;
|
||||
} catch (err) {
|
||||
if (opts.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
outcome = "rejected";
|
||||
error = err;
|
||||
opts.onError?.(err);
|
||||
shouldIncreaseDelay = true;
|
||||
}
|
||||
if (opts.abortSignal?.aborted) {
|
||||
return;
|
||||
}
|
||||
const delayMs = withJitter(retryDelay, jitterRatio, random);
|
||||
const shouldReconnect =
|
||||
opts.shouldReconnect?.({
|
||||
attempt,
|
||||
delayMs,
|
||||
outcome,
|
||||
error,
|
||||
}) ?? true;
|
||||
if (!shouldReconnect) {
|
||||
return;
|
||||
}
|
||||
opts.onReconnect?.(delayMs);
|
||||
await sleepAbortable(delayMs, opts.abortSignal);
|
||||
if (shouldIncreaseDelay) {
|
||||
retryDelay = Math.min(retryDelay * 2, maxDelayMs);
|
||||
}
|
||||
attempt++;
|
||||
}
|
||||
}
|
||||
|
||||
function withJitter(baseMs: number, jitterRatio: number, random: () => number): number {
|
||||
if (jitterRatio <= 0) {
|
||||
return baseMs;
|
||||
}
|
||||
const normalized = Math.max(0, Math.min(1, random()));
|
||||
const spread = baseMs * jitterRatio;
|
||||
return Math.max(1, Math.round(baseMs - spread + normalized * spread * 2));
|
||||
}
|
||||
|
||||
function sleepAbortable(ms: number, signal?: AbortSignal): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
if (signal?.aborted) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const onAbort = () => {
|
||||
clearTimeout(timer);
|
||||
resolve();
|
||||
};
|
||||
const timer = setTimeout(() => {
|
||||
signal?.removeEventListener("abort", onAbort);
|
||||
resolve();
|
||||
}, ms);
|
||||
signal?.addEventListener("abort", onAbort, { once: true });
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,119 @@
|
|||
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 { 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("deliverMattermostReplyPayload", () => {
|
||||
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, `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"]);
|
||||
|
||||
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",
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,72 @@
|
|||
import {
|
||||
deliverTextOrMediaReply,
|
||||
resolveSendableOutboundReplyParts,
|
||||
} from "openclaw/plugin-sdk/reply-payload";
|
||||
import {
|
||||
getAgentScopedMediaLocalRoots,
|
||||
type OpenClawConfig,
|
||||
type PluginRuntime,
|
||||
type ReplyPayload,
|
||||
} from "./runtime-api.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;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
|
||||
export async function deliverMattermostReplyPayload(params: {
|
||||
core: PluginRuntime;
|
||||
cfg: OpenClawConfig;
|
||||
payload: ReplyPayload;
|
||||
to: string;
|
||||
accountId: string;
|
||||
agentId?: string;
|
||||
replyToId?: string;
|
||||
textLimit: number;
|
||||
tableMode: MarkdownTableMode;
|
||||
sendMessage: SendMattermostMessage;
|
||||
}): Promise<void> {
|
||||
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,
|
||||
);
|
||||
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,
|
||||
});
|
||||
},
|
||||
sendMedia: async ({ mediaUrl, caption }) => {
|
||||
await params.sendMessage(params.to, caption ?? "", {
|
||||
cfg: params.cfg,
|
||||
accountId: params.accountId,
|
||||
mediaUrl,
|
||||
mediaLocalRoots,
|
||||
replyToId: params.replyToId,
|
||||
});
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export * from "../../runtime-api.js";
|
||||
659
openclaw/extensions/mattermost/src/mattermost/send.test.ts
Normal file
659
openclaw/extensions/mattermost/src/mattermost/send.test.ts
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
expectProvidedCfgSkipsRuntimeLoad,
|
||||
expectRuntimeCfgFallback,
|
||||
} from "../../../../test/helpers/plugins/send-config.js";
|
||||
|
||||
let parseMattermostTarget: typeof import("./send.js").parseMattermostTarget;
|
||||
let sendMessageMattermost: typeof import("./send.js").sendMessageMattermost;
|
||||
let resetMattermostOpaqueTargetCacheForTests: typeof import("./target-resolution.js").resetMattermostOpaqueTargetCacheForTests;
|
||||
|
||||
type SendMessageMattermostOptions = NonNullable<
|
||||
Parameters<typeof import("./send.js").sendMessageMattermost>[2]
|
||||
>;
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
loadConfig: vi.fn(() => ({})),
|
||||
loadOutboundMediaFromUrl: vi.fn(),
|
||||
recordActivity: vi.fn(),
|
||||
resolveMattermostAccount: vi.fn(() => ({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
})),
|
||||
createMattermostClient: vi.fn(),
|
||||
createMattermostDirectChannel: vi.fn(),
|
||||
createMattermostDirectChannelWithRetry: vi.fn(),
|
||||
createMattermostPost: vi.fn(),
|
||||
fetchMattermostChannelByName: vi.fn(),
|
||||
fetchMattermostMe: vi.fn(),
|
||||
fetchMattermostUser: vi.fn(),
|
||||
fetchMattermostUserTeams: vi.fn(),
|
||||
fetchMattermostUserByUsername: vi.fn(),
|
||||
normalizeMattermostBaseUrl: vi.fn((input: string | undefined) => input?.trim() ?? ""),
|
||||
uploadMattermostFile: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("../../runtime-api.js", () => ({
|
||||
loadOutboundMediaFromUrl: mockState.loadOutboundMediaFromUrl,
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/config-runtime", () => ({
|
||||
resolveMarkdownTableMode: vi.fn(() => "off"),
|
||||
}));
|
||||
|
||||
vi.mock("openclaw/plugin-sdk/text-runtime", () => ({
|
||||
convertMarkdownTables: vi.fn((text: string) => text),
|
||||
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("falls back to runtime loadConfig when cfg is omitted", async () => {
|
||||
const runtimeCfg = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
botToken: "runtime-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
mockState.loadConfig.mockReturnValueOnce(runtimeCfg);
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "runtime-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
|
||||
await sendMessageMattermost("channel:town-square", "hello");
|
||||
|
||||
expectRuntimeCfgFallback({
|
||||
loadConfig: mockState.loadConfig,
|
||||
resolveAccount: mockState.resolveMattermostAccount,
|
||||
cfg: runtimeCfg,
|
||||
accountId: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
it("sends with provided cfg even when the runtime store is not initialized", async () => {
|
||||
const providedCfg = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
botToken: "provided-token",
|
||||
},
|
||||
},
|
||||
};
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "work",
|
||||
botToken: "provided-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
mockState.recordActivity.mockImplementation(() => {
|
||||
throw new Error("Mattermost runtime not initialized");
|
||||
});
|
||||
|
||||
await expect(
|
||||
sendMessageMattermost("channel:town-square", "hello", {
|
||||
cfg: providedCfg,
|
||||
accountId: "work",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
messageId: "post-1",
|
||||
channelId: "town-square",
|
||||
});
|
||||
expect(mockState.loadConfig).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("loads outbound media with trusted local roots before upload", async () => {
|
||||
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("media-bytes"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
});
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
|
||||
await sendMessageMattermost("channel:town-square", "hello", {
|
||||
mediaUrl: "file:///tmp/agent-workspace/photo.png",
|
||||
mediaLocalRoots: ["/tmp/agent-workspace"],
|
||||
});
|
||||
|
||||
expect(mockState.loadOutboundMediaFromUrl).toHaveBeenCalledWith(
|
||||
"file:///tmp/agent-workspace/photo.png",
|
||||
{
|
||||
mediaLocalRoots: ["/tmp/agent-workspace"],
|
||||
},
|
||||
);
|
||||
expect(mockState.uploadMattermostFile).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
channelId: "town-square",
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("builds interactive button props when buttons are provided", async () => {
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
|
||||
await sendMessageMattermost("channel:town-square", "Pick a model", {
|
||||
buttons: [[{ callback_data: "mdlprov", text: "Browse providers" }]],
|
||||
});
|
||||
|
||||
expect(mockState.createMattermostPost).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
channelId: "town-square",
|
||||
message: "Pick a model",
|
||||
props: expect.objectContaining({
|
||||
attachments: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
actions: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
id: "mdlprov",
|
||||
name: "Browse providers",
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("resolves a bare Mattermost user id as a DM target before upload", async () => {
|
||||
const userId = "dthcxgoxhifn3pwh65cut3ud3w";
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("media-bytes"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
const result = await sendMessageMattermost(userId, "hello", {
|
||||
mediaUrl: "file:///tmp/agent-workspace/photo.png",
|
||||
mediaLocalRoots: ["/tmp/agent-workspace"],
|
||||
});
|
||||
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, userId);
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith(
|
||||
{},
|
||||
["bot-user", userId],
|
||||
expect.any(Object),
|
||||
);
|
||||
expect(mockState.uploadMattermostFile).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
channelId: "dm-channel-1",
|
||||
}),
|
||||
);
|
||||
expect(result.channelId).toBe("dm-channel-1");
|
||||
});
|
||||
|
||||
it("falls back to a channel target when bare Mattermost id is not a user", async () => {
|
||||
const channelId = "aaaaaaaaaaaaaaaaaaaaaaaaaa";
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {},
|
||||
});
|
||||
mockState.fetchMattermostUser.mockRejectedValueOnce(
|
||||
new Error("Mattermost API 404 Not Found: user not found"),
|
||||
);
|
||||
mockState.loadOutboundMediaFromUrl.mockResolvedValueOnce({
|
||||
buffer: Buffer.from("media-bytes"),
|
||||
fileName: "photo.png",
|
||||
contentType: "image/png",
|
||||
kind: "image",
|
||||
});
|
||||
|
||||
const result = await sendMessageMattermost(channelId, "hello", {
|
||||
mediaUrl: "file:///tmp/agent-workspace/photo.png",
|
||||
mediaLocalRoots: ["/tmp/agent-workspace"],
|
||||
});
|
||||
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledWith({}, channelId);
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled();
|
||||
expect(mockState.uploadMattermostFile).toHaveBeenCalledWith(
|
||||
{},
|
||||
expect.objectContaining({
|
||||
channelId,
|
||||
}),
|
||||
);
|
||||
expect(result.channelId).toBe(channelId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("parseMattermostTarget", () => {
|
||||
it("parses channel: prefix with valid ID as channel id", () => {
|
||||
const target = parseMattermostTarget("channel:dthcxgoxhifn3pwh65cut3ud3w");
|
||||
expect(target).toEqual({ kind: "channel", id: "dthcxgoxhifn3pwh65cut3ud3w" });
|
||||
});
|
||||
|
||||
it("parses channel: prefix with non-ID as channel name", () => {
|
||||
const target = parseMattermostTarget("channel:abc123");
|
||||
expect(target).toEqual({ kind: "channel-name", name: "abc123" });
|
||||
});
|
||||
|
||||
it("parses user: prefix as user id", () => {
|
||||
const target = parseMattermostTarget("user:usr456");
|
||||
expect(target).toEqual({ kind: "user", id: "usr456" });
|
||||
});
|
||||
|
||||
it("parses mattermost: prefix as user id", () => {
|
||||
const target = parseMattermostTarget("mattermost:usr789");
|
||||
expect(target).toEqual({ kind: "user", id: "usr789" });
|
||||
});
|
||||
|
||||
it("parses @ prefix as username", () => {
|
||||
const target = parseMattermostTarget("@alice");
|
||||
expect(target).toEqual({ kind: "user", username: "alice" });
|
||||
});
|
||||
|
||||
it("parses # prefix as channel name", () => {
|
||||
const target = parseMattermostTarget("#off-topic");
|
||||
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
|
||||
});
|
||||
|
||||
it("parses # prefix with spaces", () => {
|
||||
const target = parseMattermostTarget(" #general ");
|
||||
expect(target).toEqual({ kind: "channel-name", name: "general" });
|
||||
});
|
||||
|
||||
it("treats 26-char alphanumeric bare string as channel id", () => {
|
||||
const target = parseMattermostTarget("dthcxgoxhifn3pwh65cut3ud3w");
|
||||
expect(target).toEqual({ kind: "channel", id: "dthcxgoxhifn3pwh65cut3ud3w" });
|
||||
});
|
||||
|
||||
it("treats non-ID bare string as channel name", () => {
|
||||
const target = parseMattermostTarget("off-topic");
|
||||
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
|
||||
});
|
||||
|
||||
it("treats channel: with non-ID value as channel name", () => {
|
||||
const target = parseMattermostTarget("channel:off-topic");
|
||||
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
|
||||
});
|
||||
|
||||
it("throws on empty string", () => {
|
||||
expect(() => parseMattermostTarget("")).toThrow("Recipient is required");
|
||||
});
|
||||
|
||||
it("throws on empty # prefix", () => {
|
||||
expect(() => parseMattermostTarget("#")).toThrow("Channel name is required");
|
||||
});
|
||||
|
||||
it("throws on empty @ prefix", () => {
|
||||
expect(() => parseMattermostTarget("@")).toThrow("Username is required");
|
||||
});
|
||||
|
||||
it("parses channel:#name as channel name", () => {
|
||||
const target = parseMattermostTarget("channel:#off-topic");
|
||||
expect(target).toEqual({ kind: "channel-name", name: "off-topic" });
|
||||
});
|
||||
|
||||
it("parses channel:#name with spaces", () => {
|
||||
const target = parseMattermostTarget(" channel: #general ");
|
||||
expect(target).toEqual({ kind: "channel-name", name: "general" });
|
||||
});
|
||||
|
||||
it("is case-insensitive for prefixes", () => {
|
||||
expect(parseMattermostTarget("CHANNEL:dthcxgoxhifn3pwh65cut3ud3w")).toEqual({
|
||||
kind: "channel",
|
||||
id: "dthcxgoxhifn3pwh65cut3ud3w",
|
||||
});
|
||||
expect(parseMattermostTarget("User:XYZ")).toEqual({ kind: "user", id: "XYZ" });
|
||||
expect(parseMattermostTarget("Mattermost:QRS")).toEqual({ kind: "user", id: "QRS" });
|
||||
});
|
||||
});
|
||||
|
||||
// Each test uses a unique (token, id) pair to avoid module-level cache collisions.
|
||||
// userIdResolutionCache and dmChannelCache are module singletons that survive across tests.
|
||||
// Using unique cache keys per test ensures full isolation without needing a cache reset API.
|
||||
describe("sendMessageMattermost user-first resolution", () => {
|
||||
function makeAccount(token: string, config = {}) {
|
||||
return {
|
||||
accountId: "default",
|
||||
botToken: token,
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
mockState.createMattermostClient.mockReturnValue({});
|
||||
mockState.createMattermostPost.mockResolvedValue({ id: "post-id" });
|
||||
mockState.createMattermostDirectChannel.mockResolvedValue({ id: "dm-channel-id" });
|
||||
mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" });
|
||||
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" });
|
||||
});
|
||||
|
||||
it("resolves unprefixed 26-char id as user and sends via DM channel", async () => {
|
||||
// Unique token + id to avoid cache pollution from other tests
|
||||
const userId = "aaaaaa1111111111aaaaaa1111"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-user-dm-t1"));
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
const res = await sendMessageMattermost(userId, "hello");
|
||||
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1);
|
||||
const params = mockState.createMattermostPost.mock.calls[0]?.[1];
|
||||
expect(params.channelId).toBe("dm-channel-id");
|
||||
expect(res.channelId).toBe("dm-channel-id");
|
||||
expect(res.messageId).toBe("post-id");
|
||||
});
|
||||
|
||||
it("falls back to channel id when user lookup returns 404", async () => {
|
||||
// Unique token + id for this test
|
||||
const channelId = "bbbbbb2222222222bbbbbb2222"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-404-t2"));
|
||||
const err = new Error("Mattermost API 404: user not found");
|
||||
mockState.fetchMattermostUser.mockRejectedValueOnce(err);
|
||||
|
||||
const res = await sendMessageMattermost(channelId, "hello");
|
||||
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled();
|
||||
const params = mockState.createMattermostPost.mock.calls[0]?.[1];
|
||||
expect(params.channelId).toBe(channelId);
|
||||
expect(res.channelId).toBe(channelId);
|
||||
});
|
||||
|
||||
it("falls back to channel id without caching negative result on transient error", async () => {
|
||||
// Two unique tokens so each call has its own cache namespace
|
||||
const userId = "cccccc3333333333cccccc3333"; // 26 chars
|
||||
const tokenA = "token-transient-t3a";
|
||||
const tokenB = "token-transient-t3b";
|
||||
const transientErr = new Error("Mattermost API 503: service unavailable");
|
||||
|
||||
// First call: transient error → fall back to channel id, do NOT cache negative
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenA));
|
||||
mockState.fetchMattermostUser.mockRejectedValueOnce(transientErr);
|
||||
|
||||
const res1 = await sendMessageMattermost(userId, "first");
|
||||
expect(res1.channelId).toBe(userId);
|
||||
|
||||
// Second call with a different token (new cache key) → retries user lookup
|
||||
vi.clearAllMocks();
|
||||
mockState.createMattermostClient.mockReturnValue({});
|
||||
mockState.createMattermostPost.mockResolvedValue({ id: "post-id-2" });
|
||||
mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" });
|
||||
mockState.fetchMattermostMe.mockResolvedValue({ id: "bot-id" });
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount(tokenB));
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
const res2 = await sendMessageMattermost(userId, "second");
|
||||
expect(mockState.fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
expect(res2.channelId).toBe("dm-channel-id");
|
||||
});
|
||||
|
||||
it("does not apply user-first resolution for explicit user: prefix", async () => {
|
||||
// Unique token + id — explicit user: prefix bypasses probe, goes straight to DM
|
||||
const userId = "dddddd4444444444dddddd4444"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-user-t4"));
|
||||
mockState.createMattermostDirectChannelWithRetry.mockResolvedValue({ id: "dm-channel-id" });
|
||||
|
||||
const res = await sendMessageMattermost(`user:${userId}`, "hello");
|
||||
|
||||
expect(mockState.fetchMattermostUser).not.toHaveBeenCalled();
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledTimes(1);
|
||||
expect(res.channelId).toBe("dm-channel-id");
|
||||
});
|
||||
|
||||
it("does not apply user-first resolution for explicit channel: prefix", async () => {
|
||||
// Unique token + id — explicit channel: prefix, no probe, no DM
|
||||
const chanId = "eeeeee5555555555eeeeee5555"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-explicit-chan-t5"));
|
||||
|
||||
const res = await sendMessageMattermost(`channel:${chanId}`, "hello");
|
||||
|
||||
expect(mockState.fetchMattermostUser).not.toHaveBeenCalled();
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).not.toHaveBeenCalled();
|
||||
const params = mockState.createMattermostPost.mock.calls[0]?.[1];
|
||||
expect(params.channelId).toBe(chanId);
|
||||
expect(res.channelId).toBe(chanId);
|
||||
});
|
||||
|
||||
it("passes dmRetryOptions from opts to createMattermostDirectChannelWithRetry", async () => {
|
||||
const userId = "ffffff6666666666ffffff6666"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue(makeAccount("token-retry-opts-t6"));
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
const retryOptions = {
|
||||
maxRetries: 5,
|
||||
initialDelayMs: 500,
|
||||
maxDelayMs: 5000,
|
||||
timeoutMs: 10000,
|
||||
};
|
||||
|
||||
await sendMessageMattermost(`user:${userId}`, "hello", {
|
||||
dmRetryOptions: retryOptions,
|
||||
});
|
||||
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith(
|
||||
{},
|
||||
["bot-id", userId],
|
||||
expect.objectContaining(retryOptions),
|
||||
);
|
||||
});
|
||||
|
||||
it("uses dmChannelRetry from account config when opts.dmRetryOptions not provided", async () => {
|
||||
const userId = "gggggg7777777777gggggg7777"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "token-retry-config-t7",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {
|
||||
dmChannelRetry: {
|
||||
maxRetries: 4,
|
||||
initialDelayMs: 2000,
|
||||
maxDelayMs: 8000,
|
||||
timeoutMs: 15000,
|
||||
},
|
||||
},
|
||||
});
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
await sendMessageMattermost(`user:${userId}`, "hello");
|
||||
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith(
|
||||
{},
|
||||
["bot-id", userId],
|
||||
expect.objectContaining({
|
||||
maxRetries: 4,
|
||||
initialDelayMs: 2000,
|
||||
maxDelayMs: 8000,
|
||||
timeoutMs: 15000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("opts.dmRetryOptions overrides provided fields and preserves account defaults", async () => {
|
||||
const userId = "hhhhhh8888888888hhhhhh8888"; // 26 chars
|
||||
mockState.resolveMattermostAccount.mockReturnValue({
|
||||
accountId: "default",
|
||||
botToken: "token-retry-override-t8",
|
||||
baseUrl: "https://mattermost.example.com",
|
||||
config: {
|
||||
dmChannelRetry: {
|
||||
maxRetries: 2,
|
||||
initialDelayMs: 1000,
|
||||
},
|
||||
},
|
||||
});
|
||||
mockState.fetchMattermostUser.mockResolvedValueOnce({ id: userId });
|
||||
|
||||
const overrideOptions = {
|
||||
maxRetries: 7,
|
||||
timeoutMs: 20000,
|
||||
};
|
||||
|
||||
await sendMessageMattermost(`user:${userId}`, "hello", {
|
||||
dmRetryOptions: overrideOptions,
|
||||
});
|
||||
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith(
|
||||
{},
|
||||
["bot-id", userId],
|
||||
expect.objectContaining(overrideOptions),
|
||||
);
|
||||
expect(mockState.createMattermostDirectChannelWithRetry).toHaveBeenCalledWith(
|
||||
{},
|
||||
["bot-id", userId],
|
||||
expect.objectContaining({
|
||||
initialDelayMs: 1000,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
476
openclaw/extensions/mattermost/src/mattermost/send.ts
Normal file
476
openclaw/extensions/mattermost/src/mattermost/send.ts
Normal file
|
|
@ -0,0 +1,476 @@
|
|||
import { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
|
||||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import {
|
||||
convertMarkdownTables,
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/text-runtime";
|
||||
import { getMattermostRuntime } from "../runtime.js";
|
||||
import { resolveMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
createMattermostClient,
|
||||
createMattermostDirectChannelWithRetry,
|
||||
createMattermostPost,
|
||||
fetchMattermostChannelByName,
|
||||
fetchMattermostMe,
|
||||
fetchMattermostUserByUsername,
|
||||
fetchMattermostUserTeams,
|
||||
normalizeMattermostBaseUrl,
|
||||
uploadMattermostFile,
|
||||
type MattermostUser,
|
||||
type CreateDmChannelRetryOptions,
|
||||
} from "./client.js";
|
||||
import {
|
||||
buildButtonProps,
|
||||
resolveInteractionCallbackUrl,
|
||||
setInteractionSecret,
|
||||
type MattermostInteractiveButtonInput,
|
||||
} from "./interactions.js";
|
||||
import { loadOutboundMediaFromUrl, type OpenClawConfig } from "./runtime-api.js";
|
||||
import { isMattermostId, resolveMattermostOpaqueTarget } from "./target-resolution.js";
|
||||
|
||||
export type MattermostSendOpts = {
|
||||
cfg?: OpenClawConfig;
|
||||
botToken?: string;
|
||||
baseUrl?: string;
|
||||
accountId?: string;
|
||||
mediaUrl?: string;
|
||||
mediaLocalRoots?: readonly string[];
|
||||
mediaReadFile?: (filePath: string) => Promise<Buffer>;
|
||||
replyToId?: string;
|
||||
props?: Record<string, unknown>;
|
||||
buttons?: Array<unknown>;
|
||||
attachmentText?: string;
|
||||
/** Retry options for DM channel creation */
|
||||
dmRetryOptions?: CreateDmChannelRetryOptions;
|
||||
};
|
||||
|
||||
export type MattermostSendResult = {
|
||||
messageId: string;
|
||||
channelId: string;
|
||||
};
|
||||
|
||||
export type MattermostReplyButtons = Array<
|
||||
MattermostInteractiveButtonInput | MattermostInteractiveButtonInput[]
|
||||
>;
|
||||
|
||||
type MattermostTarget =
|
||||
| { kind: "channel"; id: string }
|
||||
| { kind: "channel-name"; name: string }
|
||||
| { kind: "user"; id?: string; username?: string };
|
||||
|
||||
const botUserCache = new Map<string, MattermostUser>();
|
||||
const userByNameCache = new Map<string, MattermostUser>();
|
||||
const channelByNameCache = new Map<string, string>();
|
||||
const dmChannelCache = new Map<string, string>();
|
||||
|
||||
const getCore = () => getMattermostRuntime();
|
||||
|
||||
function recordMattermostOutboundActivity(accountId: string): void {
|
||||
try {
|
||||
getCore().channel.activity.record({
|
||||
channel: "mattermost",
|
||||
accountId,
|
||||
direction: "outbound",
|
||||
});
|
||||
} catch (error) {
|
||||
if (!(error instanceof Error) || error.message !== "Mattermost runtime not initialized") {
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cacheKey(baseUrl: string, token: string): string {
|
||||
return `${baseUrl}::${token}`;
|
||||
}
|
||||
|
||||
function normalizeMessage(text: string, mediaUrl?: string): string {
|
||||
const trimmed = 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;
|
||||
logger?: { debug?: (msg: string) => void; warn?: (msg: string) => void };
|
||||
};
|
||||
|
||||
function mergeDmRetryOptions(
|
||||
base?: CreateDmChannelRetryOptions,
|
||||
override?: CreateDmChannelRetryOptions,
|
||||
): CreateDmChannelRetryOptions | undefined {
|
||||
const merged: CreateDmChannelRetryOptions = {
|
||||
maxRetries: override?.maxRetries ?? base?.maxRetries,
|
||||
initialDelayMs: override?.initialDelayMs ?? base?.initialDelayMs,
|
||||
maxDelayMs: override?.maxDelayMs ?? base?.maxDelayMs,
|
||||
timeoutMs: override?.timeoutMs ?? base?.timeoutMs,
|
||||
onRetry: override?.onRetry,
|
||||
};
|
||||
|
||||
if (
|
||||
merged.maxRetries === undefined &&
|
||||
merged.initialDelayMs === undefined &&
|
||||
merged.maxDelayMs === undefined &&
|
||||
merged.timeoutMs === undefined &&
|
||||
merged.onRetry === undefined
|
||||
) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return merged;
|
||||
}
|
||||
|
||||
async function resolveTargetChannelId(params: ResolveTargetChannelIdParams): Promise<string> {
|
||||
if (params.target.kind === "channel") {
|
||||
return params.target.id;
|
||||
}
|
||||
if (params.target.kind === "channel-name") {
|
||||
return await resolveChannelIdByName({
|
||||
baseUrl: params.baseUrl,
|
||||
token: params.token,
|
||||
name: params.target.name,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
});
|
||||
}
|
||||
const userId = params.target.id
|
||||
? params.target.id
|
||||
: await resolveUserIdByUsername({
|
||||
baseUrl: params.baseUrl,
|
||||
token: params.token,
|
||||
username: params.target.username ?? "",
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
});
|
||||
const dmKey = `${cacheKey(params.baseUrl, params.token)}::dm::${userId}`;
|
||||
const cachedDm = dmChannelCache.get(dmKey);
|
||||
if (cachedDm) {
|
||||
return cachedDm;
|
||||
}
|
||||
const botUser = await resolveBotUser(params.baseUrl, params.token, params.allowPrivateNetwork);
|
||||
const client = createMattermostClient({
|
||||
baseUrl: params.baseUrl,
|
||||
botToken: params.token,
|
||||
allowPrivateNetwork: params.allowPrivateNetwork,
|
||||
});
|
||||
|
||||
const channel = await createMattermostDirectChannelWithRetry(client, [botUser.id, userId], {
|
||||
...params.dmRetryOptions,
|
||||
onRetry: (attempt, delayMs, error) => {
|
||||
// Call user's onRetry if provided
|
||||
params.dmRetryOptions?.onRetry?.(attempt, delayMs, error);
|
||||
// Log if verbose mode is enabled
|
||||
if (params.logger) {
|
||||
params.logger.warn?.(
|
||||
`DM channel creation retry ${attempt} after ${delayMs}ms: ${error.message}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
});
|
||||
dmChannelCache.set(dmKey, channel.id);
|
||||
return channel.id;
|
||||
}
|
||||
|
||||
type MattermostSendContext = {
|
||||
cfg: OpenClawConfig;
|
||||
accountId: string;
|
||||
token: string;
|
||||
baseUrl: string;
|
||||
channelId: string;
|
||||
allowPrivateNetwork?: boolean;
|
||||
};
|
||||
|
||||
async function resolveMattermostSendContext(
|
||||
to: string,
|
||||
opts: MattermostSendOpts = {},
|
||||
): Promise<MattermostSendContext> {
|
||||
const core = getCore();
|
||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||
const cfg = opts.cfg ?? core.config.loadConfig();
|
||||
const account = resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId: opts.accountId,
|
||||
});
|
||||
const token = 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,
|
||||
logger: core.logging.shouldLogVerbose() ? logger : undefined,
|
||||
});
|
||||
|
||||
return {
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
token,
|
||||
baseUrl,
|
||||
channelId,
|
||||
allowPrivateNetwork,
|
||||
};
|
||||
}
|
||||
|
||||
export async function resolveMattermostSendChannelId(
|
||||
to: string,
|
||||
opts: MattermostSendOpts = {},
|
||||
): Promise<string> {
|
||||
return (await resolveMattermostSendContext(to, opts)).channelId;
|
||||
}
|
||||
|
||||
export async function sendMessageMattermost(
|
||||
to: string,
|
||||
text: string,
|
||||
opts: MattermostSendOpts = {},
|
||||
): Promise<MattermostSendResult> {
|
||||
const core = getCore();
|
||||
const logger = core.logging.getChildLogger({ module: "mattermost" });
|
||||
const { cfg, accountId, token, baseUrl, channelId, allowPrivateNetwork } =
|
||||
await resolveMattermostSendContext(to, opts);
|
||||
|
||||
const client = createMattermostClient({ baseUrl, botToken: token, allowPrivateNetwork });
|
||||
let props = opts.props;
|
||||
if (!props && Array.isArray(opts.buttons) && opts.buttons.length > 0) {
|
||||
setInteractionSecret(accountId, token);
|
||||
props = buildButtonProps({
|
||||
callbackUrl: resolveInteractionCallbackUrl(accountId, {
|
||||
gateway: cfg.gateway,
|
||||
interactions: resolveMattermostAccount({
|
||||
cfg,
|
||||
accountId,
|
||||
}).config?.interactions,
|
||||
}),
|
||||
accountId,
|
||||
channelId,
|
||||
buttons: opts.buttons,
|
||||
text: opts.attachmentText,
|
||||
});
|
||||
}
|
||||
let message = 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,
|
||||
});
|
||||
const fileInfo = await uploadMattermostFile(client, {
|
||||
channelId,
|
||||
buffer: media.buffer,
|
||||
fileName: media.fileName ?? "upload",
|
||||
contentType: media.contentType ?? undefined,
|
||||
});
|
||||
fileIds = [fileInfo.id];
|
||||
} catch (err) {
|
||||
uploadError = err instanceof Error ? err : new Error(String(err));
|
||||
if (core.logging.shouldLogVerbose()) {
|
||||
logger.debug?.(
|
||||
`mattermost send: media upload failed, falling back to URL text: ${String(err)}`,
|
||||
);
|
||||
}
|
||||
message = normalizeMessage(message, isHttpUrl(mediaUrl) ? mediaUrl : "");
|
||||
}
|
||||
}
|
||||
|
||||
if (message) {
|
||||
const tableMode = resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId,
|
||||
});
|
||||
message = convertMarkdownTables(message, tableMode);
|
||||
}
|
||||
|
||||
if (!message && (!fileIds || fileIds.length === 0)) {
|
||||
if (uploadError) {
|
||||
throw new Error(`Mattermost media upload failed: ${uploadError.message}`);
|
||||
}
|
||||
throw new Error("Mattermost message is empty");
|
||||
}
|
||||
|
||||
const post = await createMattermostPost(client, {
|
||||
channelId,
|
||||
message,
|
||||
rootId: opts.replyToId,
|
||||
fileIds,
|
||||
props,
|
||||
});
|
||||
|
||||
recordMattermostOutboundActivity(accountId);
|
||||
|
||||
return {
|
||||
messageId: post.id ?? "unknown",
|
||||
channelId,
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,163 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import type { MattermostClient } from "./client.js";
|
||||
import {
|
||||
DEFAULT_COMMAND_SPECS,
|
||||
parseSlashCommandPayload,
|
||||
registerSlashCommands,
|
||||
resolveCallbackUrl,
|
||||
resolveCommandText,
|
||||
resolveSlashCommandConfig,
|
||||
} from "./slash-commands.js";
|
||||
|
||||
describe("slash-commands", () => {
|
||||
async function registerSingleStatusCommand(
|
||||
requestImpl: (path: string, init?: { method?: string }) => Promise<unknown>,
|
||||
) {
|
||||
const client: MattermostClient = {
|
||||
baseUrl: "https://chat.example.com",
|
||||
apiBaseUrl: "https://chat.example.com/api/v4",
|
||||
token: "bot-token",
|
||||
request: async <T>(path: string, init?: RequestInit) => (await requestImpl(path, init)) as T,
|
||||
fetchImpl: vi.fn<typeof fetch>(),
|
||||
};
|
||||
return registerSlashCommands({
|
||||
client,
|
||||
teamId: "team-1",
|
||||
creatorUserId: "bot-user",
|
||||
callbackUrl: "http://gateway/callback",
|
||||
commands: [
|
||||
{
|
||||
trigger: "oc_status",
|
||||
description: "status",
|
||||
autoComplete: true,
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
it("parses application/x-www-form-urlencoded payloads", () => {
|
||||
const payload = parseSlashCommandPayload(
|
||||
"token=t1&team_id=team&channel_id=ch1&user_id=u1&command=%2Foc_status&text=now",
|
||||
"application/x-www-form-urlencoded",
|
||||
);
|
||||
expect(payload).toMatchObject({
|
||||
token: "t1",
|
||||
team_id: "team",
|
||||
channel_id: "ch1",
|
||||
user_id: "u1",
|
||||
command: "/oc_status",
|
||||
text: "now",
|
||||
});
|
||||
});
|
||||
|
||||
it("parses application/json payloads", () => {
|
||||
const payload = parseSlashCommandPayload(
|
||||
JSON.stringify({
|
||||
token: "t2",
|
||||
team_id: "team",
|
||||
channel_id: "ch2",
|
||||
user_id: "u2",
|
||||
command: "/oc_model",
|
||||
text: "gpt-5",
|
||||
}),
|
||||
"application/json; charset=utf-8",
|
||||
);
|
||||
expect(payload).toMatchObject({
|
||||
token: "t2",
|
||||
command: "/oc_model",
|
||||
text: "gpt-5",
|
||||
});
|
||||
});
|
||||
|
||||
it("returns null for malformed payloads missing required fields", () => {
|
||||
const payload = parseSlashCommandPayload(
|
||||
JSON.stringify({ token: "t3", command: "/oc_help" }),
|
||||
"application/json",
|
||||
);
|
||||
expect(payload).toBeNull();
|
||||
});
|
||||
|
||||
it("resolves command text with trigger map fallback", () => {
|
||||
const triggerMap = new Map<string, string>([["oc_status", "status"]]);
|
||||
expect(resolveCommandText("oc_status", " ", triggerMap)).toBe("/status");
|
||||
expect(resolveCommandText("oc_status", " now ", triggerMap)).toBe("/status now");
|
||||
expect(resolveCommandText("oc_models", " openai ", undefined)).toBe("/models openai");
|
||||
expect(resolveCommandText("oc_help", "", undefined)).toBe("/help");
|
||||
});
|
||||
|
||||
it("registers both public model slash commands", () => {
|
||||
expect(
|
||||
DEFAULT_COMMAND_SPECS.filter(
|
||||
(spec) => spec.trigger === "oc_model" || spec.trigger === "oc_models",
|
||||
).map((spec) => spec.trigger),
|
||||
).toEqual(["oc_model", "oc_models"]);
|
||||
});
|
||||
|
||||
it("normalizes callback path in slash config", () => {
|
||||
const config = resolveSlashCommandConfig({ callbackPath: "api/channels/mattermost/command" });
|
||||
expect(config.callbackPath).toBe("/api/channels/mattermost/command");
|
||||
});
|
||||
|
||||
it("falls back to localhost callback URL for wildcard bind hosts", () => {
|
||||
const config = resolveSlashCommandConfig({ callbackPath: "/api/channels/mattermost/command" });
|
||||
const callbackUrl = resolveCallbackUrl({
|
||||
config,
|
||||
gatewayPort: 18789,
|
||||
gatewayHost: "0.0.0.0",
|
||||
});
|
||||
expect(callbackUrl).toBe("http://localhost:18789/api/channels/mattermost/command");
|
||||
});
|
||||
|
||||
it("reuses existing command when trigger already points to callback URL", async () => {
|
||||
const request = vi.fn(async (path: string) => {
|
||||
if (path.startsWith("/commands?team_id=")) {
|
||||
return [
|
||||
{
|
||||
id: "cmd-1",
|
||||
token: "tok-1",
|
||||
team_id: "team-1",
|
||||
creator_id: "bot-user",
|
||||
trigger: "oc_status",
|
||||
method: "P",
|
||||
url: "http://gateway/callback",
|
||||
auto_complete: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
throw new Error(`unexpected request path: ${path}`);
|
||||
});
|
||||
const result = await registerSingleStatusCommand(request);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]?.managed).toBe(false);
|
||||
expect(result[0]?.id).toBe("cmd-1");
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("skips foreign command trigger collisions instead of mutating non-owned commands", async () => {
|
||||
const request = vi.fn(async (path: string, init?: { method?: string }) => {
|
||||
if (path.startsWith("/commands?team_id=")) {
|
||||
return [
|
||||
{
|
||||
id: "cmd-foreign-1",
|
||||
token: "tok-foreign-1",
|
||||
team_id: "team-1",
|
||||
creator_id: "another-bot-user",
|
||||
trigger: "oc_status",
|
||||
method: "P",
|
||||
url: "http://foreign/callback",
|
||||
auto_complete: true,
|
||||
},
|
||||
];
|
||||
}
|
||||
if (init?.method === "POST" || init?.method === "PUT" || init?.method === "DELETE") {
|
||||
throw new Error("should not mutate foreign commands");
|
||||
}
|
||||
throw new Error(`unexpected request path: ${path}`);
|
||||
});
|
||||
const result = await registerSingleStatusCommand(request);
|
||||
|
||||
expect(result).toHaveLength(0);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
593
openclaw/extensions/mattermost/src/mattermost/slash-commands.ts
Normal file
593
openclaw/extensions/mattermost/src/mattermost/slash-commands.ts
Normal file
|
|
@ -0,0 +1,593 @@
|
|||
/**
|
||||
* Mattermost native slash command support.
|
||||
*
|
||||
* Registers custom slash commands via the Mattermost REST API and handles
|
||||
* incoming command callbacks via an HTTP endpoint on the gateway.
|
||||
*
|
||||
* Architecture:
|
||||
* - On startup, registers commands with MM via POST /api/v4/commands
|
||||
* - MM sends HTTP POST to callbackUrl when a user invokes a command
|
||||
* - The callback handler reconstructs the text as `/<command> <args>` and
|
||||
* routes it through the standard inbound reply pipeline
|
||||
* - On shutdown, cleans up registered commands via DELETE /api/v4/commands/{id}
|
||||
*/
|
||||
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
|
||||
import type { MattermostClient } from "./client.js";
|
||||
|
||||
// ─── Types ───────────────────────────────────────────────────────────────────
|
||||
|
||||
export type MattermostSlashCommandConfig = {
|
||||
/** Enable native slash commands. "auto" resolves to false for now (opt-in). */
|
||||
native: boolean | "auto";
|
||||
/** Also register skill-based commands. */
|
||||
nativeSkills: boolean | "auto";
|
||||
/** Path for the callback endpoint on the gateway HTTP server. */
|
||||
callbackPath: string;
|
||||
/**
|
||||
* Explicit callback URL override (e.g. behind a reverse proxy).
|
||||
* If not set, auto-derived from baseUrl + gateway port + callbackPath.
|
||||
*/
|
||||
callbackUrl?: string;
|
||||
};
|
||||
|
||||
export type MattermostCommandSpec = {
|
||||
trigger: string;
|
||||
description: string;
|
||||
autoComplete: boolean;
|
||||
autoCompleteHint?: string;
|
||||
/** Original command name (for skill commands that start with oc_) */
|
||||
originalName?: string;
|
||||
};
|
||||
|
||||
export type MattermostRegisteredCommand = {
|
||||
id: string;
|
||||
trigger: string;
|
||||
teamId: string;
|
||||
token: string;
|
||||
/** True when this process created the command and should delete it on shutdown. */
|
||||
managed: boolean;
|
||||
};
|
||||
|
||||
/**
|
||||
* Payload sent by Mattermost when a slash command is invoked.
|
||||
* Can arrive as application/x-www-form-urlencoded or application/json.
|
||||
*/
|
||||
export type MattermostSlashCommandPayload = {
|
||||
token: string;
|
||||
team_id: string;
|
||||
team_domain?: string;
|
||||
channel_id: string;
|
||||
channel_name?: string;
|
||||
user_id: string;
|
||||
user_name?: string;
|
||||
command: string; // e.g. "/status"
|
||||
text: string; // args after the trigger word
|
||||
trigger_id?: string;
|
||||
response_url?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Response format for Mattermost slash command callbacks.
|
||||
*/
|
||||
export type MattermostSlashCommandResponse = {
|
||||
response_type?: "ephemeral" | "in_channel";
|
||||
text: string;
|
||||
username?: string;
|
||||
icon_url?: string;
|
||||
goto_location?: string;
|
||||
attachments?: unknown[];
|
||||
};
|
||||
|
||||
// ─── MM API types ────────────────────────────────────────────────────────────
|
||||
|
||||
type MattermostCommandCreate = {
|
||||
team_id: string;
|
||||
trigger: string;
|
||||
method: "P" | "G";
|
||||
url: string;
|
||||
description?: string;
|
||||
auto_complete: boolean;
|
||||
auto_complete_desc?: string;
|
||||
auto_complete_hint?: string;
|
||||
token?: string;
|
||||
creator_id?: string;
|
||||
};
|
||||
|
||||
type MattermostCommandUpdate = {
|
||||
id: string;
|
||||
team_id: string;
|
||||
trigger: string;
|
||||
method: "P" | "G";
|
||||
url: string;
|
||||
description?: string;
|
||||
auto_complete: boolean;
|
||||
auto_complete_desc?: string;
|
||||
auto_complete_hint?: string;
|
||||
};
|
||||
|
||||
type MattermostCommandResponse = {
|
||||
id: string;
|
||||
token: string;
|
||||
team_id: string;
|
||||
trigger: string;
|
||||
method: string;
|
||||
url: string;
|
||||
auto_complete: boolean;
|
||||
auto_complete_desc?: string;
|
||||
auto_complete_hint?: string;
|
||||
creator_id?: string;
|
||||
create_at?: number;
|
||||
update_at?: number;
|
||||
delete_at?: number;
|
||||
};
|
||||
|
||||
// ─── Default commands ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Built-in OpenClaw commands to register as native slash commands.
|
||||
* These mirror the text-based commands already handled by the gateway.
|
||||
*/
|
||||
export const DEFAULT_COMMAND_SPECS: MattermostCommandSpec[] = [
|
||||
{
|
||||
trigger: "oc_status",
|
||||
originalName: "status",
|
||||
description: "Show session status (model, usage, uptime)",
|
||||
autoComplete: true,
|
||||
},
|
||||
{
|
||||
trigger: "oc_model",
|
||||
originalName: "model",
|
||||
description: "View or change the current model",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[model-name]",
|
||||
},
|
||||
{
|
||||
trigger: "oc_models",
|
||||
originalName: "models",
|
||||
description: "Browse available models",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[provider]",
|
||||
},
|
||||
{
|
||||
trigger: "oc_new",
|
||||
originalName: "new",
|
||||
description: "Start a new conversation session",
|
||||
autoComplete: true,
|
||||
},
|
||||
{
|
||||
trigger: "oc_help",
|
||||
originalName: "help",
|
||||
description: "Show available commands",
|
||||
autoComplete: true,
|
||||
},
|
||||
{
|
||||
trigger: "oc_think",
|
||||
originalName: "think",
|
||||
description: "Set thinking/reasoning level",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[off|low|medium|high]",
|
||||
},
|
||||
{
|
||||
trigger: "oc_reasoning",
|
||||
originalName: "reasoning",
|
||||
description: "Toggle reasoning mode",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[on|off]",
|
||||
},
|
||||
{
|
||||
trigger: "oc_verbose",
|
||||
originalName: "verbose",
|
||||
description: "Toggle verbose mode",
|
||||
autoComplete: true,
|
||||
autoCompleteHint: "[on|off]",
|
||||
},
|
||||
];
|
||||
|
||||
// ─── Command registration ────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* List existing custom slash commands for a team.
|
||||
*/
|
||||
export async function listMattermostCommands(
|
||||
client: MattermostClient,
|
||||
teamId: string,
|
||||
): Promise<MattermostCommandResponse[]> {
|
||||
return await client.request<MattermostCommandResponse[]>(
|
||||
`/commands?team_id=${encodeURIComponent(teamId)}&custom_only=true`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a custom slash command on a Mattermost team.
|
||||
*/
|
||||
export async function createMattermostCommand(
|
||||
client: MattermostClient,
|
||||
params: MattermostCommandCreate,
|
||||
): Promise<MattermostCommandResponse> {
|
||||
return await client.request<MattermostCommandResponse>("/commands", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(params),
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a custom slash command.
|
||||
*/
|
||||
export async function deleteMattermostCommand(
|
||||
client: MattermostClient,
|
||||
commandId: string,
|
||||
): Promise<void> {
|
||||
await client.request<Record<string, unknown>>(`/commands/${encodeURIComponent(commandId)}`, {
|
||||
method: "DELETE",
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Update an existing custom slash command.
|
||||
*/
|
||||
export async function updateMattermostCommand(
|
||||
client: MattermostClient,
|
||||
params: MattermostCommandUpdate,
|
||||
): Promise<MattermostCommandResponse> {
|
||||
return await client.request<MattermostCommandResponse>(
|
||||
`/commands/${encodeURIComponent(params.id)}`,
|
||||
{
|
||||
method: "PUT",
|
||||
body: JSON.stringify(params),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Register all OpenClaw slash commands for a given team.
|
||||
* Skips commands that are already registered with the same trigger + callback URL.
|
||||
* Returns the list of newly created command IDs.
|
||||
*/
|
||||
export async function registerSlashCommands(params: {
|
||||
client: MattermostClient;
|
||||
teamId: string;
|
||||
creatorUserId: string;
|
||||
callbackUrl: string;
|
||||
commands: MattermostCommandSpec[];
|
||||
log?: (msg: string) => void;
|
||||
}): Promise<MattermostRegisteredCommand[]> {
|
||||
const { client, teamId, creatorUserId, callbackUrl, commands, log } = params;
|
||||
const normalizedCreatorUserId = creatorUserId.trim();
|
||||
if (!normalizedCreatorUserId) {
|
||||
throw new Error("creatorUserId is required for slash command reconciliation");
|
||||
}
|
||||
|
||||
// Fetch existing commands to avoid duplicates
|
||||
let existing: MattermostCommandResponse[] = [];
|
||||
try {
|
||||
existing = await listMattermostCommands(client, teamId);
|
||||
} catch (err) {
|
||||
log?.(`mattermost: failed to list existing commands: ${String(err)}`);
|
||||
// Fail closed: if we can't list existing commands, we should not attempt to
|
||||
// create/update anything because we may create duplicates and end up with an
|
||||
// empty/partial token set (causing callbacks to be rejected until restart).
|
||||
throw err;
|
||||
}
|
||||
|
||||
const existingByTrigger = new Map<string, MattermostCommandResponse[]>();
|
||||
for (const cmd of existing) {
|
||||
const list = existingByTrigger.get(cmd.trigger) ?? [];
|
||||
list.push(cmd);
|
||||
existingByTrigger.set(cmd.trigger, list);
|
||||
}
|
||||
|
||||
const registered: MattermostRegisteredCommand[] = [];
|
||||
|
||||
for (const spec of commands) {
|
||||
const existingForTrigger = existingByTrigger.get(spec.trigger) ?? [];
|
||||
const ownedCommands = existingForTrigger.filter(
|
||||
(cmd) => cmd.creator_id?.trim() === normalizedCreatorUserId,
|
||||
);
|
||||
const foreignCommands = existingForTrigger.filter(
|
||||
(cmd) => cmd.creator_id?.trim() !== normalizedCreatorUserId,
|
||||
);
|
||||
|
||||
if (ownedCommands.length === 0 && foreignCommands.length > 0) {
|
||||
log?.(
|
||||
`mattermost: trigger /${spec.trigger} already used by non-OpenClaw command(s); skipping to avoid mutating external integrations`,
|
||||
);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (ownedCommands.length > 1) {
|
||||
log?.(
|
||||
`mattermost: multiple owned commands found for /${spec.trigger}; using the first and leaving extras untouched`,
|
||||
);
|
||||
}
|
||||
|
||||
const existingCmd = ownedCommands[0];
|
||||
|
||||
// Already registered with the correct callback URL
|
||||
if (existingCmd && existingCmd.url === callbackUrl) {
|
||||
log?.(`mattermost: command /${spec.trigger} already registered (id=${existingCmd.id})`);
|
||||
registered.push({
|
||||
id: existingCmd.id,
|
||||
trigger: spec.trigger,
|
||||
teamId,
|
||||
token: existingCmd.token,
|
||||
managed: false,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exists but points to a different URL: attempt to reconcile by updating
|
||||
// (useful during callback URL migrations).
|
||||
if (existingCmd && existingCmd.url !== callbackUrl) {
|
||||
log?.(
|
||||
`mattermost: command /${spec.trigger} exists with different callback URL; updating (id=${existingCmd.id})`,
|
||||
);
|
||||
try {
|
||||
const updated = await updateMattermostCommand(client, {
|
||||
id: existingCmd.id,
|
||||
team_id: teamId,
|
||||
trigger: spec.trigger,
|
||||
method: "P",
|
||||
url: callbackUrl,
|
||||
description: spec.description,
|
||||
auto_complete: spec.autoComplete,
|
||||
auto_complete_desc: spec.description,
|
||||
auto_complete_hint: spec.autoCompleteHint,
|
||||
});
|
||||
registered.push({
|
||||
id: updated.id,
|
||||
trigger: spec.trigger,
|
||||
teamId,
|
||||
token: updated.token,
|
||||
managed: false,
|
||||
});
|
||||
continue;
|
||||
} catch (err) {
|
||||
log?.(
|
||||
`mattermost: failed to update command /${spec.trigger} (id=${existingCmd.id}): ${String(err)}`,
|
||||
);
|
||||
// Fallback: try delete+recreate for commands owned by this bot user.
|
||||
try {
|
||||
await deleteMattermostCommand(client, existingCmd.id);
|
||||
log?.(`mattermost: deleted stale command /${spec.trigger} (id=${existingCmd.id})`);
|
||||
} catch (deleteErr) {
|
||||
log?.(
|
||||
`mattermost: failed to delete stale command /${spec.trigger} (id=${existingCmd.id}): ${String(deleteErr)}`,
|
||||
);
|
||||
// Can't reconcile; skip this command.
|
||||
continue;
|
||||
}
|
||||
// Continue on to create below.
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const created = await createMattermostCommand(client, {
|
||||
team_id: teamId,
|
||||
trigger: spec.trigger,
|
||||
method: "P",
|
||||
url: callbackUrl,
|
||||
description: spec.description,
|
||||
auto_complete: spec.autoComplete,
|
||||
auto_complete_desc: spec.description,
|
||||
auto_complete_hint: spec.autoCompleteHint,
|
||||
});
|
||||
log?.(`mattermost: registered command /${spec.trigger} (id=${created.id})`);
|
||||
registered.push({
|
||||
id: created.id,
|
||||
trigger: spec.trigger,
|
||||
teamId,
|
||||
token: created.token,
|
||||
managed: true,
|
||||
});
|
||||
} catch (err) {
|
||||
log?.(`mattermost: failed to register command /${spec.trigger}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return registered;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up all registered slash commands.
|
||||
*/
|
||||
export async function cleanupSlashCommands(params: {
|
||||
client: MattermostClient;
|
||||
commands: MattermostRegisteredCommand[];
|
||||
log?: (msg: string) => void;
|
||||
}): Promise<void> {
|
||||
const { client, commands, log } = params;
|
||||
for (const cmd of commands) {
|
||||
if (!cmd.managed) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
await deleteMattermostCommand(client, cmd.id);
|
||||
log?.(`mattermost: deleted command /${cmd.trigger} (id=${cmd.id})`);
|
||||
} catch (err) {
|
||||
log?.(`mattermost: failed to delete command /${cmd.trigger}: ${String(err)}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Callback parsing ────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Parse a Mattermost slash command callback payload from a URL-encoded or JSON body.
|
||||
*/
|
||||
export function parseSlashCommandPayload(
|
||||
body: string,
|
||||
contentType?: string,
|
||||
): MattermostSlashCommandPayload | null {
|
||||
if (!body) {
|
||||
return null;
|
||||
}
|
||||
|
||||
try {
|
||||
if (contentType?.includes("application/json")) {
|
||||
const parsed = JSON.parse(body) as Record<string, unknown>;
|
||||
|
||||
// Validate required fields (same checks as the form-encoded branch)
|
||||
const token = typeof parsed.token === "string" ? parsed.token : "";
|
||||
const teamId = typeof parsed.team_id === "string" ? parsed.team_id : "";
|
||||
const channelId = typeof parsed.channel_id === "string" ? parsed.channel_id : "";
|
||||
const userId = typeof parsed.user_id === "string" ? parsed.user_id : "";
|
||||
const command = typeof parsed.command === "string" ? parsed.command : "";
|
||||
|
||||
if (!token || !teamId || !channelId || !userId || !command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
team_id: teamId,
|
||||
team_domain: typeof parsed.team_domain === "string" ? parsed.team_domain : undefined,
|
||||
channel_id: channelId,
|
||||
channel_name: typeof parsed.channel_name === "string" ? parsed.channel_name : undefined,
|
||||
user_id: userId,
|
||||
user_name: typeof parsed.user_name === "string" ? parsed.user_name : undefined,
|
||||
command,
|
||||
text: typeof parsed.text === "string" ? parsed.text : "",
|
||||
trigger_id: typeof parsed.trigger_id === "string" ? parsed.trigger_id : undefined,
|
||||
response_url: typeof parsed.response_url === "string" ? parsed.response_url : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
// Default: application/x-www-form-urlencoded
|
||||
const params = new URLSearchParams(body);
|
||||
const token = params.get("token");
|
||||
const teamId = params.get("team_id");
|
||||
const channelId = params.get("channel_id");
|
||||
const userId = params.get("user_id");
|
||||
const command = params.get("command");
|
||||
|
||||
if (!token || !teamId || !channelId || !userId || !command) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
token,
|
||||
team_id: teamId,
|
||||
team_domain: params.get("team_domain") ?? undefined,
|
||||
channel_id: channelId,
|
||||
channel_name: params.get("channel_name") ?? undefined,
|
||||
user_id: userId,
|
||||
user_name: params.get("user_name") ?? undefined,
|
||||
command,
|
||||
text: params.get("text") ?? "",
|
||||
trigger_id: params.get("trigger_id") ?? undefined,
|
||||
response_url: params.get("response_url") ?? undefined,
|
||||
};
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Map the trigger word back to the original OpenClaw command name.
|
||||
* e.g. "oc_status" -> "/status", "oc_model" -> "/model"
|
||||
*/
|
||||
export function resolveCommandText(
|
||||
trigger: string,
|
||||
text: string,
|
||||
triggerMap?: ReadonlyMap<string, string>,
|
||||
): string {
|
||||
// Use the trigger map if available for accurate name resolution
|
||||
const commandName =
|
||||
triggerMap?.get(trigger) ?? (trigger.startsWith("oc_") ? trigger.slice(3) : trigger);
|
||||
const args = text.trim();
|
||||
return args ? `/${commandName} ${args}` : `/${commandName}`;
|
||||
}
|
||||
|
||||
// ─── Config resolution ───────────────────────────────────────────────────────
|
||||
|
||||
const DEFAULT_CALLBACK_PATH = "/api/channels/mattermost/command";
|
||||
|
||||
/**
|
||||
* Ensure the callback path starts with a leading `/` to prevent
|
||||
* malformed URLs like `http://host:portapi/...`.
|
||||
*/
|
||||
function normalizeCallbackPath(path: string): string {
|
||||
const trimmed = path.trim();
|
||||
if (!trimmed) {
|
||||
return DEFAULT_CALLBACK_PATH;
|
||||
}
|
||||
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
|
||||
}
|
||||
|
||||
export function resolveSlashCommandConfig(
|
||||
raw?: Partial<MattermostSlashCommandConfig>,
|
||||
): MattermostSlashCommandConfig {
|
||||
return {
|
||||
native: raw?.native ?? "auto",
|
||||
nativeSkills: raw?.nativeSkills ?? "auto",
|
||||
callbackPath: normalizeCallbackPath(raw?.callbackPath ?? DEFAULT_CALLBACK_PATH),
|
||||
callbackUrl: 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;
|
||||
}
|
||||
|
||||
export function collectMattermostSlashCallbackPaths(raw?: Partial<MattermostSlashCommandConfig>) {
|
||||
const config = resolveSlashCommandConfig(raw);
|
||||
const paths = new Set<string>([config.callbackPath]);
|
||||
if (typeof config.callbackUrl === "string" && config.callbackUrl.trim()) {
|
||||
try {
|
||||
const pathname = new URL(config.callbackUrl).pathname;
|
||||
if (pathname) {
|
||||
paths.add(pathname);
|
||||
}
|
||||
} catch {
|
||||
// Ignore invalid callback URLs and keep the normalized callback path only.
|
||||
}
|
||||
}
|
||||
return [...paths];
|
||||
}
|
||||
|
||||
/**
|
||||
* Build the callback URL that Mattermost will POST to when a command is invoked.
|
||||
*/
|
||||
export function resolveCallbackUrl(params: {
|
||||
config: MattermostSlashCommandConfig;
|
||||
gatewayPort: number;
|
||||
gatewayHost?: string;
|
||||
}): string {
|
||||
if (params.config.callbackUrl) {
|
||||
return params.config.callbackUrl;
|
||||
}
|
||||
|
||||
const isWildcardBindHost = (rawHost: string): boolean => {
|
||||
const trimmed = rawHost.trim();
|
||||
if (!trimmed) {
|
||||
return false;
|
||||
}
|
||||
const host = trimmed.startsWith("[") && trimmed.endsWith("]") ? trimmed.slice(1, -1) : trimmed;
|
||||
|
||||
// NOTE: Wildcard listen hosts are valid bind addresses but are not routable callback
|
||||
// destinations. Don't emit callback URLs like http://0.0.0.0:3015/... or http://[::]:3015/...
|
||||
// when an operator sets gateway.customBindHost.
|
||||
return host === "0.0.0.0" || host === "::" || host === "0:0:0:0:0:0:0:0" || host === "::0";
|
||||
};
|
||||
|
||||
let host =
|
||||
params.gatewayHost && !isWildcardBindHost(params.gatewayHost)
|
||||
? params.gatewayHost
|
||||
: "localhost";
|
||||
const path = normalizeCallbackPath(params.config.callbackPath);
|
||||
|
||||
// Bracket IPv6 literals so the URL is valid: http://[::1]:3015/...
|
||||
if (host.includes(":") && !(host.startsWith("[") && host.endsWith("]"))) {
|
||||
host = `[${host}]`;
|
||||
}
|
||||
|
||||
return `http://${host}:${params.gatewayPort}${path}`;
|
||||
}
|
||||
|
|
@ -0,0 +1,264 @@
|
|||
import { ServerResponse, type IncomingMessage } from "node:http";
|
||||
import { PassThrough } from "node:stream";
|
||||
import type { OpenClawConfig, RuntimeEnv } from "openclaw/plugin-sdk/mattermost";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
|
||||
const mockState = vi.hoisted(() => ({
|
||||
readRequestBodyWithLimit: vi.fn(async () => "token=valid-token"),
|
||||
parseSlashCommandPayload: vi.fn(() => ({
|
||||
token: "valid-token",
|
||||
command: "/oc_models",
|
||||
text: "models",
|
||||
channel_id: "chan-1",
|
||||
user_id: "user-1",
|
||||
user_name: "alice",
|
||||
team_id: "team-1",
|
||||
})),
|
||||
resolveCommandText: vi.fn((_trigger: string, text: string) => text),
|
||||
buildModelsProviderData: vi.fn(async () => ({ providers: [], modelNames: new Map() })),
|
||||
resolveMattermostModelPickerEntry: vi.fn(() => ({ kind: "summary" })),
|
||||
authorizeMattermostCommandInvocation: vi.fn(() => ({
|
||||
ok: true,
|
||||
commandAuthorized: true,
|
||||
channelInfo: { id: "chan-1", type: "O", name: "town-square", display_name: "Town Square" },
|
||||
kind: "channel",
|
||||
chatType: "channel",
|
||||
channelName: "town-square",
|
||||
channelDisplay: "Town Square",
|
||||
roomLabel: "#town-square",
|
||||
})),
|
||||
createMattermostClient: vi.fn(() => ({})),
|
||||
fetchMattermostChannel: vi.fn(async () => ({
|
||||
id: "chan-1",
|
||||
type: "O",
|
||||
name: "town-square",
|
||||
display_name: "Town Square",
|
||||
})),
|
||||
sendMessageMattermost: vi.fn(async () => ({ messageId: "post-1", channelId: "chan-1" })),
|
||||
normalizeMattermostAllowList: vi.fn((value: unknown) => value),
|
||||
}));
|
||||
|
||||
vi.mock("./runtime-api.js", () => {
|
||||
return {
|
||||
buildModelsProviderData: mockState.buildModelsProviderData,
|
||||
createChannelReplyPipeline: vi.fn(() => ({
|
||||
onModelSelected: vi.fn(),
|
||||
typingCallbacks: {},
|
||||
})),
|
||||
createDedupeCache: vi.fn(() => ({
|
||||
check: () => false,
|
||||
})),
|
||||
createReplyPrefixOptions: vi.fn(() => ({})),
|
||||
createTypingCallbacks: vi.fn(() => ({ onReplyStart: vi.fn() })),
|
||||
isRequestBodyLimitError: vi.fn(() => false),
|
||||
logTypingFailure: vi.fn(),
|
||||
formatInboundFromLabel: vi.fn(() => ""),
|
||||
rawDataToString: vi.fn((value: unknown) => (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", () => ({
|
||||
parseSlashCommandPayload: mockState.parseSlashCommandPayload,
|
||||
resolveCommandText: mockState.resolveCommandText,
|
||||
}));
|
||||
|
||||
let createSlashCommandHttpHandler: typeof import("./slash-http.js").createSlashCommandHttpHandler;
|
||||
|
||||
function createRequest(body = "token=valid-token"): IncomingMessage {
|
||||
const req = new PassThrough();
|
||||
const incoming = req as PassThrough & IncomingMessage;
|
||||
incoming.method = "POST";
|
||||
incoming.headers = {
|
||||
"content-type": "application/x-www-form-urlencoded",
|
||||
};
|
||||
process.nextTick(() => {
|
||||
req.end(body);
|
||||
});
|
||||
return incoming;
|
||||
}
|
||||
|
||||
function createResponse(): {
|
||||
res: ServerResponse;
|
||||
getBody: () => string;
|
||||
} {
|
||||
let body = "";
|
||||
class TestServerResponse extends ServerResponse {
|
||||
override setHeader() {
|
||||
return this;
|
||||
}
|
||||
|
||||
override end(): this;
|
||||
override end(cb: () => void): this;
|
||||
override end(chunk: string | Buffer | Uint8Array, cb?: () => void): this;
|
||||
override end(
|
||||
chunk: string | Buffer | Uint8Array,
|
||||
encoding: BufferEncoding,
|
||||
cb?: () => void,
|
||||
): this;
|
||||
override end(
|
||||
chunkOrCb?: string | Buffer | Uint8Array | (() => void),
|
||||
encodingOrCb?: BufferEncoding | (() => void),
|
||||
cb?: () => void,
|
||||
): this {
|
||||
const chunk = typeof chunkOrCb === "function" ? undefined : chunkOrCb;
|
||||
const callback =
|
||||
typeof chunkOrCb === "function"
|
||||
? chunkOrCb
|
||||
: typeof encodingOrCb === "function"
|
||||
? encodingOrCb
|
||||
: cb;
|
||||
body = chunk ? String(chunk) : "";
|
||||
callback?.();
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
const res = new TestServerResponse(createRequest(""));
|
||||
return {
|
||||
res,
|
||||
getBody: () => body,
|
||||
};
|
||||
}
|
||||
|
||||
const accountFixture: ResolvedMattermostAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
botTokenSource: "config",
|
||||
baseUrlSource: "config",
|
||||
config: {},
|
||||
};
|
||||
|
||||
describe("slash-http cfg threading", () => {
|
||||
beforeEach(async () => {
|
||||
vi.resetModules();
|
||||
mockState.readRequestBodyWithLimit.mockClear();
|
||||
mockState.parseSlashCommandPayload.mockClear();
|
||||
mockState.resolveCommandText.mockClear();
|
||||
mockState.buildModelsProviderData.mockClear();
|
||||
mockState.resolveMattermostModelPickerEntry.mockClear();
|
||||
mockState.authorizeMattermostCommandInvocation.mockClear();
|
||||
mockState.createMattermostClient.mockClear();
|
||||
mockState.fetchMattermostChannel.mockClear();
|
||||
mockState.sendMessageMattermost.mockClear();
|
||||
mockState.normalizeMattermostAllowList.mockClear();
|
||||
({ createSlashCommandHttpHandler } = await import("./slash-http.js"));
|
||||
});
|
||||
|
||||
it("passes cfg through the no-models slash reply send path", async () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
mattermost: {
|
||||
botToken: "exec:secret-ref",
|
||||
},
|
||||
},
|
||||
} as OpenClawConfig;
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg,
|
||||
runtime: {} as RuntimeEnv,
|
||||
commandTokens: new Set(["valid-token"]),
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handler(createRequest(), response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(200);
|
||||
expect(response.getBody()).toContain("Processing");
|
||||
expect(mockState.sendMessageMattermost).toHaveBeenCalledWith(
|
||||
"channel:chan-1",
|
||||
"No models available.",
|
||||
expect.objectContaining({
|
||||
cfg,
|
||||
accountId: "default",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not rely on Set.has for command token validation", async () => {
|
||||
const commandTokens = new Set(["valid-token"]);
|
||||
const hasSpy = vi.fn(() => {
|
||||
throw new Error("Set.has should not be used for slash token validation");
|
||||
});
|
||||
Object.defineProperty(commandTokens, "has", {
|
||||
value: hasSpy,
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
commandTokens,
|
||||
});
|
||||
const response = createResponse();
|
||||
|
||||
await handler(createRequest(), response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(200);
|
||||
expect(response.getBody()).toContain("Processing");
|
||||
expect(hasSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
151
openclaw/extensions/mattermost/src/mattermost/slash-http.test.ts
Normal file
151
openclaw/extensions/mattermost/src/mattermost/slash-http.test.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig, RuntimeEnv } from "../../runtime-api.js";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import { createSlashCommandHttpHandler } from "./slash-http.js";
|
||||
|
||||
function createRequest(params: {
|
||||
method?: string;
|
||||
body?: string;
|
||||
contentType?: string;
|
||||
autoEnd?: boolean;
|
||||
}): IncomingMessage {
|
||||
const req = new PassThrough();
|
||||
const incoming = req as PassThrough & IncomingMessage;
|
||||
incoming.method = params.method ?? "POST";
|
||||
incoming.headers = {
|
||||
"content-type": params.contentType ?? "application/x-www-form-urlencoded",
|
||||
};
|
||||
process.nextTick(() => {
|
||||
if (params.body) {
|
||||
req.write(params.body);
|
||||
}
|
||||
if (params.autoEnd !== false) {
|
||||
req.end();
|
||||
}
|
||||
});
|
||||
return incoming;
|
||||
}
|
||||
|
||||
function createResponse(): {
|
||||
res: ServerResponse;
|
||||
getBody: () => string;
|
||||
getHeaders: () => Map<string, string>;
|
||||
} {
|
||||
let body = "";
|
||||
const headers = new Map<string, string>();
|
||||
const res = {
|
||||
statusCode: 200,
|
||||
setHeader(name: string, value: string) {
|
||||
headers.set(name.toLowerCase(), value);
|
||||
},
|
||||
end(chunk?: string | Buffer) {
|
||||
body = chunk ? String(chunk) : "";
|
||||
},
|
||||
} as ServerResponse;
|
||||
return {
|
||||
res,
|
||||
getBody: () => body,
|
||||
getHeaders: () => headers,
|
||||
};
|
||||
}
|
||||
|
||||
const accountFixture: ResolvedMattermostAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
botToken: "bot-token",
|
||||
baseUrl: "https://chat.example.com",
|
||||
botTokenSource: "config",
|
||||
baseUrlSource: "config",
|
||||
config: {},
|
||||
};
|
||||
|
||||
async function runSlashRequest(params: {
|
||||
commandTokens: Set<string>;
|
||||
body: string;
|
||||
method?: string;
|
||||
}) {
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
commandTokens: params.commandTokens,
|
||||
});
|
||||
const req = createRequest({ method: params.method, body: params.body });
|
||||
const response = createResponse();
|
||||
await handler(req, response.res);
|
||||
return response;
|
||||
}
|
||||
|
||||
describe("slash-http", () => {
|
||||
it("rejects non-POST methods", async () => {
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
commandTokens: new Set(["valid-token"]),
|
||||
});
|
||||
const req = createRequest({ method: "GET", body: "" });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(req, response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(405);
|
||||
expect(response.getBody()).toBe("Method Not Allowed");
|
||||
expect(response.getHeaders().get("allow")).toBe("POST");
|
||||
});
|
||||
|
||||
it("rejects malformed payloads", async () => {
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
commandTokens: new Set(["valid-token"]),
|
||||
});
|
||||
const req = createRequest({ body: "token=abc&command=%2Foc_status" });
|
||||
const response = createResponse();
|
||||
|
||||
await handler(req, response.res);
|
||||
|
||||
expect(response.res.statusCode).toBe(400);
|
||||
expect(response.getBody()).toContain("Invalid slash command payload");
|
||||
});
|
||||
|
||||
it("fails closed when no command tokens are registered", async () => {
|
||||
const response = await runSlashRequest({
|
||||
commandTokens: new Set<string>(),
|
||||
body: "token=tok1&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_status&text=",
|
||||
});
|
||||
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
|
||||
});
|
||||
|
||||
it("rejects unknown command tokens", async () => {
|
||||
const response = await runSlashRequest({
|
||||
commandTokens: new Set(["known-token"]),
|
||||
body: "token=unknown&team_id=t1&channel_id=c1&user_id=u1&command=%2Foc_status&text=",
|
||||
});
|
||||
|
||||
expect(response.res.statusCode).toBe(401);
|
||||
expect(response.getBody()).toContain("Unauthorized: invalid command token.");
|
||||
});
|
||||
|
||||
it("returns 408 when the request body stalls", async () => {
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account: accountFixture,
|
||||
cfg: {} as OpenClawConfig,
|
||||
runtime: {} as RuntimeEnv,
|
||||
commandTokens: new Set(["valid-token"]),
|
||||
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");
|
||||
});
|
||||
});
|
||||
551
openclaw/extensions/mattermost/src/mattermost/slash-http.ts
Normal file
551
openclaw/extensions/mattermost/src/mattermost/slash-http.ts
Normal file
|
|
@ -0,0 +1,551 @@
|
|||
/**
|
||||
* HTTP callback handler for Mattermost slash commands.
|
||||
*
|
||||
* Receives POST requests from Mattermost when a slash command is invoked,
|
||||
* validates the token, and routes the command through the standard inbound pipeline.
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { safeEqualSecret } from "openclaw/plugin-sdk/browser-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 { deliverMattermostReplyPayload } from "./reply-delivery.js";
|
||||
import {
|
||||
buildModelsProviderData,
|
||||
createChannelReplyPipeline,
|
||||
isRequestBodyLimitError,
|
||||
logTypingFailure,
|
||||
readRequestBodyWithLimit,
|
||||
type OpenClawConfig,
|
||||
type ReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "./runtime-api.js";
|
||||
import { sendMessageMattermost } from "./send.js";
|
||||
import {
|
||||
parseSlashCommandPayload,
|
||||
resolveCommandText,
|
||||
type MattermostSlashCommandResponse,
|
||||
} from "./slash-commands.js";
|
||||
|
||||
type SlashHttpHandlerParams = {
|
||||
account: ResolvedMattermostAccount;
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
/** Expected token from registered commands (for validation). */
|
||||
commandTokens: Set<string>;
|
||||
/** Map from trigger to original command name (for skill commands that start with oc_). */
|
||||
triggerMap?: ReadonlyMap<string, string>;
|
||||
log?: (msg: string) => void;
|
||||
bodyTimeoutMs?: number;
|
||||
};
|
||||
|
||||
const MAX_BODY_BYTES = 64 * 1024;
|
||||
const BODY_READ_TIMEOUT_MS = 5_000;
|
||||
|
||||
/**
|
||||
* Read the full request body as a string.
|
||||
*/
|
||||
function readBody(
|
||||
req: IncomingMessage,
|
||||
maxBytes: number,
|
||||
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 matchesRegisteredCommandToken(
|
||||
commandTokens: ReadonlySet<string>,
|
||||
candidate: string,
|
||||
): boolean {
|
||||
for (const token of commandTokens) {
|
||||
if (safeEqualSecret(candidate, token)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
type SlashInvocationAuth = {
|
||||
ok: boolean;
|
||||
denyResponse?: MattermostSlashCommandResponse;
|
||||
commandAuthorized: boolean;
|
||||
channelInfo: MattermostChannel | null;
|
||||
kind: "direct" | "group" | "channel";
|
||||
chatType: "direct" | "group" | "channel";
|
||||
channelName: string;
|
||||
channelDisplay: string;
|
||||
roomLabel: string;
|
||||
};
|
||||
|
||||
async function authorizeSlashInvocation(params: {
|
||||
account: ResolvedMattermostAccount;
|
||||
cfg: OpenClawConfig;
|
||||
client: ReturnType<typeof createMattermostClient>;
|
||||
commandText: string;
|
||||
channelId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
log?: (msg: string) => void;
|
||||
}): Promise<SlashInvocationAuth> {
|
||||
const { account, cfg, client, commandText, channelId, senderId, senderName, log } = params;
|
||||
const core = getMattermostRuntime();
|
||||
|
||||
// Resolve channel info so we can enforce DM vs group/channel policies.
|
||||
let channelInfo: MattermostChannel | null = null;
|
||||
try {
|
||||
channelInfo = await fetchMattermostChannel(client, channelId);
|
||||
} catch (err) {
|
||||
log?.(`mattermost: slash channel lookup failed for ${channelId}: ${String(err)}`);
|
||||
}
|
||||
|
||||
if (!channelInfo) {
|
||||
return {
|
||||
ok: false,
|
||||
denyResponse: {
|
||||
response_type: "ephemeral",
|
||||
text: "Temporary error: unable to determine channel type. Please try again.",
|
||||
},
|
||||
commandAuthorized: false,
|
||||
channelInfo: null,
|
||||
kind: "channel",
|
||||
chatType: "channel",
|
||||
channelName: "",
|
||||
channelDisplay: "",
|
||||
roomLabel: `#${channelId}`,
|
||||
};
|
||||
}
|
||||
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg,
|
||||
surface: "mattermost",
|
||||
});
|
||||
const hasControlCommand = core.channel.text.hasControlCommand(commandText, cfg);
|
||||
const storeAllowFrom = normalizeMattermostAllowList(
|
||||
await core.channel.pairing
|
||||
.readAllowFromStore({
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
})
|
||||
.catch(() => []),
|
||||
);
|
||||
const decision = authorizeMattermostCommandInvocation({
|
||||
account,
|
||||
cfg,
|
||||
senderId,
|
||||
senderName,
|
||||
channelId,
|
||||
channelInfo,
|
||||
storeAllowFrom,
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
});
|
||||
|
||||
if (!decision.ok) {
|
||||
if (decision.denyReason === "dm-pairing") {
|
||||
const { code } = await core.channel.pairing.upsertPairingRequest({
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
id: senderId,
|
||||
meta: { name: senderName },
|
||||
});
|
||||
return {
|
||||
...decision,
|
||||
denyResponse: {
|
||||
response_type: "ephemeral",
|
||||
text: core.channel.pairing.buildPairingReply({
|
||||
channel: "mattermost",
|
||||
idLine: `Your Mattermost user id: ${senderId}`,
|
||||
code,
|
||||
}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const denyText =
|
||||
decision.denyReason === "unknown-channel"
|
||||
? "Temporary error: unable to determine channel type. Please try again."
|
||||
: decision.denyReason === "dm-disabled"
|
||||
? "This bot is not accepting direct messages."
|
||||
: decision.denyReason === "channels-disabled"
|
||||
? "Slash commands are disabled in channels."
|
||||
: decision.denyReason === "channel-no-allowlist"
|
||||
? "Slash commands are not configured for this channel (no allowlist)."
|
||||
: "Unauthorized.";
|
||||
return {
|
||||
...decision,
|
||||
denyResponse: {
|
||||
response_type: "ephemeral",
|
||||
text: denyText,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...decision,
|
||||
denyResponse: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Create the HTTP request handler for Mattermost slash command callbacks.
|
||||
*
|
||||
* This handler is registered as a plugin HTTP route and receives POSTs
|
||||
* from the Mattermost server when a user invokes a registered slash command.
|
||||
*/
|
||||
export function createSlashCommandHttpHandler(params: SlashHttpHandlerParams) {
|
||||
const { account, cfg, runtime, commandTokens, triggerMap, log, 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;
|
||||
}
|
||||
|
||||
// Validate token — fail closed: reject when no tokens are registered
|
||||
// (e.g. registration failed or startup was partial)
|
||||
if (commandTokens.size === 0 || !matchesRegisteredCommandToken(commandTokens, payload.token)) {
|
||||
sendJsonResponse(res, 401, {
|
||||
response_type: "ephemeral",
|
||||
text: "Unauthorized: invalid command token.",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Extract command info
|
||||
const trigger = payload.command.replace(/^\//, "").trim();
|
||||
const commandText = resolveCommandText(trigger, payload.text, triggerMap);
|
||||
const channelId = payload.channel_id;
|
||||
const senderId = payload.user_id;
|
||||
const senderName = payload.user_name ?? senderId;
|
||||
|
||||
const client = createMattermostClient({
|
||||
baseUrl: account.baseUrl ?? "",
|
||||
botToken: account.botToken ?? "",
|
||||
allowPrivateNetwork: isPrivateNetworkOptInEnabled(account.config),
|
||||
});
|
||||
|
||||
const auth = await authorizeSlashInvocation({
|
||||
account,
|
||||
cfg,
|
||||
client,
|
||||
commandText,
|
||||
channelId,
|
||||
senderId,
|
||||
senderName,
|
||||
log,
|
||||
});
|
||||
|
||||
if (!auth.ok) {
|
||||
sendJsonResponse(
|
||||
res,
|
||||
200,
|
||||
auth.denyResponse ?? { response_type: "ephemeral", text: "Unauthorized." },
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
log?.(`mattermost: slash command /${trigger} from ${senderName} in ${channelId}`);
|
||||
|
||||
// Acknowledge immediately — we'll send the actual reply asynchronously
|
||||
sendJsonResponse(res, 200, {
|
||||
response_type: "ephemeral",
|
||||
text: "Processing...",
|
||||
});
|
||||
|
||||
// Now handle the command asynchronously (post reply as a message)
|
||||
try {
|
||||
await handleSlashCommandAsync({
|
||||
account,
|
||||
cfg,
|
||||
runtime,
|
||||
client,
|
||||
commandText,
|
||||
channelId,
|
||||
senderId,
|
||||
senderName,
|
||||
teamId: payload.team_id,
|
||||
triggerId: payload.trigger_id,
|
||||
kind: auth.kind,
|
||||
chatType: auth.chatType,
|
||||
channelName: auth.channelName,
|
||||
channelDisplay: auth.channelDisplay,
|
||||
roomLabel: auth.roomLabel,
|
||||
commandAuthorized: auth.commandAuthorized,
|
||||
log,
|
||||
});
|
||||
} catch (err) {
|
||||
log?.(`mattermost: slash command handler error: ${String(err)}`);
|
||||
try {
|
||||
const to = `channel:${channelId}`;
|
||||
await sendMessageMattermost(to, "Sorry, something went wrong processing that command.", {
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
} catch {
|
||||
// best-effort error reply
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function handleSlashCommandAsync(params: {
|
||||
account: ResolvedMattermostAccount;
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
client: ReturnType<typeof createMattermostClient>;
|
||||
commandText: string;
|
||||
channelId: string;
|
||||
senderId: string;
|
||||
senderName: string;
|
||||
teamId: string;
|
||||
kind: "direct" | "group" | "channel";
|
||||
chatType: "direct" | "group" | "channel";
|
||||
channelName: string;
|
||||
channelDisplay: string;
|
||||
roomLabel: string;
|
||||
commandAuthorized: boolean;
|
||||
triggerId?: string;
|
||||
log?: (msg: string) => void;
|
||||
}) {
|
||||
const {
|
||||
account,
|
||||
cfg,
|
||||
runtime,
|
||||
client,
|
||||
commandText,
|
||||
channelId,
|
||||
senderId,
|
||||
senderName,
|
||||
teamId,
|
||||
kind,
|
||||
chatType,
|
||||
channelName: _channelName,
|
||||
channelDisplay,
|
||||
roomLabel,
|
||||
commandAuthorized,
|
||||
triggerId,
|
||||
log,
|
||||
} = params;
|
||||
const core = getMattermostRuntime();
|
||||
|
||||
const route = core.channel.routing.resolveAgentRoute({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
teamId,
|
||||
peer: {
|
||||
kind,
|
||||
id: kind === "direct" ? senderId : channelId,
|
||||
},
|
||||
});
|
||||
|
||||
const fromLabel =
|
||||
kind === "direct"
|
||||
? `Mattermost DM from ${senderName}`
|
||||
: `Mattermost message in ${roomLabel} from ${senderName}`;
|
||||
|
||||
const to = kind === "direct" ? `user:${senderId}` : `channel:${channelId}`;
|
||||
const pickerEntry = resolveMattermostModelPickerEntry(commandText);
|
||||
if (pickerEntry) {
|
||||
const data = await buildModelsProviderData(cfg, route.agentId);
|
||||
if (data.providers.length === 0) {
|
||||
await sendMessageMattermost(to, "No models available.", {
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const currentModel = resolveMattermostModelPickerCurrentModel({
|
||||
cfg,
|
||||
route,
|
||||
data,
|
||||
});
|
||||
const view =
|
||||
pickerEntry.kind === "summary"
|
||||
? renderMattermostModelSummaryView({
|
||||
ownerUserId: senderId,
|
||||
currentModel,
|
||||
})
|
||||
: pickerEntry.kind === "providers"
|
||||
? renderMattermostProviderPickerView({
|
||||
ownerUserId: senderId,
|
||||
data,
|
||||
currentModel,
|
||||
})
|
||||
: renderMattermostModelsPickerView({
|
||||
ownerUserId: senderId,
|
||||
data,
|
||||
provider: pickerEntry.provider,
|
||||
page: 1,
|
||||
currentModel,
|
||||
});
|
||||
|
||||
await sendMessageMattermost(to, view.text, {
|
||||
cfg,
|
||||
accountId: account.accountId,
|
||||
buttons: view.buttons,
|
||||
});
|
||||
runtime.log?.(`delivered model picker to ${to}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Build inbound context — the command text is the body
|
||||
const ctxPayload = core.channel.reply.finalizeInboundContext({
|
||||
Body: commandText,
|
||||
BodyForAgent: commandText,
|
||||
RawBody: commandText,
|
||||
CommandBody: commandText,
|
||||
From:
|
||||
kind === "direct"
|
||||
? `mattermost:${senderId}`
|
||||
: kind === "group"
|
||||
? `mattermost:group:${channelId}`
|
||||
: `mattermost:channel:${channelId}`,
|
||||
To: to,
|
||||
SessionKey: route.sessionKey,
|
||||
AccountId: route.accountId,
|
||||
ChatType: chatType,
|
||||
ConversationLabel: fromLabel,
|
||||
GroupSubject: kind !== "direct" ? channelDisplay || roomLabel : undefined,
|
||||
SenderName: senderName,
|
||||
SenderId: senderId,
|
||||
Provider: "mattermost" as const,
|
||||
Surface: "mattermost" as const,
|
||||
MessageSid: triggerId ?? `slash-${Date.now()}`,
|
||||
Timestamp: Date.now(),
|
||||
WasMentioned: true,
|
||||
CommandAuthorized: commandAuthorized,
|
||||
CommandSource: "native" as const,
|
||||
OriginatingChannel: "mattermost" as const,
|
||||
OriginatingTo: to,
|
||||
});
|
||||
|
||||
const textLimit = core.channel.text.resolveTextChunkLimit(cfg, "mattermost", account.accountId, {
|
||||
fallbackLimit: account.textChunkLimit ?? 4000,
|
||||
});
|
||||
const tableMode = core.channel.text.resolveMarkdownTableMode({
|
||||
cfg,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
});
|
||||
|
||||
const { onModelSelected, typingCallbacks, ...replyPipeline } = createChannelReplyPipeline({
|
||||
cfg,
|
||||
agentId: route.agentId,
|
||||
channel: "mattermost",
|
||||
accountId: account.accountId,
|
||||
typing: {
|
||||
start: () => sendMattermostTyping(client, { channelId }),
|
||||
onStartError: (err) => {
|
||||
logTypingFailure({
|
||||
log: (message) => log?.(message),
|
||||
channel: "mattermost",
|
||||
target: channelId,
|
||||
error: err,
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
const humanDelay = core.channel.reply.resolveHumanDelayConfig(cfg, route.agentId);
|
||||
|
||||
const { dispatcher, replyOptions, markDispatchIdle } =
|
||||
core.channel.reply.createReplyDispatcherWithTyping({
|
||||
...replyPipeline,
|
||||
humanDelay,
|
||||
deliver: async (payload: ReplyPayload) => {
|
||||
await deliverMattermostReplyPayload({
|
||||
core,
|
||||
cfg,
|
||||
payload,
|
||||
to,
|
||||
accountId: account.accountId,
|
||||
agentId: route.agentId,
|
||||
textLimit,
|
||||
tableMode,
|
||||
sendMessage: sendMessageMattermost,
|
||||
});
|
||||
runtime.log?.(`delivered slash reply to ${to}`);
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`mattermost slash ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
onReplyStart: typingCallbacks?.onReplyStart,
|
||||
});
|
||||
|
||||
await core.channel.reply.withReplyDispatcher({
|
||||
dispatcher,
|
||||
onSettled: () => {
|
||||
markDispatchIdle();
|
||||
},
|
||||
run: () =>
|
||||
core.channel.reply.dispatchReplyFromConfig({
|
||||
ctx: ctxPayload,
|
||||
cfg,
|
||||
dispatcher,
|
||||
replyOptions: {
|
||||
...replyOptions,
|
||||
disableBlockStreaming:
|
||||
typeof account.blockStreaming === "boolean" ? !account.blockStreaming : undefined,
|
||||
onModelSelected,
|
||||
},
|
||||
}),
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,66 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig, RuntimeEnv } from "../runtime-api.js";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import {
|
||||
activateSlashCommands,
|
||||
deactivateSlashCommands,
|
||||
resolveSlashHandlerForToken,
|
||||
} from "./slash-state.js";
|
||||
|
||||
function createResolvedMattermostAccount(accountId: string): ResolvedMattermostAccount {
|
||||
return {
|
||||
accountId,
|
||||
enabled: true,
|
||||
botTokenSource: "config",
|
||||
baseUrlSource: "config",
|
||||
config: {},
|
||||
};
|
||||
}
|
||||
|
||||
const slashApi = {
|
||||
cfg: {},
|
||||
runtime: {
|
||||
log: () => {},
|
||||
error: () => {},
|
||||
exit: () => {},
|
||||
},
|
||||
} satisfies {
|
||||
cfg: OpenClawConfig;
|
||||
runtime: RuntimeEnv;
|
||||
};
|
||||
|
||||
describe("slash-state token routing", () => {
|
||||
it("returns single match when token belongs to one account", () => {
|
||||
deactivateSlashCommands();
|
||||
activateSlashCommands({
|
||||
account: createResolvedMattermostAccount("a1"),
|
||||
commandTokens: ["tok-a"],
|
||||
registeredCommands: [],
|
||||
api: slashApi,
|
||||
});
|
||||
|
||||
const match = resolveSlashHandlerForToken("tok-a");
|
||||
expect(match.kind).toBe("single");
|
||||
expect(match.accountIds).toEqual(["a1"]);
|
||||
});
|
||||
|
||||
it("returns ambiguous when same token exists in multiple accounts", () => {
|
||||
deactivateSlashCommands();
|
||||
activateSlashCommands({
|
||||
account: createResolvedMattermostAccount("a1"),
|
||||
commandTokens: ["tok-shared"],
|
||||
registeredCommands: [],
|
||||
api: slashApi,
|
||||
});
|
||||
activateSlashCommands({
|
||||
account: createResolvedMattermostAccount("a2"),
|
||||
commandTokens: ["tok-shared"],
|
||||
registeredCommands: [],
|
||||
api: slashApi,
|
||||
});
|
||||
|
||||
const match = resolveSlashHandlerForToken("tok-shared");
|
||||
expect(match.kind).toBe("ambiguous");
|
||||
expect(match.accountIds?.toSorted()).toEqual(["a1", "a2"]);
|
||||
});
|
||||
});
|
||||
311
openclaw/extensions/mattermost/src/mattermost/slash-state.ts
Normal file
311
openclaw/extensions/mattermost/src/mattermost/slash-state.ts
Normal file
|
|
@ -0,0 +1,311 @@
|
|||
/**
|
||||
* Shared state for Mattermost slash commands.
|
||||
*
|
||||
* Bridges the plugin registration phase (HTTP route) with the monitor phase
|
||||
* (command registration with MM API). The HTTP handler needs to know which
|
||||
* tokens are valid, and the monitor needs to store registered command IDs.
|
||||
*
|
||||
* State is kept per-account so that multi-account deployments don't
|
||||
* overwrite each other's tokens, registered commands, or handlers.
|
||||
*/
|
||||
|
||||
import type { IncomingMessage, ServerResponse } from "node:http";
|
||||
import { Readable } from "node:stream";
|
||||
import type { MattermostConfig } from "../types.js";
|
||||
import type { ResolvedMattermostAccount } from "./accounts.js";
|
||||
import type { OpenClawPluginApi } from "./runtime-api.js";
|
||||
import { resolveSlashCommandConfig, type MattermostRegisteredCommand } from "./slash-commands.js";
|
||||
import { createSlashCommandHttpHandler } from "./slash-http.js";
|
||||
|
||||
// ─── Per-account state ───────────────────────────────────────────────────────
|
||||
|
||||
export type SlashCommandAccountState = {
|
||||
/** Tokens from registered commands, used for validation. */
|
||||
commandTokens: Set<string>;
|
||||
/** Registered command IDs for cleanup on shutdown. */
|
||||
registeredCommands: MattermostRegisteredCommand[];
|
||||
/** Current HTTP handler for this account. */
|
||||
handler: ((req: IncomingMessage, res: ServerResponse) => Promise<void>) | null;
|
||||
/** The account that activated slash commands. */
|
||||
account: ResolvedMattermostAccount;
|
||||
/** Map from trigger to original command name (for skill commands that start with oc_). */
|
||||
triggerMap: Map<string, string>;
|
||||
};
|
||||
|
||||
/** Map from accountId → per-account slash command state. */
|
||||
const accountStates = new Map<string, SlashCommandAccountState>();
|
||||
|
||||
export function resolveSlashHandlerForToken(token: string): {
|
||||
kind: "none" | "single" | "ambiguous";
|
||||
handler?: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
accountIds?: string[];
|
||||
} {
|
||||
const matches: Array<{
|
||||
accountId: string;
|
||||
handler: (req: IncomingMessage, res: ServerResponse) => Promise<void>;
|
||||
}> = [];
|
||||
|
||||
for (const [accountId, state] of accountStates) {
|
||||
if (state.commandTokens.has(token) && state.handler) {
|
||||
matches.push({ accountId, handler: state.handler });
|
||||
}
|
||||
}
|
||||
|
||||
if (matches.length === 0) {
|
||||
return { kind: "none" };
|
||||
}
|
||||
if (matches.length === 1) {
|
||||
return { kind: "single", handler: matches[0].handler, accountIds: [matches[0].accountId] };
|
||||
}
|
||||
|
||||
return {
|
||||
kind: "ambiguous",
|
||||
accountIds: matches.map((entry) => entry.accountId),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the slash command state for a specific account, or null if not activated.
|
||||
*/
|
||||
export function getSlashCommandState(accountId: string): SlashCommandAccountState | null {
|
||||
return accountStates.get(accountId) ?? null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all active slash command account states.
|
||||
*/
|
||||
export function getAllSlashCommandStates(): ReadonlyMap<string, SlashCommandAccountState> {
|
||||
return accountStates;
|
||||
}
|
||||
|
||||
/**
|
||||
* Activate slash commands for a specific account.
|
||||
* Called from the monitor after bot connects.
|
||||
*/
|
||||
export function activateSlashCommands(params: {
|
||||
account: ResolvedMattermostAccount;
|
||||
commandTokens: string[];
|
||||
registeredCommands: MattermostRegisteredCommand[];
|
||||
triggerMap?: Map<string, string>;
|
||||
api: {
|
||||
cfg: import("./runtime-api.js").OpenClawConfig;
|
||||
runtime: import("./runtime-api.js").RuntimeEnv;
|
||||
};
|
||||
log?: (msg: string) => void;
|
||||
}) {
|
||||
const { account, commandTokens, registeredCommands, triggerMap, api, log } = params;
|
||||
const accountId = account.accountId;
|
||||
|
||||
const tokenSet = new Set(commandTokens);
|
||||
|
||||
const handler = createSlashCommandHttpHandler({
|
||||
account,
|
||||
cfg: api.cfg,
|
||||
runtime: api.runtime,
|
||||
commandTokens: tokenSet,
|
||||
triggerMap,
|
||||
log,
|
||||
});
|
||||
|
||||
accountStates.set(accountId, {
|
||||
commandTokens: tokenSet,
|
||||
registeredCommands,
|
||||
handler,
|
||||
account,
|
||||
triggerMap: triggerMap ?? new Map(),
|
||||
});
|
||||
|
||||
log?.(
|
||||
`mattermost: slash commands activated for account ${accountId} (${registeredCommands.length} commands)`,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Deactivate slash commands for a specific account (on shutdown/disconnect).
|
||||
*/
|
||||
export function deactivateSlashCommands(accountId?: string) {
|
||||
if (accountId) {
|
||||
const state = accountStates.get(accountId);
|
||||
if (state) {
|
||||
state.commandTokens.clear();
|
||||
state.registeredCommands = [];
|
||||
state.handler = null;
|
||||
accountStates.delete(accountId);
|
||||
}
|
||||
} else {
|
||||
// Deactivate all accounts (full shutdown)
|
||||
for (const [, state] of accountStates) {
|
||||
state.commandTokens.clear();
|
||||
state.registeredCommands = [];
|
||||
state.handler = null;
|
||||
}
|
||||
accountStates.clear();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the HTTP route for slash command callbacks.
|
||||
* Called during plugin registration.
|
||||
*
|
||||
* The single HTTP route dispatches to the correct per-account handler
|
||||
* by matching the inbound token against each account's registered tokens.
|
||||
*/
|
||||
export function registerSlashCommandRoute(api: OpenClawPluginApi) {
|
||||
const mmConfig = api.config.channels?.mattermost as MattermostConfig | undefined;
|
||||
|
||||
// Collect callback paths from both top-level and per-account config.
|
||||
// Command registration uses account.config.commands, so the HTTP route
|
||||
// registration must include any account-specific callbackPath overrides.
|
||||
// Also extract the pathname from an explicit callbackUrl when it differs
|
||||
// from callbackPath, so that Mattermost callbacks hit a registered route.
|
||||
const callbackPaths = new Set<string>();
|
||||
|
||||
const addCallbackPaths = (
|
||||
raw: Partial<import("./slash-commands.js").MattermostSlashCommandConfig> | undefined,
|
||||
) => {
|
||||
const resolved = resolveSlashCommandConfig(raw);
|
||||
callbackPaths.add(resolved.callbackPath);
|
||||
if (resolved.callbackUrl) {
|
||||
try {
|
||||
const urlPath = new URL(resolved.callbackUrl).pathname;
|
||||
if (urlPath && urlPath !== resolved.callbackPath) {
|
||||
callbackPaths.add(urlPath);
|
||||
}
|
||||
} catch {
|
||||
// Invalid URL — ignore, will be caught during registration
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const commandsRaw = mmConfig?.commands as
|
||||
| Partial<import("./slash-commands.js").MattermostSlashCommandConfig>
|
||||
| undefined;
|
||||
addCallbackPaths(commandsRaw);
|
||||
|
||||
const accountsRaw = mmConfig?.accounts ?? {};
|
||||
for (const accountId of Object.keys(accountsRaw)) {
|
||||
const accountCommandsRaw = accountsRaw[accountId]?.commands;
|
||||
addCallbackPaths(accountCommandsRaw);
|
||||
}
|
||||
|
||||
const routeHandler = async (req: IncomingMessage, res: ServerResponse) => {
|
||||
if (accountStates.size === 0) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
response_type: "ephemeral",
|
||||
text: "Slash commands are not yet initialized. Please try again in a moment.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
// We need to peek at the token to route to the right account handler.
|
||||
// Since each account handler also validates the token, we find the
|
||||
// account whose token set contains the inbound token and delegate.
|
||||
|
||||
// If there's only one active account (common case), route directly.
|
||||
if (accountStates.size === 1) {
|
||||
const [, state] = [...accountStates.entries()][0];
|
||||
if (!state.handler) {
|
||||
res.statusCode = 503;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
response_type: "ephemeral",
|
||||
text: "Slash commands are not yet initialized. Please try again in a moment.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
await state.handler(req, res);
|
||||
return;
|
||||
}
|
||||
|
||||
// Multi-account: buffer the body, find the matching account by token,
|
||||
// then replay the request to the correct handler.
|
||||
const chunks: Buffer[] = [];
|
||||
const MAX_BODY = 64 * 1024;
|
||||
let size = 0;
|
||||
for await (const chunk of req) {
|
||||
size += (chunk as Buffer).length;
|
||||
if (size > MAX_BODY) {
|
||||
res.statusCode = 413;
|
||||
res.end("Payload Too Large");
|
||||
return;
|
||||
}
|
||||
chunks.push(chunk as Buffer);
|
||||
}
|
||||
const bodyStr = Buffer.concat(chunks).toString("utf8");
|
||||
|
||||
// Parse just the token to find the right account
|
||||
let token: string | null = null;
|
||||
const ct = req.headers["content-type"] ?? "";
|
||||
try {
|
||||
if (ct.includes("application/json")) {
|
||||
token = (JSON.parse(bodyStr) as { token?: string }).token ?? null;
|
||||
} else {
|
||||
token = new URLSearchParams(bodyStr).get("token");
|
||||
}
|
||||
} catch {
|
||||
// parse failed — will be caught by handler
|
||||
}
|
||||
|
||||
const match = token ? resolveSlashHandlerForToken(token) : { kind: "none" as const };
|
||||
|
||||
if (match.kind === "none") {
|
||||
// No matching account — reject
|
||||
res.statusCode = 401;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
response_type: "ephemeral",
|
||||
text: "Unauthorized: invalid command token.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if (match.kind === "ambiguous") {
|
||||
api.logger.warn?.(
|
||||
`mattermost: slash callback token matched multiple accounts (${match.accountIds?.join(", ")})`,
|
||||
);
|
||||
res.statusCode = 409;
|
||||
res.setHeader("Content-Type", "application/json; charset=utf-8");
|
||||
res.end(
|
||||
JSON.stringify({
|
||||
response_type: "ephemeral",
|
||||
text: "Conflict: command token is not unique across accounts.",
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const matchedHandler = match.handler!;
|
||||
|
||||
// Replay: create a synthetic readable that re-emits the buffered body
|
||||
const syntheticReq = new Readable({
|
||||
read() {
|
||||
this.push(Buffer.from(bodyStr, "utf8"));
|
||||
this.push(null);
|
||||
},
|
||||
}) as IncomingMessage;
|
||||
|
||||
// Copy necessary IncomingMessage properties
|
||||
syntheticReq.method = req.method;
|
||||
syntheticReq.url = req.url;
|
||||
syntheticReq.headers = req.headers;
|
||||
|
||||
await matchedHandler(syntheticReq, res);
|
||||
};
|
||||
|
||||
for (const callbackPath of callbackPaths) {
|
||||
api.registerHttpRoute({
|
||||
path: callbackPath,
|
||||
auth: "plugin",
|
||||
handler: routeHandler,
|
||||
});
|
||||
api.logger.info?.(`mattermost: registered slash command callback at ${callbackPath}`);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,128 @@
|
|||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
const resolveMattermostAccount = vi.fn();
|
||||
const createMattermostClient = vi.fn();
|
||||
const fetchMattermostUser = vi.fn();
|
||||
const normalizeMattermostBaseUrl = vi.fn((value: string | undefined) => value?.trim());
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
resolveMattermostAccount,
|
||||
}));
|
||||
|
||||
vi.mock("./client.js", () => ({
|
||||
createMattermostClient,
|
||||
fetchMattermostUser,
|
||||
normalizeMattermostBaseUrl,
|
||||
}));
|
||||
|
||||
describe("mattermost target resolution", () => {
|
||||
let isExplicitMattermostTarget: typeof import("./target-resolution.js").isExplicitMattermostTarget;
|
||||
let isMattermostId: typeof import("./target-resolution.js").isMattermostId;
|
||||
let parseMattermostApiStatus: typeof import("./target-resolution.js").parseMattermostApiStatus;
|
||||
let resolveMattermostOpaqueTarget: typeof import("./target-resolution.js").resolveMattermostOpaqueTarget;
|
||||
let resetMattermostOpaqueTargetCacheForTests: typeof import("./target-resolution.js").resetMattermostOpaqueTargetCacheForTests;
|
||||
|
||||
beforeAll(async () => {
|
||||
({
|
||||
isExplicitMattermostTarget,
|
||||
isMattermostId,
|
||||
parseMattermostApiStatus,
|
||||
resolveMattermostOpaqueTarget,
|
||||
resetMattermostOpaqueTargetCacheForTests,
|
||||
} = await import("./target-resolution.js"));
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
resolveMattermostAccount.mockReset();
|
||||
createMattermostClient.mockReset();
|
||||
fetchMattermostUser.mockReset();
|
||||
normalizeMattermostBaseUrl.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
resetMattermostOpaqueTargetCacheForTests();
|
||||
});
|
||||
|
||||
it("recognizes explicit targets and ID-shaped values", () => {
|
||||
expect(isExplicitMattermostTarget("@alice")).toBe(true);
|
||||
expect(isExplicitMattermostTarget("#town-square")).toBe(true);
|
||||
expect(isExplicitMattermostTarget("mattermost:chan")).toBe(true);
|
||||
expect(isExplicitMattermostTarget(" plain ")).toBe(false);
|
||||
expect(isMattermostId("abcd1234abcd1234abcd1234ab")).toBe(true);
|
||||
expect(isMattermostId("short")).toBe(false);
|
||||
expect(parseMattermostApiStatus(new Error("Mattermost API 404 Not Found"))).toBe(404);
|
||||
expect(parseMattermostApiStatus(new Error("other error"))).toBeUndefined();
|
||||
});
|
||||
|
||||
it("resolves opaque ids as users and caches the result", async () => {
|
||||
createMattermostClient.mockReturnValue({ client: true });
|
||||
fetchMattermostUser.mockResolvedValue({ id: "abcd1234abcd1234abcd1234ab" });
|
||||
const input = "abcd1234abcd1234abcd1234ab";
|
||||
|
||||
await expect(
|
||||
resolveMattermostOpaqueTarget({
|
||||
input,
|
||||
token: "token",
|
||||
baseUrl: "https://mm.example.com",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "user",
|
||||
id: input,
|
||||
to: `user:${input}`,
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveMattermostOpaqueTarget({
|
||||
input,
|
||||
token: "token",
|
||||
baseUrl: "https://mm.example.com",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "user",
|
||||
id: input,
|
||||
to: `user:${input}`,
|
||||
});
|
||||
|
||||
expect(createMattermostClient).toHaveBeenCalledTimes(1);
|
||||
expect(fetchMattermostUser).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("falls back to channel targets on 404 lookups", async () => {
|
||||
createMattermostClient.mockReturnValue({ client: true });
|
||||
fetchMattermostUser.mockRejectedValue(new Error("Mattermost API 404 Not Found"));
|
||||
const input = "bcde1234abcd1234abcd1234ab";
|
||||
|
||||
await expect(
|
||||
resolveMattermostOpaqueTarget({
|
||||
input,
|
||||
token: "token",
|
||||
baseUrl: "https://mm.example.com",
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
kind: "channel",
|
||||
id: input,
|
||||
to: `channel:${input}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("uses account resolution when token/base url are not passed", async () => {
|
||||
resolveMattermostAccount.mockReturnValue({
|
||||
baseUrl: "https://mm.example.com",
|
||||
botToken: "token",
|
||||
});
|
||||
createMattermostClient.mockReturnValue({ client: true });
|
||||
fetchMattermostUser.mockResolvedValue({ id: "cdef1234abcd1234abcd1234ab" });
|
||||
const input = "cdef1234abcd1234abcd1234ab";
|
||||
|
||||
await resolveMattermostOpaqueTarget({
|
||||
input,
|
||||
cfg: { channels: { mattermost: {} } },
|
||||
accountId: "acct-1",
|
||||
});
|
||||
|
||||
expect(resolveMattermostAccount).toHaveBeenCalledWith({
|
||||
cfg: { channels: { mattermost: {} } },
|
||||
accountId: "acct-1",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,103 @@
|
|||
import { isPrivateNetworkOptInEnabled } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-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();
|
||||
}
|
||||
96
openclaw/extensions/mattermost/src/normalize.test.ts
Normal file
96
openclaw/extensions/mattermost/src/normalize.test.ts
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { looksLikeMattermostTargetId, normalizeMattermostMessagingTarget } from "./normalize.js";
|
||||
|
||||
describe("normalizeMattermostMessagingTarget", () => {
|
||||
it("returns undefined for empty input", () => {
|
||||
expect(normalizeMattermostMessagingTarget("")).toBeUndefined();
|
||||
expect(normalizeMattermostMessagingTarget(" ")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("normalizes channel: prefix", () => {
|
||||
expect(normalizeMattermostMessagingTarget("channel:abc123")).toBe("channel:abc123");
|
||||
expect(normalizeMattermostMessagingTarget("Channel:ABC")).toBe("channel:ABC");
|
||||
});
|
||||
|
||||
it("normalizes group: prefix to channel:", () => {
|
||||
expect(normalizeMattermostMessagingTarget("group:abc123")).toBe("channel:abc123");
|
||||
});
|
||||
|
||||
it("normalizes user: prefix", () => {
|
||||
expect(normalizeMattermostMessagingTarget("user:abc123")).toBe("user:abc123");
|
||||
});
|
||||
|
||||
it("normalizes mattermost: prefix to user:", () => {
|
||||
expect(normalizeMattermostMessagingTarget("mattermost:abc123")).toBe("user:abc123");
|
||||
});
|
||||
|
||||
it("keeps @username targets", () => {
|
||||
expect(normalizeMattermostMessagingTarget("@alice")).toBe("@alice");
|
||||
expect(normalizeMattermostMessagingTarget("@Alice")).toBe("@Alice");
|
||||
});
|
||||
|
||||
it("returns undefined for #channel (triggers directory lookup)", () => {
|
||||
expect(normalizeMattermostMessagingTarget("#bookmarks")).toBeUndefined();
|
||||
expect(normalizeMattermostMessagingTarget("#off-topic")).toBeUndefined();
|
||||
expect(normalizeMattermostMessagingTarget("# ")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for bare names (triggers directory lookup)", () => {
|
||||
expect(normalizeMattermostMessagingTarget("bookmarks")).toBeUndefined();
|
||||
expect(normalizeMattermostMessagingTarget("off-topic")).toBeUndefined();
|
||||
});
|
||||
|
||||
it("returns undefined for empty prefixed values", () => {
|
||||
expect(normalizeMattermostMessagingTarget("channel:")).toBeUndefined();
|
||||
expect(normalizeMattermostMessagingTarget("user:")).toBeUndefined();
|
||||
expect(normalizeMattermostMessagingTarget("@")).toBeUndefined();
|
||||
expect(normalizeMattermostMessagingTarget("#")).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe("looksLikeMattermostTargetId", () => {
|
||||
it("returns false for empty input", () => {
|
||||
expect(looksLikeMattermostTargetId("")).toBe(false);
|
||||
expect(looksLikeMattermostTargetId(" ")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes prefixed targets", () => {
|
||||
expect(looksLikeMattermostTargetId("channel:abc")).toBe(true);
|
||||
expect(looksLikeMattermostTargetId("Channel:abc")).toBe(true);
|
||||
expect(looksLikeMattermostTargetId("user:abc")).toBe(true);
|
||||
expect(looksLikeMattermostTargetId("group:abc")).toBe(true);
|
||||
expect(looksLikeMattermostTargetId("mattermost:abc")).toBe(true);
|
||||
});
|
||||
|
||||
it("recognizes @username", () => {
|
||||
expect(looksLikeMattermostTargetId("@alice")).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT recognize #channel (should go to directory)", () => {
|
||||
expect(looksLikeMattermostTargetId("#bookmarks")).toBe(false);
|
||||
expect(looksLikeMattermostTargetId("#off-topic")).toBe(false);
|
||||
});
|
||||
|
||||
it("recognizes 26-char alphanumeric Mattermost IDs", () => {
|
||||
expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz")).toBe(true);
|
||||
expect(looksLikeMattermostTargetId("12345678901234567890123456")).toBe(true);
|
||||
expect(looksLikeMattermostTargetId("AbCdEf1234567890abcdef1234")).toBe(true); // pragma: allowlist secret
|
||||
});
|
||||
|
||||
it("recognizes DM channel format (26__26)", () => {
|
||||
expect(
|
||||
looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz__12345678901234567890123456"), // pragma: allowlist secret
|
||||
).toBe(true);
|
||||
});
|
||||
|
||||
it("rejects short strings that are not Mattermost IDs", () => {
|
||||
expect(looksLikeMattermostTargetId("password")).toBe(false);
|
||||
expect(looksLikeMattermostTargetId("hi")).toBe(false);
|
||||
expect(looksLikeMattermostTargetId("bookmarks")).toBe(false);
|
||||
expect(looksLikeMattermostTargetId("off-topic")).toBe(false);
|
||||
});
|
||||
|
||||
it("rejects strings longer than 26 chars that are not DM format", () => {
|
||||
expect(looksLikeMattermostTargetId("abcdefghijklmnopqrstuvwxyz1")).toBe(false); // pragma: allowlist secret
|
||||
});
|
||||
});
|
||||
52
openclaw/extensions/mattermost/src/normalize.ts
Normal file
52
openclaw/extensions/mattermost/src/normalize.ts
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-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);
|
||||
}
|
||||
1
openclaw/extensions/mattermost/src/runtime-api.ts
Normal file
1
openclaw/extensions/mattermost/src/runtime-api.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export * from "../runtime-api.js";
|
||||
9
openclaw/extensions/mattermost/src/runtime.ts
Normal file
9
openclaw/extensions/mattermost/src/runtime.ts
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
|
||||
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
|
||||
|
||||
const { setRuntime: setMattermostRuntime, getRuntime: getMattermostRuntime } =
|
||||
createPluginRuntimeStore<PluginRuntime>({
|
||||
pluginId: "mattermost",
|
||||
errorMessage: "Mattermost runtime not initialized",
|
||||
});
|
||||
export { getMattermostRuntime, setMattermostRuntime };
|
||||
59
openclaw/extensions/mattermost/src/secret-contract.ts
Normal file
59
openclaw/extensions/mattermost/src/secret-contract.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import {
|
||||
collectSimpleChannelFieldAssignments,
|
||||
getChannelSurface,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
type SecretTargetRegistryEntry,
|
||||
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
|
||||
|
||||
export const secretTargetRegistryEntries = [
|
||||
{
|
||||
id: "channels.mattermost.accounts.*.botToken",
|
||||
targetType: "channels.mattermost.accounts.*.botToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.mattermost.accounts.*.botToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
{
|
||||
id: "channels.mattermost.botToken",
|
||||
targetType: "channels.mattermost.botToken",
|
||||
configFile: "openclaw.json",
|
||||
pathPattern: "channels.mattermost.botToken",
|
||||
secretShape: "secret_input",
|
||||
expectedResolvedValue: "string",
|
||||
includeInPlan: true,
|
||||
includeInConfigure: true,
|
||||
includeInAudit: true,
|
||||
},
|
||||
] satisfies SecretTargetRegistryEntry[];
|
||||
|
||||
export function collectRuntimeConfigAssignments(params: {
|
||||
config: { channels?: Record<string, unknown> };
|
||||
defaults?: SecretDefaults;
|
||||
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,
|
||||
};
|
||||
7
openclaw/extensions/mattermost/src/secret-input.ts
Normal file
7
openclaw/extensions/mattermost/src/secret-input.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
export type { SecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
export {
|
||||
buildSecretInputSchema,
|
||||
hasConfiguredSecretInput,
|
||||
normalizeResolvedSecretInputString,
|
||||
normalizeSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
54
openclaw/extensions/mattermost/src/session-route.test.ts
Normal file
54
openclaw/extensions/mattermost/src/session-route.test.ts
Normal file
|
|
@ -0,0 +1,54 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { resolveMattermostOutboundSessionRoute } from "./session-route.js";
|
||||
|
||||
describe("mattermost session route", () => {
|
||||
it("builds direct-message routes for user targets", () => {
|
||||
const route = resolveMattermostOutboundSessionRoute({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
accountId: "acct-1",
|
||||
target: "@user123",
|
||||
});
|
||||
|
||||
expect(route).toMatchObject({
|
||||
peer: {
|
||||
kind: "direct",
|
||||
id: "user123",
|
||||
},
|
||||
from: "mattermost:user123",
|
||||
to: "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",
|
||||
});
|
||||
|
||||
expect(route).toMatchObject({
|
||||
peer: {
|
||||
kind: "channel",
|
||||
id: "chan123",
|
||||
},
|
||||
from: "mattermost:channel:chan123",
|
||||
to: "channel:chan123",
|
||||
threadId: "thread456",
|
||||
});
|
||||
expect(route?.sessionKey).toContain("thread456");
|
||||
});
|
||||
|
||||
it("returns null when the target is empty after normalization", () => {
|
||||
expect(
|
||||
resolveMattermostOutboundSessionRoute({
|
||||
cfg: {},
|
||||
agentId: "main",
|
||||
accountId: "acct-1",
|
||||
target: "mattermost:",
|
||||
}),
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
53
openclaw/extensions/mattermost/src/session-route.ts
Normal file
53
openclaw/extensions/mattermost/src/session-route.ts
Normal file
|
|
@ -0,0 +1,53 @@
|
|||
import {
|
||||
buildChannelOutboundSessionRoute,
|
||||
resolveThreadSessionKeys,
|
||||
stripChannelTargetPrefix,
|
||||
stripTargetKindPrefix,
|
||||
type ChannelOutboundSessionRouteParams,
|
||||
} from "openclaw/plugin-sdk/core";
|
||||
import { normalizeOutboundThreadId } from "openclaw/plugin-sdk/routing";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-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}`,
|
||||
});
|
||||
const threadId = normalizeOutboundThreadId(params.replyToId ?? params.threadId);
|
||||
const threadKeys = resolveThreadSessionKeys({
|
||||
baseSessionKey: baseRoute.baseSessionKey,
|
||||
threadId,
|
||||
});
|
||||
return {
|
||||
...baseRoute,
|
||||
sessionKey: threadKeys.sessionKey,
|
||||
...(threadId !== undefined ? { threadId } : {}),
|
||||
};
|
||||
}
|
||||
95
openclaw/extensions/mattermost/src/setup-core.ts
Normal file
95
openclaw/extensions/mattermost/src/setup-core.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
import type { ChannelSetupAdapter } from "openclaw/plugin-sdk/channel-setup";
|
||||
import { createSetupInputPresenceValidator } from "openclaw/plugin-sdk/setup-runtime";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
applySetupAccountConfigPatch,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
migrateBaseNameToDefaultAccount,
|
||||
normalizeAccountId,
|
||||
type OpenClawConfig,
|
||||
} from "./runtime-api.js";
|
||||
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 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);
|
||||
const namedConfig = applyAccountNameToChannelSection({
|
||||
cfg,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
name: input.name,
|
||||
});
|
||||
const next =
|
||||
accountId !== DEFAULT_ACCOUNT_ID
|
||||
? migrateBaseNameToDefaultAccount({
|
||||
cfg: namedConfig,
|
||||
channelKey: channel,
|
||||
})
|
||||
: namedConfig;
|
||||
return applySetupAccountConfigPatch({
|
||||
cfg: next,
|
||||
channelKey: channel,
|
||||
accountId,
|
||||
patch: input.useEnv
|
||||
? {}
|
||||
: {
|
||||
...(token ? { botToken: token } : {}),
|
||||
...(baseUrl ? { baseUrl } : {}),
|
||||
},
|
||||
});
|
||||
},
|
||||
};
|
||||
123
openclaw/extensions/mattermost/src/setup-surface.ts
Normal file
123
openclaw/extensions/mattermost/src/setup-surface.ts
Normal file
|
|
@ -0,0 +1,123 @@
|
|||
import {
|
||||
createStandardChannelSetupStatus,
|
||||
formatDocsLink,
|
||||
type ChannelSetupWizard,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
applySetupAccountConfigPatch,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
type OpenClawConfig,
|
||||
} from "./runtime-api.js";
|
||||
import { isMattermostConfigured, resolveMattermostAccountWithSecrets } from "./setup-core.js";
|
||||
import { normalizeMattermostBaseUrl } from "./setup.client.runtime.js";
|
||||
import { hasConfiguredSecretInput } from "./setup.secret-input.runtime.js";
|
||||
|
||||
const channel = "mattermost" as const;
|
||||
export { mattermostSetupAdapter } from "./setup-core.js";
|
||||
|
||||
export const mattermostSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
status: createStandardChannelSetupStatus({
|
||||
channelLabel: "Mattermost",
|
||||
configuredLabel: "configured",
|
||||
unconfiguredLabel: "needs token + url",
|
||||
configuredHint: "configured",
|
||||
unconfiguredHint: "needs setup",
|
||||
configuredScore: 2,
|
||||
unconfiguredScore: 1,
|
||||
resolveConfigured: ({ cfg, accountId }) =>
|
||||
isMattermostConfigured(
|
||||
resolveMattermostAccountWithSecrets(cfg, accountId ?? DEFAULT_ACCOUNT_ID),
|
||||
),
|
||||
}),
|
||||
introNote: {
|
||||
title: "Mattermost bot token",
|
||||
lines: [
|
||||
"1) Mattermost System Console -> Integrations -> Bot Accounts",
|
||||
"2) Create a bot + copy its token",
|
||||
"3) Use your server base URL (e.g., https://chat.example.com)",
|
||||
"Tip: the bot must be a member of any channel you want it to monitor.",
|
||||
`Docs: ${formatDocsLink("/mattermost", "mattermost")}`,
|
||||
],
|
||||
shouldShow: ({ cfg, accountId }) =>
|
||||
!isMattermostConfigured(resolveMattermostAccountWithSecrets(cfg, accountId)),
|
||||
},
|
||||
envShortcut: {
|
||||
prompt: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?",
|
||||
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: "bot token",
|
||||
preferredEnvVar: "MATTERMOST_BOT_TOKEN",
|
||||
envPrompt: "MATTERMOST_BOT_TOKEN + MATTERMOST_URL detected. Use env vars?",
|
||||
keepPrompt: "Mattermost bot token already configured. Keep it?",
|
||||
inputPrompt: "Enter Mattermost bot token",
|
||||
inspect: ({ cfg, accountId }) => {
|
||||
const resolvedAccount = resolveMattermostAccountWithSecrets(cfg, accountId);
|
||||
return {
|
||||
accountConfigured: isMattermostConfigured(resolvedAccount),
|
||||
hasConfiguredValue: hasConfiguredSecretInput(resolvedAccount.config.botToken),
|
||||
};
|
||||
},
|
||||
},
|
||||
],
|
||||
textInputs: [
|
||||
{
|
||||
inputKey: "httpUrl",
|
||||
message: "Enter Mattermost base URL",
|
||||
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(),
|
||||
},
|
||||
],
|
||||
disable: (cfg: OpenClawConfig) => ({
|
||||
...cfg,
|
||||
channels: {
|
||||
...cfg.channels,
|
||||
mattermost: {
|
||||
...cfg.channels?.mattermost,
|
||||
enabled: false,
|
||||
},
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
export {
|
||||
listMattermostAccountIds,
|
||||
resolveMattermostAccount,
|
||||
type ResolvedMattermostAccount,
|
||||
} from "./mattermost/accounts.js";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { normalizeMattermostBaseUrl } from "./mattermost/client.js";
|
||||
|
|
@ -0,0 +1 @@
|
|||
export { hasConfiguredSecretInput } from "./secret-input.js";
|
||||
364
openclaw/extensions/mattermost/src/setup.test.ts
Normal file
364
openclaw/extensions/mattermost/src/setup.test.ts
Normal file
|
|
@ -0,0 +1,364 @@
|
|||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/setup";
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createTestPluginApi } from "../../../test/helpers/plugins/plugin-api.js";
|
||||
import type { OpenClawConfig, OpenClawPluginApi } from "../runtime-api.js";
|
||||
|
||||
vi.mock("../../../test/helpers/config/bundled-channel-config-runtime.js", () => ({
|
||||
getBundledChannelRuntimeMap: () => new Map(),
|
||||
getBundledChannelConfigSchemaMap: () => new Map(),
|
||||
}));
|
||||
|
||||
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");
|
||||
|
||||
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),
|
||||
).toMatchObject({
|
||||
channels: {
|
||||
mattermost: {
|
||||
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);
|
||||
expect(registerHttpRoute).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
path: "/api/channels/mattermost/command",
|
||||
auth: "plugin",
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function registerEnvDefaults() {
|
||||
vi.unstubAllEnvs();
|
||||
}
|
||||
113
openclaw/extensions/mattermost/src/types.ts
Normal file
113
openclaw/extensions/mattermost/src/types.ts
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
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";
|
||||
export 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";
|
||||
/** 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;
|
||||
Loading…
Add table
Add a link
Reference in a new issue