diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts index 473299b74e0..b6a2c2ad5ca 100644 --- a/extensions/nextcloud-talk/src/channel.ts +++ b/extensions/nextcloud-talk/src/channel.ts @@ -7,18 +7,15 @@ import { mapAllowFromEntries, } from "openclaw/plugin-sdk/compat"; import { - applyAccountNameToChannelSection, buildBaseChannelStatusSummary, buildChannelConfigSchema, buildRuntimeAccountStatusSnapshot, clearAccountEntryFields, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, - normalizeAccountId, setAccountEnabledInConfigSection, type ChannelPlugin, type OpenClawConfig, - type ChannelSetupInput, } from "openclaw/plugin-sdk/nextcloud-talk"; import { runStoppablePassiveMonitor } from "../../shared/passive-monitor.js"; import { @@ -33,10 +30,10 @@ import { looksLikeNextcloudTalkTargetId, normalizeNextcloudTalkMessagingTarget, } from "./normalize.js"; -import { nextcloudTalkOnboardingAdapter } from "./onboarding.js"; import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import { sendMessageNextcloudTalk } from "./send.js"; +import { nextcloudTalkSetupAdapter, nextcloudTalkSetupWizard } from "./setup-surface.js"; import type { CoreConfig } from "./types.js"; const meta = { @@ -51,17 +48,10 @@ const meta = { quickstartAllowFrom: true, }; -type NextcloudSetupInput = ChannelSetupInput & { - baseUrl?: string; - secret?: string; - secretFile?: string; - useEnv?: boolean; -}; - export const nextcloudTalkPlugin: ChannelPlugin = { id: "nextcloud-talk", meta, - onboarding: nextcloudTalkOnboardingAdapter, + setupWizard: nextcloudTalkSetupWizard, pairing: { idLabel: "nextcloudUserId", normalizeAllowEntry: (entry) => @@ -190,81 +180,7 @@ export const nextcloudTalkPlugin: ChannelPlugin = hint: "", }, }, - setup: { - resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => - applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "nextcloud-talk", - accountId, - name, - }), - validateInput: ({ accountId, input }) => { - const setupInput = input as NextcloudSetupInput; - if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account."; - } - if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) { - return "Nextcloud Talk requires bot secret or --secret-file (or --use-env)."; - } - if (!setupInput.baseUrl) { - return "Nextcloud Talk requires --base-url."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const setupInput = input as NextcloudSetupInput; - const namedConfig = applyAccountNameToChannelSection({ - cfg: cfg, - channelKey: "nextcloud-talk", - accountId, - name: setupInput.name, - }); - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...namedConfig, - channels: { - ...namedConfig.channels, - "nextcloud-talk": { - ...namedConfig.channels?.["nextcloud-talk"], - enabled: true, - baseUrl: setupInput.baseUrl, - ...(setupInput.useEnv - ? {} - : setupInput.secretFile - ? { botSecretFile: setupInput.secretFile } - : setupInput.secret - ? { botSecret: setupInput.secret } - : {}), - }, - }, - } as OpenClawConfig; - } - return { - ...namedConfig, - channels: { - ...namedConfig.channels, - "nextcloud-talk": { - ...namedConfig.channels?.["nextcloud-talk"], - enabled: true, - accounts: { - ...namedConfig.channels?.["nextcloud-talk"]?.accounts, - [accountId]: { - ...namedConfig.channels?.["nextcloud-talk"]?.accounts?.[accountId], - enabled: true, - baseUrl: setupInput.baseUrl, - ...(setupInput.secretFile - ? { botSecretFile: setupInput.secretFile } - : setupInput.secret - ? { botSecret: setupInput.secret } - : {}), - }, - }, - }, - }, - } as OpenClawConfig; - }, - }, + setup: nextcloudTalkSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit), diff --git a/extensions/nextcloud-talk/src/onboarding.ts b/extensions/nextcloud-talk/src/onboarding.ts deleted file mode 100644 index 7b1a8b11d28..00000000000 --- a/extensions/nextcloud-talk/src/onboarding.ts +++ /dev/null @@ -1,302 +0,0 @@ -import { - formatDocsLink, - hasConfiguredSecretInput, - mapAllowFromEntries, - mergeAllowFromEntries, - patchScopedAccountConfig, - runSingleChannelSecretStep, - resolveAccountIdForConfigure, - DEFAULT_ACCOUNT_ID, - normalizeAccountId, - setTopLevelChannelDmPolicyWithAllowFrom, - type ChannelOnboardingAdapter, - type ChannelOnboardingDmPolicy, - type OpenClawConfig, - type WizardPrompter, -} from "openclaw/plugin-sdk/nextcloud-talk"; -import { - listNextcloudTalkAccountIds, - resolveDefaultNextcloudTalkAccountId, - resolveNextcloudTalkAccount, -} from "./accounts.js"; -import type { CoreConfig, DmPolicy } from "./types.js"; - -const channel = "nextcloud-talk" as const; - -function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { - return setTopLevelChannelDmPolicyWithAllowFrom({ - cfg, - channel: "nextcloud-talk", - dmPolicy, - getAllowFrom: (inputCfg) => - mapAllowFromEntries(inputCfg.channels?.["nextcloud-talk"]?.allowFrom), - }) as CoreConfig; -} - -function setNextcloudTalkAccountConfig( - cfg: CoreConfig, - accountId: string, - updates: Record, -): CoreConfig { - return patchScopedAccountConfig({ - cfg, - channelKey: channel, - accountId, - patch: updates, - }) as CoreConfig; -} - -async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "1) SSH into your Nextcloud server", - '2) Run: ./occ talk:bot:install "OpenClaw" "" "" --feature reaction', - "3) Copy the shared secret you used in the command", - "4) Enable the bot in your Nextcloud Talk room settings", - "Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.", - `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, - ].join("\n"), - "Nextcloud Talk bot setup", - ); -} - -async function noteNextcloudTalkUserIdHelp(prompter: WizardPrompter): Promise { - await prompter.note( - [ - "1) Check the Nextcloud admin panel for user IDs", - "2) Or look at the webhook payload logs when someone messages", - "3) User IDs are typically lowercase usernames in Nextcloud", - `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, - ].join("\n"), - "Nextcloud Talk user id", - ); -} - -async function promptNextcloudTalkAllowFrom(params: { - cfg: CoreConfig; - prompter: WizardPrompter; - accountId: string; -}): Promise { - const { cfg, prompter, accountId } = params; - const resolved = resolveNextcloudTalkAccount({ cfg, accountId }); - const existingAllowFrom = resolved.config.allowFrom ?? []; - await noteNextcloudTalkUserIdHelp(prompter); - - const parseInput = (value: string) => - value - .split(/[\n,;]+/g) - .map((entry) => entry.trim().toLowerCase()) - .filter(Boolean); - - let resolvedIds: string[] = []; - while (resolvedIds.length === 0) { - const entry = await prompter.text({ - message: "Nextcloud Talk allowFrom (user id)", - placeholder: "username", - initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }); - resolvedIds = parseInput(String(entry)); - if (resolvedIds.length === 0) { - await prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk allowlist"); - } - } - - const merged = [ - ...existingAllowFrom.map((item) => String(item).trim().toLowerCase()).filter(Boolean), - ...resolvedIds, - ]; - const unique = mergeAllowFromEntries(undefined, merged); - - return setNextcloudTalkAccountConfig(cfg, accountId, { - dmPolicy: "allowlist", - allowFrom: unique, - }); -} - -async function promptNextcloudTalkAllowFromForAccount(params: { - cfg: CoreConfig; - prompter: WizardPrompter; - accountId?: string; -}): Promise { - const accountId = - params.accountId && normalizeAccountId(params.accountId) - ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) - : resolveDefaultNextcloudTalkAccountId(params.cfg); - return promptNextcloudTalkAllowFrom({ - cfg: params.cfg, - prompter: params.prompter, - accountId, - }); -} - -const dmPolicy: ChannelOnboardingDmPolicy = { - label: "Nextcloud Talk", - channel, - policyKey: "channels.nextcloud-talk.dmPolicy", - allowFromKey: "channels.nextcloud-talk.allowFrom", - getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", - setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy), - promptAllowFrom: promptNextcloudTalkAllowFromForAccount as (params: { - cfg: OpenClawConfig; - prompter: WizardPrompter; - accountId?: string | undefined; - }) => Promise, -}; - -export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = { - channel, - getStatus: async ({ cfg }) => { - const configured = listNextcloudTalkAccountIds(cfg as CoreConfig).some((accountId) => { - const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); - return Boolean(account.secret && account.baseUrl); - }); - return { - channel, - configured, - statusLines: [`Nextcloud Talk: ${configured ? "configured" : "needs setup"}`], - selectionHint: configured ? "configured" : "self-hosted chat", - quickstartScore: configured ? 1 : 5, - }; - }, - configure: async ({ - cfg, - prompter, - accountOverrides, - shouldPromptAccountIds, - forceAllowFrom, - }) => { - const defaultAccountId = resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig); - const accountId = await resolveAccountIdForConfigure({ - cfg, - prompter, - label: "Nextcloud Talk", - accountOverride: accountOverrides["nextcloud-talk"], - shouldPromptAccountIds, - listAccountIds: listNextcloudTalkAccountIds as (cfg: OpenClawConfig) => string[], - defaultAccountId, - }); - - let next = cfg as CoreConfig; - const resolvedAccount = resolveNextcloudTalkAccount({ - cfg: next, - accountId, - }); - const accountConfigured = Boolean(resolvedAccount.secret && resolvedAccount.baseUrl); - const allowEnv = accountId === DEFAULT_ACCOUNT_ID; - const hasConfigSecret = Boolean( - hasConfiguredSecretInput(resolvedAccount.config.botSecret) || - resolvedAccount.config.botSecretFile, - ); - - let baseUrl = resolvedAccount.baseUrl; - if (!baseUrl) { - baseUrl = String( - await prompter.text({ - message: "Enter Nextcloud instance URL (e.g., https://cloud.example.com)", - validate: (value) => { - const v = String(value ?? "").trim(); - if (!v) { - return "Required"; - } - if (!v.startsWith("http://") && !v.startsWith("https://")) { - return "URL must start with http:// or https://"; - } - return undefined; - }, - }), - ).trim(); - } - - const secretStep = await runSingleChannelSecretStep({ - cfg: next, - prompter, - providerHint: "nextcloud-talk", - credentialLabel: "bot secret", - accountConfigured, - hasConfigToken: hasConfigSecret, - allowEnv, - envValue: process.env.NEXTCLOUD_TALK_BOT_SECRET, - envPrompt: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?", - keepPrompt: "Nextcloud Talk bot secret already configured. Keep it?", - inputPrompt: "Enter Nextcloud Talk bot secret", - preferredEnvVar: "NEXTCLOUD_TALK_BOT_SECRET", - onMissingConfigured: async () => await noteNextcloudTalkSecretHelp(prompter), - applyUseEnv: async (cfg) => - setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, { - baseUrl, - }), - applySet: async (cfg, value) => - setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, { - baseUrl, - botSecret: value, - }), - }); - next = secretStep.cfg as CoreConfig; - - if (secretStep.action === "keep" && baseUrl !== resolvedAccount.baseUrl) { - next = setNextcloudTalkAccountConfig(next, accountId, { - baseUrl, - }); - } - - const existingApiUser = resolvedAccount.config.apiUser?.trim(); - const existingApiPasswordConfigured = Boolean( - hasConfiguredSecretInput(resolvedAccount.config.apiPassword) || - resolvedAccount.config.apiPasswordFile, - ); - const configureApiCredentials = await prompter.confirm({ - message: "Configure optional Nextcloud Talk API credentials for room lookups?", - initialValue: Boolean(existingApiUser && existingApiPasswordConfigured), - }); - if (configureApiCredentials) { - const apiUser = String( - await prompter.text({ - message: "Nextcloud Talk API user", - initialValue: existingApiUser, - validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), - }), - ).trim(); - const apiPasswordStep = await runSingleChannelSecretStep({ - cfg: next, - prompter, - providerHint: "nextcloud-talk-api", - credentialLabel: "API password", - accountConfigured: Boolean(existingApiUser && existingApiPasswordConfigured), - hasConfigToken: existingApiPasswordConfigured, - allowEnv: false, - envPrompt: "", - keepPrompt: "Nextcloud Talk API password already configured. Keep it?", - inputPrompt: "Enter Nextcloud Talk API password", - preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD", - applySet: async (cfg, value) => - setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, { - apiUser, - apiPassword: value, - }), - }); - next = - apiPasswordStep.action === "keep" - ? setNextcloudTalkAccountConfig(next, accountId, { apiUser }) - : (apiPasswordStep.cfg as CoreConfig); - } - - if (forceAllowFrom) { - next = await promptNextcloudTalkAllowFrom({ - cfg: next, - prompter, - accountId, - }); - } - - return { cfg: next, accountId }; - }, - dmPolicy, - disable: (cfg) => ({ - ...cfg, - channels: { - ...cfg.channels, - "nextcloud-talk": { ...cfg.channels?.["nextcloud-talk"], enabled: false }, - }, - }), -}; diff --git a/extensions/nextcloud-talk/src/setup-surface.test.ts b/extensions/nextcloud-talk/src/setup-surface.test.ts new file mode 100644 index 00000000000..3889cc7ff8a --- /dev/null +++ b/extensions/nextcloud-talk/src/setup-surface.test.ts @@ -0,0 +1,53 @@ +import { describe, expect, it } from "vitest"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { nextcloudTalkSetupAdapter, nextcloudTalkSetupWizard } from "./setup-surface.js"; + +describe("nextcloudTalk setup surface", () => { + it("clears stored bot secret fields when switching the default account to env", () => { + type ApplyAccountConfigContext = Parameters< + typeof nextcloudTalkSetupAdapter.applyAccountConfig + >[0]; + + const next = nextcloudTalkSetupAdapter.applyAccountConfig({ + cfg: { + channels: { + "nextcloud-talk": { + enabled: true, + baseUrl: "https://cloud.old.example", + botSecret: "stored-secret", + botSecretFile: "/tmp/secret.txt", + }, + }, + }, + accountId: DEFAULT_ACCOUNT_ID, + input: { + baseUrl: "https://cloud.example.com", + useEnv: true, + }, + } as unknown as ApplyAccountConfigContext); + + expect(next.channels?.["nextcloud-talk"]?.baseUrl).toBe("https://cloud.example.com"); + expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret"); + expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile"); + }); + + it("clears stored bot secret fields when the wizard switches to env", async () => { + const credential = nextcloudTalkSetupWizard.credentials[0]; + const next = await credential.applyUseEnv?.({ + cfg: { + channels: { + "nextcloud-talk": { + enabled: true, + baseUrl: "https://cloud.example.com", + botSecret: "stored-secret", + botSecretFile: "/tmp/secret.txt", + }, + }, + }, + accountId: DEFAULT_ACCOUNT_ID, + }); + + expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret"); + expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile"); + }); +}); diff --git a/extensions/nextcloud-talk/src/setup-surface.ts b/extensions/nextcloud-talk/src/setup-surface.ts new file mode 100644 index 00000000000..758ae4d3214 --- /dev/null +++ b/extensions/nextcloud-talk/src/setup-surface.ts @@ -0,0 +1,406 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + mergeAllowFromEntries, + resolveOnboardingAccountId, + setOnboardingChannelEnabled, + setTopLevelChannelDmPolicyWithAllowFrom, +} from "../../../src/channels/plugins/onboarding/helpers.js"; +import { + applyAccountNameToChannelSection, + patchScopedAccountConfig, +} 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 { ChannelSetupInput } from "../../../src/channels/plugins/types.core.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { hasConfiguredSecretInput } from "../../../src/config/types.secrets.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 { + listNextcloudTalkAccountIds, + resolveDefaultNextcloudTalkAccountId, + resolveNextcloudTalkAccount, +} from "./accounts.js"; +import type { CoreConfig, DmPolicy } from "./types.js"; + +const channel = "nextcloud-talk" as const; +const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials"; + +type NextcloudSetupInput = ChannelSetupInput & { + baseUrl?: string; + secret?: string; + secretFile?: string; +}; +type NextcloudTalkSection = NonNullable["nextcloud-talk"]; + +function normalizeNextcloudTalkBaseUrl(value: string | undefined): string { + return value?.trim().replace(/\/+$/, "") ?? ""; +} + +function validateNextcloudTalkBaseUrl(value: string): string | undefined { + if (!value) { + return "Required"; + } + if (!value.startsWith("http://") && !value.startsWith("https://")) { + return "URL must start with http:// or https://"; + } + return undefined; +} + +function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig { + return setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy, + }) as CoreConfig; +} + +function setNextcloudTalkAccountConfig( + cfg: CoreConfig, + accountId: string, + updates: Record, +): CoreConfig { + return patchScopedAccountConfig({ + cfg, + channelKey: channel, + accountId, + patch: updates, + }) as CoreConfig; +} + +function clearNextcloudTalkAccountFields( + cfg: CoreConfig, + accountId: string, + fields: string[], +): CoreConfig { + const section = cfg.channels?.["nextcloud-talk"]; + if (!section) { + return cfg; + } + + if (accountId === DEFAULT_ACCOUNT_ID) { + const nextSection = { ...section } as Record; + for (const field of fields) { + delete nextSection[field]; + } + return { + ...cfg, + channels: { + ...(cfg.channels ?? {}), + "nextcloud-talk": nextSection as NextcloudTalkSection, + }, + } as CoreConfig; + } + + const currentAccount = section.accounts?.[accountId]; + if (!currentAccount) { + return cfg; + } + + const nextAccount = { ...currentAccount } as Record; + for (const field of fields) { + delete nextAccount[field]; + } + return { + ...cfg, + channels: { + ...(cfg.channels ?? {}), + "nextcloud-talk": { + ...section, + accounts: { + ...section.accounts, + [accountId]: nextAccount as NonNullable[string], + }, + }, + }, + } as CoreConfig; +} + +async function promptNextcloudTalkAllowFrom(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise { + const resolved = resolveNextcloudTalkAccount({ cfg: params.cfg, accountId: params.accountId }); + const existingAllowFrom = resolved.config.allowFrom ?? []; + await params.prompter.note( + [ + "1) Check the Nextcloud admin panel for user IDs", + "2) Or look at the webhook payload logs when someone messages", + "3) User IDs are typically lowercase usernames in Nextcloud", + `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, + ].join("\n"), + "Nextcloud Talk user id", + ); + + let resolvedIds: string[] = []; + while (resolvedIds.length === 0) { + const entry = await params.prompter.text({ + message: "Nextcloud Talk allowFrom (user id)", + placeholder: "username", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + resolvedIds = String(entry) + .split(/[\n,;]+/g) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + if (resolvedIds.length === 0) { + await params.prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk"); + } + } + + return setNextcloudTalkAccountConfig(params.cfg, params.accountId, { + dmPolicy: "allowlist", + allowFrom: mergeAllowFromEntries( + existingAllowFrom.map((value) => String(value).trim().toLowerCase()), + resolvedIds, + ), + }); +} + +async function promptNextcloudTalkAllowFromForAccount(params: { + cfg: OpenClawConfig; + prompter: WizardPrompter; + accountId?: string; +}): Promise { + const accountId = resolveOnboardingAccountId({ + accountId: params.accountId, + defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig), + }); + return await promptNextcloudTalkAllowFrom({ + cfg: params.cfg as CoreConfig, + prompter: params.prompter, + accountId, + }); +} + +const nextcloudTalkDmPolicy: ChannelOnboardingDmPolicy = { + label: "Nextcloud Talk", + channel, + policyKey: "channels.nextcloud-talk.dmPolicy", + allowFromKey: "channels.nextcloud-talk.allowFrom", + getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy), + promptAllowFrom: promptNextcloudTalkAllowFromForAccount, +}; + +export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name, + }), + validateInput: ({ accountId, input }) => { + const setupInput = input as NextcloudSetupInput; + if (setupInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account."; + } + if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) { + return "Nextcloud Talk requires bot secret or --secret-file (or --use-env)."; + } + if (!setupInput.baseUrl) { + return "Nextcloud Talk requires --base-url."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const setupInput = input as NextcloudSetupInput; + const namedConfig = applyAccountNameToChannelSection({ + cfg, + channelKey: channel, + accountId, + name: setupInput.name, + }); + const next = setupInput.useEnv + ? clearNextcloudTalkAccountFields(namedConfig as CoreConfig, accountId, [ + "botSecret", + "botSecretFile", + ]) + : namedConfig; + const patch = { + baseUrl: normalizeNextcloudTalkBaseUrl(setupInput.baseUrl), + ...(setupInput.useEnv + ? {} + : setupInput.secretFile + ? { botSecretFile: setupInput.secretFile } + : setupInput.secret + ? { botSecret: setupInput.secret } + : {}), + }; + return setNextcloudTalkAccountConfig(next as CoreConfig, accountId, patch); + }, +}; + +export const nextcloudTalkSetupWizard: ChannelSetupWizard = { + channel, + stepOrder: "text-first", + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs setup", + configuredHint: "configured", + unconfiguredHint: "self-hosted chat", + configuredScore: 1, + unconfiguredScore: 5, + resolveConfigured: ({ cfg }) => + listNextcloudTalkAccountIds(cfg as CoreConfig).some((accountId) => { + const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); + return Boolean(account.secret && account.baseUrl); + }), + }, + introNote: { + title: "Nextcloud Talk bot setup", + lines: [ + "1) SSH into your Nextcloud server", + '2) Run: ./occ talk:bot:install "OpenClaw" "" "" --feature reaction', + "3) Copy the shared secret you used in the command", + "4) Enable the bot in your Nextcloud Talk room settings", + "Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.", + `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, + ], + shouldShow: ({ cfg, accountId }) => { + const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); + return !account.secret || !account.baseUrl; + }, + }, + prepare: async ({ cfg, accountId, credentialValues, prompter }) => { + const resolvedAccount = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); + const hasApiCredentials = Boolean( + resolvedAccount.config.apiUser?.trim() && + (hasConfiguredSecretInput(resolvedAccount.config.apiPassword) || + resolvedAccount.config.apiPasswordFile), + ); + const configureApiCredentials = await prompter.confirm({ + message: "Configure optional Nextcloud Talk API credentials for room lookups?", + initialValue: hasApiCredentials, + }); + if (!configureApiCredentials) { + return; + } + return { + credentialValues: { + ...credentialValues, + [CONFIGURE_API_FLAG]: "1", + }, + }; + }, + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "bot secret", + preferredEnvVar: "NEXTCLOUD_TALK_BOT_SECRET", + envPrompt: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?", + keepPrompt: "Nextcloud Talk bot secret already configured. Keep it?", + inputPrompt: "Enter Nextcloud Talk bot secret", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolvedAccount = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); + return { + accountConfigured: Boolean(resolvedAccount.secret && resolvedAccount.baseUrl), + hasConfiguredValue: Boolean( + hasConfiguredSecretInput(resolvedAccount.config.botSecret) || + resolvedAccount.config.botSecretFile, + ), + resolvedValue: resolvedAccount.secret || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim() || undefined + : undefined, + }; + }, + applyUseEnv: async (params) => { + const resolvedAccount = resolveNextcloudTalkAccount({ + cfg: params.cfg as CoreConfig, + accountId: params.accountId, + }); + const cleared = clearNextcloudTalkAccountFields( + params.cfg as CoreConfig, + params.accountId, + ["botSecret", "botSecretFile"], + ); + return setNextcloudTalkAccountConfig(cleared, params.accountId, { + baseUrl: resolvedAccount.baseUrl, + }); + }, + applySet: async (params) => + setNextcloudTalkAccountConfig( + clearNextcloudTalkAccountFields(params.cfg as CoreConfig, params.accountId, [ + "botSecret", + "botSecretFile", + ]), + params.accountId, + { + botSecret: params.value, + }, + ), + }, + { + inputKey: "password", + providerHint: "nextcloud-talk-api", + credentialLabel: "API password", + preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD", + envPrompt: "", + keepPrompt: "Nextcloud Talk API password already configured. Keep it?", + inputPrompt: "Enter Nextcloud Talk API password", + inspect: ({ cfg, accountId }) => { + const resolvedAccount = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); + const apiUser = resolvedAccount.config.apiUser?.trim(); + const apiPasswordConfigured = Boolean( + hasConfiguredSecretInput(resolvedAccount.config.apiPassword) || + resolvedAccount.config.apiPasswordFile, + ); + return { + accountConfigured: Boolean(apiUser && apiPasswordConfigured), + hasConfiguredValue: apiPasswordConfigured, + }; + }, + shouldPrompt: ({ credentialValues }) => credentialValues[CONFIGURE_API_FLAG] === "1", + applySet: async (params) => + setNextcloudTalkAccountConfig( + clearNextcloudTalkAccountFields(params.cfg as CoreConfig, params.accountId, [ + "apiPassword", + "apiPasswordFile", + ]), + params.accountId, + { + apiPassword: params.value, + }, + ), + }, + ], + textInputs: [ + { + inputKey: "httpUrl", + message: "Enter Nextcloud instance URL (e.g., https://cloud.example.com)", + currentValue: ({ cfg, accountId }) => + resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).baseUrl || undefined, + shouldPrompt: ({ currentValue }) => !currentValue, + validate: ({ value }) => validateNextcloudTalkBaseUrl(value), + normalizeValue: ({ value }) => normalizeNextcloudTalkBaseUrl(value), + applySet: async (params) => + setNextcloudTalkAccountConfig(params.cfg as CoreConfig, params.accountId, { + baseUrl: params.value, + }), + }, + { + inputKey: "userId", + message: "Nextcloud Talk API user", + currentValue: ({ cfg, accountId }) => + resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.apiUser?.trim() || + undefined, + shouldPrompt: ({ credentialValues }) => credentialValues[CONFIGURE_API_FLAG] === "1", + validate: ({ value }) => (value ? undefined : "Required"), + applySet: async (params) => + setNextcloudTalkAccountConfig(params.cfg as CoreConfig, params.accountId, { + apiUser: params.value, + }), + }, + ], + dmPolicy: nextcloudTalkDmPolicy, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), +}; diff --git a/src/plugin-sdk/nextcloud-talk.ts b/src/plugin-sdk/nextcloud-talk.ts index 6e5c6a28b5b..7e2434914bb 100644 --- a/src/plugin-sdk/nextcloud-talk.ts +++ b/src/plugin-sdk/nextcloud-talk.ts @@ -17,10 +17,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 { buildSingleChannelSecretPromptState, addWildcardAllowFrom,