mirror of
https://github.com/openclaw/openclaw.git
synced 2026-03-16 12:30:49 +00:00
refactor: move nextcloud talk to setup wizard
This commit is contained in:
@@ -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),
|
||||
|
||||
@@ -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 },
|
||||
},
|
||||
}),
|
||||
};
|
||||
53
extensions/nextcloud-talk/src/setup-surface.test.ts
Normal file
53
extensions/nextcloud-talk/src/setup-surface.test.ts
Normal 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");
|
||||
});
|
||||
});
|
||||
406
extensions/nextcloud-talk/src/setup-surface.ts
Normal file
406
extensions/nextcloud-talk/src/setup-surface.ts
Normal 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),
|
||||
};
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user