import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup"; import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime"; import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing"; import { applyAccountNameToChannelSection, patchScopedAccountConfig, } from "openclaw/plugin-sdk/setup"; import { mergeAllowFromEntries, createTopLevelChannelDmPolicy, promptParsedAllowFromForAccount, resolveSetupAccountId, setSetupChannelEnabled, } from "openclaw/plugin-sdk/setup"; import type { ChannelSetupDmPolicy } from "openclaw/plugin-sdk/setup"; import { type ChannelSetupWizard } from "openclaw/plugin-sdk/setup"; import { formatDocsLink } from "openclaw/plugin-sdk/setup"; import type { WizardPrompter } from "openclaw/plugin-sdk/setup"; import { listNextcloudTalkAccountIds, resolveDefaultNextcloudTalkAccountId, resolveNextcloudTalkAccount, } from "./accounts.js"; import type { CoreConfig } from "./types.js"; const channel = "nextcloud-talk" as const; type NextcloudSetupInput = ChannelSetupInput & { baseUrl?: string; secret?: string; secretFile?: string; }; type NextcloudTalkSection = NonNullable["nextcloud-talk"]; export function normalizeNextcloudTalkBaseUrl(value: string | undefined): string { return value?.trim().replace(/\/+$/, "") ?? ""; } export 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; } export function setNextcloudTalkAccountConfig( cfg: CoreConfig, accountId: string, updates: Record, ): CoreConfig { return patchScopedAccountConfig({ cfg, channelKey: channel, accountId, patch: updates, }) as CoreConfig; } export 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 { return await promptParsedAllowFromForAccount({ cfg: params.cfg, accountId: params.accountId, defaultAccountId: params.accountId, prompter: params.prompter, noteTitle: "Nextcloud Talk user id", noteLines: [ "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", "nextcloud-talk")}`, ], message: "Nextcloud Talk allowFrom (user id)", placeholder: "username", parseEntries: (raw) => ({ entries: String(raw) .split(/[\n,;]+/g) .map((value) => value.trim().toLowerCase()) .filter(Boolean), }), getExistingAllowFrom: ({ cfg, accountId }) => resolveNextcloudTalkAccount({ cfg, accountId }).config.allowFrom ?? [], mergeEntries: ({ existing, parsed }) => mergeAllowFromEntries( existing.map((value) => String(value).trim().toLowerCase()), parsed, ), applyAllowFrom: ({ cfg, accountId, allowFrom }) => setNextcloudTalkAccountConfig(cfg, accountId, { dmPolicy: "allowlist", allowFrom, }), }); } async function promptNextcloudTalkAllowFromForAccount(params: { cfg: OpenClawConfig; prompter: WizardPrompter; accountId?: string; }): Promise { const accountId = resolveSetupAccountId({ accountId: params.accountId, defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig), }); return await promptNextcloudTalkAllowFrom({ cfg: params.cfg as CoreConfig, prompter: params.prompter, accountId, }); } export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = createTopLevelChannelDmPolicy({ label: "Nextcloud Talk", channel, policyKey: "channels.nextcloud-talk.dmPolicy", allowFromKey: "channels.nextcloud-talk.allowFrom", getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", 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); }, };