refactor: move nextcloud talk to setup wizard

This commit is contained in:
Peter Steinberger
2026-03-15 17:33:51 -07:00
parent bad65f130e
commit c455cccd3d
5 changed files with 462 additions and 393 deletions

View File

@@ -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<ResolvedNextcloudTalkAccount> = {
id: "nextcloud-talk",
meta,
onboarding: nextcloudTalkOnboardingAdapter,
setupWizard: nextcloudTalkSetupWizard,
pairing: {
idLabel: "nextcloudUserId",
normalizeAllowEntry: (entry) =>
@@ -190,81 +180,7 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
hint: "<roomToken>",
},
},
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),

View File

@@ -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<string, unknown>,
): CoreConfig {
return patchScopedAccountConfig({
cfg,
channelKey: channel,
accountId,
patch: updates,
}) as CoreConfig;
}
async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) SSH into your Nextcloud server",
'2) Run: ./occ talk:bot:install "OpenClaw" "<shared-secret>" "<webhook-url>" --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<void> {
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<CoreConfig> {
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<CoreConfig> {
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<OpenClawConfig>,
};
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 },
},
}),
};

View File

@@ -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");
});
});

View File

@@ -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<CoreConfig["channels"]>["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<string, unknown>,
): 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<string, unknown>;
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<string, unknown>;
for (const field of fields) {
delete nextAccount[field];
}
return {
...cfg,
channels: {
...(cfg.channels ?? {}),
"nextcloud-talk": {
...section,
accounts: {
...section.accounts,
[accountId]: nextAccount as NonNullable<typeof section.accounts>[string],
},
},
},
} as CoreConfig;
}
async function promptNextcloudTalkAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<CoreConfig> {
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<OpenClawConfig> {
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" "<shared-secret>" "<webhook-url>" --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),
};

View File

@@ -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,