import { applyAccountNameToChannelSection, buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, deleteAccountFromConfigSection, formatPairingApproveHint, normalizeAccountId, setAccountEnabledInConfigSection, type ChannelPlugin, type ClawdbotConfig, type ChannelSetupInput, } from "clawdbot/plugin-sdk"; import { listNextcloudTalkAccountIds, resolveDefaultNextcloudTalkAccountId, resolveNextcloudTalkAccount, type ResolvedNextcloudTalkAccount, } from "./accounts.js"; import { NextcloudTalkConfigSchema } from "./config-schema.js"; import { monitorNextcloudTalkProvider } from "./monitor.js"; import { looksLikeNextcloudTalkTargetId, normalizeNextcloudTalkMessagingTarget } from "./normalize.js"; import { nextcloudTalkOnboardingAdapter } from "./onboarding.js"; import { getNextcloudTalkRuntime } from "./runtime.js"; import { sendMessageNextcloudTalk } from "./send.js"; import type { CoreConfig } from "./types.js"; const meta = { id: "nextcloud-talk", label: "Nextcloud Talk", selectionLabel: "Nextcloud Talk (self-hosted)", docsPath: "/channels/nextcloud-talk", docsLabel: "nextcloud-talk", blurb: "Self-hosted chat via Nextcloud Talk webhook bots.", aliases: ["nc-talk", "nc"], order: 65, quickstartAllowFrom: true, }; type NextcloudSetupInput = ChannelSetupInput & { baseUrl?: string; secret?: string; secretFile?: string; useEnv?: boolean; }; export const nextcloudTalkPlugin: ChannelPlugin = { id: "nextcloud-talk", meta, onboarding: nextcloudTalkOnboardingAdapter, pairing: { idLabel: "nextcloudUserId", normalizeAllowEntry: (entry) => entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(), notifyApproval: async ({ id }) => { console.log(`[nextcloud-talk] User ${id} approved for pairing`); }, }, capabilities: { chatTypes: ["direct", "group"], reactions: true, threads: false, media: true, nativeCommands: false, blockStreaming: true, }, reload: { configPrefixes: ["channels.nextcloud-talk"] }, configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema), config: { listAccountIds: (cfg) => listNextcloudTalkAccountIds(cfg as CoreConfig), resolveAccount: (cfg, accountId) => resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }), defaultAccountId: (cfg) => resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig), setAccountEnabled: ({ cfg, accountId, enabled }) => setAccountEnabledInConfigSection({ cfg, sectionKey: "nextcloud-talk", accountId, enabled, allowTopLevel: true, }), deleteAccount: ({ cfg, accountId }) => deleteAccountFromConfigSection({ cfg, sectionKey: "nextcloud-talk", accountId, clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"], }), isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()), describeAccount: (account) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured: Boolean(account.secret?.trim() && account.baseUrl?.trim()), secretSource: account.secretSource, baseUrl: account.baseUrl ? "[set]" : "[missing]", }), resolveAllowFrom: ({ cfg, accountId }) => (resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []).map( (entry) => String(entry).toLowerCase(), ), formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, "")) .map((entry) => entry.toLowerCase()), }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; const useAccountPath = Boolean( cfg.channels?.["nextcloud-talk"]?.accounts?.[resolvedAccountId], ); const basePath = useAccountPath ? `channels.nextcloud-talk.accounts.${resolvedAccountId}.` : "channels.nextcloud-talk."; return { policy: account.config.dmPolicy ?? "pairing", allowFrom: account.config.allowFrom ?? [], policyPath: `${basePath}dmPolicy`, allowFromPath: basePath, approveHint: formatPairingApproveHint("nextcloud-talk"), normalizeEntry: (raw) => raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(), }; }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = cfg.channels?.defaults?.groupPolicy; const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; if (groupPolicy !== "open") return []; const roomAllowlistConfigured = account.config.rooms && Object.keys(account.config.rooms).length > 0; if (roomAllowlistConfigured) { return [ `- Nextcloud Talk rooms: groupPolicy="open" allows any member in allowed rooms to trigger (mention-gated). Set channels.nextcloud-talk.groupPolicy="allowlist" + channels.nextcloud-talk.groupAllowFrom to restrict senders.`, ]; } return [ `- Nextcloud Talk rooms: groupPolicy="open" with no channels.nextcloud-talk.rooms allowlist; any room can add + ping (mention-gated). Set channels.nextcloud-talk.groupPolicy="allowlist" + channels.nextcloud-talk.groupAllowFrom or configure channels.nextcloud-talk.rooms.`, ]; }, }, groups: { resolveRequireMention: ({ cfg, accountId, groupId }) => { const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }); const rooms = account.config.rooms; if (!rooms || !groupId) return true; const roomConfig = rooms[groupId]; if (roomConfig?.requireMention !== undefined) { return roomConfig.requireMention; } const wildcardConfig = rooms["*"]; if (wildcardConfig?.requireMention !== undefined) { return wildcardConfig.requireMention; } return true; }, }, messaging: { normalizeTarget: normalizeNextcloudTalkMessagingTarget, targetResolver: { looksLikeId: looksLikeNextcloudTalkTargetId, hint: "", }, }, setup: { resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => applyAccountNameToChannelSection({ cfg: cfg as ClawdbotConfig, 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 as ClawdbotConfig, 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 ClawdbotConfig; } 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 ClawdbotConfig; }, }, outbound: { deliveryMode: "direct", chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit), textChunkLimit: 4000, sendText: async ({ to, text, accountId, replyToId }) => { const result = await sendMessageNextcloudTalk(to, text, { accountId: accountId ?? undefined, replyTo: replyToId ?? undefined, }); return { channel: "nextcloud-talk", ...result }; }, sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => { const messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text; const result = await sendMessageNextcloudTalk(to, messageWithMedia, { accountId: accountId ?? undefined, replyTo: replyToId ?? undefined, }); return { channel: "nextcloud-talk", ...result }; }, }, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, running: false, lastStartAt: null, lastStopAt: null, lastError: null, }, buildChannelSummary: ({ snapshot }) => ({ configured: snapshot.configured ?? false, secretSource: snapshot.secretSource ?? "none", running: snapshot.running ?? false, mode: "webhook", lastStartAt: snapshot.lastStartAt ?? null, lastStopAt: snapshot.lastStopAt ?? null, lastError: snapshot.lastError ?? null, }), buildAccountSnapshot: ({ account, runtime }) => { const configured = Boolean(account.secret?.trim() && account.baseUrl?.trim()); return { accountId: account.accountId, name: account.name, enabled: account.enabled, configured, secretSource: account.secretSource, baseUrl: account.baseUrl ? "[set]" : "[missing]", running: runtime?.running ?? false, lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, lastError: runtime?.lastError ?? null, mode: "webhook", lastInboundAt: runtime?.lastInboundAt ?? null, lastOutboundAt: runtime?.lastOutboundAt ?? null, }; }, }, gateway: { startAccount: async (ctx) => { const account = ctx.account; if (!account.secret || !account.baseUrl) { throw new Error( `Nextcloud Talk not configured for account "${account.accountId}" (missing secret or baseUrl)`, ); } ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk webhook server`); const { stop } = await monitorNextcloudTalkProvider({ accountId: account.accountId, config: ctx.cfg as CoreConfig, runtime: ctx.runtime, abortSignal: ctx.abortSignal, statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }), }); return { stop }; }, logoutAccount: async ({ accountId, cfg }) => { const nextCfg = { ...cfg } as ClawdbotConfig; const nextSection = cfg.channels?.["nextcloud-talk"] ? { ...cfg.channels["nextcloud-talk"] } : undefined; let cleared = false; let changed = false; if (nextSection) { if (accountId === DEFAULT_ACCOUNT_ID && nextSection.botSecret) { delete nextSection.botSecret; cleared = true; changed = true; } const accounts = nextSection.accounts && typeof nextSection.accounts === "object" ? { ...nextSection.accounts } : undefined; if (accounts && accountId in accounts) { const entry = accounts[accountId]; if (entry && typeof entry === "object") { const nextEntry = { ...entry } as Record; if ("botSecret" in nextEntry) { const secret = nextEntry.botSecret; if (typeof secret === "string" ? secret.trim() : secret) { cleared = true; } delete nextEntry.botSecret; changed = true; } if (Object.keys(nextEntry).length === 0) { delete accounts[accountId]; changed = true; } else { accounts[accountId] = nextEntry as typeof entry; } } } if (accounts) { if (Object.keys(accounts).length === 0) { delete nextSection.accounts; changed = true; } else { nextSection.accounts = accounts; } } } if (changed) { if (nextSection && Object.keys(nextSection).length > 0) { nextCfg.channels = { ...nextCfg.channels, "nextcloud-talk": nextSection }; } else { const nextChannels = { ...nextCfg.channels } as Record; delete nextChannels["nextcloud-talk"]; if (Object.keys(nextChannels).length > 0) { nextCfg.channels = nextChannels as ClawdbotConfig["channels"]; } else { delete nextCfg.channels; } } } const resolved = resolveNextcloudTalkAccount({ cfg: (changed ? (nextCfg as CoreConfig) : (cfg as CoreConfig)), accountId, }); const loggedOut = resolved.secretSource === "none"; if (changed) { await getNextcloudTalkRuntime().config.writeConfigFile(nextCfg); } return { cleared, envSecret: Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()), loggedOut, }; }, }, };