diff --git a/extensions/msteams/src/channel-config.ts b/extensions/msteams/src/channel-config.ts new file mode 100644 index 000000000000..5669160a501f --- /dev/null +++ b/extensions/msteams/src/channel-config.ts @@ -0,0 +1,43 @@ +import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id"; +import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; +import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; +import { resolveMSTeamsCredentials } from "./token.js"; + +export type ResolvedMSTeamsAccount = { + accountId: string; + enabled: boolean; + configured: boolean; +}; + +export const msteamsMeta = { + id: "msteams", + label: "Microsoft Teams", + selectionLabel: "Microsoft Teams (Bot Framework)", + docsPath: "/channels/msteams", + docsLabel: "msteams", + blurb: "Teams SDK; enterprise support.", + aliases: ["teams"], + order: 60, +} as const; + +export const msteamsConfigAdapter = createTopLevelChannelConfigAdapter< + ResolvedMSTeamsAccount, + { + allowFrom?: Array; + defaultTo?: string; + } +>({ + sectionKey: "msteams", + resolveAccount: (cfg) => ({ + accountId: DEFAULT_ACCOUNT_ID, + enabled: cfg.channels?.msteams?.enabled !== false, + configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)), + }), + resolveAccessorAccount: ({ cfg }) => ({ + allowFrom: cfg.channels?.msteams?.allowFrom, + defaultTo: cfg.channels?.msteams?.defaultTo, + }), + resolveAllowFrom: (account) => account.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account) => account.defaultTo, +}); diff --git a/extensions/msteams/src/channel.setup.ts b/extensions/msteams/src/channel.setup.ts index 7799df1e55a2..94047a934137 100644 --- a/extensions/msteams/src/channel.setup.ts +++ b/extensions/msteams/src/channel.setup.ts @@ -1,60 +1,21 @@ // Msteams plugin module implements channel.setup behavior. import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; -import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; import type { ChannelPlugin } from "openclaw/plugin-sdk/channel-core"; -import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; +import { + msteamsConfigAdapter, + msteamsMeta, + type ResolvedMSTeamsAccount, +} from "./channel-config.js"; import { MSTeamsChannelConfigSchema } from "./config-schema.js"; import { msteamsSetupAdapter, msteamsSetupContract } from "./setup-core.js"; import { msteamsSetupWizard } from "./setup-surface.js"; import { resolveMSTeamsCredentials } from "./token.js"; -type ResolvedMSTeamsAccount = { - accountId: string; - enabled: boolean; - configured: boolean; -}; - -const meta = { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams (Bot Framework)", - docsPath: "/channels/msteams", - docsLabel: "msteams", - blurb: "Teams SDK; enterprise support.", - aliases: ["teams"], - order: 60, -} as const; - -const resolveMSTeamsChannelConfig = (cfg: OpenClawConfig) => ({ - allowFrom: cfg.channels?.msteams?.allowFrom, - defaultTo: cfg.channels?.msteams?.defaultTo, -}); - -const msteamsConfigAdapter = createTopLevelChannelConfigAdapter< - ResolvedMSTeamsAccount, - { - allowFrom?: Array; - defaultTo?: string; - } ->({ - sectionKey: "msteams", - resolveAccount: (cfg) => ({ - accountId: "default", - enabled: cfg.channels?.msteams?.enabled !== false, - configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)), - }), - resolveAccessorAccount: ({ cfg }) => resolveMSTeamsChannelConfig(cfg), - resolveAllowFrom: (account) => account.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account) => account.defaultTo, -}); - export const msteamsSetupPlugin: ChannelPlugin = { id: "msteams", meta: { - ...meta, - aliases: [...meta.aliases], + ...msteamsMeta, + aliases: [...msteamsMeta.aliases], }, capabilities: { chatTypes: ["direct", "channel", "thread"], diff --git a/extensions/msteams/src/channel.test.ts b/extensions/msteams/src/channel.test.ts index 86abc7286aef..284171e15475 100644 --- a/extensions/msteams/src/channel.test.ts +++ b/extensions/msteams/src/channel.test.ts @@ -4,6 +4,7 @@ import { describe, expect, it } from "vitest"; import { MSTeamsConfigSchema } from "../config-api.js"; import { msTeamsApprovalAuth } from "./approval-auth.js"; import { msteamsPlugin } from "./channel.js"; +import { msteamsSetupPlugin } from "./channel.setup.js"; function createConfiguredMSTeamsCfg(): OpenClawConfig { return { @@ -18,6 +19,58 @@ function createConfiguredMSTeamsCfg(): OpenClawConfig { } describe("msteamsPlugin", () => { + it("shares account and metadata contracts with the lightweight setup plugin", () => { + expect(msteamsSetupPlugin.meta).toEqual(msteamsPlugin.meta); + + for (const key of [ + "listAccountIds", + "resolveAccount", + "defaultAccountId", + "setAccountEnabled", + "deleteAccount", + "resolveAllowFrom", + "formatAllowFrom", + "resolveDefaultTo", + ] as const) { + expect(msteamsSetupPlugin.config[key]).toBe(msteamsPlugin.config[key]); + } + }); + + it("preserves the default account and allowlist across runtime and setup", () => { + const cfg: OpenClawConfig = { + channels: { + msteams: { + ...createConfiguredMSTeamsCfg().channels?.msteams, + allowFrom: ["OWNER", " Team.Member "], + defaultTo: "19:team@thread.tacv2", + }, + }, + }; + + for (const plugin of [msteamsPlugin, msteamsSetupPlugin]) { + expect(plugin.config.defaultAccountId?.(cfg)).toBe("default"); + expect(plugin.config.resolveAccount(cfg, "ignored")).toEqual({ + accountId: "default", + enabled: true, + configured: true, + }); + expect(plugin.config.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual([ + "OWNER", + " Team.Member ", + ]); + expect( + plugin.config.formatAllowFrom?.({ + cfg, + accountId: "default", + allowFrom: ["OWNER", " Team.Member "], + }), + ).toEqual(["owner", "team.member"]); + expect(plugin.config.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe( + "19:team@thread.tacv2", + ); + } + }); + it("exposes approval auth through approvalCapability", () => { expect(msteamsPlugin.approvalCapability).toBe(msTeamsApprovalAuth); }); diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index 0477933aeb8f..49b3aaf9263a 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -1,7 +1,5 @@ // Msteams plugin module implements channel behavior. import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; -import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { createTopLevelChannelConfigAdapter } from "openclaw/plugin-sdk/channel-config-helpers"; import type { ChannelMessageActionAdapter, ChannelMessageToolDiscovery, @@ -43,6 +41,11 @@ import { PAIRING_APPROVED_MESSAGE, } from "../runtime-api.js"; import { msTeamsApprovalAuth } from "./approval-auth.js"; +import { + msteamsConfigAdapter, + msteamsMeta, + type ResolvedMSTeamsAccount, +} from "./channel-config.js"; import { MSTeamsChannelConfigSchema } from "./config-schema.js"; import { collectMSTeamsMutableAllowlistWarnings } from "./doctor.js"; import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; @@ -67,23 +70,6 @@ import { msteamsSetupAdapter, msteamsSetupContract } from "./setup-core.js"; import { msteamsSetupWizard } from "./setup-surface.js"; import { resolveMSTeamsCredentials } from "./token.js"; -type ResolvedMSTeamsAccount = { - accountId: string; - enabled: boolean; - configured: boolean; -}; - -const meta = { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams (Bot Framework)", - docsPath: "/channels/msteams", - docsLabel: "msteams", - blurb: "Teams SDK; enterprise support.", - aliases: ["teams"], - order: 60, -} as const; - const TEAMS_GRAPH_PERMISSION_HINTS: Record = { "ChannelMessage.Read.All": "channel history", "Chat.Read.All": "chat history", @@ -118,30 +104,6 @@ const loadMSTeamsChannelRuntime = createLazyRuntimeNamedExport( "msTeamsChannelRuntime", ); -const resolveMSTeamsChannelConfig = (cfg: OpenClawConfig) => ({ - allowFrom: cfg.channels?.msteams?.allowFrom, - defaultTo: cfg.channels?.msteams?.defaultTo, -}); - -const msteamsConfigAdapter = createTopLevelChannelConfigAdapter< - ResolvedMSTeamsAccount, - { - allowFrom?: Array; - defaultTo?: string; - } ->({ - sectionKey: "msteams", - resolveAccount: (cfg) => ({ - accountId: DEFAULT_ACCOUNT_ID, - enabled: cfg.channels?.msteams?.enabled !== false, - configured: Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)), - }), - resolveAccessorAccount: ({ cfg }) => resolveMSTeamsChannelConfig(cfg), - resolveAllowFrom: (account) => account.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account) => account.defaultTo, -}); - function jsonActionResult(data: Record) { const text = JSON.stringify(data); return { @@ -529,8 +491,8 @@ export const msteamsPlugin: ChannelPlugin ({ slackSetupWizard: (await import("./setup-surface.js")).slackSetupWizard, })); -const slackSetupConfigAdapter = createScopedChannelConfigAdapter< - ResolvedSlackAccount, - SlackConfigAccessorAccount ->({ - sectionKey: SLACK_CHANNEL, - listAccountIds: listSlackAccountIds, - resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount), - resolveAccessorAccount: resolveSlackConfigAccessorAccount, - defaultAccountId: resolveDefaultSlackAccountId, - clearBaseFields: ["botToken", "appToken", "userToken", "signingSecret", "name"], - resolveAllowFrom: (account) => account.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account) => account.defaultTo, -}); - export const slackSetupPlugin: ChannelPlugin = { id: SLACK_CHANNEL, meta: { @@ -78,7 +52,7 @@ export const slackSetupPlugin: ChannelPlugin = { reload: { configPrefixes: ["channels.slack"] }, configSchema: SlackChannelConfigSchema, config: { - ...slackSetupConfigAdapter, + ...slackBaseConfigAdapter, hasConfiguredState: ({ env }) => ["SLACK_APP_TOKEN", "SLACK_BOT_TOKEN", "SLACK_USER_TOKEN"].some( (key) => typeof env?.[key] === "string" && env[key]?.trim().length > 0, diff --git a/extensions/slack/src/config-adapter.ts b/extensions/slack/src/config-adapter.ts new file mode 100644 index 000000000000..308331d49054 --- /dev/null +++ b/extensions/slack/src/config-adapter.ts @@ -0,0 +1,29 @@ +import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; +import { + adaptScopedAccountAccessor, + createScopedChannelConfigAdapter, +} from "openclaw/plugin-sdk/channel-config-helpers"; +import { + listSlackAccountIds, + resolveDefaultSlackAccountId, + resolveSlackAccount, + resolveSlackConfigAccessorAccount, + type ResolvedSlackAccount, + type SlackConfigAccessorAccount, +} from "./accounts.js"; +import { SLACK_CHANNEL } from "./setup-shared.js"; + +export const slackBaseConfigAdapter = createScopedChannelConfigAdapter< + ResolvedSlackAccount, + SlackConfigAccessorAccount +>({ + sectionKey: SLACK_CHANNEL, + listAccountIds: listSlackAccountIds, + resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount), + resolveAccessorAccount: resolveSlackConfigAccessorAccount, + defaultAccountId: resolveDefaultSlackAccountId, + clearBaseFields: ["botToken", "appToken", "userToken", "signingSecret", "name"], + resolveAllowFrom: (account) => account.allowFrom, + formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), + resolveDefaultTo: (account) => account.defaultTo, +}); diff --git a/extensions/slack/src/shared.test.ts b/extensions/slack/src/shared.test.ts index 856abe7b3557..6286c490efd3 100644 --- a/extensions/slack/src/shared.test.ts +++ b/extensions/slack/src/shared.test.ts @@ -1,6 +1,7 @@ // Slack tests cover shared plugin behavior. import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts"; import { describe, expect, it } from "vitest"; +import { slackSetupPlugin } from "./channel.setup.js"; import { setSlackChannelAllowlist } from "./setup-shared.js"; import { createSlackPluginBase, slackConfigAdapter } from "./shared.js"; @@ -67,6 +68,24 @@ describe("setSlackChannelAllowlist", () => { }); describe("slackConfigAdapter", () => { + it("shares lightweight account accessors with setup but keeps inspection runtime-only", () => { + for (const key of [ + "listAccountIds", + "resolveAccount", + "defaultAccountId", + "setAccountEnabled", + "deleteAccount", + "resolveAllowFrom", + "formatAllowFrom", + "resolveDefaultTo", + ] as const) { + expect(slackSetupPlugin.config[key]).toBe(slackConfigAdapter[key]); + } + + expect(slackConfigAdapter.inspectAccount).toBeTypeOf("function"); + expect(slackSetupPlugin.config.inspectAccount).toBeUndefined(); + }); + it("clears user-identity credentials when deleting the root account", () => { const cfg = { channels: { @@ -124,5 +143,9 @@ describe("slackConfigAdapter", () => { expect(slackConfigAdapter.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual(["U123"]); expect(slackConfigAdapter.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe("C123"); + expect(slackSetupPlugin.config.resolveAllowFrom?.({ cfg, accountId: "default" })).toEqual([ + "U123", + ]); + expect(slackSetupPlugin.config.resolveDefaultTo?.({ cfg, accountId: "default" })).toBe("C123"); }); }); diff --git a/extensions/slack/src/shared.ts b/extensions/slack/src/shared.ts index bbe81e027036..669e22925ecf 100644 --- a/extensions/slack/src/shared.ts +++ b/extensions/slack/src/shared.ts @@ -1,21 +1,11 @@ // Slack plugin module implements shared behavior. import { describeAccountSnapshot } from "openclaw/plugin-sdk/account-helpers"; -import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from"; -import { - adaptScopedAccountAccessor, - createScopedChannelConfigAdapter, -} from "openclaw/plugin-sdk/channel-config-helpers"; +import { adaptScopedAccountAccessor } from "openclaw/plugin-sdk/channel-config-helpers"; import { isSlackPluginAccountConfigured } from "./account-configured.js"; import { inspectSlackAccount } from "./account-inspect.js"; -import { - listSlackAccountIds, - resolveSlackConfigAccessorAccount, - resolveDefaultSlackAccountId, - resolveSlackAccount, - type SlackConfigAccessorAccount, - type ResolvedSlackAccount, -} from "./accounts.js"; +import type { ResolvedSlackAccount } from "./accounts.js"; import { getChatChannelMeta, type ChannelPlugin } from "./channel-api.js"; +import { slackBaseConfigAdapter } from "./config-adapter.js"; import { SlackChannelConfigSchema } from "./config-schema.js"; import { slackDoctor } from "./doctor.js"; import { isSlackInteractiveRepliesEnabled } from "./interactive-replies.js"; @@ -27,21 +17,10 @@ export { SLACK_CHANNEL } from "./setup-shared.js"; export { isSlackPluginAccountConfigured }; -export const slackConfigAdapter = createScopedChannelConfigAdapter< - ResolvedSlackAccount, - SlackConfigAccessorAccount ->({ - sectionKey: SLACK_CHANNEL, - listAccountIds: listSlackAccountIds, - resolveAccount: adaptScopedAccountAccessor(resolveSlackAccount), - resolveAccessorAccount: resolveSlackConfigAccessorAccount, +export const slackConfigAdapter = { + ...slackBaseConfigAdapter, inspectAccount: adaptScopedAccountAccessor(inspectSlackAccount), - defaultAccountId: resolveDefaultSlackAccountId, - clearBaseFields: ["botToken", "appToken", "userToken", "signingSecret", "name"], - resolveAllowFrom: (account) => account.allowFrom, - formatAllowFrom: (allowFrom) => formatAllowFromLowercase({ allowFrom }), - resolveDefaultTo: (account) => account.defaultTo, -}); +}; export function createSlackPluginBase(params: { setupWizard: NonNullable["setupWizard"]>;