From 00dcc1744e741dcb449a7dd30de2d182e50515d8 Mon Sep 17 00:00:00 2001 From: Peter Steinberger Date: Mon, 6 Apr 2026 14:40:53 +0100 Subject: [PATCH] fix: narrow mattermost setup entry seam --- .../mattermost/channel-plugin-api.test.ts | 38 ++++++++ extensions/mattermost/channel-plugin-api.ts | 33 +------ extensions/mattermost/setup-entry.ts | 2 +- extensions/mattermost/src/channel.setup.ts | 86 +++++++++++++++++++ 4 files changed, 127 insertions(+), 32 deletions(-) create mode 100644 extensions/mattermost/channel-plugin-api.test.ts create mode 100644 extensions/mattermost/src/channel.setup.ts diff --git a/extensions/mattermost/channel-plugin-api.test.ts b/extensions/mattermost/channel-plugin-api.test.ts new file mode 100644 index 00000000000..0b523a29910 --- /dev/null +++ b/extensions/mattermost/channel-plugin-api.test.ts @@ -0,0 +1,38 @@ +import { execFile } from "node:child_process"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import { promisify } from "node:util"; +import { describe, expect, it } from "vitest"; + +const execFileAsync = promisify(execFile); +const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..", ".."); +const importEnv = { + HOME: process.env.HOME, + NODE_OPTIONS: process.env.NODE_OPTIONS, + NODE_PATH: process.env.NODE_PATH, + PATH: process.env.PATH, + TERM: process.env.TERM, +} satisfies NodeJS.ProcessEnv; + +describe("mattermost bundled api seam", () => { + it("loads the narrow channel plugin api in direct smoke", async () => { + const { stdout } = await execFileAsync( + process.execPath, + [ + "--import", + "tsx", + "-e", + 'const mod = await import("./extensions/mattermost/channel-plugin-api.ts"); process.stdout.write(JSON.stringify({keys:Object.keys(mod).sort(), id: mod.mattermostPlugin.id, setupId: mod.mattermostSetupPlugin.id}));', + ], + { + cwd: repoRoot, + env: importEnv, + timeout: 40_000, + }, + ); + + expect(stdout).toBe( + '{"keys":["mattermostPlugin","mattermostSetupPlugin"],"id":"mattermost","setupId":"mattermost"}', + ); + }, 45_000); +}); diff --git a/extensions/mattermost/channel-plugin-api.ts b/extensions/mattermost/channel-plugin-api.ts index 054f73c143e..5851e550c95 100644 --- a/extensions/mattermost/channel-plugin-api.ts +++ b/extensions/mattermost/channel-plugin-api.ts @@ -1,33 +1,4 @@ -import { loadBundledEntryExportSync } from "openclaw/plugin-sdk/channel-entry-contract"; - -type ChannelPluginModule = typeof import("./channel-plugin-runtime.js"); - -function createLazyObjectValue(load: () => T): T { - return new Proxy({} as T, { - get(_target, property, receiver) { - return Reflect.get(load(), property, receiver); - }, - has(_target, property) { - return property in load(); - }, - ownKeys() { - return Reflect.ownKeys(load()); - }, - getOwnPropertyDescriptor(_target, property) { - const descriptor = Object.getOwnPropertyDescriptor(load(), property); - return descriptor ? { ...descriptor, configurable: true } : undefined; - }, - }); -} - -function loadChannelPluginModule(): ChannelPluginModule { - return loadBundledEntryExportSync(import.meta.url, { - specifier: "./channel-plugin-runtime.js", - }); -} - // Keep bundled channel entry imports narrow so bootstrap/discovery paths do // not drag the broader Mattermost helper surfaces into lightweight plugin loads. -export const mattermostPlugin: ChannelPluginModule["mattermostPlugin"] = createLazyObjectValue( - () => loadChannelPluginModule().mattermostPlugin as object, -) as ChannelPluginModule["mattermostPlugin"]; +export { mattermostPlugin } from "./channel-plugin-runtime.js"; +export { mattermostSetupPlugin } from "./src/channel.setup.js"; diff --git a/extensions/mattermost/setup-entry.ts b/extensions/mattermost/setup-entry.ts index 20a6932f1ae..fe9a329085b 100644 --- a/extensions/mattermost/setup-entry.ts +++ b/extensions/mattermost/setup-entry.ts @@ -4,6 +4,6 @@ export default defineBundledChannelSetupEntry({ importMetaUrl: import.meta.url, plugin: { specifier: "./channel-plugin-api.js", - exportName: "mattermostPlugin", + exportName: "mattermostSetupPlugin", }, }); diff --git a/extensions/mattermost/src/channel.setup.ts b/extensions/mattermost/src/channel.setup.ts new file mode 100644 index 00000000000..a4543bf16d1 --- /dev/null +++ b/extensions/mattermost/src/channel.setup.ts @@ -0,0 +1,86 @@ +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 type { ChannelPlugin } from "./channel-api.js"; +import { MattermostChannelConfigSchema } from "./config-surface.js"; +import { + listMattermostAccountIds, + resolveDefaultMattermostAccountId, + resolveMattermostAccount, + type ResolvedMattermostAccount, +} from "./mattermost/accounts.js"; +import { mattermostSetupAdapter } from "./setup-core.js"; +import { mattermostSetupWizard } from "./setup-surface.js"; + +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; + +function formatAllowEntry(entry: string): string { + const trimmed = entry.trim(); + if (!trimmed) { + return ""; + } + if (trimmed.startsWith("@")) { + const username = trimmed.slice(1).trim(); + return username ? `@${username.toLowerCase()}` : ""; + } + return trimmed.replace(/^(mattermost|user):/i, "").toLowerCase(); +} + +const mattermostConfigAdapter = createScopedChannelConfigAdapter({ + sectionKey: "mattermost", + listAccountIds: listMattermostAccountIds, + resolveAccount: adaptScopedAccountAccessor(resolveMattermostAccount), + defaultAccountId: resolveDefaultMattermostAccountId, + clearBaseFields: ["botToken", "baseUrl", "name"], + resolveAllowFrom: (account) => account.config.allowFrom, + formatAllowFrom: (allowFrom) => + formatNormalizedAllowFromEntries({ + allowFrom, + normalizeEntry: formatAllowEntry, + }), +}); + +export const mattermostSetupPlugin: ChannelPlugin = { + 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: (account) => Boolean(account.botToken && account.baseUrl), + describeAccount: (account) => + describeAccountSnapshot({ + account, + configured: Boolean(account.botToken && account.baseUrl), + extra: { + botTokenSource: account.botTokenSource, + baseUrl: account.baseUrl, + }, + }), + }, + setup: mattermostSetupAdapter, + setupWizard: mattermostSetupWizard, +};