diff --git a/extensions/matrix/src/channel.ts b/extensions/matrix/src/channel.ts index a6a33a7f627..8e3c858ecde 100644 --- a/extensions/matrix/src/channel.ts +++ b/extensions/matrix/src/channel.ts @@ -6,12 +6,10 @@ import { createScopedDmSecurityResolver, } from "openclaw/plugin-sdk/compat"; import { - applyAccountNameToChannelSection, buildChannelConfigSchema, buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, DEFAULT_ACCOUNT_ID, - normalizeAccountId, PAIRING_APPROVED_MESSAGE, type ChannelPlugin, } from "openclaw/plugin-sdk/matrix"; @@ -30,9 +28,8 @@ import { type ResolvedMatrixAccount, } from "./matrix/accounts.js"; import { normalizeMatrixAllowList, normalizeMatrixUserId } from "./matrix/monitor/allowlist.js"; -import { matrixOnboardingAdapter } from "./onboarding.js"; import { getMatrixRuntime } from "./runtime.js"; -import { normalizeSecretInputString } from "./secret-input.js"; +import { matrixSetupAdapter, matrixSetupWizard } from "./setup-surface.js"; import type { CoreConfig } from "./types.js"; // Mutex for serializing account startup (workaround for concurrent dynamic import race condition) @@ -66,38 +63,6 @@ function normalizeMatrixMessagingTarget(raw: string): string | undefined { return stripped || undefined; } -function buildMatrixConfigUpdate( - cfg: CoreConfig, - input: { - homeserver?: string; - userId?: string; - accessToken?: string; - password?: string; - deviceName?: string; - initialSyncLimit?: number; - }, -): CoreConfig { - const existing = cfg.channels?.matrix ?? {}; - return { - ...cfg, - channels: { - ...cfg.channels, - matrix: { - ...existing, - enabled: true, - ...(input.homeserver ? { homeserver: input.homeserver } : {}), - ...(input.userId ? { userId: input.userId } : {}), - ...(input.accessToken ? { accessToken: input.accessToken } : {}), - ...(input.password ? { password: input.password } : {}), - ...(input.deviceName ? { deviceName: input.deviceName } : {}), - ...(typeof input.initialSyncLimit === "number" - ? { initialSyncLimit: input.initialSyncLimit } - : {}), - }, - }, - }; -} - const matrixConfigAccessors = createScopedAccountConfigAccessors({ resolveAccount: ({ cfg, accountId }) => resolveMatrixAccountConfig({ cfg: cfg as CoreConfig, accountId }), @@ -132,7 +97,7 @@ const resolveMatrixDmPolicy = createScopedDmSecurityResolver = { id: "matrix", meta, - onboarding: matrixOnboardingAdapter, + setupWizard: matrixSetupWizard, pairing: { idLabel: "matrixUserId", normalizeAllowEntry: (entry) => entry.replace(/^matrix:/i, ""), @@ -316,67 +281,7 @@ export const matrixPlugin: ChannelPlugin = { (await loadMatrixChannelRuntime()).resolveMatrixTargets({ cfg, inputs, kind, runtime }), }, actions: matrixMessageActions, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg as CoreConfig, - channelKey: "matrix", - accountId, - name, - }), - validateInput: ({ input }) => { - if (input.useEnv) { - return null; - } - if (!input.homeserver?.trim()) { - return "Matrix requires --homeserver"; - } - const accessToken = input.accessToken?.trim(); - const password = normalizeSecretInputString(input.password); - const userId = input.userId?.trim(); - if (!accessToken && !password) { - return "Matrix requires --access-token or --password"; - } - if (!accessToken) { - if (!userId) { - return "Matrix requires --user-id when using --password"; - } - if (!password) { - return "Matrix requires --password when using --user-id"; - } - } - return null; - }, - applyAccountConfig: ({ cfg, input }) => { - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg as CoreConfig, - channelKey: "matrix", - accountId: DEFAULT_ACCOUNT_ID, - name: input.name, - }); - if (input.useEnv) { - return { - ...namedConfig, - channels: { - ...namedConfig.channels, - matrix: { - ...namedConfig.channels?.matrix, - enabled: true, - }, - }, - } as CoreConfig; - } - return buildMatrixConfigUpdate(namedConfig as CoreConfig, { - homeserver: input.homeserver?.trim(), - userId: input.userId?.trim(), - accessToken: input.accessToken?.trim(), - password: normalizeSecretInputString(input.password), - deviceName: input.deviceName?.trim(), - initialSyncLimit: input.initialSyncLimit, - }); - }, - }, + setup: matrixSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getMatrixRuntime().channel.text.chunkMarkdownText!(text, limit), diff --git a/extensions/matrix/src/onboarding.ts b/extensions/matrix/src/setup-surface.ts similarity index 71% rename from extensions/matrix/src/onboarding.ts rename to extensions/matrix/src/setup-surface.ts index 642522dbc50..9f37f000c46 100644 --- a/extensions/matrix/src/onboarding.ts +++ b/extensions/matrix/src/setup-surface.ts @@ -1,19 +1,29 @@ -import type { DmPolicy } from "openclaw/plugin-sdk/matrix"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import { addWildcardAllowFrom, buildSingleChannelSecretPromptState, - formatResolvedUnresolvedNote, - formatDocsLink, - hasConfiguredSecretInput, mergeAllowFromEntries, promptSingleChannelSecretInput, - promptChannelAccessConfig, setTopLevelChannelGroupPolicy, - type SecretInput, - type ChannelOnboardingAdapter, - type ChannelOnboardingDmPolicy, - type WizardPrompter, -} from "openclaw/plugin-sdk/matrix"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + migrateBaseNameToDefaultAccount, +} from "../../../src/channels/plugins/setup-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy } from "../../../src/config/types.js"; +import type { SecretInput } from "../../../src/config/types.secrets.js"; +import { + hasConfiguredSecretInput, + normalizeSecretInputString, +} from "../../../src/config/types.secrets.js"; +import { formatResolvedUnresolvedNote } from "../../../src/plugin-sdk/resolution-notes.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { listMatrixDirectoryGroupsLive } from "./directory-live.js"; import { resolveMatrixAccount } from "./matrix/accounts.js"; import { ensureMatrixSdkInstalled, isMatrixSdkAvailable } from "./matrix/deps.js"; @@ -22,6 +32,38 @@ import type { CoreConfig } from "./types.js"; const channel = "matrix" as const; +function buildMatrixConfigUpdate( + cfg: CoreConfig, + input: { + homeserver?: string; + userId?: string; + accessToken?: string; + password?: string; + deviceName?: string; + initialSyncLimit?: number; + }, +): CoreConfig { + const existing = cfg.channels?.matrix ?? {}; + return { + ...cfg, + channels: { + ...cfg.channels, + matrix: { + ...existing, + enabled: true, + ...(input.homeserver ? { homeserver: input.homeserver } : {}), + ...(input.userId ? { userId: input.userId } : {}), + ...(input.accessToken ? { accessToken: input.accessToken } : {}), + ...(input.password ? { password: input.password } : {}), + ...(input.deviceName ? { deviceName: input.deviceName } : {}), + ...(typeof input.initialSyncLimit === "number" + ? { initialSyncLimit: input.initialSyncLimit } + : {}), + }, + }, + }; +} + function setMatrixDmPolicy(cfg: CoreConfig, policy: DmPolicy) { const allowFrom = policy === "open" ? addWildcardAllowFrom(cfg.channels?.matrix?.dm?.allowFrom) : undefined; @@ -168,7 +210,7 @@ function setMatrixGroupRooms(cfg: CoreConfig, roomKeys: string[]) { }; } -const dmPolicy: ChannelOnboardingDmPolicy = { +const matrixDmPolicy: ChannelOnboardingDmPolicy = { label: "Matrix", channel, policyKey: "channels.matrix.dm.policy", @@ -178,26 +220,100 @@ const dmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptMatrixAllowFrom, }; -export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const account = resolveMatrixAccount({ cfg: cfg as CoreConfig }); - const configured = account.configured; - const sdkReady = isMatrixSdkAvailable(); - return { - channel, - configured, - statusLines: [ - `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, - ], - selectionHint: !sdkReady - ? "install @vector-im/matrix-bot-sdk" - : configured - ? "configured" - : "needs auth", - }; +export const matrixSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ input }) => { + if (input.useEnv) { + return null; + } + if (!input.homeserver?.trim()) { + return "Matrix requires --homeserver"; + } + const accessToken = input.accessToken?.trim(); + const password = normalizeSecretInputString(input.password); + const userId = input.userId?.trim(); + if (!accessToken && !password) { + return "Matrix requires --access-token or --password"; + } + if (!accessToken) { + if (!userId) { + return "Matrix requires --user-id when using --password"; + } + if (!password) { + return "Matrix requires --password when using --user-id"; + } + } + return null; }, - configure: async ({ cfg, runtime, prompter, forceAllowFrom }) => { + applyAccountConfig: ({ cfg, accountId, input }) => { + const namedConfig = applyAccountNameToChannelSection({ + cfg: cfg as CoreConfig, + channelKey: channel, + accountId, + name: input.name, + }); + const next = + accountId !== DEFAULT_ACCOUNT_ID + ? migrateBaseNameToDefaultAccount({ + cfg: namedConfig, + channelKey: channel, + }) + : namedConfig; + if (input.useEnv) { + return { + ...next, + channels: { + ...next.channels, + matrix: { + ...next.channels?.matrix, + enabled: true, + }, + }, + } as CoreConfig; + } + return buildMatrixConfigUpdate(next as CoreConfig, { + homeserver: input.homeserver?.trim(), + userId: input.userId?.trim(), + accessToken: input.accessToken?.trim(), + password: normalizeSecretInputString(input.password), + deviceName: input.deviceName?.trim(), + initialSyncLimit: input.initialSyncLimit, + }); + }, +}; + +export const matrixSetupWizard: ChannelSetupWizard = { + channel, + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs homeserver + access token or password", + configuredHint: "configured", + unconfiguredHint: "needs auth", + resolveConfigured: ({ cfg }) => resolveMatrixAccount({ cfg: cfg as CoreConfig }).configured, + resolveStatusLines: ({ cfg }) => { + const configured = resolveMatrixAccount({ cfg: cfg as CoreConfig }).configured; + return [ + `Matrix: ${configured ? "configured" : "needs homeserver + access token or password"}`, + ]; + }, + resolveSelectionHint: ({ cfg, configured }) => { + if (!isMatrixSdkAvailable()) { + return "install @vector-im/matrix-bot-sdk"; + } + return configured ? "configured" : "needs auth"; + }, + }, + credentials: [], + finalize: async ({ cfg, runtime, prompter, forceAllowFrom }) => { let next = cfg as CoreConfig; await ensureMatrixSdkInstalled({ runtime, @@ -231,16 +347,11 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (useEnv) { - next = { - ...next, - channels: { - ...next.channels, - matrix: { - ...next.channels?.matrix, - enabled: true, - }, - }, - }; + next = matrixSetupAdapter.applyAccountConfig({ + cfg: next, + accountId: DEFAULT_ACCOUNT_ID, + input: { useEnv: true }, + }) as CoreConfig; if (forceAllowFrom) { next = await promptMatrixAllowFrom({ cfg: next, prompter }); } @@ -284,7 +395,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { } if (!accessToken && !passwordConfigured()) { - // Ask auth method FIRST before asking for user ID const authMode = await prompter.select({ message: "Matrix auth method", options: [ @@ -300,11 +410,8 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); - // With access token, we can fetch the userId automatically - don't prompt for it - // The client.ts will use whoami() to get it userId = ""; } else { - // Password auth requires user ID upfront userId = String( await prompter.text({ message: "Matrix user ID", @@ -333,7 +440,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { const passwordResult = await promptSingleChannelSecretInput({ cfg: next, prompter, - providerHint: "matrix", + providerHint: channel, credentialLabel: "password", accountConfigured: passwordPromptState.accountConfigured, canUseEnv: passwordPromptState.canUseEnv, @@ -359,7 +466,6 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { }), ).trim(); - // Ask about E2EE encryption const enableEncryption = await prompter.confirm({ message: "Enable end-to-end encryption (E2EE)?", initialValue: existing.encryption ?? false, @@ -375,7 +481,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { homeserver, userId: userId || undefined, accessToken: accessToken || undefined, - password: password, + password, deviceName: deviceName || undefined, encryption: enableEncryption || undefined, }, @@ -451,7 +557,7 @@ export const matrixOnboardingAdapter: ChannelOnboardingAdapter = { return { cfg: next }; }, - dmPolicy, + dmPolicy: matrixDmPolicy, disable: (cfg) => ({ ...(cfg as CoreConfig), channels: { diff --git a/extensions/msteams/src/channel.ts b/extensions/msteams/src/channel.ts index a5c8f0bbe58..a4e62e5e310 100644 --- a/extensions/msteams/src/channel.ts +++ b/extensions/msteams/src/channel.ts @@ -16,7 +16,6 @@ import { MSTeamsConfigSchema, PAIRING_APPROVED_MESSAGE, } from "openclaw/plugin-sdk/msteams"; -import { msteamsOnboardingAdapter } from "./onboarding.js"; import { resolveMSTeamsGroupToolPolicy } from "./policy.js"; import { normalizeMSTeamsMessagingTarget, @@ -27,6 +26,7 @@ import { resolveMSTeamsUserAllowlist, } from "./resolve-allowlist.js"; import { getMSTeamsRuntime } from "./runtime.js"; +import { msteamsSetupAdapter, msteamsSetupWizard } from "./setup-surface.js"; import { resolveMSTeamsCredentials } from "./token.js"; type ResolvedMSTeamsAccount = { @@ -56,7 +56,7 @@ export const msteamsPlugin: ChannelPlugin = { ...meta, aliases: [...meta.aliases], }, - onboarding: msteamsOnboardingAdapter, + setupWizard: msteamsSetupWizard, pairing: { idLabel: "msteamsUserId", normalizeAllowEntry: (entry) => entry.replace(/^(msteams|user):/i, ""), @@ -145,19 +145,7 @@ export const msteamsPlugin: ChannelPlugin = { }); }, }, - setup: { - resolveAccountId: () => DEFAULT_ACCOUNT_ID, - applyAccountConfig: ({ cfg }) => ({ - ...cfg, - channels: { - ...cfg.channels, - msteams: { - ...cfg.channels?.msteams, - enabled: true, - }, - }, - }), - }, + setup: msteamsSetupAdapter, messaging: { normalizeTarget: normalizeMSTeamsMessagingTarget, targetResolver: { diff --git a/extensions/msteams/src/onboarding.ts b/extensions/msteams/src/setup-surface.ts similarity index 80% rename from extensions/msteams/src/onboarding.ts rename to extensions/msteams/src/setup-surface.ts index 11207e8ee49..8d5ebdbb5ef 100644 --- a/extensions/msteams/src/onboarding.ts +++ b/extensions/msteams/src/setup-surface.ts @@ -1,21 +1,19 @@ -import type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, - OpenClawConfig, - DmPolicy, - WizardPrompter, - MSTeamsTeamConfig, -} from "openclaw/plugin-sdk/msteams"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; import { - DEFAULT_ACCOUNT_ID, - formatDocsLink, mergeAllowFromEntries, - promptChannelAccessConfig, setTopLevelChannelAllowFrom, setTopLevelChannelDmPolicyWithAllowFrom, setTopLevelChannelGroupPolicy, splitOnboardingEntries, -} from "openclaw/plugin-sdk/msteams"; +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { DmPolicy, MSTeamsTeamConfig } from "../../../src/config/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { parseMSTeamsTeamEntry, resolveMSTeamsChannelAllowlist, @@ -29,7 +27,7 @@ const channel = "msteams" as const; function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { return setTopLevelChannelDmPolicyWithAllowFrom({ cfg, - channel: "msteams", + channel, dmPolicy, }); } @@ -37,7 +35,7 @@ function setMSTeamsDmPolicy(cfg: OpenClawConfig, dmPolicy: DmPolicy) { function setMSTeamsAllowFrom(cfg: OpenClawConfig, allowFrom: string[]): OpenClawConfig { return setTopLevelChannelAllowFrom({ cfg, - channel: "msteams", + channel, allowFrom, }); } @@ -138,7 +136,7 @@ async function promptMSTeamsAllowFrom(params: { async function noteMSTeamsCredentialHelp(prompter: WizardPrompter): Promise { await prompter.note( [ - "1) Azure Bot registration → get App ID + Tenant ID", + "1) Azure Bot registration -> get App ID + Tenant ID", "2) Add a client secret (App Password)", "3) Set webhook URL + messaging endpoint", "Tip: you can also set MSTEAMS_APP_ID / MSTEAMS_APP_PASSWORD / MSTEAMS_TENANT_ID.", @@ -154,7 +152,7 @@ function setMSTeamsGroupPolicy( ): OpenClawConfig { return setTopLevelChannelGroupPolicy({ cfg, - channel: "msteams", + channel, groupPolicy, enabled: true, }); @@ -193,7 +191,7 @@ function setMSTeamsTeamsAllowlist( }; } -const dmPolicy: ChannelOnboardingDmPolicy = { +const msteamsDmPolicy: ChannelOnboardingDmPolicy = { label: "MS Teams", channel, policyKey: "channels.msteams.dmPolicy", @@ -203,21 +201,46 @@ const dmPolicy: ChannelOnboardingDmPolicy = { promptAllowFrom: promptMSTeamsAllowFrom, }; -export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { +export const msteamsSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg }) => ({ + ...cfg, + channels: { + ...cfg.channels, + msteams: { + ...cfg.channels?.msteams, + enabled: true, + }, + }, + }), +}; + +export const msteamsSetupWizard: ChannelSetupWizard = { channel, - getStatus: async ({ cfg }) => { - const configured = - Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) || - hasConfiguredMSTeamsCredentials(cfg.channels?.msteams); - return { - channel, - configured, - statusLines: [`MS Teams: ${configured ? "configured" : "needs app credentials"}`], - selectionHint: configured ? "configured" : "needs app creds", - quickstartScore: configured ? 2 : 0, - }; + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs app credentials", + configuredHint: "configured", + unconfiguredHint: "needs app creds", + configuredScore: 2, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => { + return ( + Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) || + hasConfiguredMSTeamsCredentials(cfg.channels?.msteams) + ); + }, + resolveStatusLines: ({ cfg }) => { + const configured = + Boolean(resolveMSTeamsCredentials(cfg.channels?.msteams)) || + hasConfiguredMSTeamsCredentials(cfg.channels?.msteams); + return [`MS Teams: ${configured ? "configured" : "needs app credentials"}`]; + }, }, - configure: async ({ cfg, prompter }) => { + credentials: [], + finalize: async ({ cfg, prompter }) => { const resolved = resolveMSTeamsCredentials(cfg.channels?.msteams); const hasConfigCreds = hasConfiguredMSTeamsCredentials(cfg.channels?.msteams); const canUseEnv = Boolean( @@ -243,13 +266,11 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { initialValue: true, }); if (keepEnv) { - next = { - ...next, - channels: { - ...next.channels, - msteams: { ...next.channels?.msteams, enabled: true }, - }, - }; + next = msteamsSetupAdapter.applyAccountConfig({ + cfg: next, + accountId: DEFAULT_ACCOUNT_ID, + input: {}, + }); } else { ({ appId, appPassword, tenantId } = await promptMSTeamsCredentials(prompter)); } @@ -308,17 +329,17 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { .filter(Boolean) as Array<{ teamKey: string; channelKey?: string }>; if (accessConfig.entries.length > 0 && resolveMSTeamsCredentials(next.channels?.msteams)) { try { - const resolved = await resolveMSTeamsChannelAllowlist({ + const resolvedEntries = await resolveMSTeamsChannelAllowlist({ cfg: next, entries: accessConfig.entries, }); - const resolvedChannels = resolved.filter( + const resolvedChannels = resolvedEntries.filter( (entry) => entry.resolved && entry.teamId && entry.channelId, ); - const resolvedTeams = resolved.filter( + const resolvedTeams = resolvedEntries.filter( (entry) => entry.resolved && entry.teamId && !entry.channelId, ); - const unresolved = resolved + const unresolved = resolvedEntries .filter((entry) => !entry.resolved) .map((entry) => entry.input); @@ -370,7 +391,7 @@ export const msteamsOnboardingAdapter: ChannelOnboardingAdapter = { return { cfg: next, accountId: DEFAULT_ACCOUNT_ID }; }, - dmPolicy, + dmPolicy: msteamsDmPolicy, disable: (cfg) => ({ ...cfg, channels: { diff --git a/extensions/twitch/src/onboarding.test.ts b/extensions/twitch/src/onboarding.test.ts index b8946eefc49..47b4e179e5e 100644 --- a/extensions/twitch/src/onboarding.test.ts +++ b/extensions/twitch/src/onboarding.test.ts @@ -1,5 +1,5 @@ /** - * Tests for onboarding.ts helpers + * Tests for setup-surface.ts helpers * * Tests cover: * - promptToken helper @@ -15,11 +15,6 @@ import type { WizardPrompter } from "openclaw/plugin-sdk/twitch"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import type { TwitchAccountConfig } from "./types.js"; -vi.mock("openclaw/plugin-sdk/twitch", () => ({ - formatDocsLink: (url: string, fallback: string) => fallback || url, - promptChannelAccessConfig: vi.fn(async () => null), -})); - // Mock the helpers we're testing const mockPromptText = vi.fn(); const mockPromptConfirm = vi.fn(); @@ -35,7 +30,7 @@ const mockAccount: TwitchAccountConfig = { channel: "#testchannel", }; -describe("onboarding helpers", () => { +describe("setup surface helpers", () => { beforeEach(() => { vi.clearAllMocks(); }); @@ -46,7 +41,7 @@ describe("onboarding helpers", () => { describe("promptToken", () => { it("should return existing token when user confirms to keep it", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); mockPromptConfirm.mockResolvedValue(true); @@ -61,7 +56,7 @@ describe("onboarding helpers", () => { }); it("should prompt for new token when user doesn't keep existing", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); mockPromptConfirm.mockResolvedValue(false); mockPromptText.mockResolvedValue("oauth:newtoken123"); @@ -77,7 +72,7 @@ describe("onboarding helpers", () => { }); it("should use env token as initial value when provided", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); mockPromptConfirm.mockResolvedValue(false); mockPromptText.mockResolvedValue("oauth:fromenv"); @@ -92,7 +87,7 @@ describe("onboarding helpers", () => { }); it("should validate token format", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); // Set up mocks - user doesn't want to keep existing token mockPromptConfirm.mockResolvedValueOnce(false); @@ -124,7 +119,7 @@ describe("onboarding helpers", () => { }); it("should return early when no existing token and no env token", async () => { - const { promptToken } = await import("./onboarding.js"); + const { promptToken } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("oauth:newtoken"); @@ -137,7 +132,7 @@ describe("onboarding helpers", () => { describe("promptUsername", () => { it("should prompt for username with validation", async () => { - const { promptUsername } = await import("./onboarding.js"); + const { promptUsername } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("mybot"); @@ -152,7 +147,7 @@ describe("onboarding helpers", () => { }); it("should use existing username as initial value", async () => { - const { promptUsername } = await import("./onboarding.js"); + const { promptUsername } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("testbot"); @@ -168,7 +163,7 @@ describe("onboarding helpers", () => { describe("promptClientId", () => { it("should prompt for client ID with validation", async () => { - const { promptClientId } = await import("./onboarding.js"); + const { promptClientId } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("abc123xyz"); @@ -185,7 +180,7 @@ describe("onboarding helpers", () => { describe("promptChannelName", () => { it("should return channel name when provided", async () => { - const { promptChannelName } = await import("./onboarding.js"); + const { promptChannelName } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue("#mychannel"); @@ -195,7 +190,7 @@ describe("onboarding helpers", () => { }); it("should require a non-empty channel name", async () => { - const { promptChannelName } = await import("./onboarding.js"); + const { promptChannelName } = await import("./setup-surface.js"); mockPromptText.mockResolvedValue(""); @@ -210,7 +205,7 @@ describe("onboarding helpers", () => { describe("promptRefreshTokenSetup", () => { it("should return empty object when user declines", async () => { - const { promptRefreshTokenSetup } = await import("./onboarding.js"); + const { promptRefreshTokenSetup } = await import("./setup-surface.js"); mockPromptConfirm.mockResolvedValue(false); @@ -224,7 +219,7 @@ describe("onboarding helpers", () => { }); it("should prompt for credentials when user accepts", async () => { - const { promptRefreshTokenSetup } = await import("./onboarding.js"); + const { promptRefreshTokenSetup } = await import("./setup-surface.js"); mockPromptConfirm .mockResolvedValueOnce(true) // First call: useRefresh @@ -242,7 +237,7 @@ describe("onboarding helpers", () => { }); it("should use existing values as initial prompts", async () => { - const { promptRefreshTokenSetup } = await import("./onboarding.js"); + const { promptRefreshTokenSetup } = await import("./setup-surface.js"); const accountWithRefresh = { ...mockAccount, @@ -267,7 +262,7 @@ describe("onboarding helpers", () => { describe("configureWithEnvToken", () => { it("should return null when user declines env token", async () => { - const { configureWithEnvToken } = await import("./onboarding.js"); + const { configureWithEnvToken } = await import("./setup-surface.js"); // Reset and set up mock - user declines env token mockPromptConfirm.mockReset().mockResolvedValue(false as never); @@ -287,7 +282,7 @@ describe("onboarding helpers", () => { }); it("should prompt for username and clientId when using env token", async () => { - const { configureWithEnvToken } = await import("./onboarding.js"); + const { configureWithEnvToken } = await import("./setup-surface.js"); // Reset and set up mocks - user accepts env token mockPromptConfirm.mockReset().mockResolvedValue(true as never); diff --git a/extensions/twitch/src/plugin.ts b/extensions/twitch/src/plugin.ts index 11cf90b8893..3958a05fd8b 100644 --- a/extensions/twitch/src/plugin.ts +++ b/extensions/twitch/src/plugin.ts @@ -12,10 +12,10 @@ import { twitchMessageActions } from "./actions.js"; import { removeClientManager } from "./client-manager-registry.js"; import { TwitchConfigSchema } from "./config-schema.js"; import { DEFAULT_ACCOUNT_ID, getAccountConfig, listAccountIds } from "./config.js"; -import { twitchOnboardingAdapter } from "./onboarding.js"; import { twitchOutbound } from "./outbound.js"; import { probeTwitch } from "./probe.js"; import { resolveTwitchTargets } from "./resolver.js"; +import { twitchSetupAdapter, twitchSetupWizard } from "./setup-surface.js"; import { collectTwitchStatusIssues } from "./status.js"; import { resolveTwitchToken } from "./token.js"; import type { @@ -51,8 +51,9 @@ export const twitchPlugin: ChannelPlugin = { aliases: ["twitch-chat"], } satisfies ChannelMeta, - /** Onboarding adapter */ - onboarding: twitchOnboardingAdapter, + /** Setup wizard surface */ + setup: twitchSetupAdapter, + setupWizard: twitchSetupWizard, /** Pairing configuration */ pairing: { diff --git a/extensions/twitch/src/onboarding.ts b/extensions/twitch/src/setup-surface.ts similarity index 76% rename from extensions/twitch/src/onboarding.ts rename to extensions/twitch/src/setup-surface.ts index 060857bf383..776644a2d23 100644 --- a/extensions/twitch/src/onboarding.ts +++ b/extensions/twitch/src/setup-surface.ts @@ -1,25 +1,21 @@ /** - * Twitch onboarding adapter for CLI setup wizard. + * Twitch setup wizard surface for CLI setup. */ -import type { OpenClawConfig } from "openclaw/plugin-sdk/twitch"; -import { - formatDocsLink, - promptChannelAccessConfig, - type ChannelOnboardingAdapter, - type ChannelOnboardingDmPolicy, - type WizardPrompter, -} from "openclaw/plugin-sdk/twitch"; +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { promptChannelAccessConfig } from "../../../src/channels/plugins/onboarding/channel-access.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; import { DEFAULT_ACCOUNT_ID, getAccountConfig } from "./config.js"; import type { TwitchAccountConfig, TwitchRole } from "./types.js"; import { isAccountConfigured } from "./utils/twitch.js"; const channel = "twitch" as const; -/** - * Set Twitch account configuration - */ -function setTwitchAccount( +export function setTwitchAccount( cfg: OpenClawConfig, account: Partial, ): OpenClawConfig { @@ -59,9 +55,6 @@ function setTwitchAccount( }; } -/** - * Note about Twitch setup - */ async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise { await prompter.note( [ @@ -77,17 +70,13 @@ async function noteTwitchSetupHelp(prompter: WizardPrompter): Promise { ); } -/** - * Prompt for Twitch OAuth token with early returns. - */ -async function promptToken( +export async function promptToken( prompter: WizardPrompter, account: TwitchAccountConfig | null, envToken: string | undefined, ): Promise { const existingToken = account?.accessToken ?? ""; - // If we have an existing token and no env var, ask if we should keep it if (existingToken && !envToken) { const keepToken = await prompter.confirm({ message: "Access token already configured. Keep it?", @@ -98,7 +87,6 @@ async function promptToken( } } - // Prompt for new token return String( await prompter.text({ message: "Twitch OAuth token (oauth:...)", @@ -117,10 +105,7 @@ async function promptToken( ).trim(); } -/** - * Prompt for Twitch username. - */ -async function promptUsername( +export async function promptUsername( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise { @@ -133,10 +118,7 @@ async function promptUsername( ).trim(); } -/** - * Prompt for Twitch Client ID. - */ -async function promptClientId( +export async function promptClientId( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise { @@ -149,27 +131,20 @@ async function promptClientId( ).trim(); } -/** - * Prompt for optional channel name. - */ -async function promptChannelName( +export async function promptChannelName( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise { - const channelName = String( + return String( await prompter.text({ message: "Channel to join", initialValue: account?.channel ?? "", validate: (value) => (value?.trim() ? undefined : "Required"), }), ).trim(); - return channelName; } -/** - * Prompt for token refresh credentials (client secret and refresh token). - */ -async function promptRefreshTokenSetup( +export async function promptRefreshTokenSetup( prompter: WizardPrompter, account: TwitchAccountConfig | null, ): Promise<{ clientSecret?: string; refreshToken?: string }> { @@ -203,10 +178,7 @@ async function promptRefreshTokenSetup( return { clientSecret, refreshToken }; } -/** - * Configure with env token path (returns early if user chooses env token). - */ -async function configureWithEnvToken( +export async function configureWithEnvToken( cfg: OpenClawConfig, prompter: WizardPrompter, account: TwitchAccountConfig | null, @@ -228,7 +200,7 @@ async function configureWithEnvToken( const cfgWithAccount = setTwitchAccount(cfg, { username, clientId, - accessToken: "", // Will use env var + accessToken: "", enabled: true, }); @@ -239,9 +211,6 @@ async function configureWithEnvToken( return { cfg: cfgWithAccount }; } -/** - * Set Twitch access control (role-based) - */ function setTwitchAccessControl( cfg: OpenClawConfig, allowedRoles: TwitchRole[], @@ -259,14 +228,13 @@ function setTwitchAccessControl( }); } -const dmPolicy: ChannelOnboardingDmPolicy = { +const twitchDmPolicy: ChannelOnboardingDmPolicy = { label: "Twitch", channel, - policyKey: "channels.twitch.allowedRoles", // Twitch uses roles instead of DM policy + policyKey: "channels.twitch.allowedRoles", allowFromKey: "channels.twitch.accounts.default.allowFrom", getCurrent: (cfg) => { - const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); - // Map allowedRoles to policy equivalent + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); if (account?.allowedRoles?.includes("all")) { return "open"; } @@ -278,10 +246,10 @@ const dmPolicy: ChannelOnboardingDmPolicy = { setPolicy: (cfg, policy) => { const allowedRoles: TwitchRole[] = policy === "open" ? ["all"] : policy === "allowlist" ? [] : ["moderator"]; - return setTwitchAccessControl(cfg, allowedRoles, true); + return setTwitchAccessControl(cfg as OpenClawConfig, allowedRoles, true); }, promptAllowFrom: async ({ cfg, prompter }) => { - const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); const existingAllowFrom = account?.allowFrom ?? []; const entry = await prompter.text({ @@ -295,28 +263,43 @@ const dmPolicy: ChannelOnboardingDmPolicy = { .map((s) => s.trim()) .filter(Boolean); - return setTwitchAccount(cfg, { + return setTwitchAccount(cfg as OpenClawConfig, { ...(account ?? undefined), allowFrom, }); }, }; -export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); - const configured = account ? isAccountConfigured(account) : false; +export const twitchSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: () => DEFAULT_ACCOUNT_ID, + applyAccountConfig: ({ cfg }) => + setTwitchAccount(cfg, { + enabled: true, + }), +}; - return { - channel, - configured, - statusLines: [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`], - selectionHint: configured ? "configured" : "needs setup", - }; +export const twitchSetupWizard: ChannelSetupWizard = { + channel, + resolveAccountIdForConfigure: () => DEFAULT_ACCOUNT_ID, + resolveShouldPromptAccountIds: () => false, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs username, token, and clientId", + configuredHint: "configured", + unconfiguredHint: "needs setup", + resolveConfigured: ({ cfg }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); + return account ? isAccountConfigured(account) : false; + }, + resolveStatusLines: ({ cfg }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); + const configured = account ? isAccountConfigured(account) : false; + return [`Twitch: ${configured ? "configured" : "needs username, token, and clientId"}`]; + }, }, - configure: async ({ cfg, prompter, forceAllowFrom }) => { - const account = getAccountConfig(cfg, DEFAULT_ACCOUNT_ID); + credentials: [], + finalize: async ({ cfg, prompter, forceAllowFrom }) => { + const account = getAccountConfig(cfg as OpenClawConfig, DEFAULT_ACCOUNT_ID); if (!account || !isAccountConfigured(account)) { await noteTwitchSetupHelp(prompter); @@ -324,29 +307,27 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { const envToken = process.env.OPENCLAW_TWITCH_ACCESS_TOKEN?.trim(); - // Check if env var is set and config is empty if (envToken && !account?.accessToken) { const envResult = await configureWithEnvToken( - cfg, + cfg as OpenClawConfig, prompter, account, envToken, forceAllowFrom, - dmPolicy, + twitchDmPolicy, ); if (envResult) { return envResult; } } - // Prompt for credentials const username = await promptUsername(prompter, account); const token = await promptToken(prompter, account, envToken); const clientId = await promptClientId(prompter, account); const channelName = await promptChannelName(prompter, account); const { clientSecret, refreshToken } = await promptRefreshTokenSetup(prompter, account); - const cfgWithAccount = setTwitchAccount(cfg, { + const cfgWithAccount = setTwitchAccount(cfg as OpenClawConfig, { username, accessToken: token, clientId, @@ -357,11 +338,10 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { }); const cfgWithAllowFrom = - forceAllowFrom && dmPolicy.promptAllowFrom - ? await dmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) + forceAllowFrom && twitchDmPolicy.promptAllowFrom + ? await twitchDmPolicy.promptAllowFrom({ cfg: cfgWithAccount, prompter }) : cfgWithAccount; - // Prompt for access control if allowFrom not set if (!account?.allowFrom || account.allowFrom.length === 0) { const accessConfig = await promptChannelAccessConfig({ prompter, @@ -384,14 +364,15 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { ? ["moderator", "vip"] : []; - const cfgWithAccessControl = setTwitchAccessControl(cfgWithAllowFrom, allowedRoles, true); - return { cfg: cfgWithAccessControl }; + return { + cfg: setTwitchAccessControl(cfgWithAllowFrom, allowedRoles, true), + }; } } return { cfg: cfgWithAllowFrom }; }, - dmPolicy, + dmPolicy: twitchDmPolicy, disable: (cfg) => { const twitch = (cfg.channels as Record)?.twitch as | Record @@ -405,13 +386,3 @@ export const twitchOnboardingAdapter: ChannelOnboardingAdapter = { }; }, }; - -// Export helper functions for testing -export { - promptToken, - promptUsername, - promptClientId, - promptChannelName, - promptRefreshTokenSetup, - configureWithEnvToken, -}; diff --git a/src/plugin-sdk/matrix.ts b/src/plugin-sdk/matrix.ts index ba4cad93a92..52d18e4665f 100644 --- a/src/plugin-sdk/matrix.ts +++ b/src/plugin-sdk/matrix.ts @@ -32,11 +32,6 @@ export { } from "../channels/plugins/config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { formatPairingApproveHint } from "../channels/plugins/helpers.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; export { buildSingleChannelSecretPromptState, addWildcardAllowFrom, @@ -113,3 +108,7 @@ export { buildProbeChannelStatusSummary, collectStatusIssuesFromLastError, } from "./status-helpers.js"; +export { + matrixSetupAdapter, + matrixSetupWizard, +} from "../../extensions/matrix/src/setup-surface.js"; diff --git a/src/plugin-sdk/msteams.ts b/src/plugin-sdk/msteams.ts index b73aec7c779..d99f703ed64 100644 --- a/src/plugin-sdk/msteams.ts +++ b/src/plugin-sdk/msteams.ts @@ -32,11 +32,6 @@ export { export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { buildMediaPayload } from "../channels/plugins/media-payload.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; export { addWildcardAllowFrom, mergeAllowFromEntries, @@ -122,3 +117,7 @@ export { createDefaultChannelRuntimeState, } from "./status-helpers.js"; export { normalizeStringEntries } from "../shared/string-normalization.js"; +export { + msteamsSetupAdapter, + msteamsSetupWizard, +} from "../../extensions/msteams/src/setup-surface.js"; diff --git a/src/plugin-sdk/twitch.ts b/src/plugin-sdk/twitch.ts index 7ea8a9f5f4b..907cdd171fa 100644 --- a/src/plugin-sdk/twitch.ts +++ b/src/plugin-sdk/twitch.ts @@ -22,11 +22,6 @@ export type { ChannelStatusIssue, } from "../channels/plugins/types.js"; export type { ChannelPlugin } from "../channels/plugins/types.plugin.js"; -export type { - ChannelOnboardingAdapter, - ChannelOnboardingDmPolicy, -} from "../channels/plugins/onboarding-types.js"; -export { promptChannelAccessConfig } from "../channels/plugins/onboarding/channel-access.js"; export { createReplyPrefixOptions } from "../channels/reply-prefix.js"; export type { OpenClawConfig } from "../config/config.js"; export { MarkdownConfigSchema } from "../config/zod-schema.core.js"; @@ -38,3 +33,7 @@ export type { OpenClawPluginApi } from "../plugins/types.js"; export type { RuntimeEnv } from "../runtime.js"; export { formatDocsLink } from "../terminal/links.js"; export type { WizardPrompter } from "../wizard/prompts.js"; +export { + twitchSetupAdapter, + twitchSetupWizard, +} from "../../extensions/twitch/src/setup-surface.js";