diff --git a/CHANGELOG.md b/CHANGELOG.md index 3648aee59..ad08d0330 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,7 @@ Docs: https://docs.clawd.bot ### Changes - Repo: remove the Peekaboo git submodule now that the SPM release is used. - Plugins: require manifest-embedded config schemas, validate configs without loading plugin code, and surface plugin config warnings. (#1272) — thanks @thewilloftheshadow. - +- Plugins: move channel catalog metadata into plugin manifests; align Nextcloud Talk policy helpers with core patterns. (#1290) — thanks @NicholaiVogel. ### Fixes - Web search: infer Perplexity base URL from API key source (direct vs OpenRouter). - TUI: keep thinking blocks ordered before content during streaming and isolate per-run assembly. (#1202) — thanks @aaronveklabs. diff --git a/docs/channels/index.md b/docs/channels/index.md index d294cb03c..aa5c8fd2f 100644 --- a/docs/channels/index.md +++ b/docs/channels/index.md @@ -19,6 +19,7 @@ Text is supported everywhere; media and reactions vary by channel. - [iMessage](/channels/imessage) — macOS only; native integration. - [BlueBubbles](/channels/bluebubbles) — iMessage via BlueBubbles macOS server (bundled plugin, disabled by default). - [Microsoft Teams](/channels/msteams) — Bot Framework; enterprise support (plugin, installed separately). +- [Nextcloud Talk](/channels/nextcloud-talk) — Self-hosted chat via Nextcloud Talk (plugin, installed separately). - [Matrix](/channels/matrix) — Matrix protocol (plugin, installed separately). - [Zalo](/channels/zalo) — Zalo Bot API; Vietnam's popular messenger (plugin, installed separately). - [Zalo Personal](/channels/zalouser) — Zalo personal account via QR login (plugin, installed separately). diff --git a/docs/channels/nextcloud-talk.md b/docs/channels/nextcloud-talk.md new file mode 100644 index 000000000..756b2fe30 --- /dev/null +++ b/docs/channels/nextcloud-talk.md @@ -0,0 +1,119 @@ +--- +summary: "Nextcloud Talk support status, capabilities, and configuration" +read_when: + - Working on Nextcloud Talk channel features +--- +# Nextcloud Talk (plugin) + +Status: supported via plugin (webhook bot). Direct messages, rooms, reactions, and markdown messages are supported. + +## Plugin required +Nextcloud Talk ships as a plugin and is not bundled with the core install. + +Install via CLI (npm registry): +```bash +clawdbot plugins install @clawdbot/nextcloud-talk +``` + +Local checkout (when running from a git repo): +```bash +clawdbot plugins install ./extensions/nextcloud-talk +``` + +If you choose Nextcloud Talk during configure/onboarding and a git checkout is detected, +Clawdbot will offer the local install path automatically. + +Details: [Plugins](/plugin) + +## Quick setup (beginner) +1) Install the Nextcloud Talk plugin. +2) On your Nextcloud server, create a bot: + ```bash + ./occ talk:bot:install "Clawdbot" "" "" --feature reaction + ``` +3) Enable the bot in the target room settings. +4) Configure Clawdbot: + - Config: `channels.nextcloud-talk.baseUrl` + `channels.nextcloud-talk.botSecret` + - Or env: `NEXTCLOUD_TALK_BOT_SECRET` (default account only) +5) Restart the gateway (or finish onboarding). + +Minimal config: +```json5 +{ + channels: { + "nextcloud-talk": { + enabled: true, + baseUrl: "https://cloud.example.com", + botSecret: "shared-secret", + dmPolicy: "pairing" + } + } +} +``` + +## Notes +- Bots cannot initiate DMs. The user must message the bot first. +- Webhook URL must be reachable by the Gateway; set `webhookPublicUrl` if behind a proxy. +- Media uploads are not supported by the bot API; media is sent as URLs. +- The webhook payload does not distinguish DMs vs rooms; set `apiUser` + `apiPassword` to enable room-type lookups (otherwise DMs are treated as rooms). + +## Access control (DMs) +- Default: `channels.nextcloud-talk.dmPolicy = "pairing"`. Unknown senders get a pairing code. +- Approve via: + - `clawdbot pairing list nextcloud-talk` + - `clawdbot pairing approve nextcloud-talk ` +- Public DMs: `channels.nextcloud-talk.dmPolicy="open"` plus `channels.nextcloud-talk.allowFrom=["*"]`. + +## Rooms (groups) +- Default: `channels.nextcloud-talk.groupPolicy = "allowlist"` (mention-gated). +- Allowlist rooms with `channels.nextcloud-talk.rooms`: +```json5 +{ + channels: { + "nextcloud-talk": { + rooms: { + "room-token": { requireMention: true } + } + } + } +} +``` +- To allow no rooms, keep the allowlist empty or set `channels.nextcloud-talk.groupPolicy="disabled"`. + +## Capabilities +| Feature | Status | +|---------|--------| +| Direct messages | Supported | +| Rooms | Supported | +| Threads | Not supported | +| Media | URL-only | +| Reactions | Supported | +| Native commands | Not supported | + +## Configuration reference (Nextcloud Talk) +Full configuration: [Configuration](/gateway/configuration) + +Provider options: +- `channels.nextcloud-talk.enabled`: enable/disable channel startup. +- `channels.nextcloud-talk.baseUrl`: Nextcloud instance URL. +- `channels.nextcloud-talk.botSecret`: bot shared secret. +- `channels.nextcloud-talk.botSecretFile`: secret file path. +- `channels.nextcloud-talk.apiUser`: API user for room lookups (DM detection). +- `channels.nextcloud-talk.apiPassword`: API/app password for room lookups. +- `channels.nextcloud-talk.apiPasswordFile`: API password file path. +- `channels.nextcloud-talk.webhookPort`: webhook listener port (default: 8788). +- `channels.nextcloud-talk.webhookHost`: webhook host (default: 0.0.0.0). +- `channels.nextcloud-talk.webhookPath`: webhook path (default: /nextcloud-talk-webhook). +- `channels.nextcloud-talk.webhookPublicUrl`: externally reachable webhook URL. +- `channels.nextcloud-talk.dmPolicy`: `pairing | allowlist | open | disabled`. +- `channels.nextcloud-talk.allowFrom`: DM allowlist (user IDs). `open` requires `"*"`. +- `channels.nextcloud-talk.groupPolicy`: `allowlist | open | disabled`. +- `channels.nextcloud-talk.groupAllowFrom`: group allowlist (user IDs). +- `channels.nextcloud-talk.rooms`: per-room settings and allowlist. +- `channels.nextcloud-talk.historyLimit`: group history limit (0 disables). +- `channels.nextcloud-talk.dmHistoryLimit`: DM history limit (0 disables). +- `channels.nextcloud-talk.dms`: per-DM overrides (historyLimit). +- `channels.nextcloud-talk.textChunkLimit`: outbound text chunk size (chars). +- `channels.nextcloud-talk.blockStreaming`: disable block streaming for this channel. +- `channels.nextcloud-talk.blockStreamingCoalesce`: block streaming coalesce tuning. +- `channels.nextcloud-talk.mediaMaxMb`: inbound media cap (MB). diff --git a/docs/plugin.md b/docs/plugin.md index bea964041..507246b04 100644 --- a/docs/plugin.md +++ b/docs/plugin.md @@ -112,6 +112,37 @@ becomes `name/`. If your plugin imports npm deps, install them in that directory so `node_modules` is available (`npm install` / `pnpm install`). +### Channel catalog metadata + +Channel plugins can advertise onboarding metadata via `clawdbot.channel` and +install hints via `clawdbot.install`. This keeps the core catalog data-free. + +Example: + +```json +{ + "name": "@clawdbot/nextcloud-talk", + "clawdbot": { + "extensions": ["./index.ts"], + "channel": { + "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.", + "order": 65, + "aliases": ["nc-talk", "nc"] + }, + "install": { + "npmSpec": "@clawdbot/nextcloud-talk", + "localPath": "extensions/nextcloud-talk", + "defaultChoice": "npm" + } + } +} +``` + ## Plugin IDs Default plugin ids: diff --git a/extensions/bluebubbles/package.json b/extensions/bluebubbles/package.json index db43a0023..af84d53dd 100644 --- a/extensions/bluebubbles/package.json +++ b/extensions/bluebubbles/package.json @@ -4,6 +4,20 @@ "type": "module", "description": "Clawdbot BlueBubbles channel plugin", "clawdbot": { - "extensions": ["./index.ts"] + "extensions": ["./index.ts"], + "channel": { + "id": "bluebubbles", + "label": "BlueBubbles", + "selectionLabel": "BlueBubbles (macOS app)", + "docsPath": "/channels/bluebubbles", + "docsLabel": "bluebubbles", + "blurb": "iMessage via the BlueBubbles mac app + REST API.", + "order": 75 + }, + "install": { + "npmSpec": "@clawdbot/bluebubbles", + "localPath": "extensions/bluebubbles", + "defaultChoice": "npm" + } } } diff --git a/extensions/matrix/package.json b/extensions/matrix/package.json index 80abd39b9..7cb454ff7 100644 --- a/extensions/matrix/package.json +++ b/extensions/matrix/package.json @@ -6,7 +6,22 @@ "clawdbot": { "extensions": [ "./index.ts" - ] + ], + "channel": { + "id": "matrix", + "label": "Matrix", + "selectionLabel": "Matrix (plugin)", + "docsPath": "/channels/matrix", + "docsLabel": "matrix", + "blurb": "open protocol; install the plugin to enable.", + "order": 70, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@clawdbot/matrix", + "localPath": "extensions/matrix", + "defaultChoice": "npm" + } }, "dependencies": { "clawdbot": "workspace:*", diff --git a/extensions/msteams/package.json b/extensions/msteams/package.json index de2c03a8f..50ed3ba62 100644 --- a/extensions/msteams/package.json +++ b/extensions/msteams/package.json @@ -6,7 +6,22 @@ "clawdbot": { "extensions": [ "./index.ts" - ] + ], + "channel": { + "id": "msteams", + "label": "Microsoft Teams", + "selectionLabel": "Microsoft Teams (Bot Framework)", + "docsPath": "/channels/msteams", + "docsLabel": "msteams", + "blurb": "Bot Framework; enterprise support.", + "aliases": ["teams"], + "order": 60 + }, + "install": { + "npmSpec": "@clawdbot/msteams", + "localPath": "extensions/msteams", + "defaultChoice": "npm" + } }, "dependencies": { "@microsoft/agents-hosting": "^1.2.2", diff --git a/extensions/nextcloud-talk/index.ts b/extensions/nextcloud-talk/index.ts new file mode 100644 index 000000000..772408da5 --- /dev/null +++ b/extensions/nextcloud-talk/index.ts @@ -0,0 +1,18 @@ +import type { ClawdbotPluginApi } from "clawdbot/plugin-sdk"; +import { emptyPluginConfigSchema } from "clawdbot/plugin-sdk"; + +import { nextcloudTalkPlugin } from "./src/channel.js"; +import { setNextcloudTalkRuntime } from "./src/runtime.js"; + +const plugin = { + id: "nextcloud-talk", + name: "Nextcloud Talk", + description: "Nextcloud Talk channel plugin", + configSchema: emptyPluginConfigSchema(), + register(api: ClawdbotPluginApi) { + setNextcloudTalkRuntime(api.runtime); + api.registerChannel({ plugin: nextcloudTalkPlugin }); + }, +}; + +export default plugin; diff --git a/extensions/nextcloud-talk/package.json b/extensions/nextcloud-talk/package.json new file mode 100644 index 000000000..982ac5325 --- /dev/null +++ b/extensions/nextcloud-talk/package.json @@ -0,0 +1,25 @@ +{ + "name": "@clawdbot/nextcloud-talk", + "version": "2026.1.17-1", + "type": "module", + "description": "Clawdbot Nextcloud Talk channel plugin", + "clawdbot": { + "extensions": ["./index.ts"], + "channel": { + "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 + }, + "install": { + "npmSpec": "@clawdbot/nextcloud-talk", + "localPath": "extensions/nextcloud-talk", + "defaultChoice": "npm" + } + } +} diff --git a/extensions/nextcloud-talk/src/accounts.ts b/extensions/nextcloud-talk/src/accounts.ts new file mode 100644 index 000000000..9baa465a3 --- /dev/null +++ b/extensions/nextcloud-talk/src/accounts.ts @@ -0,0 +1,154 @@ +import { readFileSync } from "node:fs"; + +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "clawdbot/plugin-sdk"; + +import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js"; + +const TRUTHY_ENV = new Set(["true", "1", "yes", "on"]); + +function isTruthyEnvValue(value?: string): boolean { + if (!value) return false; + return TRUTHY_ENV.has(value.trim().toLowerCase()); +} + +const debugAccounts = (...args: unknown[]) => { + if (isTruthyEnvValue(process.env.CLAWDBOT_DEBUG_NEXTCLOUD_TALK_ACCOUNTS)) { + console.warn("[nextcloud-talk:accounts]", ...args); + } +}; + +export type ResolvedNextcloudTalkAccount = { + accountId: string; + enabled: boolean; + name?: string; + baseUrl: string; + secret: string; + secretSource: "env" | "secretFile" | "config" | "none"; + config: NextcloudTalkAccountConfig; +}; + +function listConfiguredAccountIds(cfg: CoreConfig): string[] { + const accounts = cfg.channels?.["nextcloud-talk"]?.accounts; + if (!accounts || typeof accounts !== "object") return []; + const ids = new Set(); + for (const key of Object.keys(accounts)) { + if (!key) continue; + ids.add(normalizeAccountId(key)); + } + return [...ids]; +} + +export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] { + const ids = listConfiguredAccountIds(cfg); + debugAccounts("listNextcloudTalkAccountIds", ids); + if (ids.length === 0) return [DEFAULT_ACCOUNT_ID]; + return ids.sort((a, b) => a.localeCompare(b)); +} + +export function resolveDefaultNextcloudTalkAccountId(cfg: CoreConfig): string { + const ids = listNextcloudTalkAccountIds(cfg); + if (ids.includes(DEFAULT_ACCOUNT_ID)) return DEFAULT_ACCOUNT_ID; + return ids[0] ?? DEFAULT_ACCOUNT_ID; +} + +function resolveAccountConfig( + cfg: CoreConfig, + accountId: string, +): NextcloudTalkAccountConfig | undefined { + const accounts = cfg.channels?.["nextcloud-talk"]?.accounts; + if (!accounts || typeof accounts !== "object") return undefined; + const direct = accounts[accountId] as NextcloudTalkAccountConfig | undefined; + if (direct) return direct; + const normalized = normalizeAccountId(accountId); + const matchKey = Object.keys(accounts).find((key) => normalizeAccountId(key) === normalized); + return matchKey ? (accounts[matchKey] as NextcloudTalkAccountConfig | undefined) : undefined; +} + +function mergeNextcloudTalkAccountConfig( + cfg: CoreConfig, + accountId: string, +): NextcloudTalkAccountConfig { + const { accounts: _ignored, ...base } = (cfg.channels?.["nextcloud-talk"] ?? + {}) as NextcloudTalkAccountConfig & { accounts?: unknown }; + const account = resolveAccountConfig(cfg, accountId) ?? {}; + return { ...base, ...account }; +} + +function resolveNextcloudTalkSecret( + cfg: CoreConfig, + opts: { accountId?: string }, +): { secret: string; source: ResolvedNextcloudTalkAccount["secretSource"] } { + const merged = mergeNextcloudTalkAccountConfig(cfg, opts.accountId ?? DEFAULT_ACCOUNT_ID); + + const envSecret = process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim(); + if (envSecret && (!opts.accountId || opts.accountId === DEFAULT_ACCOUNT_ID)) { + return { secret: envSecret, source: "env" }; + } + + if (merged.botSecretFile) { + try { + const fileSecret = readFileSync(merged.botSecretFile, "utf-8").trim(); + if (fileSecret) return { secret: fileSecret, source: "secretFile" }; + } catch { + // File not found or unreadable, fall through. + } + } + + if (merged.botSecret?.trim()) { + return { secret: merged.botSecret.trim(), source: "config" }; + } + + return { secret: "", source: "none" }; +} + +export function resolveNextcloudTalkAccount(params: { + cfg: CoreConfig; + accountId?: string | null; +}): ResolvedNextcloudTalkAccount { + const hasExplicitAccountId = Boolean(params.accountId?.trim()); + const baseEnabled = params.cfg.channels?.["nextcloud-talk"]?.enabled !== false; + + const resolve = (accountId: string) => { + const merged = mergeNextcloudTalkAccountConfig(params.cfg, accountId); + const accountEnabled = merged.enabled !== false; + const enabled = baseEnabled && accountEnabled; + const secretResolution = resolveNextcloudTalkSecret(params.cfg, { accountId }); + const baseUrl = merged.baseUrl?.trim()?.replace(/\/$/, "") ?? ""; + + debugAccounts("resolve", { + accountId, + enabled, + secretSource: secretResolution.source, + baseUrl: baseUrl ? "[set]" : "[missing]", + }); + + return { + accountId, + enabled, + name: merged.name?.trim() || undefined, + baseUrl, + secret: secretResolution.secret, + secretSource: secretResolution.source, + config: merged, + } satisfies ResolvedNextcloudTalkAccount; + }; + + const normalized = normalizeAccountId(params.accountId); + const primary = resolve(normalized); + if (hasExplicitAccountId) return primary; + if (primary.secretSource !== "none") return primary; + + const fallbackId = resolveDefaultNextcloudTalkAccountId(params.cfg); + if (fallbackId === primary.accountId) return primary; + const fallback = resolve(fallbackId); + if (fallback.secretSource === "none") return primary; + return fallback; +} + +export function listEnabledNextcloudTalkAccounts( + cfg: CoreConfig, +): ResolvedNextcloudTalkAccount[] { + return listNextcloudTalkAccountIds(cfg) + .map((accountId) => resolveNextcloudTalkAccount({ cfg, accountId })) + .filter((account) => account.enabled); +} diff --git a/extensions/nextcloud-talk/src/channel.ts b/extensions/nextcloud-talk/src/channel.ts new file mode 100644 index 000000000..23858ebc1 --- /dev/null +++ b/extensions/nextcloud-talk/src/channel.ts @@ -0,0 +1,401 @@ +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, + }; + }, + }, +}; diff --git a/extensions/nextcloud-talk/src/config-schema.ts b/extensions/nextcloud-talk/src/config-schema.ts new file mode 100644 index 000000000..c442f6b59 --- /dev/null +++ b/extensions/nextcloud-talk/src/config-schema.ts @@ -0,0 +1,73 @@ +import { + BlockStreamingCoalesceSchema, + DmConfigSchema, + DmPolicySchema, + GroupPolicySchema, + requireOpenAllowFrom, +} from "clawdbot/plugin-sdk"; +import { z } from "zod"; + +export const NextcloudTalkRoomSchema = z + .object({ + requireMention: z.boolean().optional(), + skills: z.array(z.string()).optional(), + enabled: z.boolean().optional(), + allowFrom: z.array(z.string()).optional(), + systemPrompt: z.string().optional(), + }) + .strict(); + +export const NextcloudTalkAccountSchemaBase = z + .object({ + name: z.string().optional(), + enabled: z.boolean().optional(), + baseUrl: z.string().optional(), + botSecret: z.string().optional(), + botSecretFile: z.string().optional(), + apiUser: z.string().optional(), + apiPassword: z.string().optional(), + apiPasswordFile: z.string().optional(), + dmPolicy: DmPolicySchema.optional().default("pairing"), + webhookPort: z.number().int().positive().optional(), + webhookHost: z.string().optional(), + webhookPath: z.string().optional(), + webhookPublicUrl: z.string().optional(), + allowFrom: z.array(z.string()).optional(), + groupAllowFrom: z.array(z.string()).optional(), + groupPolicy: GroupPolicySchema.optional().default("allowlist"), + rooms: z.record(z.string(), NextcloudTalkRoomSchema.optional()).optional(), + historyLimit: z.number().int().min(0).optional(), + dmHistoryLimit: z.number().int().min(0).optional(), + dms: z.record(z.string(), DmConfigSchema.optional()).optional(), + textChunkLimit: z.number().int().positive().optional(), + blockStreaming: z.boolean().optional(), + blockStreamingCoalesce: BlockStreamingCoalesceSchema.optional(), + mediaMaxMb: z.number().positive().optional(), + }) + .strict(); + +export const NextcloudTalkAccountSchema = NextcloudTalkAccountSchemaBase.superRefine( + (value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.nextcloud-talk.dmPolicy="open" requires channels.nextcloud-talk.allowFrom to include "*"', + }); + }, +); + +export const NextcloudTalkConfigSchema = NextcloudTalkAccountSchemaBase.extend({ + accounts: z.record(z.string(), NextcloudTalkAccountSchema.optional()).optional(), +}).superRefine((value, ctx) => { + requireOpenAllowFrom({ + policy: value.dmPolicy, + allowFrom: value.allowFrom, + ctx, + path: ["allowFrom"], + message: + 'channels.nextcloud-talk.dmPolicy="open" requires channels.nextcloud-talk.allowFrom to include "*"', + }); +}); diff --git a/extensions/nextcloud-talk/src/format.ts b/extensions/nextcloud-talk/src/format.ts new file mode 100644 index 000000000..6ae58c0d9 --- /dev/null +++ b/extensions/nextcloud-talk/src/format.ts @@ -0,0 +1,79 @@ +/** + * Format utilities for Nextcloud Talk messages. + * + * Nextcloud Talk supports markdown natively, so most formatting passes through. + * This module handles any edge cases or transformations needed. + */ + +/** + * Convert markdown to Nextcloud Talk compatible format. + * Nextcloud Talk supports standard markdown, so minimal transformation needed. + */ +export function markdownToNextcloudTalk(text: string): string { + return text.trim(); +} + +/** + * Escape special characters in text to prevent markdown interpretation. + */ +export function escapeNextcloudTalkMarkdown(text: string): string { + return text.replace(/([*_`~[\]()#>+\-=|{}!\\])/g, "\\$1"); +} + +/** + * Format a mention for a Nextcloud user. + * Nextcloud Talk uses @user format for mentions. + */ +export function formatNextcloudTalkMention(userId: string): string { + return `@${userId.replace(/^@/, "")}`; +} + +/** + * Format a code block for Nextcloud Talk. + */ +export function formatNextcloudTalkCodeBlock(code: string, language?: string): string { + const lang = language ?? ""; + return `\`\`\`${lang}\n${code}\n\`\`\``; +} + +/** + * Format inline code for Nextcloud Talk. + */ +export function formatNextcloudTalkInlineCode(code: string): string { + if (code.includes("`")) { + return `\`\` ${code} \`\``; + } + return `\`${code}\``; +} + +/** + * Strip Nextcloud Talk specific formatting from text. + * Useful for extracting plain text content. + */ +export function stripNextcloudTalkFormatting(text: string): string { + return ( + text + .replace(/```[\s\S]*?```/g, "") + .replace(/`[^`]+`/g, "") + .replace(/\*\*([^*]+)\*\*/g, "$1") + .replace(/\*([^*]+)\*/g, "$1") + .replace(/_([^_]+)_/g, "$1") + .replace(/~~([^~]+)~~/g, "$1") + .replace(/\[([^\]]+)\]\([^)]+\)/g, "$1") + .replace(/\s+/g, " ") + .trim() + ); +} + +/** + * Truncate text to a maximum length, preserving word boundaries. + */ +export function truncateNextcloudTalkText(text: string, maxLength: number, suffix = "..."): string { + if (text.length <= maxLength) return text; + const truncated = text.slice(0, maxLength - suffix.length); + const lastSpace = truncated.lastIndexOf(" "); + if (lastSpace > maxLength * 0.7) { + return truncated.slice(0, lastSpace) + suffix; + } + return truncated + suffix; +} diff --git a/extensions/nextcloud-talk/src/inbound.ts b/extensions/nextcloud-talk/src/inbound.ts new file mode 100644 index 000000000..1c6984848 --- /dev/null +++ b/extensions/nextcloud-talk/src/inbound.ts @@ -0,0 +1,331 @@ +import type { ClawdbotConfig, RuntimeEnv } from "clawdbot/plugin-sdk"; + +import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; +import { + normalizeNextcloudTalkAllowlist, + resolveNextcloudTalkAllowlistMatch, + resolveNextcloudTalkGroupAllow, + resolveNextcloudTalkMentionGate, + resolveNextcloudTalkRequireMention, + resolveNextcloudTalkRoomMatch, +} from "./policy.js"; +import { resolveNextcloudTalkRoomKind } from "./room-info.js"; +import { sendMessageNextcloudTalk } from "./send.js"; +import { getNextcloudTalkRuntime } from "./runtime.js"; +import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js"; + +const CHANNEL_ID = "nextcloud-talk" as const; + +async function deliverNextcloudTalkReply(params: { + payload: { text?: string; mediaUrls?: string[]; mediaUrl?: string; replyToId?: string }; + roomToken: string; + accountId: string; + statusSink?: (patch: { lastOutboundAt?: number }) => void; +}): Promise { + const { payload, roomToken, accountId, statusSink } = params; + const text = payload.text ?? ""; + const mediaList = payload.mediaUrls?.length + ? payload.mediaUrls + : payload.mediaUrl + ? [payload.mediaUrl] + : []; + + if (!text.trim() && mediaList.length === 0) return; + + const mediaBlock = mediaList.length + ? mediaList.map((url) => `Attachment: ${url}`).join("\n") + : ""; + const combined = text.trim() + ? mediaBlock + ? `${text.trim()}\n\n${mediaBlock}` + : text.trim() + : mediaBlock; + + await sendMessageNextcloudTalk(roomToken, combined, { + accountId, + replyTo: payload.replyToId, + }); + statusSink?.({ lastOutboundAt: Date.now() }); +} + +export async function handleNextcloudTalkInbound(params: { + message: NextcloudTalkInboundMessage; + account: ResolvedNextcloudTalkAccount; + config: CoreConfig; + runtime: RuntimeEnv; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}): Promise { + const { message, account, config, runtime, statusSink } = params; + const core = getNextcloudTalkRuntime(); + + const rawBody = message.text?.trim() ?? ""; + if (!rawBody) return; + + const roomKind = await resolveNextcloudTalkRoomKind({ + account, + roomToken: message.roomToken, + runtime, + }); + const isGroup = + roomKind === "direct" ? false : roomKind === "group" ? true : message.isGroupChat; + const senderId = message.senderId; + const senderName = message.senderName; + const roomToken = message.roomToken; + const roomName = message.roomName; + + statusSink?.({ lastInboundAt: message.timestamp }); + + const dmPolicy = account.config.dmPolicy ?? "pairing"; + const defaultGroupPolicy = config.channels?.defaults?.groupPolicy; + const groupPolicy = account.config.groupPolicy ?? defaultGroupPolicy ?? "allowlist"; + + const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom); + const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom); + const storeAllowFrom = await core.channel.pairing + .readAllowFromStore(CHANNEL_ID) + .catch(() => []); + const storeAllowList = normalizeNextcloudTalkAllowlist(storeAllowFrom); + + const roomMatch = resolveNextcloudTalkRoomMatch({ + rooms: account.config.rooms, + roomToken, + roomName, + }); + const roomConfig = roomMatch.roomConfig; + if (isGroup && !roomMatch.allowed) { + runtime.log?.(`nextcloud-talk: drop room ${roomToken} (not allowlisted)`); + return; + } + if (roomConfig?.enabled === false) { + runtime.log?.(`nextcloud-talk: drop room ${roomToken} (disabled)`); + return; + } + + const roomAllowFrom = normalizeNextcloudTalkAllowlist(roomConfig?.allowFrom); + const baseGroupAllowFrom = + configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom; + + const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean); + const effectiveGroupAllowFrom = [...baseGroupAllowFrom, ...storeAllowList].filter(Boolean); + + const allowTextCommands = core.channel.commands.shouldHandleTextCommands({ + cfg: config as ClawdbotConfig, + surface: CHANNEL_ID, + }); + const useAccessGroups = config.commands?.useAccessGroups !== false; + const senderAllowedForCommands = resolveNextcloudTalkAllowlistMatch({ + allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, + senderId, + senderName, + }).allowed; + const commandAuthorized = core.channel.commands.resolveCommandAuthorizedFromAuthorizers({ + useAccessGroups, + authorizers: [ + { + configured: + (isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0, + allowed: senderAllowedForCommands, + }, + ], + }); + + if (isGroup) { + const groupAllow = resolveNextcloudTalkGroupAllow({ + groupPolicy, + outerAllowFrom: effectiveGroupAllowFrom, + innerAllowFrom: roomAllowFrom, + senderId, + senderName, + }); + if (!groupAllow.allowed) { + runtime.log?.( + `nextcloud-talk: drop group sender ${senderId} (policy=${groupPolicy})`, + ); + return; + } + } else { + if (dmPolicy === "disabled") { + runtime.log?.(`nextcloud-talk: drop DM sender=${senderId} (dmPolicy=disabled)`); + return; + } + if (dmPolicy !== "open") { + const dmAllowed = resolveNextcloudTalkAllowlistMatch({ + allowFrom: effectiveAllowFrom, + senderId, + senderName, + }).allowed; + if (!dmAllowed) { + if (dmPolicy === "pairing") { + const { code, created } = await core.channel.pairing.upsertPairingRequest({ + channel: CHANNEL_ID, + id: senderId, + meta: { name: senderName || undefined }, + }); + if (created) { + try { + await sendMessageNextcloudTalk( + roomToken, + core.channel.pairing.buildPairingReply({ + channel: CHANNEL_ID, + idLine: `Your Nextcloud user id: ${senderId}`, + code, + }), + { accountId: account.accountId }, + ); + statusSink?.({ lastOutboundAt: Date.now() }); + } catch (err) { + runtime.error?.( + `nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`, + ); + } + } + } + runtime.log?.( + `nextcloud-talk: drop DM sender ${senderId} (dmPolicy=${dmPolicy})`, + ); + return; + } + } + } + + if ( + isGroup && + allowTextCommands && + core.channel.text.hasControlCommand(rawBody, config as ClawdbotConfig) && + commandAuthorized !== true + ) { + runtime.log?.( + `nextcloud-talk: drop control command from unauthorized sender ${senderId}`, + ); + return; + } + + const mentionRegexes = core.channel.mentions.buildMentionRegexes( + config as ClawdbotConfig, + ); + const wasMentioned = mentionRegexes.length + ? core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes) + : false; + const shouldRequireMention = isGroup + ? resolveNextcloudTalkRequireMention({ + roomConfig, + wildcardConfig: roomMatch.wildcardConfig, + }) + : false; + const hasControlCommand = core.channel.text.hasControlCommand( + rawBody, + config as ClawdbotConfig, + ); + const mentionGate = resolveNextcloudTalkMentionGate({ + isGroup, + requireMention: shouldRequireMention, + wasMentioned, + allowTextCommands, + hasControlCommand, + commandAuthorized, + }); + if (isGroup && mentionGate.shouldSkip) { + runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`); + return; + } + + const route = core.channel.routing.resolveAgentRoute({ + cfg: config as ClawdbotConfig, + channel: CHANNEL_ID, + accountId: account.accountId, + peer: { + kind: isGroup ? "group" : "dm", + id: isGroup ? roomToken : senderId, + }, + }); + + const fromLabel = isGroup + ? `room:${roomName || roomToken}` + : senderName || `user:${senderId}`; + const storePath = core.channel.session.resolveStorePath(config.session?.store, { + agentId: route.agentId, + }); + const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions( + config as ClawdbotConfig, + ); + const previousTimestamp = core.channel.session.readSessionUpdatedAt({ + storePath, + sessionKey: route.sessionKey, + }); + const body = core.channel.reply.formatAgentEnvelope({ + channel: "Nextcloud Talk", + from: fromLabel, + timestamp: message.timestamp, + previousTimestamp, + envelope: envelopeOptions, + body: rawBody, + }); + + const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined; + + const ctxPayload = core.channel.reply.finalizeInboundContext({ + Body: body, + RawBody: rawBody, + CommandBody: rawBody, + From: isGroup ? `nextcloud-talk:room:${roomToken}` : `nextcloud-talk:${senderId}`, + To: `nextcloud-talk:${roomToken}`, + SessionKey: route.sessionKey, + AccountId: route.accountId, + ChatType: isGroup ? "group" : "direct", + ConversationLabel: fromLabel, + SenderName: senderName || undefined, + SenderId: senderId, + GroupSubject: isGroup ? roomName || roomToken : undefined, + GroupSystemPrompt: isGroup ? groupSystemPrompt : undefined, + Provider: CHANNEL_ID, + Surface: CHANNEL_ID, + WasMentioned: isGroup ? wasMentioned : undefined, + MessageSid: message.messageId, + Timestamp: message.timestamp, + OriginatingChannel: CHANNEL_ID, + OriginatingTo: `nextcloud-talk:${roomToken}`, + CommandAuthorized: commandAuthorized, + }); + + void core.channel.session + .recordSessionMetaFromInbound({ + storePath, + sessionKey: ctxPayload.SessionKey ?? route.sessionKey, + ctx: ctxPayload, + }) + .catch((err) => { + runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`); + }); + + await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({ + ctx: ctxPayload, + cfg: config as ClawdbotConfig, + dispatcherOptions: { + deliver: async (payload) => { + await deliverNextcloudTalkReply({ + payload: payload as { + text?: string; + mediaUrls?: string[]; + mediaUrl?: string; + replyToId?: string; + }, + roomToken, + accountId: account.accountId, + statusSink, + }); + }, + onError: (err, info) => { + runtime.error?.( + `nextcloud-talk ${info.kind} reply failed: ${String(err)}`, + ); + }, + }, + replyOptions: { + skillFilter: roomConfig?.skills, + disableBlockStreaming: + typeof account.config.blockStreaming === "boolean" + ? !account.config.blockStreaming + : undefined, + }, + }); +} diff --git a/extensions/nextcloud-talk/src/monitor.ts b/extensions/nextcloud-talk/src/monitor.ts new file mode 100644 index 000000000..0a1e44c88 --- /dev/null +++ b/extensions/nextcloud-talk/src/monitor.ts @@ -0,0 +1,246 @@ +import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http"; + +import type { RuntimeEnv } from "clawdbot/plugin-sdk"; + +import { resolveNextcloudTalkAccount } from "./accounts.js"; +import { handleNextcloudTalkInbound } from "./inbound.js"; +import { getNextcloudTalkRuntime } from "./runtime.js"; +import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js"; +import type { + CoreConfig, + NextcloudTalkInboundMessage, + NextcloudTalkWebhookPayload, + NextcloudTalkWebhookServerOptions, +} from "./types.js"; + +const DEFAULT_WEBHOOK_PORT = 8788; +const DEFAULT_WEBHOOK_HOST = "0.0.0.0"; +const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook"; +const HEALTH_PATH = "/healthz"; + +function formatError(err: unknown): string { + if (err instanceof Error) return err.message; + return typeof err === "string" ? err : JSON.stringify(err); +} + +function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null { + try { + const data = JSON.parse(body); + if ( + !data.type || + !data.actor?.type || + !data.actor?.id || + !data.object?.type || + !data.object?.id || + !data.target?.type || + !data.target?.id + ) { + return null; + } + return data as NextcloudTalkWebhookPayload; + } catch { + return null; + } +} + +function payloadToInboundMessage( + payload: NextcloudTalkWebhookPayload, +): NextcloudTalkInboundMessage { + // Payload doesn't indicate DM vs room; mark as group and let inbound handler refine. + const isGroupChat = true; + + return { + messageId: String(payload.object.id), + roomToken: payload.target.id, + roomName: payload.target.name, + senderId: payload.actor.id, + senderName: payload.actor.name, + text: payload.object.content || payload.object.name || "", + mediaType: payload.object.mediaType || "text/plain", + timestamp: Date.now(), + isGroupChat, + }; +} + +function readBody(req: IncomingMessage): Promise { + return new Promise((resolve, reject) => { + const chunks: Buffer[] = []; + req.on("data", (chunk: Buffer) => chunks.push(chunk)); + req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8"))); + req.on("error", reject); + }); +} + +export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServerOptions): { + server: Server; + start: () => Promise; + stop: () => void; +} { + const { port, host, path, secret, onMessage, onError, abortSignal } = opts; + + const server = createServer(async (req: IncomingMessage, res: ServerResponse) => { + if (req.url === HEALTH_PATH) { + res.writeHead(200, { "Content-Type": "text/plain" }); + res.end("ok"); + return; + } + + if (req.url !== path || req.method !== "POST") { + res.writeHead(404); + res.end(); + return; + } + + try { + const body = await readBody(req); + + const headers = extractNextcloudTalkHeaders( + req.headers as Record, + ); + if (!headers) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Missing signature headers" })); + return; + } + + const isValid = verifyNextcloudTalkSignature({ + signature: headers.signature, + random: headers.random, + body, + secret, + }); + + if (!isValid) { + res.writeHead(401, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid signature" })); + return; + } + + const payload = parseWebhookPayload(body); + if (!payload) { + res.writeHead(400, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Invalid payload format" })); + return; + } + + if (payload.type !== "Create") { + res.writeHead(200); + res.end(); + return; + } + + const message = payloadToInboundMessage(payload); + + res.writeHead(200); + res.end(); + + try { + await onMessage(message); + } catch (err) { + onError?.(err instanceof Error ? err : new Error(formatError(err))); + } + } catch (err) { + const error = err instanceof Error ? err : new Error(formatError(err)); + onError?.(error); + if (!res.headersSent) { + res.writeHead(500, { "Content-Type": "application/json" }); + res.end(JSON.stringify({ error: "Internal server error" })); + } + } + }); + + const start = (): Promise => { + return new Promise((resolve) => { + server.listen(port, host, () => resolve()); + }); + }; + + const stop = () => { + server.close(); + }; + + if (abortSignal) { + abortSignal.addEventListener("abort", stop, { once: true }); + } + + return { server, start, stop }; +} + +export type NextcloudTalkMonitorOptions = { + accountId?: string; + config?: CoreConfig; + runtime?: RuntimeEnv; + abortSignal?: AbortSignal; + onMessage?: (message: NextcloudTalkInboundMessage) => void | Promise; + statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void; +}; + +export async function monitorNextcloudTalkProvider( + opts: NextcloudTalkMonitorOptions, +): Promise<{ stop: () => void }> { + const core = getNextcloudTalkRuntime(); + const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig); + const account = resolveNextcloudTalkAccount({ + cfg, + accountId: opts.accountId, + }); + const runtime: RuntimeEnv = opts.runtime ?? { + log: (message: string) => core.logging.getChildLogger().info(message), + error: (message: string) => core.logging.getChildLogger().error(message), + exit: () => { + throw new Error("Runtime exit not available"); + }, + }; + + if (!account.secret) { + throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`); + } + + const port = account.config.webhookPort ?? DEFAULT_WEBHOOK_PORT; + const host = account.config.webhookHost ?? DEFAULT_WEBHOOK_HOST; + const path = account.config.webhookPath ?? DEFAULT_WEBHOOK_PATH; + + const logger = core.logging.getChildLogger({ + channel: "nextcloud-talk", + accountId: account.accountId, + }); + + const { start, stop } = createNextcloudTalkWebhookServer({ + port, + host, + path, + secret: account.secret, + onMessage: async (message) => { + core.channel.activity.record({ + channel: "nextcloud-talk", + accountId: account.accountId, + direction: "inbound", + at: message.timestamp, + }); + if (opts.onMessage) { + await opts.onMessage(message); + return; + } + await handleNextcloudTalkInbound({ + message, + account, + config: cfg, + runtime, + statusSink: opts.statusSink, + }); + }, + onError: (error) => { + logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`); + }, + abortSignal: opts.abortSignal, + }); + + await start(); + + const publicUrl = + account.config.webhookPublicUrl ?? + `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`; + logger.info(`[nextcloud-talk:${account.accountId}] webhook listening on ${publicUrl}`); + + return { stop }; +} diff --git a/extensions/nextcloud-talk/src/normalize.ts b/extensions/nextcloud-talk/src/normalize.ts new file mode 100644 index 000000000..c8365d69d --- /dev/null +++ b/extensions/nextcloud-talk/src/normalize.ts @@ -0,0 +1,31 @@ +export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined { + const trimmed = raw.trim(); + if (!trimmed) return undefined; + + let normalized = trimmed; + + if (normalized.startsWith("nextcloud-talk:")) { + normalized = normalized.slice("nextcloud-talk:".length).trim(); + } else if (normalized.startsWith("nc-talk:")) { + normalized = normalized.slice("nc-talk:".length).trim(); + } else if (normalized.startsWith("nc:")) { + normalized = normalized.slice("nc:".length).trim(); + } + + if (normalized.startsWith("room:")) { + normalized = normalized.slice("room:".length).trim(); + } + + if (!normalized) return undefined; + + return `nextcloud-talk:${normalized}`.toLowerCase(); +} + +export function looksLikeNextcloudTalkTargetId(raw: string): boolean { + const trimmed = raw.trim(); + if (!trimmed) return false; + + if (/^(nextcloud-talk|nc-talk|nc):/i.test(trimmed)) return true; + + return /^[a-z0-9]{8,}$/i.test(trimmed); +} diff --git a/extensions/nextcloud-talk/src/onboarding.ts b/extensions/nextcloud-talk/src/onboarding.ts new file mode 100644 index 000000000..7c340a8c4 --- /dev/null +++ b/extensions/nextcloud-talk/src/onboarding.ts @@ -0,0 +1,341 @@ +import { + addWildcardAllowFrom, + formatDocsLink, + promptAccountId, + DEFAULT_ACCOUNT_ID, + normalizeAccountId, + type ChannelOnboardingAdapter, + type ChannelOnboardingDmPolicy, + type WizardPrompter, +} from "clawdbot/plugin-sdk"; + +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 { + const existingConfig = cfg.channels?.["nextcloud-talk"]; + const existingAllowFrom: string[] = (existingConfig?.allowFrom ?? []).map((x) => String(x)); + const allowFrom: string[] = + dmPolicy === "open" ? (addWildcardAllowFrom(existingAllowFrom) as string[]) : existingAllowFrom; + + const newNextcloudTalkConfig = { + ...existingConfig, + dmPolicy, + allowFrom, + }; + + return { + ...cfg, + channels: { + ...cfg.channels, + "nextcloud-talk": newNextcloudTalkConfig, + }, + } as CoreConfig; +} + +async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise { + await prompter.note( + [ + "1) SSH into your Nextcloud server", + '2) Run: ./occ talk:bot:install "Clawdbot" "" "" --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 = [...new Set(merged)]; + + if (accountId === DEFAULT_ACCOUNT_ID) { + return { + ...cfg, + channels: { + ...cfg.channels, + "nextcloud-talk": { + ...cfg.channels?.["nextcloud-talk"], + enabled: true, + dmPolicy: "allowlist", + allowFrom: unique, + }, + }, + }; + } + + return { + ...cfg, + channels: { + ...cfg.channels, + "nextcloud-talk": { + ...cfg.channels?.["nextcloud-talk"], + enabled: true, + accounts: { + ...cfg.channels?.["nextcloud-talk"]?.accounts, + [accountId]: { + ...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId], + enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true, + 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, +}; + +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 nextcloudTalkOverride = accountOverrides["nextcloud-talk"]?.trim(); + const defaultAccountId = resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig); + let accountId = nextcloudTalkOverride + ? normalizeAccountId(nextcloudTalkOverride) + : defaultAccountId; + + if (shouldPromptAccountIds && !nextcloudTalkOverride) { + accountId = await promptAccountId({ + cfg: cfg as CoreConfig, + prompter, + label: "Nextcloud Talk", + currentId: accountId, + listAccountIds: listNextcloudTalkAccountIds, + 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 canUseEnv = allowEnv && Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()); + const hasConfigSecret = Boolean( + 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(); + } + + let secret: string | null = null; + if (!accountConfigured) { + await noteNextcloudTalkSecretHelp(prompter); + } + + if (canUseEnv && !resolvedAccount.config.botSecret) { + const keepEnv = await prompter.confirm({ + message: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?", + initialValue: true, + }); + if (keepEnv) { + next = { + ...next, + channels: { + ...next.channels, + "nextcloud-talk": { + ...next.channels?.["nextcloud-talk"], + enabled: true, + baseUrl, + }, + }, + }; + } else { + secret = String( + await prompter.text({ + message: "Enter Nextcloud Talk bot secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else if (hasConfigSecret) { + const keep = await prompter.confirm({ + message: "Nextcloud Talk secret already configured. Keep it?", + initialValue: true, + }); + if (!keep) { + secret = String( + await prompter.text({ + message: "Enter Nextcloud Talk bot secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + } else { + secret = String( + await prompter.text({ + message: "Enter Nextcloud Talk bot secret", + validate: (value) => (value?.trim() ? undefined : "Required"), + }), + ).trim(); + } + + if (secret || baseUrl !== resolvedAccount.baseUrl) { + if (accountId === DEFAULT_ACCOUNT_ID) { + next = { + ...next, + channels: { + ...next.channels, + "nextcloud-talk": { + ...next.channels?.["nextcloud-talk"], + enabled: true, + baseUrl, + ...(secret ? { botSecret: secret } : {}), + }, + }, + }; + } else { + next = { + ...next, + channels: { + ...next.channels, + "nextcloud-talk": { + ...next.channels?.["nextcloud-talk"], + enabled: true, + accounts: { + ...next.channels?.["nextcloud-talk"]?.accounts, + [accountId]: { + ...next.channels?.["nextcloud-talk"]?.accounts?.[accountId], + enabled: next.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true, + baseUrl, + ...(secret ? { botSecret: secret } : {}), + }, + }, + }, + }, + }; + } + } + + 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/policy.ts b/extensions/nextcloud-talk/src/policy.ts new file mode 100644 index 000000000..6c9599d44 --- /dev/null +++ b/extensions/nextcloud-talk/src/policy.ts @@ -0,0 +1,160 @@ +import type { AllowlistMatch, GroupPolicy } from "clawdbot/plugin-sdk"; +import { + buildChannelKeyCandidates, + normalizeChannelSlug, + resolveChannelEntryMatchWithFallback, + resolveMentionGatingWithBypass, + resolveNestedAllowlistDecision, +} from "clawdbot/plugin-sdk"; + +import type { NextcloudTalkRoomConfig } from "./types.js"; + +function normalizeAllowEntry(raw: string): string { + return raw.trim().toLowerCase().replace(/^(nextcloud-talk|nc-talk|nc):/i, ""); +} + +export function normalizeNextcloudTalkAllowlist( + values: Array | undefined, +): string[] { + return (values ?? []).map((value) => normalizeAllowEntry(String(value))).filter(Boolean); +} + +export function resolveNextcloudTalkAllowlistMatch(params: { + allowFrom: Array | undefined; + senderId: string; + senderName?: string | null; +}): AllowlistMatch<"wildcard" | "id" | "name"> { + const allowFrom = normalizeNextcloudTalkAllowlist(params.allowFrom); + if (allowFrom.length === 0) return { allowed: false }; + if (allowFrom.includes("*")) { + return { allowed: true, matchKey: "*", matchSource: "wildcard" }; + } + const senderId = normalizeAllowEntry(params.senderId); + if (allowFrom.includes(senderId)) { + return { allowed: true, matchKey: senderId, matchSource: "id" }; + } + const senderName = params.senderName ? normalizeAllowEntry(params.senderName) : ""; + if (senderName && allowFrom.includes(senderName)) { + return { allowed: true, matchKey: senderName, matchSource: "name" }; + } + return { allowed: false }; +} + +export type NextcloudTalkRoomMatch = { + roomConfig?: NextcloudTalkRoomConfig; + wildcardConfig?: NextcloudTalkRoomConfig; + roomKey?: string; + matchSource?: "direct" | "parent" | "wildcard"; + allowed: boolean; + allowlistConfigured: boolean; +}; + +export function resolveNextcloudTalkRoomMatch(params: { + rooms?: Record; + roomToken: string; + roomName?: string | null; +}): NextcloudTalkRoomMatch { + const rooms = params.rooms ?? {}; + const allowlistConfigured = Object.keys(rooms).length > 0; + const roomName = params.roomName?.trim() || undefined; + const roomCandidates = buildChannelKeyCandidates( + params.roomToken, + roomName, + roomName ? normalizeChannelSlug(roomName) : undefined, + ); + const match = resolveChannelEntryMatchWithFallback({ + entries: rooms, + keys: roomCandidates, + wildcardKey: "*", + normalizeKey: normalizeChannelSlug, + }); + const roomConfig = match.entry; + const allowed = resolveNestedAllowlistDecision({ + outerConfigured: allowlistConfigured, + outerMatched: Boolean(roomConfig), + innerConfigured: false, + innerMatched: false, + }); + + return { + roomConfig, + wildcardConfig: match.wildcardEntry, + roomKey: match.matchKey ?? match.key, + matchSource: match.matchSource, + allowed, + allowlistConfigured, + }; +} + +export function resolveNextcloudTalkRequireMention(params: { + roomConfig?: NextcloudTalkRoomConfig; + wildcardConfig?: NextcloudTalkRoomConfig; +}): boolean { + if (typeof params.roomConfig?.requireMention === "boolean") { + return params.roomConfig.requireMention; + } + if (typeof params.wildcardConfig?.requireMention === "boolean") { + return params.wildcardConfig.requireMention; + } + return true; +} + +export function resolveNextcloudTalkGroupAllow(params: { + groupPolicy: GroupPolicy; + outerAllowFrom: Array | undefined; + innerAllowFrom: Array | undefined; + senderId: string; + senderName?: string | null; +}): { allowed: boolean; outerMatch: AllowlistMatch; innerMatch: AllowlistMatch } { + if (params.groupPolicy === "disabled") { + return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } }; + } + if (params.groupPolicy === "open") { + return { allowed: true, outerMatch: { allowed: true }, innerMatch: { allowed: true } }; + } + + const outerAllow = normalizeNextcloudTalkAllowlist(params.outerAllowFrom); + const innerAllow = normalizeNextcloudTalkAllowlist(params.innerAllowFrom); + if (outerAllow.length === 0 && innerAllow.length === 0) { + return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } }; + } + + const outerMatch = resolveNextcloudTalkAllowlistMatch({ + allowFrom: params.outerAllowFrom, + senderId: params.senderId, + senderName: params.senderName, + }); + const innerMatch = resolveNextcloudTalkAllowlistMatch({ + allowFrom: params.innerAllowFrom, + senderId: params.senderId, + senderName: params.senderName, + }); + const allowed = resolveNestedAllowlistDecision({ + outerConfigured: outerAllow.length > 0 || innerAllow.length > 0, + outerMatched: outerAllow.length > 0 ? outerMatch.allowed : true, + innerConfigured: innerAllow.length > 0, + innerMatched: innerMatch.allowed, + }); + + return { allowed, outerMatch, innerMatch }; +} + +export function resolveNextcloudTalkMentionGate(params: { + isGroup: boolean; + requireMention: boolean; + wasMentioned: boolean; + allowTextCommands: boolean; + hasControlCommand: boolean; + commandAuthorized: boolean; +}): { shouldSkip: boolean; shouldBypassMention: boolean } { + const result = resolveMentionGatingWithBypass({ + isGroup: params.isGroup, + requireMention: params.requireMention, + canDetectMention: true, + wasMentioned: params.wasMentioned, + allowTextCommands: params.allowTextCommands, + hasControlCommand: params.hasControlCommand, + commandAuthorized: params.commandAuthorized, + }); + return { shouldSkip: result.shouldSkip, shouldBypassMention: result.shouldBypassMention }; +} diff --git a/extensions/nextcloud-talk/src/room-info.ts b/extensions/nextcloud-talk/src/room-info.ts new file mode 100644 index 000000000..812ede51d --- /dev/null +++ b/extensions/nextcloud-talk/src/room-info.ts @@ -0,0 +1,111 @@ +import { readFileSync } from "node:fs"; + +import type { RuntimeEnv } from "clawdbot/plugin-sdk"; + +import type { ResolvedNextcloudTalkAccount } from "./accounts.js"; + +const ROOM_CACHE_TTL_MS = 5 * 60 * 1000; +const ROOM_CACHE_ERROR_TTL_MS = 30 * 1000; + +const roomCache = new Map< + string, + { kind?: "direct" | "group"; fetchedAt: number; error?: string } +>(); + +function resolveRoomCacheKey(params: { accountId: string; roomToken: string }) { + return `${params.accountId}:${params.roomToken}`; +} + +function readApiPassword(params: { + apiPassword?: string; + apiPasswordFile?: string; +}): string | undefined { + if (params.apiPassword?.trim()) return params.apiPassword.trim(); + if (!params.apiPasswordFile) return undefined; + try { + const value = readFileSync(params.apiPasswordFile, "utf-8").trim(); + return value || undefined; + } catch { + return undefined; + } +} + +function coerceRoomType(value: unknown): number | undefined { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const parsed = Number.parseInt(value, 10); + return Number.isFinite(parsed) ? parsed : undefined; + } + return undefined; +} + +function resolveRoomKindFromType(type: number | undefined): "direct" | "group" | undefined { + if (!type) return undefined; + if (type === 1 || type === 5 || type === 6) return "direct"; + return "group"; +} + +export async function resolveNextcloudTalkRoomKind(params: { + account: ResolvedNextcloudTalkAccount; + roomToken: string; + runtime?: RuntimeEnv; +}): Promise<"direct" | "group" | undefined> { + const { account, roomToken, runtime } = params; + const key = resolveRoomCacheKey({ accountId: account.accountId, roomToken }); + const cached = roomCache.get(key); + if (cached) { + const age = Date.now() - cached.fetchedAt; + if (cached.kind && age < ROOM_CACHE_TTL_MS) return cached.kind; + if (cached.error && age < ROOM_CACHE_ERROR_TTL_MS) return undefined; + } + + const apiUser = account.config.apiUser?.trim(); + const apiPassword = readApiPassword({ + apiPassword: account.config.apiPassword, + apiPasswordFile: account.config.apiPasswordFile, + }); + if (!apiUser || !apiPassword) return undefined; + + const baseUrl = account.baseUrl?.trim(); + if (!baseUrl) return undefined; + + const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room/${roomToken}`; + const auth = Buffer.from(`${apiUser}:${apiPassword}`, "utf-8").toString("base64"); + + try { + const response = await fetch(url, { + method: "GET", + headers: { + Authorization: `Basic ${auth}`, + "OCS-APIRequest": "true", + Accept: "application/json", + }, + }); + + if (!response.ok) { + roomCache.set(key, { + fetchedAt: Date.now(), + error: `status:${response.status}`, + }); + runtime?.log?.( + `nextcloud-talk: room lookup failed (${response.status}) token=${roomToken}`, + ); + return undefined; + } + + const payload = (await response.json()) as { + ocs?: { data?: { type?: number | string } }; + }; + const type = coerceRoomType(payload.ocs?.data?.type); + const kind = resolveRoomKindFromType(type); + roomCache.set(key, { fetchedAt: Date.now(), kind }); + return kind; + } catch (err) { + roomCache.set(key, { + fetchedAt: Date.now(), + error: err instanceof Error ? err.message : String(err), + }); + runtime?.error?.(`nextcloud-talk: room lookup error: ${String(err)}`); + return undefined; + } +} diff --git a/extensions/nextcloud-talk/src/runtime.ts b/extensions/nextcloud-talk/src/runtime.ts new file mode 100644 index 000000000..8ccfa17ca --- /dev/null +++ b/extensions/nextcloud-talk/src/runtime.ts @@ -0,0 +1,14 @@ +import type { PluginRuntime } from "clawdbot/plugin-sdk"; + +let runtime: PluginRuntime | null = null; + +export function setNextcloudTalkRuntime(next: PluginRuntime) { + runtime = next; +} + +export function getNextcloudTalkRuntime(): PluginRuntime { + if (!runtime) { + throw new Error("Nextcloud Talk runtime not initialized"); + } + return runtime; +} diff --git a/extensions/nextcloud-talk/src/send.ts b/extensions/nextcloud-talk/src/send.ts new file mode 100644 index 000000000..cf55f5509 --- /dev/null +++ b/extensions/nextcloud-talk/src/send.ts @@ -0,0 +1,196 @@ +import { resolveNextcloudTalkAccount } from "./accounts.js"; +import { getNextcloudTalkRuntime } from "./runtime.js"; +import { generateNextcloudTalkSignature } from "./signature.js"; +import type { CoreConfig, NextcloudTalkSendResult } from "./types.js"; + +type NextcloudTalkSendOpts = { + baseUrl?: string; + secret?: string; + accountId?: string; + replyTo?: string; + verbose?: boolean; +}; + +function resolveCredentials( + explicit: { baseUrl?: string; secret?: string }, + account: { baseUrl: string; secret: string; accountId: string }, +): { baseUrl: string; secret: string } { + const baseUrl = explicit.baseUrl?.trim() ?? account.baseUrl; + const secret = explicit.secret?.trim() ?? account.secret; + + if (!baseUrl) { + throw new Error( + `Nextcloud Talk baseUrl missing for account "${account.accountId}" (set channels.nextcloud-talk.baseUrl).`, + ); + } + if (!secret) { + throw new Error( + `Nextcloud Talk bot secret missing for account "${account.accountId}" (set channels.nextcloud-talk.botSecret/botSecretFile or NEXTCLOUD_TALK_BOT_SECRET for default).`, + ); + } + + return { baseUrl, secret }; +} + +function normalizeRoomToken(to: string): string { + const trimmed = to.trim(); + if (!trimmed) throw new Error("Room token is required for Nextcloud Talk sends"); + + let normalized = trimmed; + if (normalized.startsWith("nextcloud-talk:")) { + normalized = normalized.slice("nextcloud-talk:".length).trim(); + } else if (normalized.startsWith("nc:")) { + normalized = normalized.slice("nc:".length).trim(); + } + + if (normalized.startsWith("room:")) { + normalized = normalized.slice("room:".length).trim(); + } + + if (!normalized) throw new Error("Room token is required for Nextcloud Talk sends"); + return normalized; +} + +export async function sendMessageNextcloudTalk( + to: string, + text: string, + opts: NextcloudTalkSendOpts = {}, +): Promise { + const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig; + const account = resolveNextcloudTalkAccount({ + cfg, + accountId: opts.accountId, + }); + const { baseUrl, secret } = resolveCredentials( + { baseUrl: opts.baseUrl, secret: opts.secret }, + account, + ); + const roomToken = normalizeRoomToken(to); + + if (!text?.trim()) { + throw new Error("Message must be non-empty for Nextcloud Talk sends"); + } + + const body: Record = { + message: text.trim(), + }; + if (opts.replyTo) { + body.replyTo = opts.replyTo; + } + const bodyStr = JSON.stringify(body); + + const { random, signature } = generateNextcloudTalkSignature({ + body: bodyStr, + secret, + }); + + const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${roomToken}/message`; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "OCS-APIRequest": "true", + "X-Nextcloud-Talk-Bot-Random": random, + "X-Nextcloud-Talk-Bot-Signature": signature, + }, + body: bodyStr, + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + const status = response.status; + let errorMsg = `Nextcloud Talk send failed (${status})`; + + if (status === 400) { + errorMsg = `Nextcloud Talk: bad request - ${errorBody || "invalid message format"}`; + } else if (status === 401) { + errorMsg = "Nextcloud Talk: authentication failed - check bot secret"; + } else if (status === 403) { + errorMsg = "Nextcloud Talk: forbidden - bot may not have permission in this room"; + } else if (status === 404) { + errorMsg = `Nextcloud Talk: room not found (token=${roomToken})`; + } else if (errorBody) { + errorMsg = `Nextcloud Talk send failed: ${errorBody}`; + } + + throw new Error(errorMsg); + } + + let messageId = "unknown"; + let timestamp: number | undefined; + try { + const data = (await response.json()) as { + ocs?: { + data?: { + id?: number | string; + timestamp?: number; + }; + }; + }; + if (data.ocs?.data?.id != null) { + messageId = String(data.ocs.data.id); + } + if (typeof data.ocs?.data?.timestamp === "number") { + timestamp = data.ocs.data.timestamp; + } + } catch { + // Response parsing failed, but message was sent. + } + + if (opts.verbose) { + console.log(`[nextcloud-talk] Sent message ${messageId} to room ${roomToken}`); + } + + getNextcloudTalkRuntime().channel.activity.record({ + channel: "nextcloud-talk", + accountId: account.accountId, + direction: "outbound", + }); + + return { messageId, roomToken, timestamp }; +} + +export async function sendReactionNextcloudTalk( + roomToken: string, + messageId: string, + reaction: string, + opts: Omit = {}, +): Promise<{ ok: true }> { + const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig; + const account = resolveNextcloudTalkAccount({ + cfg, + accountId: opts.accountId, + }); + const { baseUrl, secret } = resolveCredentials( + { baseUrl: opts.baseUrl, secret: opts.secret }, + account, + ); + const normalizedToken = normalizeRoomToken(roomToken); + + const body = JSON.stringify({ reaction }); + const { random, signature } = generateNextcloudTalkSignature({ + body, + secret, + }); + + const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${normalizedToken}/reaction/${messageId}`; + + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + "OCS-APIRequest": "true", + "X-Nextcloud-Talk-Bot-Random": random, + "X-Nextcloud-Talk-Bot-Signature": signature, + }, + body, + }); + + if (!response.ok) { + const errorBody = await response.text().catch(() => ""); + throw new Error(`Nextcloud Talk reaction failed: ${response.status} ${errorBody}`.trim()); + } + + return { ok: true }; +} diff --git a/extensions/nextcloud-talk/src/signature.ts b/extensions/nextcloud-talk/src/signature.ts new file mode 100644 index 000000000..93384720d --- /dev/null +++ b/extensions/nextcloud-talk/src/signature.ts @@ -0,0 +1,67 @@ +import { createHmac, randomBytes } from "node:crypto"; + +import type { NextcloudTalkWebhookHeaders } from "./types.js"; + +const SIGNATURE_HEADER = "x-nextcloud-talk-signature"; +const RANDOM_HEADER = "x-nextcloud-talk-random"; +const BACKEND_HEADER = "x-nextcloud-talk-backend"; + +/** + * Verify the HMAC-SHA256 signature of an incoming webhook request. + * Signature is calculated as: HMAC-SHA256(random + body, secret) + */ +export function verifyNextcloudTalkSignature(params: { + signature: string; + random: string; + body: string; + secret: string; +}): boolean { + const { signature, random, body, secret } = params; + if (!signature || !random || !secret) return false; + + const expected = createHmac("sha256", secret) + .update(random + body) + .digest("hex"); + + if (signature.length !== expected.length) return false; + let result = 0; + for (let i = 0; i < signature.length; i++) { + result |= signature.charCodeAt(i) ^ expected.charCodeAt(i); + } + return result === 0; +} + +/** + * Extract webhook headers from an incoming request. + */ +export function extractNextcloudTalkHeaders( + headers: Record, +): NextcloudTalkWebhookHeaders | null { + const getHeader = (name: string): string | undefined => { + const value = headers[name] ?? headers[name.toLowerCase()]; + return Array.isArray(value) ? value[0] : value; + }; + + const signature = getHeader(SIGNATURE_HEADER); + const random = getHeader(RANDOM_HEADER); + const backend = getHeader(BACKEND_HEADER); + + if (!signature || !random || !backend) return null; + + return { signature, random, backend }; +} + +/** + * Generate signature headers for an outbound request to Nextcloud Talk. + */ +export function generateNextcloudTalkSignature(params: { body: string; secret: string }): { + random: string; + signature: string; +} { + const { body, secret } = params; + const random = randomBytes(32).toString("hex"); + const signature = createHmac("sha256", secret) + .update(random + body) + .digest("hex"); + return { random, signature }; +} diff --git a/extensions/nextcloud-talk/src/types.ts b/extensions/nextcloud-talk/src/types.ts new file mode 100644 index 000000000..97d11c4ab --- /dev/null +++ b/extensions/nextcloud-talk/src/types.ts @@ -0,0 +1,175 @@ +import type { + BlockStreamingCoalesceConfig, + DmConfig, + DmPolicy, + GroupPolicy, +} from "clawdbot/plugin-sdk"; + +export type NextcloudTalkRoomConfig = { + requireMention?: boolean; + /** If specified, only load these skills for this room. Omit = all skills; empty = no skills. */ + skills?: string[]; + /** If false, disable the bot for this room. */ + enabled?: boolean; + /** Optional allowlist for room senders (user ids). */ + allowFrom?: string[]; + /** Optional system prompt snippet for this room. */ + systemPrompt?: string; +}; + +export type NextcloudTalkAccountConfig = { + /** Optional display name for this account (used in CLI/UI lists). */ + name?: string; + /** If false, do not start this Nextcloud Talk account. Default: true. */ + enabled?: boolean; + /** Base URL of the Nextcloud instance (e.g., "https://cloud.example.com"). */ + baseUrl?: string; + /** Bot shared secret from occ talk:bot:install output. */ + botSecret?: string; + /** Path to file containing bot secret (for secret managers). */ + botSecretFile?: string; + /** Optional API user for room lookups (DM detection). */ + apiUser?: string; + /** Optional API password/app password for room lookups. */ + apiPassword?: string; + /** Path to file containing API password/app password. */ + apiPasswordFile?: string; + /** Direct message policy (default: pairing). */ + dmPolicy?: DmPolicy; + /** Webhook server port. Default: 8788. */ + webhookPort?: number; + /** Webhook server host. Default: "0.0.0.0". */ + webhookHost?: string; + /** Webhook endpoint path. Default: "/nextcloud-talk-webhook". */ + webhookPath?: string; + /** Public URL for the webhook (used if behind reverse proxy). */ + webhookPublicUrl?: string; + /** Optional allowlist of user IDs allowed to DM the bot. */ + allowFrom?: string[]; + /** Optional allowlist for Nextcloud Talk room senders (user ids). */ + groupAllowFrom?: string[]; + /** Group message policy (default: allowlist). */ + groupPolicy?: GroupPolicy; + /** Per-room configuration (key is room token). */ + rooms?: Record; + /** Max group messages to keep as history context (0 disables). */ + historyLimit?: number; + /** Max DM turns to keep as history context. */ + dmHistoryLimit?: number; + /** Per-DM config overrides keyed by user ID. */ + dms?: Record; + /** Outbound text chunk size (chars). Default: 4000. */ + textChunkLimit?: number; + /** Disable block streaming for this account. */ + blockStreaming?: boolean; + /** Merge streamed block replies before sending. */ + blockStreamingCoalesce?: BlockStreamingCoalesceConfig; + /** Media upload max size in MB. */ + mediaMaxMb?: number; +}; + +export type NextcloudTalkConfig = { + /** Optional per-account Nextcloud Talk configuration (multi-account). */ + accounts?: Record; +} & NextcloudTalkAccountConfig; + +export type CoreConfig = { + channels?: { + "nextcloud-talk"?: NextcloudTalkConfig; + }; + [key: string]: unknown; +}; + +/** + * Nextcloud Talk webhook payload types based on Activity Streams 2.0 format. + * Reference: https://nextcloud-talk.readthedocs.io/en/latest/bots/ + */ + +/** Actor in the activity (the message sender). */ +export type NextcloudTalkActor = { + type: "Person"; + /** User ID in Nextcloud. */ + id: string; + /** Display name of the user. */ + name: string; +}; + +/** The message object in the activity. */ +export type NextcloudTalkObject = { + type: "Note"; + /** Message ID. */ + id: string; + /** Message text (same as content for text/plain). */ + name: string; + /** Message content. */ + content: string; + /** Media type of the content. */ + mediaType: string; +}; + +/** Target conversation/room. */ +export type NextcloudTalkTarget = { + type: "Collection"; + /** Room token. */ + id: string; + /** Room display name. */ + name: string; +}; + +/** Incoming webhook payload from Nextcloud Talk. */ +export type NextcloudTalkWebhookPayload = { + type: "Create" | "Update" | "Delete"; + actor: NextcloudTalkActor; + object: NextcloudTalkObject; + target: NextcloudTalkTarget; +}; + +/** Result from sending a message to Nextcloud Talk. */ +export type NextcloudTalkSendResult = { + messageId: string; + roomToken: string; + timestamp?: number; +}; + +/** Parsed incoming message context. */ +export type NextcloudTalkInboundMessage = { + messageId: string; + roomToken: string; + roomName: string; + senderId: string; + senderName: string; + text: string; + mediaType: string; + timestamp: number; + isGroupChat: boolean; +}; + +/** Headers sent by Nextcloud Talk webhook. */ +export type NextcloudTalkWebhookHeaders = { + /** HMAC-SHA256 signature of the request. */ + signature: string; + /** Random string used in signature calculation. */ + random: string; + /** Backend Nextcloud server URL. */ + backend: string; +}; + +/** Options for the webhook server. */ +export type NextcloudTalkWebhookServerOptions = { + port: number; + host: string; + path: string; + secret: string; + onMessage: (message: NextcloudTalkInboundMessage) => void | Promise; + onError?: (error: Error) => void; + abortSignal?: AbortSignal; +}; + +/** Options for sending a message. */ +export type NextcloudTalkSendOptions = { + baseUrl: string; + secret: string; + roomToken: string; + message: string; + replyTo?: string; +}; diff --git a/extensions/zalo/package.json b/extensions/zalo/package.json index 6527ddce5..d811f16ec 100644 --- a/extensions/zalo/package.json +++ b/extensions/zalo/package.json @@ -6,7 +6,23 @@ "clawdbot": { "extensions": [ "./index.ts" - ] + ], + "channel": { + "id": "zalo", + "label": "Zalo", + "selectionLabel": "Zalo (Bot API)", + "docsPath": "/channels/zalo", + "docsLabel": "zalo", + "blurb": "Vietnam-focused messaging platform with Bot API.", + "aliases": ["zl"], + "order": 80, + "quickstartAllowFrom": true + }, + "install": { + "npmSpec": "@clawdbot/zalo", + "localPath": "extensions/zalo", + "defaultChoice": "npm" + } }, "dependencies": { "clawdbot": "workspace:*", diff --git a/src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.returns-undefined-sessionkey-is-undefined.test.ts b/src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.returns-undefined-sessionkey-is-undefined.test.ts index 74a98c9d5..f9bc1b69d 100644 --- a/src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.returns-undefined-sessionkey-is-undefined.test.ts +++ b/src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.returns-undefined-sessionkey-is-undefined.test.ts @@ -150,6 +150,7 @@ describe("getDmHistoryLimitFromSessionKey", () => { "signal", "imessage", "msteams", + "nextcloud-talk", ] as const; for (const provider of providers) { @@ -168,6 +169,7 @@ describe("getDmHistoryLimitFromSessionKey", () => { "signal", "imessage", "msteams", + "nextcloud-talk", ] as const; for (const provider of providers) { diff --git a/src/agents/pi-embedded-runner/history.ts b/src/agents/pi-embedded-runner/history.ts index 921bd47e3..bcc0625c7 100644 --- a/src/agents/pi-embedded-runner/history.ts +++ b/src/agents/pi-embedded-runner/history.ts @@ -62,22 +62,16 @@ export function getDmHistoryLimitFromSessionKey( return providerConfig.dmHistoryLimit; }; - switch (provider) { - case "telegram": - return getLimit(config.channels?.telegram); - case "whatsapp": - return getLimit(config.channels?.whatsapp); - case "discord": - return getLimit(config.channels?.discord); - case "slack": - return getLimit(config.channels?.slack); - case "signal": - return getLimit(config.channels?.signal); - case "imessage": - return getLimit(config.channels?.imessage); - case "msteams": - return getLimit(config.channels?.msteams); - default: - return undefined; - } + const resolveProviderConfig = ( + cfg: ClawdbotConfig | undefined, + providerId: string, + ): { dmHistoryLimit?: number; dms?: Record } | undefined => { + const channels = cfg?.channels; + if (!channels || typeof channels !== "object") return undefined; + const entry = (channels as Record)[providerId]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined; + return entry as { dmHistoryLimit?: number; dms?: Record }; + }; + + return getLimit(resolveProviderConfig(config, provider)); } diff --git a/src/channels/plugins/catalog.ts b/src/channels/plugins/catalog.ts index a88435861..0e8225481 100644 --- a/src/channels/plugins/catalog.ts +++ b/src/channels/plugins/catalog.ts @@ -1,3 +1,8 @@ +import path from "node:path"; + +import { discoverClawdbotPlugins } from "../../plugins/discovery.js"; +import type { PluginOrigin } from "../../plugins/types.js"; +import type { ClawdbotManifest } from "../../plugins/manifest.js"; import type { ChannelMeta } from "./types.js"; export type ChannelPluginCatalogEntry = { @@ -10,86 +15,133 @@ export type ChannelPluginCatalogEntry = { }; }; -const CATALOG: ChannelPluginCatalogEntry[] = [ - { - id: "msteams", - meta: { - id: "msteams", - label: "Microsoft Teams", - selectionLabel: "Microsoft Teams (Bot Framework)", - docsPath: "/channels/msteams", - docsLabel: "msteams", - blurb: "Bot Framework; enterprise support.", - aliases: ["teams"], - order: 60, - }, - install: { - npmSpec: "@clawdbot/msteams", - localPath: "extensions/msteams", - defaultChoice: "npm", - }, - }, - { - id: "matrix", - meta: { - id: "matrix", - label: "Matrix", - selectionLabel: "Matrix (plugin)", - docsPath: "/channels/matrix", - docsLabel: "matrix", - blurb: "open protocol; install the plugin to enable.", - order: 70, - quickstartAllowFrom: true, - }, - install: { - npmSpec: "@clawdbot/matrix", - localPath: "extensions/matrix", - defaultChoice: "npm", - }, - }, - { - id: "bluebubbles", - meta: { - id: "bluebubbles", - label: "BlueBubbles", - selectionLabel: "BlueBubbles (macOS app)", - docsPath: "/channels/bluebubbles", - docsLabel: "bluebubbles", - blurb: "iMessage via the BlueBubbles mac app + REST API.", - order: 75, - }, - install: { - npmSpec: "@clawdbot/bluebubbles", - localPath: "extensions/bluebubbles", - defaultChoice: "npm", - }, - }, - { - id: "zalo", - meta: { - id: "zalo", - label: "Zalo", - selectionLabel: "Zalo (Bot API)", - docsPath: "/channels/zalo", - docsLabel: "zalo", - blurb: "Vietnam-focused messaging platform with Bot API.", - aliases: ["zl"], - order: 80, - quickstartAllowFrom: true, - }, - install: { - npmSpec: "@clawdbot/zalo", - localPath: "extensions/zalo", - }, - }, -]; +type CatalogOptions = { + workspaceDir?: string; +}; -export function listChannelPluginCatalogEntries(): ChannelPluginCatalogEntry[] { - return [...CATALOG]; +const ORIGIN_PRIORITY: Record = { + config: 0, + workspace: 1, + global: 2, + bundled: 3, +}; + +function toChannelMeta(params: { + channel: NonNullable; + id: string; +}): ChannelMeta | null { + const label = params.channel.label?.trim(); + if (!label) return null; + const selectionLabel = params.channel.selectionLabel?.trim() || label; + const docsPath = params.channel.docsPath?.trim() || `/channels/${params.id}`; + const blurb = params.channel.blurb?.trim() || ""; + + return { + id: params.id, + label, + selectionLabel, + docsPath, + docsLabel: params.channel.docsLabel?.trim() || undefined, + blurb, + ...(params.channel.aliases ? { aliases: params.channel.aliases } : {}), + ...(params.channel.order !== undefined ? { order: params.channel.order } : {}), + ...(params.channel.selectionDocsPrefix + ? { selectionDocsPrefix: params.channel.selectionDocsPrefix } + : {}), + ...(params.channel.selectionDocsOmitLabel !== undefined + ? { selectionDocsOmitLabel: params.channel.selectionDocsOmitLabel } + : {}), + ...(params.channel.selectionExtras ? { selectionExtras: params.channel.selectionExtras } : {}), + ...(params.channel.showConfigured !== undefined + ? { showConfigured: params.channel.showConfigured } + : {}), + ...(params.channel.quickstartAllowFrom !== undefined + ? { quickstartAllowFrom: params.channel.quickstartAllowFrom } + : {}), + ...(params.channel.forceAccountBinding !== undefined + ? { forceAccountBinding: params.channel.forceAccountBinding } + : {}), + ...(params.channel.preferSessionLookupForAnnounceTarget !== undefined + ? { + preferSessionLookupForAnnounceTarget: params.channel.preferSessionLookupForAnnounceTarget, + } + : {}), + }; } -export function getChannelPluginCatalogEntry(id: string): ChannelPluginCatalogEntry | undefined { +function resolveInstallInfo(params: { + manifest: ClawdbotManifest; + packageName?: string; + packageDir?: string; + workspaceDir?: string; +}): ChannelPluginCatalogEntry["install"] | null { + const npmSpec = params.manifest.install?.npmSpec?.trim() ?? params.packageName?.trim(); + if (!npmSpec) return null; + let localPath = params.manifest.install?.localPath?.trim() || undefined; + if (!localPath && params.workspaceDir && params.packageDir) { + localPath = path.relative(params.workspaceDir, params.packageDir) || undefined; + } + const defaultChoice = params.manifest.install?.defaultChoice ?? (localPath ? "local" : "npm"); + return { + npmSpec, + ...(localPath ? { localPath } : {}), + ...(defaultChoice ? { defaultChoice } : {}), + }; +} + +function buildCatalogEntry(candidate: { + packageName?: string; + packageDir?: string; + workspaceDir?: string; + packageClawdbot?: ClawdbotManifest; +}): ChannelPluginCatalogEntry | null { + const manifest = candidate.packageClawdbot; + if (!manifest?.channel) return null; + const id = manifest.channel.id?.trim(); + if (!id) return null; + const meta = toChannelMeta({ channel: manifest.channel, id }); + if (!meta) return null; + const install = resolveInstallInfo({ + manifest, + packageName: candidate.packageName, + packageDir: candidate.packageDir, + workspaceDir: candidate.workspaceDir, + }); + if (!install) return null; + return { id, meta, install }; +} + +export function listChannelPluginCatalogEntries( + options: CatalogOptions = {}, +): ChannelPluginCatalogEntry[] { + const discovery = discoverClawdbotPlugins({ workspaceDir: options.workspaceDir }); + const resolved = new Map(); + + for (const candidate of discovery.candidates) { + const entry = buildCatalogEntry(candidate); + if (!entry) continue; + const priority = ORIGIN_PRIORITY[candidate.origin] ?? 99; + const existing = resolved.get(entry.id); + if (!existing || priority < existing.priority) { + resolved.set(entry.id, { entry, priority }); + } + } + + return Array.from(resolved.values()) + .map(({ entry }) => entry) + .sort((a, b) => { + const orderA = a.meta.order ?? 999; + const orderB = b.meta.order ?? 999; + if (orderA !== orderB) return orderA - orderB; + return a.meta.label.localeCompare(b.meta.label); + }); +} + +export function getChannelPluginCatalogEntry( + id: string, + options: CatalogOptions = {}, +): ChannelPluginCatalogEntry | undefined { const trimmed = id.trim(); if (!trimmed) return undefined; - return CATALOG.find((entry) => entry.id === trimmed); + return listChannelPluginCatalogEntries(options).find((entry) => entry.id === trimmed); } diff --git a/src/config/plugin-auto-enable.ts b/src/config/plugin-auto-enable.ts index 41c4eec12..ce8398606 100644 --- a/src/config/plugin-auto-enable.ts +++ b/src/config/plugin-auto-enable.ts @@ -1,6 +1,7 @@ import type { ClawdbotConfig } from "./config.js"; -import { hasAnyWhatsAppAuth } from "../web/accounts.js"; import { normalizeProviderId } from "../agents/model-selection.js"; +import { listChatChannels } from "../channels/registry.js"; +import { hasAnyWhatsAppAuth } from "../web/accounts.js"; type PluginEnableChange = { pluginId: string; @@ -12,20 +13,6 @@ export type PluginAutoEnableResult = { changes: string[]; }; -const CHANNEL_PLUGIN_IDS = [ - "whatsapp", - "telegram", - "discord", - "slack", - "signal", - "imessage", - "msteams", - "matrix", - "zalo", - "zalouser", - "bluebubbles", -] as const; - const PROVIDER_PLUGIN_IDS: Array<{ pluginId: string; providerId: string }> = [ { pluginId: "google-antigravity-auth", providerId: "google-antigravity" }, { pluginId: "google-gemini-cli-auth", providerId: "google-gemini-cli" }, @@ -239,7 +226,19 @@ function resolveConfiguredPlugins( env: NodeJS.ProcessEnv, ): PluginEnableChange[] { const changes: PluginEnableChange[] = []; - for (const channelId of CHANNEL_PLUGIN_IDS) { + const channelIds = new Set(); + for (const meta of listChatChannels()) { + channelIds.add(meta.id); + } + const configuredChannels = cfg.channels as Record | undefined; + if (configuredChannels && typeof configuredChannels === "object") { + for (const key of Object.keys(configuredChannels)) { + if (key === "defaults") continue; + channelIds.add(key); + } + } + for (const channelId of channelIds) { + if (!channelId) continue; if (isChannelConfigured(cfg, channelId, env)) { changes.push({ pluginId: channelId, diff --git a/src/plugin-sdk/index.ts b/src/plugin-sdk/index.ts index edc9ea061..0956f31d7 100644 --- a/src/plugin-sdk/index.ts +++ b/src/plugin-sdk/index.ts @@ -60,7 +60,9 @@ export type { ClawdbotConfig } from "../config/config.js"; export type { ChannelDock } from "../channels/dock.js"; export { getChatChannelMeta } from "../channels/registry.js"; export type { + BlockStreamingCoalesceConfig, DmPolicy, + DmConfig, GroupPolicy, MSTeamsChannelConfig, MSTeamsConfig, @@ -76,6 +78,14 @@ export { TelegramConfigSchema, } from "../config/zod-schema.providers-core.js"; export { WhatsAppConfigSchema } from "../config/zod-schema.providers-whatsapp.js"; +export { + BlockStreamingCoalesceSchema, + DmConfigSchema, + DmPolicySchema, + GroupPolicySchema, + normalizeAllowFrom, + requireOpenAllowFrom, +} from "../config/zod-schema.core.js"; export type { RuntimeEnv } from "../runtime.js"; export type { WizardPrompter } from "../wizard/prompts.js"; export { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../routing/session-key.js"; @@ -89,7 +99,10 @@ export { } from "../auto-reply/reply/history.js"; export type { HistoryEntry } from "../auto-reply/reply/history.js"; export { mergeAllowlist, summarizeMapping } from "../channels/allowlists/resolve-utils.js"; -export { resolveMentionGating } from "../channels/mention-gating.js"; +export { + resolveMentionGating, + resolveMentionGatingWithBypass, +} from "../channels/mention-gating.js"; export { resolveChannelMediaMaxBytes } from "../channels/plugins/media-limits.js"; export { resolveDiscordGroupRequireMention, diff --git a/src/plugins/discovery.ts b/src/plugins/discovery.ts index 76cc18b26..a71b1caa0 100644 --- a/src/plugins/discovery.ts +++ b/src/plugins/discovery.ts @@ -3,6 +3,7 @@ import path from "node:path"; import { resolveConfigDir, resolveUserPath } from "../utils.js"; import { resolveBundledPluginsDir } from "./bundled-dir.js"; +import type { ClawdbotManifest, PackageManifest } from "./manifest.js"; import type { PluginDiagnostic, PluginOrigin } from "./types.js"; const EXTENSION_EXTS = new Set([".ts", ".js", ".mts", ".cts", ".mjs", ".cjs"]); @@ -16,6 +17,8 @@ export type PluginCandidate = { packageName?: string; packageVersion?: string; packageDescription?: string; + packageDir?: string; + packageClawdbot?: ClawdbotManifest; }; export type PluginDiscoveryResult = { @@ -23,15 +26,6 @@ export type PluginDiscoveryResult = { diagnostics: PluginDiagnostic[]; }; -type PackageManifest = { - name?: string; - version?: string; - description?: string; - clawdbot?: { - extensions?: string[]; - }; -}; - function isExtensionFile(filePath: string): boolean { const ext = path.extname(filePath); if (!EXTENSION_EXTS.has(ext)) return false; @@ -83,6 +77,7 @@ function addCandidate(params: { origin: PluginOrigin; workspaceDir?: string; manifest?: PackageManifest | null; + packageDir?: string; }) { const resolved = path.resolve(params.source); if (params.seen.has(resolved)) return; @@ -97,6 +92,8 @@ function addCandidate(params: { packageName: manifest?.name?.trim() || undefined, packageVersion: manifest?.version?.trim() || undefined, packageDescription: manifest?.description?.trim() || undefined, + packageDir: params.packageDir, + packageClawdbot: manifest?.clawdbot, }); } @@ -156,6 +153,7 @@ function discoverInDirectory(params: { origin: params.origin, workspaceDir: params.workspaceDir, manifest, + packageDir: fullPath, }); } continue; @@ -174,6 +172,8 @@ function discoverInDirectory(params: { rootDir: fullPath, origin: params.origin, workspaceDir: params.workspaceDir, + manifest, + packageDir: fullPath, }); } } @@ -239,6 +239,7 @@ function discoverFromPath(params: { origin: params.origin, workspaceDir: params.workspaceDir, manifest, + packageDir: resolved, }); } return; @@ -258,6 +259,8 @@ function discoverFromPath(params: { rootDir: resolved, origin: params.origin, workspaceDir: params.workspaceDir, + manifest, + packageDir: resolved, }); return; } diff --git a/src/plugins/manifest.ts b/src/plugins/manifest.ts index 40f4d1e03..d4db46f6c 100644 --- a/src/plugins/manifest.ts +++ b/src/plugins/manifest.ts @@ -1,91 +1,36 @@ -import fs from "node:fs"; -import path from "node:path"; - -import type { PluginConfigUiHint, PluginKind } from "./types.js"; - -export const PLUGIN_MANIFEST_FILENAME = "clawdbot.plugin.json"; - -export type PluginManifest = { - id: string; - configSchema: Record; - kind?: PluginKind; - channels?: string[]; - providers?: string[]; - name?: string; - description?: string; - version?: string; - uiHints?: Record; +export type PluginManifestChannel = { + id?: string; + label?: string; + selectionLabel?: string; + docsPath?: string; + docsLabel?: string; + blurb?: string; + order?: number; + aliases?: string[]; + selectionDocsPrefix?: string; + selectionDocsOmitLabel?: boolean; + selectionExtras?: string[]; + showConfigured?: boolean; + quickstartAllowFrom?: boolean; + forceAccountBinding?: boolean; + preferSessionLookupForAnnounceTarget?: boolean; }; -export type PluginManifestLoadResult = - | { ok: true; manifest: PluginManifest; manifestPath: string } - | { ok: false; error: string; manifestPath: string }; +export type PluginManifestInstall = { + npmSpec?: string; + localPath?: string; + defaultChoice?: "npm" | "local"; +}; -function normalizeStringList(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); -} +export type ClawdbotManifest = { + extensions?: string[]; + channel?: PluginManifestChannel; + install?: PluginManifestInstall; +}; -function isRecord(value: unknown): value is Record { - return Boolean(value && typeof value === "object" && !Array.isArray(value)); -} - -export function resolvePluginManifestPath(rootDir: string): string { - return path.join(rootDir, PLUGIN_MANIFEST_FILENAME); -} - -export function loadPluginManifest(rootDir: string): PluginManifestLoadResult { - const manifestPath = resolvePluginManifestPath(rootDir); - if (!fs.existsSync(manifestPath)) { - return { ok: false, error: `plugin manifest not found: ${manifestPath}`, manifestPath }; - } - let raw: unknown; - try { - raw = JSON.parse(fs.readFileSync(manifestPath, "utf-8")) as unknown; - } catch (err) { - return { - ok: false, - error: `failed to parse plugin manifest: ${String(err)}`, - manifestPath, - }; - } - if (!isRecord(raw)) { - return { ok: false, error: "plugin manifest must be an object", manifestPath }; - } - const id = typeof raw.id === "string" ? raw.id.trim() : ""; - if (!id) { - return { ok: false, error: "plugin manifest requires id", manifestPath }; - } - const configSchema = isRecord(raw.configSchema) ? raw.configSchema : null; - if (!configSchema) { - return { ok: false, error: "plugin manifest requires configSchema", manifestPath }; - } - - const kind = typeof raw.kind === "string" ? (raw.kind as PluginKind) : undefined; - const name = typeof raw.name === "string" ? raw.name.trim() : undefined; - const description = typeof raw.description === "string" ? raw.description.trim() : undefined; - const version = typeof raw.version === "string" ? raw.version.trim() : undefined; - const channels = normalizeStringList(raw.channels); - const providers = normalizeStringList(raw.providers); - - let uiHints: Record | undefined; - if (isRecord(raw.uiHints)) { - uiHints = raw.uiHints as Record; - } - - return { - ok: true, - manifest: { - id, - configSchema, - kind, - channels, - providers, - name, - description, - version, - uiHints, - }, - manifestPath, - }; -} +export type PackageManifest = { + name?: string; + version?: string; + description?: string; + clawdbot?: ClawdbotManifest; +}; diff --git a/src/plugins/runtime/index.ts b/src/plugins/runtime/index.ts index 51eff2734..655073c12 100644 --- a/src/plugins/runtime/index.ts +++ b/src/plugins/runtime/index.ts @@ -52,6 +52,7 @@ import { probeDiscord } from "../../discord/probe.js"; import { resolveDiscordChannelAllowlist } from "../../discord/resolve-channels.js"; import { resolveDiscordUserAllowlist } from "../../discord/resolve-users.js"; import { sendMessageDiscord, sendPollDiscord } from "../../discord/send.js"; +import { getChannelActivity, recordChannelActivity } from "../../infra/channel-activity.js"; import { enqueueSystemEvent } from "../../infra/system-events.js"; import { monitorIMessageProvider } from "../../imessage/monitor.js"; import { probeIMessage } from "../../imessage/probe.js"; @@ -177,6 +178,10 @@ export function createPluginRuntime(): PluginRuntime { fetchRemoteMedia, saveMediaBuffer, }, + activity: { + record: recordChannelActivity, + get: getChannelActivity, + }, session: { resolveStorePath, readSessionUpdatedAt, diff --git a/src/plugins/runtime/types.ts b/src/plugins/runtime/types.ts index 397a79f09..f707a20bb 100644 --- a/src/plugins/runtime/types.ts +++ b/src/plugins/runtime/types.ts @@ -55,6 +55,8 @@ type ReadSessionUpdatedAt = typeof import("../../config/sessions.js").readSessio type UpdateLastRoute = typeof import("../../config/sessions.js").updateLastRoute; type LoadConfig = typeof import("../../config/config.js").loadConfig; type WriteConfigFile = typeof import("../../config/config.js").writeConfigFile; +type RecordChannelActivity = typeof import("../../infra/channel-activity.js").recordChannelActivity; +type GetChannelActivity = typeof import("../../infra/channel-activity.js").getChannelActivity; type EnqueueSystemEvent = typeof import("../../infra/system-events.js").enqueueSystemEvent; type RunCommandWithTimeout = typeof import("../../process/exec.js").runCommandWithTimeout; type LoadWebMedia = typeof import("../../web/media.js").loadWebMedia; @@ -188,6 +190,10 @@ export type PluginRuntime = { fetchRemoteMedia: FetchRemoteMedia; saveMediaBuffer: SaveMediaBuffer; }; + activity: { + record: RecordChannelActivity; + get: GetChannelActivity; + }; session: { resolveStorePath: ResolveStorePath; readSessionUpdatedAt: ReadSessionUpdatedAt;