Critical severity9.8NVD Advisory· Published Mar 5, 2026· Updated May 6, 2026
CVE-2026-28474
CVE-2026-28474
Description
OpenClaw's Nextcloud Talk plugin versions prior to 2026.2.6 accept equality matching on the mutable actor.name display name field for allowlist validation, allowing attackers to bypass DM and room allowlists. An attacker can change their Nextcloud display name to match an allowlisted user ID and gain unauthorized access to restricted conversations.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
@openclaw/nextcloud-talknpm | < 2026.2.6 | 2026.2.6 |
Affected products
2- OpenClaw/nextcloud-talkv5Range: 0
Patches
26b4b6049b47cfix: enforce Nextcloud Talk allowlist by user id
5 files changed · +37 −13
docs/channels/nextcloud-talk.md+1 −0 modified@@ -72,6 +72,7 @@ Minimal config: - `openclaw pairing list nextcloud-talk` - `openclaw pairing approve nextcloud-talk <CODE>` - Public DMs: `channels.nextcloud-talk.dmPolicy="open"` plus `channels.nextcloud-talk.allowFrom=["*"]`. +- `allowFrom` matches Nextcloud user IDs only; display names are ignored. ## Rooms (groups)
extensions/nextcloud-talk/src/inbound.ts+0 −3 modified@@ -121,7 +121,6 @@ export async function handleNextcloudTalkInbound(params: { const senderAllowedForCommands = resolveNextcloudTalkAllowlistMatch({ allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom, senderId, - senderName, }).allowed; const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig); const commandGate = resolveControlCommandGate({ @@ -143,7 +142,6 @@ export async function handleNextcloudTalkInbound(params: { outerAllowFrom: effectiveGroupAllowFrom, innerAllowFrom: roomAllowFrom, senderId, - senderName, }); if (!groupAllow.allowed) { runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (policy=${groupPolicy})`); @@ -158,7 +156,6 @@ export async function handleNextcloudTalkInbound(params: { const dmAllowed = resolveNextcloudTalkAllowlistMatch({ allowFrom: effectiveAllowFrom, senderId, - senderName, }).allowed; if (!dmAllowed) { if (dmPolicy === "pairing") {
extensions/nextcloud-talk/src/monitor.ts+1 −1 modified@@ -54,7 +54,7 @@ function payloadToInboundMessage( roomToken: payload.target.id, roomName: payload.target.name, senderId: payload.actor.id, - senderName: payload.actor.name, + senderName: payload.actor.name ?? "", text: payload.object.content || payload.object.name || "", mediaType: payload.object.mediaType || "text/plain", timestamp: Date.now(),
extensions/nextcloud-talk/src/policy.test.ts+34 −0 added@@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { resolveNextcloudTalkAllowlistMatch } from "./policy.js"; + +describe("nextcloud-talk policy", () => { + describe("resolveNextcloudTalkAllowlistMatch", () => { + it("allows wildcard", () => { + expect( + resolveNextcloudTalkAllowlistMatch({ + allowFrom: ["*"], + senderId: "user-id", + }).allowed, + ).toBe(true); + }); + + it("allows sender id match with normalization", () => { + expect( + resolveNextcloudTalkAllowlistMatch({ + allowFrom: ["nc:User-Id"], + senderId: "user-id", + }), + ).toEqual({ allowed: true, matchKey: "user-id", matchSource: "id" }); + }); + + it("blocks when sender id does not match", () => { + expect( + resolveNextcloudTalkAllowlistMatch({ + allowFrom: ["allowed"], + senderId: "other", + }).allowed, + ).toBe(false); + }); + }); +});
extensions/nextcloud-talk/src/policy.ts+1 −9 modified@@ -29,8 +29,7 @@ export function normalizeNextcloudTalkAllowlist( export function resolveNextcloudTalkAllowlistMatch(params: { allowFrom: Array<string | number> | undefined; senderId: string; - senderName?: string | null; -}): AllowlistMatch<"wildcard" | "id" | "name"> { +}): AllowlistMatch<"wildcard" | "id"> { const allowFrom = normalizeNextcloudTalkAllowlist(params.allowFrom); if (allowFrom.length === 0) { return { allowed: false }; @@ -42,10 +41,6 @@ export function resolveNextcloudTalkAllowlistMatch(params: { 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 }; } @@ -132,7 +127,6 @@ export function resolveNextcloudTalkGroupAllow(params: { outerAllowFrom: Array<string | number> | undefined; innerAllowFrom: Array<string | number> | 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 } }; @@ -150,12 +144,10 @@ export function resolveNextcloudTalkGroupAllow(params: { 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,
660f87278c9frefactor: plugin catalog + nextcloud policy
33 files changed · +2863 −211
CHANGELOG.md+1 −1 modified@@ -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.
docs/channels/index.md+1 −0 modified@@ -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).
docs/channels/nextcloud-talk.md+119 −0 added@@ -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" "<shared-secret>" "<webhook-url>" --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 <CODE>` +- 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).
docs/plugin.md+31 −0 modified@@ -112,6 +112,37 @@ becomes `name/<fileBase>`. 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:
extensions/bluebubbles/package.json+15 −1 modified@@ -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" + } } }
extensions/matrix/package.json+16 −1 modified@@ -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:*",
extensions/msteams/package.json+16 −1 modified@@ -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",
extensions/nextcloud-talk/index.ts+18 −0 added@@ -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;
extensions/nextcloud-talk/package.json+25 −0 added@@ -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" + } + } +}
extensions/nextcloud-talk/src/accounts.ts+154 −0 added@@ -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<string>(); + 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); +}
extensions/nextcloud-talk/src/channel.ts+401 −0 added@@ -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<ResolvedNextcloudTalkAccount> = { + 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: "<roomToken>", + }, + }, + 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<string, unknown>; + 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<string, unknown>; + 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, + }; + }, + }, +};
extensions/nextcloud-talk/src/config-schema.ts+73 −0 added@@ -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 "*"', + }); +});
extensions/nextcloud-talk/src/format.ts+79 −0 added@@ -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; +}
extensions/nextcloud-talk/src/inbound.ts+331 −0 added@@ -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<void> { + 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<void> { + 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, + }, + }); +}
extensions/nextcloud-talk/src/monitor.ts+246 −0 added@@ -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<string> { + 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<void>; + 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<string, string | string[] | undefined>, + ); + 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<void> => { + 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<void>; + 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 }; +}
extensions/nextcloud-talk/src/normalize.ts+31 −0 added@@ -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); +}
extensions/nextcloud-talk/src/onboarding.ts+341 −0 added@@ -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<void> { + await prompter.note( + [ + "1) SSH into your Nextcloud server", + '2) Run: ./occ talk:bot:install "Clawdbot" "<shared-secret>" "<webhook-url>" --feature reaction', + "3) Copy the shared secret you used in the command", + "4) Enable the bot in your Nextcloud Talk room settings", + "Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.", + `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, + ].join("\n"), + "Nextcloud Talk bot setup", + ); +} + +async function noteNextcloudTalkUserIdHelp(prompter: WizardPrompter): Promise<void> { + await prompter.note( + [ + "1) Check the Nextcloud admin panel for user IDs", + "2) Or look at the webhook payload logs when someone messages", + "3) User IDs are typically lowercase usernames in Nextcloud", + `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`, + ].join("\n"), + "Nextcloud Talk user id", + ); +} + +async function promptNextcloudTalkAllowFrom(params: { + cfg: CoreConfig; + prompter: WizardPrompter; + accountId: string; +}): Promise<CoreConfig> { + const { cfg, prompter, accountId } = params; + const resolved = resolveNextcloudTalkAccount({ cfg, accountId }); + const existingAllowFrom = resolved.config.allowFrom ?? []; + await noteNextcloudTalkUserIdHelp(prompter); + + const parseInput = (value: string) => + value + .split(/[\n,;]+/g) + .map((entry) => entry.trim().toLowerCase()) + .filter(Boolean); + + let resolvedIds: string[] = []; + while (resolvedIds.length === 0) { + const entry = await prompter.text({ + message: "Nextcloud Talk allowFrom (user id)", + placeholder: "username", + initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined, + validate: (value) => (String(value ?? "").trim() ? undefined : "Required"), + }); + resolvedIds = parseInput(String(entry)); + if (resolvedIds.length === 0) { + await prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk allowlist"); + } + } + + const merged = [ + ...existingAllowFrom.map((item) => String(item).trim().toLowerCase()).filter(Boolean), + ...resolvedIds, + ]; + const unique = [...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<CoreConfig> { + const accountId = + params.accountId && normalizeAccountId(params.accountId) + ? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID) + : resolveDefaultNextcloudTalkAccountId(params.cfg); + return promptNextcloudTalkAllowFrom({ + cfg: params.cfg, + prompter: params.prompter, + accountId, + }); +} + +const dmPolicy: ChannelOnboardingDmPolicy = { + label: "Nextcloud Talk", + channel, + policyKey: "channels.nextcloud-talk.dmPolicy", + allowFromKey: "channels.nextcloud-talk.allowFrom", + getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy), + promptAllowFrom: promptNextcloudTalkAllowFromForAccount, +}; + +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 }, + }, + }), +};
extensions/nextcloud-talk/src/policy.ts+160 −0 added@@ -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<string | number> | undefined, +): string[] { + return (values ?? []).map((value) => normalizeAllowEntry(String(value))).filter(Boolean); +} + +export function resolveNextcloudTalkAllowlistMatch(params: { + allowFrom: Array<string | number> | 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<string, NextcloudTalkRoomConfig>; + 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<string | number> | undefined; + innerAllowFrom: Array<string | number> | 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 }; +}
extensions/nextcloud-talk/src/room-info.ts+111 −0 added@@ -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; + } +}
extensions/nextcloud-talk/src/runtime.ts+14 −0 added@@ -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; +}
extensions/nextcloud-talk/src/send.ts+196 −0 added@@ -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<NextcloudTalkSendResult> { + 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<string, unknown> = { + 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<NextcloudTalkSendOpts, "replyTo"> = {}, +): 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 }; +}
extensions/nextcloud-talk/src/signature.ts+67 −0 added@@ -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<string, string | string[] | undefined>, +): 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 }; +}
extensions/nextcloud-talk/src/types.ts+175 −0 added@@ -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<string, NextcloudTalkRoomConfig>; + /** 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<string, DmConfig>; + /** 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<string, NextcloudTalkAccountConfig>; +} & 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<void>; + onError?: (error: Error) => void; + abortSignal?: AbortSignal; +}; + +/** Options for sending a message. */ +export type NextcloudTalkSendOptions = { + baseUrl: string; + secret: string; + roomToken: string; + message: string; + replyTo?: string; +};
extensions/zalo/package.json+17 −1 modified@@ -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:*",
src/agents/pi-embedded-runner.get-dm-history-limit-from-session-key.returns-undefined-sessionkey-is-undefined.test.ts+2 −0 modified@@ -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) {
src/agents/pi-embedded-runner/history.ts+12 −18 modified@@ -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<string, { historyLimit?: number }> } | undefined => { + const channels = cfg?.channels; + if (!channels || typeof channels !== "object") return undefined; + const entry = (channels as Record<string, unknown>)[providerId]; + if (!entry || typeof entry !== "object" || Array.isArray(entry)) return undefined; + return entry as { dmHistoryLimit?: number; dms?: Record<string, { historyLimit?: number }> }; + }; + + return getLimit(resolveProviderConfig(config, provider)); }
src/channels/plugins/catalog.ts+129 −77 modified@@ -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; +}; + +const ORIGIN_PRIORITY: Record<PluginOrigin, number> = { + config: 0, + workspace: 1, + global: 2, + bundled: 3, +}; + +function toChannelMeta(params: { + channel: NonNullable<ClawdbotManifest["channel"]>; + 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, + } + : {}), + }; +} + +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<string, { entry: ChannelPluginCatalogEntry; priority: number }>(); + + 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 }); + } + } -export function listChannelPluginCatalogEntries(): ChannelPluginCatalogEntry[] { - return [...CATALOG]; + 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): ChannelPluginCatalogEntry | undefined { +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); }
src/config/plugin-auto-enable.ts+15 −16 modified@@ -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<string>(); + for (const meta of listChatChannels()) { + channelIds.add(meta.id); + } + const configuredChannels = cfg.channels as Record<string, unknown> | 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,
src/plugins/discovery.ts+12 −9 modified@@ -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,22 +17,15 @@ export type PluginCandidate = { packageName?: string; packageVersion?: string; packageDescription?: string; + packageDir?: string; + packageClawdbot?: ClawdbotManifest; }; export type PluginDiscoveryResult = { candidates: PluginCandidate[]; 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; }
src/plugin-sdk/index.ts+14 −1 modified@@ -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,
src/plugins/manifest.ts+30 −85 modified@@ -1,91 +1,36 @@ -import fs from "node:fs"; -import path from "node:path"; +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; +}; -import type { PluginConfigUiHint, PluginKind } from "./types.js"; +export type PluginManifestInstall = { + npmSpec?: string; + localPath?: string; + defaultChoice?: "npm" | "local"; +}; -export const PLUGIN_MANIFEST_FILENAME = "clawdbot.plugin.json"; +export type ClawdbotManifest = { + extensions?: string[]; + channel?: PluginManifestChannel; + install?: PluginManifestInstall; +}; -export type PluginManifest = { - id: string; - configSchema: Record<string, unknown>; - kind?: PluginKind; - channels?: string[]; - providers?: string[]; +export type PackageManifest = { name?: string; - description?: string; version?: string; - uiHints?: Record<string, PluginConfigUiHint>; + description?: string; + clawdbot?: ClawdbotManifest; }; - -export type PluginManifestLoadResult = - | { ok: true; manifest: PluginManifest; manifestPath: string } - | { ok: false; error: string; manifestPath: string }; - -function normalizeStringList(value: unknown): string[] { - if (!Array.isArray(value)) return []; - return value.map((entry) => (typeof entry === "string" ? entry.trim() : "")).filter(Boolean); -} - -function isRecord(value: unknown): value is Record<string, unknown> { - 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<string, PluginConfigUiHint> | undefined; - if (isRecord(raw.uiHints)) { - uiHints = raw.uiHints as Record<string, PluginConfigUiHint>; - } - - return { - ok: true, - manifest: { - id, - configSchema, - kind, - channels, - providers, - name, - description, - version, - uiHints, - }, - manifestPath, - }; -}
src/plugins/runtime/index.ts+5 −0 modified@@ -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,
src/plugins/runtime/types.ts+6 −0 modified@@ -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;
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
6- github.com/openclaw/openclaw/commit/6b4b6049b47c3329a7014509594647826669892dnvdPatchWEB
- github.com/openclaw/openclaw/security/advisories/GHSA-r5h9-vjqc-hq3rnvdPatchVendor AdvisoryWEB
- github.com/advisories/GHSA-r5h9-vjqc-hq3rghsaADVISORY
- www.vulncheck.com/advisories/openclaw-nextcloud-talk-allowlist-bypass-via-actorname-display-name-spoofingnvdThird Party AdvisoryBroken Link
- github.com/openclaw/openclaw/commit/660f87278c9f292061e097441e0b10c20d62b31bghsaWEB
- github.com/openclaw/openclaw/releases/tag/v2026.2.3ghsaWEB
News mentions
0No linked articles in our index yet.