重构主控编排与运行时预热链路,统一工作区提示词/专家调度协议并补齐 wiki 记忆注入与写回闭环。

同时收敛启动与运维脚本默认行为(含 wiki worker)、更新 Admin 可观测性与相关测试,降低首轮时延并提高运行稳定性。

Made-with: Cursor
This commit is contained in:
oliver 2026-04-26 08:34:33 +08:00
parent 4a23b715a2
commit dbbe3add6a
14438 changed files with 2693620 additions and 2546 deletions

View file

@ -0,0 +1 @@
export { nextcloudTalkPlugin } from "./src/channel.js";

View file

@ -0,0 +1,4 @@
export {
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View file

@ -0,0 +1,20 @@
import { defineBundledChannelEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelEntry({
id: "nextcloud-talk",
name: "Nextcloud Talk",
description: "Nextcloud Talk channel plugin",
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "nextcloudTalkPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
runtime: {
specifier: "./runtime-api.js",
exportName: "setNextcloudTalkRuntime",
},
});

View file

@ -0,0 +1,12 @@
{
"id": "nextcloud-talk",
"channels": ["nextcloud-talk"],
"channelEnvVars": {
"nextcloud-talk": ["NEXTCLOUD_TALK_BOT_SECRET", "NEXTCLOUD_TALK_API_PASSWORD"]
},
"configSchema": {
"type": "object",
"additionalProperties": false,
"properties": {}
}
}

View file

@ -0,0 +1,53 @@
{
"name": "@openclaw/nextcloud-talk",
"version": "2026.4.20",
"description": "OpenClaw Nextcloud Talk channel plugin",
"type": "module",
"devDependencies": {
"@openclaw/plugin-sdk": "workspace:*",
"openclaw": "workspace:*"
},
"peerDependencies": {
"openclaw": ">=2026.4.20"
},
"peerDependenciesMeta": {
"openclaw": {
"optional": true
}
},
"openclaw": {
"extensions": [
"./index.ts"
],
"setupEntry": "./setup-entry.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": "@openclaw/nextcloud-talk",
"defaultChoice": "npm",
"minHostVersion": ">=2026.4.10"
},
"compat": {
"pluginApi": ">=2026.4.20"
},
"build": {
"openclawVersion": "2026.4.20"
},
"release": {
"publishToClawHub": true,
"publishToNpm": true
}
}
}

View file

@ -0,0 +1,5 @@
// Private runtime barrel for the bundled Nextcloud Talk extension.
// Keep this barrel thin and aligned with the local extension surface.
export * from "openclaw/plugin-sdk/nextcloud-talk";
export { setNextcloudTalkRuntime } from "./src/runtime.js";

View file

@ -0,0 +1,5 @@
export {
channelSecrets,
collectRuntimeConfigAssignments,
secretTargetRegistryEntries,
} from "./src/secret-contract.js";

View file

@ -0,0 +1,13 @@
import { defineBundledChannelSetupEntry } from "openclaw/plugin-sdk/channel-entry-contract";
export default defineBundledChannelSetupEntry({
importMetaUrl: import.meta.url,
plugin: {
specifier: "./api.js",
exportName: "nextcloudTalkPlugin",
},
secrets: {
specifier: "./secret-contract-api.js",
exportName: "channelSecrets",
},
});

View file

@ -0,0 +1,153 @@
import {
createAccountListHelpers,
DEFAULT_ACCOUNT_ID,
normalizeAccountId,
resolveAccountWithDefaultFallback,
resolveMergedAccountConfig,
} from "openclaw/plugin-sdk/account-core";
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
import { normalizeResolvedSecretInputString } from "./secret-input.js";
import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return normalizeOptionalString(value)?.toLowerCase() ?? "";
}
function isTruthyEnvValue(value?: string): boolean {
const normalized = normalizeLowercaseStringOrEmpty(value);
return normalized === "true" || normalized === "1" || normalized === "yes" || normalized === "on";
}
const debugAccounts = (...args: unknown[]) => {
if (isTruthyEnvValue(process.env.OPENCLAW_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;
};
const {
listAccountIds: listNextcloudTalkAccountIdsInternal,
resolveDefaultAccountId: resolveDefaultNextcloudTalkAccountId,
} = createAccountListHelpers("nextcloud-talk", {
normalizeAccountId,
});
export { resolveDefaultNextcloudTalkAccountId };
export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] {
const ids = listNextcloudTalkAccountIdsInternal(cfg);
debugAccounts("listNextcloudTalkAccountIds", ids);
return ids;
}
function mergeNextcloudTalkAccountConfig(
cfg: CoreConfig,
accountId: string,
): NextcloudTalkAccountConfig {
return resolveMergedAccountConfig<NextcloudTalkAccountConfig>({
channelConfig: cfg.channels?.["nextcloud-talk"] as NextcloudTalkAccountConfig | undefined,
accounts: cfg.channels?.["nextcloud-talk"]?.accounts as
| Record<string, Partial<NextcloudTalkAccountConfig>>
| undefined,
accountId,
omitKeys: ["defaultAccount"],
normalizeAccountId,
});
}
function resolveNextcloudTalkSecret(
cfg: CoreConfig,
opts: { accountId?: string },
): { secret: string; source: ResolvedNextcloudTalkAccount["secretSource"] } {
const resolvedAccountId = opts.accountId ?? resolveDefaultNextcloudTalkAccountId(cfg);
const merged = mergeNextcloudTalkAccountConfig(cfg, resolvedAccountId);
const envSecret = normalizeOptionalString(process.env.NEXTCLOUD_TALK_BOT_SECRET);
if (envSecret && resolvedAccountId === DEFAULT_ACCOUNT_ID) {
return { secret: envSecret, source: "env" };
}
if (merged.botSecretFile) {
const fileSecret = tryReadSecretFileSync(
merged.botSecretFile,
"Nextcloud Talk bot secret file",
{ rejectSymlink: true },
);
if (fileSecret) {
return { secret: fileSecret, source: "secretFile" };
}
}
const inlineSecret = normalizeResolvedSecretInputString({
value: merged.botSecret,
path: `channels.nextcloud-talk.accounts.${resolvedAccountId}.botSecret`,
});
if (inlineSecret) {
return { secret: inlineSecret, source: "config" };
}
return { secret: "", source: "none" };
}
export function resolveNextcloudTalkAccount(params: {
cfg: CoreConfig;
accountId?: string | null;
}): ResolvedNextcloudTalkAccount {
const baseEnabled = params.cfg.channels?.["nextcloud-talk"]?.enabled !== false;
const resolvedAccountId = params.accountId ?? resolveDefaultNextcloudTalkAccountId(params.cfg);
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: normalizeOptionalString(merged.name),
baseUrl,
secret: secretResolution.secret,
secretSource: secretResolution.source,
config: merged,
} satisfies ResolvedNextcloudTalkAccount;
};
return resolveAccountWithDefaultFallback({
accountId: resolvedAccountId,
normalizeAccountId,
resolvePrimary: resolve,
hasCredential: (account) => account.secretSource !== "none",
resolveDefaultAccountId: () => resolveDefaultNextcloudTalkAccountId(params.cfg),
});
}
export function listEnabledNextcloudTalkAccounts(cfg: CoreConfig): ResolvedNextcloudTalkAccount[] {
return listNextcloudTalkAccountIds(cfg)
.map((accountId) => resolveNextcloudTalkAccount({ cfg, accountId }))
.filter((account) => account.enabled);
}

View file

@ -0,0 +1 @@
export { createAuthRateLimiter } from "openclaw/plugin-sdk/nextcloud-talk";

View file

@ -0,0 +1,17 @@
import { describe, expect, it } from "vitest";
import { nextcloudTalkApprovalAuth } from "./approval-auth.js";
describe("nextcloudTalkApprovalAuth", () => {
it("matches Nextcloud Talk actor ids case-insensitively", () => {
const cfg = { channels: { "nextcloud-talk": { allowFrom: ["Owner"] } } };
expect(
nextcloudTalkApprovalAuth.authorizeActorAction({
cfg,
senderId: "owner",
action: "approve",
approvalKind: "exec",
}),
).toEqual({ authorized: true });
});
});

View file

@ -0,0 +1,27 @@
import {
createResolvedApproverActionAuthAdapter,
resolveApprovalApprovers,
} from "openclaw/plugin-sdk/approval-auth-runtime";
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/text-runtime";
import { resolveNextcloudTalkAccount } from "./accounts.js";
import type { CoreConfig } from "./types.js";
function normalizeNextcloudTalkApproverId(value: string | number): string | undefined {
return normalizeOptionalLowercaseString(
String(value)
.trim()
.replace(/^(nextcloud-talk|nc-talk|nc):/i, ""),
);
}
export const nextcloudTalkApprovalAuth = createResolvedApproverActionAuthAdapter({
channelLabel: "Nextcloud Talk",
resolveApprovers: ({ cfg, accountId }) => {
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
return resolveApprovalApprovers({
allowFrom: account.config.allowFrom,
normalizeApprover: normalizeNextcloudTalkApproverId,
});
},
normalizeSenderId: (value) => normalizeNextcloudTalkApproverId(value),
});

View file

@ -0,0 +1,5 @@
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-plugin-common";
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
export { clearAccountEntryFields } from "openclaw/plugin-sdk/channel-plugin-common";
export { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
export { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";

View file

@ -0,0 +1,55 @@
import { formatAllowFromLowercase } from "openclaw/plugin-sdk/allow-from";
import {
adaptScopedAccountAccessor,
createScopedChannelConfigAdapter,
createScopedDmSecurityResolver,
} from "openclaw/plugin-sdk/channel-config-helpers";
import { createPairingPrefixStripper } from "openclaw/plugin-sdk/channel-pairing";
import {
listNextcloudTalkAccountIds,
resolveDefaultNextcloudTalkAccountId,
resolveNextcloudTalkAccount,
type ResolvedNextcloudTalkAccount,
} from "./accounts.js";
import type { CoreConfig } from "./types.js";
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
export const nextcloudTalkConfigAdapter = createScopedChannelConfigAdapter<
ResolvedNextcloudTalkAccount,
ResolvedNextcloudTalkAccount,
CoreConfig
>({
sectionKey: "nextcloud-talk",
listAccountIds: listNextcloudTalkAccountIds,
resolveAccount: adaptScopedAccountAccessor(resolveNextcloudTalkAccount),
defaultAccountId: resolveDefaultNextcloudTalkAccountId,
clearBaseFields: ["botSecret", "botSecretFile", "baseUrl", "name"],
resolveAllowFrom: (account) => account.config.allowFrom,
formatAllowFrom: (allowFrom) =>
formatAllowFromLowercase({
allowFrom,
stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i,
}),
});
export const nextcloudTalkSecurityAdapter = {
resolveDmPolicy: createScopedDmSecurityResolver<ResolvedNextcloudTalkAccount>({
channelKey: "nextcloud-talk",
resolvePolicy: (account) => account.config.dmPolicy,
resolveAllowFrom: (account) => account.config.allowFrom,
policyPathSuffix: "dmPolicy",
normalizeEntry: (raw) =>
normalizeLowercaseStringOrEmpty(raw.trim().replace(/^(nextcloud-talk|nc-talk|nc):/i, "")),
}),
};
export const nextcloudTalkPairingTextAdapter = {
idLabel: "nextcloudUserId",
message: "OpenClaw: your access has been approved.",
normalizeAllowEntry: createPairingPrefixStripper(/^(nextcloud-talk|nc-talk|nc):/i, (entry) =>
normalizeLowercaseStringOrEmpty(entry),
),
};

View file

@ -0,0 +1,85 @@
import { describe, expect, it, vi } from "vitest";
import {
nextcloudTalkConfigAdapter,
nextcloudTalkPairingTextAdapter,
nextcloudTalkSecurityAdapter,
} from "./channel.adapters.js";
import { NextcloudTalkConfigSchema } from "./config-schema.js";
import type { CoreConfig } from "./types.js";
vi.mock("../../../test/helpers/config/bundled-channel-config-runtime.js", () => ({
getBundledChannelRuntimeMap: () => new Map(),
getBundledChannelConfigSchemaMap: () => new Map(),
}));
vi.mock("../../../src/channels/plugins/bundled.js", () => ({
bundledChannelPlugins: [],
bundledChannelSetupPlugins: [],
}));
describe("nextcloud talk channel core", () => {
it("accepts SecretRef botSecret and apiPassword at top-level", () => {
const result = NextcloudTalkConfigSchema.safeParse({
baseUrl: "https://cloud.example.com",
botSecret: { source: "env", provider: "default", id: "NEXTCLOUD_TALK_BOT_SECRET" },
apiUser: "bot",
apiPassword: { source: "env", provider: "default", id: "NEXTCLOUD_TALK_API_PASSWORD" },
});
expect(result.success).toBe(true);
});
it("accepts SecretRef botSecret and apiPassword on account", () => {
const result = NextcloudTalkConfigSchema.safeParse({
accounts: {
main: {
baseUrl: "https://cloud.example.com",
botSecret: {
source: "env",
provider: "default",
id: "NEXTCLOUD_TALK_MAIN_BOT_SECRET",
},
apiUser: "bot",
apiPassword: {
source: "env",
provider: "default",
id: "NEXTCLOUD_TALK_MAIN_API_PASSWORD",
},
},
},
});
expect(result.success).toBe(true);
});
it("normalizes trimmed DM allowlist prefixes to lowercase ids", () => {
const resolveDmPolicy = nextcloudTalkSecurityAdapter.resolveDmPolicy;
if (!resolveDmPolicy) {
throw new Error("resolveDmPolicy unavailable");
}
const cfg = {
channels: {
"nextcloud-talk": {
baseUrl: "https://cloud.example.com",
botSecret: "secret",
dmPolicy: "allowlist",
allowFrom: [" nc:User-Id "],
},
},
} as CoreConfig;
const result = resolveDmPolicy({
cfg,
account: nextcloudTalkConfigAdapter.resolveAccount(cfg, "default"),
});
if (!result) {
throw new Error("nextcloud-talk resolveDmPolicy returned null");
}
expect(result.policy).toBe("allowlist");
expect(result.allowFrom).toEqual([" nc:User-Id "]);
expect(result.normalizeEntry?.(" nc:User-Id ")).toBe("user-id");
expect(nextcloudTalkPairingTextAdapter.normalizeAllowEntry(" nextcloud-talk:User-Id ")).toBe(
"user-id",
);
});
});

View file

@ -0,0 +1,81 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { createStartAccountContext } from "../../../test/helpers/plugins/start-account-context.js";
import {
expectStopPendingUntilAbort,
startAccountAndTrackLifecycle,
waitForStartedMocks,
} from "../../../test/helpers/plugins/start-account-lifecycle.js";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
const hoisted = vi.hoisted(() => ({
monitorNextcloudTalkProvider: vi.fn(),
}));
vi.mock("./monitor-runtime.js", () => ({
monitorNextcloudTalkProvider: hoisted.monitorNextcloudTalkProvider,
}));
const { nextcloudTalkGatewayAdapter } = await import("./gateway.js");
function buildAccount(): ResolvedNextcloudTalkAccount {
return {
accountId: "default",
enabled: true,
baseUrl: "https://nextcloud.example.com",
secret: "secret", // pragma: allowlist secret
secretSource: "config", // pragma: allowlist secret
config: {
baseUrl: "https://nextcloud.example.com",
botSecret: "secret", // pragma: allowlist secret
webhookPath: "/nextcloud-talk-webhook",
webhookPort: 8788,
},
};
}
function mockStartedMonitor() {
const stop = vi.fn();
hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop });
return stop;
}
function startNextcloudAccount(abortSignal?: AbortSignal) {
return nextcloudTalkGatewayAdapter.startAccount!(
createStartAccountContext({
account: buildAccount(),
abortSignal,
}),
);
}
describe("nextcloud-talk startAccount lifecycle", () => {
afterEach(() => {
vi.clearAllMocks();
});
it("keeps startAccount pending until abort, then stops the monitor", async () => {
const stop = mockStartedMonitor();
const { abort, task, isSettled } = startAccountAndTrackLifecycle({
startAccount: nextcloudTalkGatewayAdapter.startAccount!,
account: buildAccount(),
});
await expectStopPendingUntilAbort({
waitForStarted: waitForStartedMocks(hoisted.monitorNextcloudTalkProvider),
isSettled,
abort,
task,
stop,
});
});
it("stops immediately when startAccount receives an already-aborted signal", async () => {
const stop = mockStartedMonitor();
const abort = new AbortController();
abort.abort();
await startNextcloudAccount(abort.signal);
expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce();
expect(stop).toHaveBeenCalledOnce();
});
});

View file

@ -0,0 +1,194 @@
import { describeWebhookAccountSnapshot } from "openclaw/plugin-sdk/account-helpers";
import { createChatChannelPlugin } from "openclaw/plugin-sdk/channel-core";
import { createLoggedPairingApprovalNotifier } from "openclaw/plugin-sdk/channel-pairing";
import { createAllowlistProviderRouteAllowlistWarningCollector } from "openclaw/plugin-sdk/channel-policy";
import {
buildWebhookChannelStatusSummary,
createComputedAccountStatusAdapter,
createDefaultChannelRuntimeState,
} from "openclaw/plugin-sdk/status-helpers";
import { resolveNextcloudTalkAccount, type ResolvedNextcloudTalkAccount } from "./accounts.js";
import { nextcloudTalkApprovalAuth } from "./approval-auth.js";
import { buildChannelConfigSchema, DEFAULT_ACCOUNT_ID, type ChannelPlugin } from "./channel-api.js";
import {
nextcloudTalkConfigAdapter,
nextcloudTalkPairingTextAdapter,
nextcloudTalkSecurityAdapter,
} from "./channel.adapters.js";
import { NextcloudTalkConfigSchema } from "./config-schema.js";
import { nextcloudTalkDoctor } from "./doctor.js";
import { nextcloudTalkGatewayAdapter } from "./gateway.js";
import {
looksLikeNextcloudTalkTargetId,
normalizeNextcloudTalkMessagingTarget,
} from "./normalize.js";
import { resolveNextcloudTalkGroupToolPolicy } from "./policy.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import { collectRuntimeConfigAssignments, secretTargetRegistryEntries } from "./secret-contract.js";
import { sendMessageNextcloudTalk } from "./send.js";
import { resolveNextcloudTalkOutboundSessionRoute } from "./session-route.js";
import { nextcloudTalkSetupAdapter } from "./setup-core.js";
import { nextcloudTalkSetupWizard } from "./setup-surface.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,
};
const collectNextcloudTalkSecurityWarnings =
createAllowlistProviderRouteAllowlistWarningCollector<ResolvedNextcloudTalkAccount>({
providerConfigPresent: (cfg) =>
(cfg.channels as Record<string, unknown> | undefined)?.["nextcloud-talk"] !== undefined,
resolveGroupPolicy: (account) => account.config.groupPolicy,
resolveRouteAllowlistConfigured: (account) =>
Boolean(account.config.rooms) && Object.keys(account.config.rooms ?? {}).length > 0,
restrictSenders: {
surface: "Nextcloud Talk rooms",
openScope: "any member in allowed rooms",
groupPolicyPath: "channels.nextcloud-talk.groupPolicy",
groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom",
},
noRouteAllowlist: {
surface: "Nextcloud Talk rooms",
routeAllowlistPath: "channels.nextcloud-talk.rooms",
routeScope: "room",
groupPolicyPath: "channels.nextcloud-talk.groupPolicy",
groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom",
},
});
export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
createChatChannelPlugin({
base: {
id: "nextcloud-talk",
meta,
setupWizard: nextcloudTalkSetupWizard,
capabilities: {
chatTypes: ["direct", "group"],
reactions: true,
threads: false,
media: true,
nativeCommands: false,
blockStreaming: true,
},
reload: { configPrefixes: ["channels.nextcloud-talk"] },
configSchema: buildChannelConfigSchema(NextcloudTalkConfigSchema),
config: {
...nextcloudTalkConfigAdapter,
isConfigured: (account) => Boolean(account.secret?.trim() && account.baseUrl?.trim()),
describeAccount: (account) =>
describeWebhookAccountSnapshot({
account,
configured: Boolean(account.secret?.trim() && account.baseUrl?.trim()),
extra: {
secretSource: account.secretSource,
baseUrl: account.baseUrl ? "[set]" : "[missing]",
},
}),
},
approvalCapability: nextcloudTalkApprovalAuth,
doctor: nextcloudTalkDoctor,
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;
},
resolveToolPolicy: resolveNextcloudTalkGroupToolPolicy,
},
messaging: {
normalizeTarget: normalizeNextcloudTalkMessagingTarget,
resolveOutboundSessionRoute: (params) => resolveNextcloudTalkOutboundSessionRoute(params),
targetResolver: {
looksLikeId: looksLikeNextcloudTalkTargetId,
hint: "<roomToken>",
},
},
secrets: {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
},
setup: nextcloudTalkSetupAdapter,
status: createComputedAccountStatusAdapter<ResolvedNextcloudTalkAccount>({
defaultRuntime: createDefaultChannelRuntimeState(DEFAULT_ACCOUNT_ID),
buildChannelSummary: ({ snapshot }) =>
buildWebhookChannelStatusSummary(snapshot, {
secretSource: snapshot.secretSource ?? "none",
}),
resolveAccountSnapshot: ({ account }) => ({
accountId: account.accountId,
name: account.name,
enabled: account.enabled,
configured: Boolean(account.secret?.trim() && account.baseUrl?.trim()),
extra: {
secretSource: account.secretSource,
baseUrl: account.baseUrl ? "[set]" : "[missing]",
mode: "webhook",
},
}),
}),
gateway: nextcloudTalkGatewayAdapter,
},
pairing: {
text: {
...nextcloudTalkPairingTextAdapter,
notify: createLoggedPairingApprovalNotifier(
({ id }) => `[nextcloud-talk] User ${id} approved for pairing`,
),
},
},
security: {
...nextcloudTalkSecurityAdapter,
collectWarnings: collectNextcloudTalkSecurityWarnings,
},
outbound: {
base: {
deliveryMode: "direct",
chunker: (text, limit) =>
getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
chunkerMode: "markdown",
textChunkLimit: 4000,
},
attachedResults: {
channel: "nextcloud-talk",
sendText: async ({ cfg, to, text, accountId, replyToId }) =>
await sendMessageNextcloudTalk(to, text, {
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
cfg: cfg as CoreConfig,
}),
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) =>
await sendMessageNextcloudTalk(
to,
mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text,
{
accountId: accountId ?? undefined,
replyTo: replyToId ?? undefined,
cfg: cfg as CoreConfig,
},
),
},
},
});

View file

@ -0,0 +1,81 @@
import {
DmPolicySchema,
GroupPolicySchema,
MarkdownConfigSchema,
ReplyRuntimeConfigSchemaShape,
ToolPolicySchema,
requireOpenAllowFrom,
} from "openclaw/plugin-sdk/channel-config-schema";
import { requireChannelOpenAllowFrom } from "openclaw/plugin-sdk/extension-shared";
import { z } from "openclaw/plugin-sdk/zod";
import { buildSecretInputSchema } from "./secret-input.js";
export const NextcloudTalkRoomSchema = z
.object({
requireMention: z.boolean().optional(),
tools: ToolPolicySchema,
skills: z.array(z.string()).optional(),
enabled: z.boolean().optional(),
allowFrom: z.array(z.string()).optional(),
systemPrompt: z.string().optional(),
})
.strict();
const NextcloudTalkNetworkSchema = z
.object({
/** Dangerous opt-in for self-hosted Nextcloud Talk on trusted private/internal hosts. */
dangerouslyAllowPrivateNetwork: z.boolean().optional(),
})
.strict()
.optional();
export const NextcloudTalkAccountSchemaBase = z
.object({
name: z.string().optional(),
enabled: z.boolean().optional(),
markdown: MarkdownConfigSchema,
baseUrl: z.string().optional(),
botSecret: buildSecretInputSchema().optional(),
botSecretFile: z.string().optional(),
apiUser: z.string().optional(),
apiPassword: buildSecretInputSchema().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(),
/** Network policy overrides for self-hosted Nextcloud Talk on trusted private/internal hosts. */
network: NextcloudTalkNetworkSchema,
...ReplyRuntimeConfigSchemaShape,
})
.strict();
export const NextcloudTalkAccountSchema = NextcloudTalkAccountSchemaBase.superRefine(
(value, ctx) => {
requireChannelOpenAllowFrom({
channel: "nextcloud-talk",
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
requireOpenAllowFrom,
});
},
);
export const NextcloudTalkConfigSchema = NextcloudTalkAccountSchemaBase.extend({
accounts: z.record(z.string(), NextcloudTalkAccountSchema.optional()).optional(),
defaultAccount: z.string().optional(),
}).superRefine((value, ctx) => {
requireChannelOpenAllowFrom({
channel: "nextcloud-talk",
policy: value.dmPolicy,
allowFrom: value.allowFrom,
ctx,
requireOpenAllowFrom,
});
});

View file

@ -0,0 +1,304 @@
import { mkdtemp, rm } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { afterEach, describe, expect, it } from "vitest";
import {
looksLikeNextcloudTalkTargetId,
normalizeNextcloudTalkMessagingTarget,
stripNextcloudTalkTargetPrefix,
} from "./normalize.js";
import { resolveNextcloudTalkAllowlistMatch, resolveNextcloudTalkGroupAllow } from "./policy.js";
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
import { resolveNextcloudTalkOutboundSessionRoute } from "./session-route.js";
import {
extractNextcloudTalkHeaders,
generateNextcloudTalkSignature,
verifyNextcloudTalkSignature,
} from "./signature.js";
const tempDirs: string[] = [];
afterEach(async () => {
while (tempDirs.length > 0) {
const dir = tempDirs.pop();
if (dir) {
await rm(dir, { recursive: true, force: true });
}
}
});
async function makeTempDir(): Promise<string> {
const dir = await mkdtemp(path.join(os.tmpdir(), "nextcloud-talk-replay-"));
tempDirs.push(dir);
return dir;
}
describe("nextcloud talk core", () => {
it("builds an outbound session route for normalized room targets", () => {
const route = resolveNextcloudTalkOutboundSessionRoute({
cfg: {},
agentId: "main",
accountId: "acct-1",
target: "nextcloud-talk:room-123",
});
expect(route).toMatchObject({
peer: {
kind: "group",
id: "room-123",
},
from: "nextcloud-talk:room:room-123",
to: "nextcloud-talk:room-123",
});
});
it("returns null when the target cannot be normalized to a room id", () => {
expect(
resolveNextcloudTalkOutboundSessionRoute({
cfg: {},
agentId: "main",
accountId: "acct-1",
target: "",
}),
).toBeNull();
});
it("normalizes and recognizes supported room target formats", () => {
expect(stripNextcloudTalkTargetPrefix(" room:abc123 ")).toBe("abc123");
expect(stripNextcloudTalkTargetPrefix("nextcloud-talk:room:AbC123")).toBe("AbC123");
expect(stripNextcloudTalkTargetPrefix("nc-talk:room:ops")).toBe("ops");
expect(stripNextcloudTalkTargetPrefix("nc:room:ops")).toBe("ops");
expect(stripNextcloudTalkTargetPrefix("room: ")).toBeUndefined();
expect(normalizeNextcloudTalkMessagingTarget("room:AbC123")).toBe("nextcloud-talk:abc123");
expect(normalizeNextcloudTalkMessagingTarget("nc-talk:room:Ops")).toBe("nextcloud-talk:ops");
expect(looksLikeNextcloudTalkTargetId("nextcloud-talk:room:abc12345")).toBe(true);
expect(looksLikeNextcloudTalkTargetId("nc:opsroom1")).toBe(true);
expect(looksLikeNextcloudTalkTargetId("abc12345")).toBe(true);
expect(looksLikeNextcloudTalkTargetId("")).toBe(false);
});
it("verifies generated signatures and extracts normalized headers", () => {
const body = JSON.stringify({ hello: "world" });
const generated = generateNextcloudTalkSignature({
body,
secret: "secret-123",
});
expect(generated.random).toMatch(/^[0-9a-f]{64}$/);
expect(generated.signature).toMatch(/^[0-9a-f]{64}$/);
expect(
verifyNextcloudTalkSignature({
signature: generated.signature,
random: generated.random,
body,
secret: "secret-123",
}),
).toBe(true);
expect(
verifyNextcloudTalkSignature({
signature: "",
random: "abc",
body: "body",
secret: "secret",
}),
).toBe(false);
expect(
verifyNextcloudTalkSignature({
signature: "deadbeef",
random: "abc",
body: "body",
secret: "secret",
}),
).toBe(false);
expect(
extractNextcloudTalkHeaders({
"x-nextcloud-talk-signature": "sig",
"x-nextcloud-talk-random": "rand",
"x-nextcloud-talk-backend": "backend",
}),
).toEqual({
signature: "sig",
random: "rand",
backend: "backend",
});
expect(
extractNextcloudTalkHeaders({
"X-Nextcloud-Talk-Signature": "sig",
}),
).toBeNull();
});
it("persists replay decisions across guard instances and scopes account namespaces", async () => {
const stateDir = await makeTempDir();
const firstGuard = createNextcloudTalkReplayGuard({ stateDir });
const firstAttempt = await firstGuard.shouldProcessMessage({
accountId: "account-a",
roomToken: "room-1",
messageId: "msg-1",
});
const replayAttempt = await firstGuard.shouldProcessMessage({
accountId: "account-a",
roomToken: "room-1",
messageId: "msg-1",
});
const secondGuard = createNextcloudTalkReplayGuard({ stateDir });
const restartReplayAttempt = await secondGuard.shouldProcessMessage({
accountId: "account-a",
roomToken: "room-1",
messageId: "msg-1",
});
const otherAccountFirstAttempt = await secondGuard.shouldProcessMessage({
accountId: "account-b",
roomToken: "room-1",
messageId: "msg-1",
});
expect(firstAttempt).toBe(true);
expect(replayAttempt).toBe(false);
expect(restartReplayAttempt).toBe(false);
expect(otherAccountFirstAttempt).toBe(true);
});
it("releases in-flight replay claims when processing fails", async () => {
const guard = createNextcloudTalkReplayGuard({});
const firstClaim = await guard.claimMessage({
accountId: "account-a",
roomToken: "room-1",
messageId: "msg-claim",
});
const secondClaim = await guard.claimMessage({
accountId: "account-a",
roomToken: "room-1",
messageId: "msg-claim",
});
expect(firstClaim).toBe("claimed");
expect(secondClaim).toBe("inflight");
guard.releaseMessage({
accountId: "account-a",
roomToken: "room-1",
messageId: "msg-claim",
error: new Error("transient"),
});
const retryClaim = await guard.claimMessage({
accountId: "account-a",
roomToken: "room-1",
messageId: "msg-claim",
});
expect(retryClaim).toBe("claimed");
});
it("resolves allowlist matches and group policy decisions", () => {
expect(
resolveNextcloudTalkAllowlistMatch({
allowFrom: ["*"],
senderId: "user-id",
}).allowed,
).toBe(true);
expect(
resolveNextcloudTalkAllowlistMatch({
allowFrom: ["nc:User-Id"],
senderId: "user-id",
}),
).toEqual({ allowed: true, matchKey: "user-id", matchSource: "id" });
expect(
resolveNextcloudTalkAllowlistMatch({
allowFrom: ["allowed"],
senderId: "other",
}).allowed,
).toBe(false);
expect(
resolveNextcloudTalkGroupAllow({
groupPolicy: "disabled",
outerAllowFrom: ["owner"],
innerAllowFrom: ["room-user"],
senderId: "owner",
}),
).toEqual({
allowed: false,
outerMatch: { allowed: false },
innerMatch: { allowed: false },
});
expect(
resolveNextcloudTalkGroupAllow({
groupPolicy: "open",
outerAllowFrom: [],
innerAllowFrom: [],
senderId: "owner",
}),
).toEqual({
allowed: true,
outerMatch: { allowed: true },
innerMatch: { allowed: true },
});
expect(
resolveNextcloudTalkGroupAllow({
groupPolicy: "allowlist",
outerAllowFrom: [],
innerAllowFrom: [],
senderId: "owner",
}),
).toEqual({
allowed: false,
outerMatch: { allowed: false },
innerMatch: { allowed: false },
});
expect(
resolveNextcloudTalkGroupAllow({
groupPolicy: "allowlist",
outerAllowFrom: [],
innerAllowFrom: ["room-user"],
senderId: "room-user",
}),
).toEqual({
allowed: true,
outerMatch: { allowed: false },
innerMatch: { allowed: true, matchKey: "room-user", matchSource: "id" },
});
expect(
resolveNextcloudTalkGroupAllow({
groupPolicy: "allowlist",
outerAllowFrom: ["team-owner"],
innerAllowFrom: ["room-user"],
senderId: "room-user",
}),
).toEqual({
allowed: false,
outerMatch: { allowed: false },
innerMatch: { allowed: true, matchKey: "room-user", matchSource: "id" },
});
expect(
resolveNextcloudTalkGroupAllow({
groupPolicy: "allowlist",
outerAllowFrom: ["team-owner"],
innerAllowFrom: ["room-user"],
senderId: "team-owner",
}),
).toEqual({
allowed: false,
outerMatch: { allowed: true, matchKey: "team-owner", matchSource: "id" },
innerMatch: { allowed: false },
});
expect(
resolveNextcloudTalkGroupAllow({
groupPolicy: "allowlist",
outerAllowFrom: ["shared-user"],
innerAllowFrom: ["shared-user"],
senderId: "shared-user",
}),
).toEqual({
allowed: true,
outerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" },
innerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" },
});
});
});

View file

@ -0,0 +1,9 @@
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
const contract = createLegacyPrivateNetworkDoctorContract({
channelKey: "nextcloud-talk",
});
export const legacyConfigRules = contract.legacyConfigRules;
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;

View file

@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";
import { nextcloudTalkDoctor } from "./doctor.js";
describe("nextcloud-talk doctor", () => {
it("normalizes legacy private-network aliases", () => {
const normalize = nextcloudTalkDoctor.normalizeCompatibilityConfig;
expect(normalize).toBeDefined();
if (!normalize) {
return;
}
const result = normalize({
cfg: {
channels: {
"nextcloud-talk": {
allowPrivateNetwork: true,
accounts: {
work: {
allowPrivateNetwork: false,
},
},
},
},
} as never,
});
expect(result.config.channels?.["nextcloud-talk"]?.network).toEqual({
dangerouslyAllowPrivateNetwork: true,
});
expect(
(
result.config.channels?.["nextcloud-talk"]?.accounts?.work as
| { network?: Record<string, unknown> }
| undefined
)?.network,
).toEqual({
dangerouslyAllowPrivateNetwork: false,
});
});
});

View file

@ -0,0 +1,10 @@
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
import {
legacyConfigRules as NEXTCLOUD_TALK_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig as normalizeNextcloudTalkCompatibilityConfig,
} from "./doctor-contract.js";
export const nextcloudTalkDoctor: ChannelDoctorAdapter = {
legacyConfigRules: NEXTCLOUD_TALK_LEGACY_CONFIG_RULES,
normalizeCompatibilityConfig: normalizeNextcloudTalkCompatibilityConfig,
};

View file

@ -0,0 +1,106 @@
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
import { resolveNextcloudTalkAccount, type ResolvedNextcloudTalkAccount } from "./accounts.js";
import {
clearAccountEntryFields,
DEFAULT_ACCOUNT_ID,
type ChannelPlugin,
type OpenClawConfig,
} from "./channel-api.js";
import { monitorNextcloudTalkProvider } from "./monitor-runtime.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import type { CoreConfig } from "./types.js";
export const nextcloudTalkGatewayAdapter: NonNullable<
ChannelPlugin<ResolvedNextcloudTalkAccount>["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 statusSink = createAccountStatusSink({
accountId: ctx.accountId,
setStatus: ctx.setStatus,
});
await runStoppablePassiveMonitor({
abortSignal: ctx.abortSignal,
start: async () =>
await monitorNextcloudTalkProvider({
accountId: account.accountId,
config: ctx.cfg as CoreConfig,
runtime: ctx.runtime,
abortSignal: ctx.abortSignal,
statusSink,
}),
});
},
logoutAccount: async ({ accountId, cfg }) => {
const nextCfg = { ...cfg } as OpenClawConfig;
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 accountCleanup = clearAccountEntryFields({
accounts: nextSection.accounts as Record<string, object> | undefined,
accountId,
fields: ["botSecret"],
});
if (accountCleanup.changed) {
changed = true;
if (accountCleanup.cleared) {
cleared = true;
}
if (accountCleanup.nextAccounts) {
nextSection.accounts = accountCleanup.nextAccounts as Record<string, unknown>;
} else {
delete nextSection.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 OpenClawConfig["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,
};
},
};

View file

@ -0,0 +1,149 @@
import { describe, expect, it, vi } from "vitest";
import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
import { handleNextcloudTalkInbound } from "./inbound.js";
import { setNextcloudTalkRuntime } from "./runtime.js";
import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
function installInboundAuthzRuntime(params: {
readAllowFromStore: () => Promise<string[]>;
buildMentionRegexes: () => RegExp[];
}) {
setNextcloudTalkRuntime({
channel: {
pairing: {
readAllowFromStore: params.readAllowFromStore,
},
commands: {
shouldHandleTextCommands: () => false,
},
text: {
hasControlCommand: () => false,
},
mentions: {
buildMentionRegexes: params.buildMentionRegexes,
matchesMentionPatterns: () => false,
},
},
} as unknown as PluginRuntime);
}
function createTestRuntimeEnv(): RuntimeEnv {
return {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
}
describe("nextcloud-talk inbound authz", () => {
it("does not treat DM pairing-store entries as group allowlist entries", async () => {
const readAllowFromStore = vi.fn(async () => ["attacker"]);
const buildMentionRegexes = vi.fn(() => [/@openclaw/i]);
installInboundAuthzRuntime({ readAllowFromStore, buildMentionRegexes });
const message: NextcloudTalkInboundMessage = {
messageId: "m-1",
roomToken: "room-1",
roomName: "Room 1",
senderId: "attacker",
senderName: "Attacker",
text: "hello",
mediaType: "text/plain",
timestamp: Date.now(),
isGroupChat: true,
};
const account: ResolvedNextcloudTalkAccount = {
accountId: "default",
enabled: true,
baseUrl: "",
secret: "",
secretSource: "none", // pragma: allowlist secret
config: {
dmPolicy: "pairing",
allowFrom: [],
groupPolicy: "allowlist",
groupAllowFrom: [],
},
};
const config: CoreConfig = {
channels: {
"nextcloud-talk": {
dmPolicy: "pairing",
allowFrom: [],
groupPolicy: "allowlist",
groupAllowFrom: [],
},
},
};
await handleNextcloudTalkInbound({
message,
account,
config,
runtime: createTestRuntimeEnv(),
});
expect(readAllowFromStore).toHaveBeenCalledWith({
channel: "nextcloud-talk",
accountId: "default",
});
expect(buildMentionRegexes).not.toHaveBeenCalled();
});
it("matches group rooms by token instead of colliding room names", async () => {
const readAllowFromStore = vi.fn(async () => []);
const buildMentionRegexes = vi.fn(() => [/@openclaw/i]);
installInboundAuthzRuntime({ readAllowFromStore, buildMentionRegexes });
const message: NextcloudTalkInboundMessage = {
messageId: "m-2",
roomToken: "room-attacker",
roomName: "Room Trusted",
senderId: "trusted-user",
senderName: "Trusted User",
text: "hello",
mediaType: "text/plain",
timestamp: Date.now(),
isGroupChat: true,
};
const account: ResolvedNextcloudTalkAccount = {
accountId: "default",
enabled: true,
baseUrl: "",
secret: "",
secretSource: "none",
config: {
dmPolicy: "pairing",
allowFrom: [],
groupPolicy: "allowlist",
groupAllowFrom: ["trusted-user"],
rooms: {
"room-trusted": {
enabled: true,
},
},
},
};
await handleNextcloudTalkInbound({
message,
account,
config: {
channels: {
"nextcloud-talk": {
groupPolicy: "allowlist",
groupAllowFrom: ["trusted-user"],
},
},
},
runtime: createTestRuntimeEnv(),
});
expect(buildMentionRegexes).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,202 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
import { handleNextcloudTalkInbound } from "./inbound.js";
import { setNextcloudTalkRuntime } from "./runtime.js";
import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
const {
createChannelPairingControllerMock,
dispatchInboundReplyWithBaseMock,
readStoreAllowFromForDmPolicyMock,
resolveDmGroupAccessWithCommandGateMock,
resolveAllowlistProviderRuntimeGroupPolicyMock,
resolveDefaultGroupPolicyMock,
warnMissingProviderGroupPolicyFallbackOnceMock,
} = vi.hoisted(() => {
return {
createChannelPairingControllerMock: vi.fn(),
dispatchInboundReplyWithBaseMock: vi.fn(),
readStoreAllowFromForDmPolicyMock: vi.fn(),
resolveDmGroupAccessWithCommandGateMock: vi.fn(),
resolveAllowlistProviderRuntimeGroupPolicyMock: vi.fn(),
resolveDefaultGroupPolicyMock: vi.fn(),
warnMissingProviderGroupPolicyFallbackOnceMock: vi.fn(),
};
});
const sendMessageNextcloudTalkMock = vi.hoisted(() => vi.fn());
const resolveNextcloudTalkRoomKindMock = vi.hoisted(() => vi.fn());
vi.mock("../runtime-api.js", async () => {
const actual = await vi.importActual<typeof import("../runtime-api.js")>("../runtime-api.js");
return {
...actual,
createChannelPairingController: createChannelPairingControllerMock,
dispatchInboundReplyWithBase: dispatchInboundReplyWithBaseMock,
readStoreAllowFromForDmPolicy: readStoreAllowFromForDmPolicyMock,
resolveDmGroupAccessWithCommandGate: resolveDmGroupAccessWithCommandGateMock,
resolveAllowlistProviderRuntimeGroupPolicy: resolveAllowlistProviderRuntimeGroupPolicyMock,
resolveDefaultGroupPolicy: resolveDefaultGroupPolicyMock,
warnMissingProviderGroupPolicyFallbackOnce: warnMissingProviderGroupPolicyFallbackOnceMock,
};
});
vi.mock("./send.js", () => ({
sendMessageNextcloudTalk: sendMessageNextcloudTalkMock,
}));
vi.mock("./room-info.js", async () => {
const actual = await vi.importActual<typeof import("./room-info.js")>("./room-info.js");
return {
...actual,
resolveNextcloudTalkRoomKind: resolveNextcloudTalkRoomKindMock,
};
});
function installRuntime(params?: {
buildMentionRegexes?: () => RegExp[];
matchesMentionPatterns?: (body: string, regexes: RegExp[]) => boolean;
}) {
setNextcloudTalkRuntime({
channel: {
pairing: {
readAllowFromStore: vi.fn(async () => []),
upsertPairingRequest: vi.fn(async () => ({ code: "123456", created: true })),
},
commands: {
shouldHandleTextCommands: vi.fn(() => false),
},
text: {
hasControlCommand: vi.fn(() => false),
},
mentions: {
buildMentionRegexes: params?.buildMentionRegexes ?? vi.fn(() => []),
matchesMentionPatterns: params?.matchesMentionPatterns ?? vi.fn(() => false),
},
},
} as unknown as PluginRuntime);
}
function createRuntimeEnv() {
return {
log: vi.fn(),
error: vi.fn(),
} as unknown as RuntimeEnv;
}
function createAccount(
overrides?: Partial<ResolvedNextcloudTalkAccount>,
): ResolvedNextcloudTalkAccount {
return {
accountId: "default",
enabled: true,
baseUrl: "https://cloud.example.com",
secret: "secret",
secretSource: "config",
config: {
dmPolicy: "pairing",
allowFrom: [],
groupPolicy: "allowlist",
groupAllowFrom: [],
},
...overrides,
};
}
function createMessage(
overrides?: Partial<NextcloudTalkInboundMessage>,
): NextcloudTalkInboundMessage {
return {
messageId: "msg-1",
roomToken: "room-1",
roomName: "Room 1",
senderId: "user-1",
senderName: "Alice",
text: "hello",
mediaType: "text/plain",
timestamp: Date.now(),
isGroupChat: false,
...overrides,
};
}
describe("nextcloud-talk inbound behavior", () => {
beforeEach(() => {
vi.clearAllMocks();
installRuntime();
resolveNextcloudTalkRoomKindMock.mockResolvedValue("direct");
resolveDefaultGroupPolicyMock.mockReturnValue("allowlist");
resolveAllowlistProviderRuntimeGroupPolicyMock.mockReturnValue({
groupPolicy: "allowlist",
providerMissingFallbackApplied: false,
});
warnMissingProviderGroupPolicyFallbackOnceMock.mockReturnValue(undefined);
readStoreAllowFromForDmPolicyMock.mockResolvedValue([]);
});
// The DM pairing assertion currently depends on a mocked runtime barrel that Vitest
// does not bind reliably for this extension package.
it.skip("issues a DM pairing challenge and sends the challenge text", async () => {
createChannelPairingControllerMock.mockReturnValue({
readStoreForDmPolicy: vi.fn(),
issueChallenge: vi.fn(),
});
resolveDmGroupAccessWithCommandGateMock.mockReturnValue({
decision: "pairing",
reason: "pairing_required",
commandAuthorized: false,
effectiveGroupAllowFrom: [],
});
sendMessageNextcloudTalkMock.mockResolvedValue(undefined);
const statusSink = vi.fn();
await handleNextcloudTalkInbound({
message: createMessage(),
account: createAccount(),
config: { channels: { "nextcloud-talk": {} } } as CoreConfig,
runtime: createRuntimeEnv(),
statusSink,
});
});
it("drops unmentioned group traffic before dispatch", async () => {
installRuntime({
buildMentionRegexes: vi.fn(() => [/@openclaw/i]),
matchesMentionPatterns: vi.fn(() => false),
});
createChannelPairingControllerMock.mockReturnValue({
readStoreForDmPolicy: vi.fn(),
issueChallenge: vi.fn(),
});
resolveNextcloudTalkRoomKindMock.mockResolvedValue("group");
resolveDmGroupAccessWithCommandGateMock.mockReturnValue({
decision: "allow",
reason: "allow",
commandAuthorized: false,
effectiveGroupAllowFrom: ["user-1"],
});
const runtime = createRuntimeEnv();
await handleNextcloudTalkInbound({
message: createMessage({
roomToken: "room-group",
roomName: "Ops",
isGroupChat: true,
}),
account: createAccount({
config: {
dmPolicy: "pairing",
allowFrom: [],
groupPolicy: "allowlist",
groupAllowFrom: ["user-1"],
},
}),
config: { channels: { "nextcloud-talk": {} } } as CoreConfig,
runtime,
});
expect(dispatchInboundReplyWithBaseMock).not.toHaveBeenCalled();
expect(runtime.log).toHaveBeenCalledWith("nextcloud-talk: drop room room-group (no mention)");
});
});

View file

@ -0,0 +1,314 @@
import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
import {
GROUP_POLICY_BLOCKED_LABEL,
createChannelPairingController,
deliverFormattedTextWithAttachments,
dispatchInboundReplyWithBase,
logInboundDrop,
readStoreAllowFromForDmPolicy,
resolveAllowlistProviderRuntimeGroupPolicy,
resolveDefaultGroupPolicy,
resolveDmGroupAccessWithCommandGate,
warnMissingProviderGroupPolicyFallbackOnce,
type OpenClawConfig,
type OutboundReplyPayload,
type RuntimeEnv,
} from "../runtime-api.js";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
import {
normalizeNextcloudTalkAllowlist,
resolveNextcloudTalkAllowlistMatch,
resolveNextcloudTalkGroupAllow,
resolveNextcloudTalkMentionGate,
resolveNextcloudTalkRequireMention,
resolveNextcloudTalkRoomMatch,
} from "./policy.js";
import { resolveNextcloudTalkRoomKind } from "./room-info.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import { sendMessageNextcloudTalk } from "./send.js";
import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
const CHANNEL_ID = "nextcloud-talk" as const;
async function deliverNextcloudTalkReply(params: {
payload: OutboundReplyPayload;
roomToken: string;
accountId: string;
statusSink?: (patch: { lastOutboundAt?: number }) => void;
}): Promise<void> {
const { payload, roomToken, accountId, statusSink } = params;
await deliverFormattedTextWithAttachments({
payload,
send: async ({ text, replyToId }) => {
await sendMessageNextcloudTalk(roomToken, text, {
accountId,
replyTo: 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 pairing = createChannelPairingController({
core,
channel: CHANNEL_ID,
accountId: account.accountId,
});
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 = resolveDefaultGroupPolicy(config as OpenClawConfig);
const { groupPolicy, providerMissingFallbackApplied } =
resolveAllowlistProviderRuntimeGroupPolicy({
providerConfigPresent:
((config.channels as Record<string, unknown> | undefined)?.["nextcloud-talk"] ??
undefined) !== undefined,
groupPolicy: account.config.groupPolicy,
defaultGroupPolicy,
});
warnMissingProviderGroupPolicyFallbackOnce({
providerMissingFallbackApplied,
providerKey: "nextcloud-talk",
accountId: account.accountId,
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
log: (message) => runtime.log?.(message),
});
const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom);
const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom);
const storeAllowFrom = await readStoreAllowFromForDmPolicy({
provider: CHANNEL_ID,
accountId: account.accountId,
dmPolicy,
readStore: pairing.readStoreForDmPolicy,
});
const storeAllowList = normalizeNextcloudTalkAllowlist(storeAllowFrom);
const roomMatch = resolveNextcloudTalkRoomMatch({
rooms: account.config.rooms,
roomToken,
});
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 allowTextCommands = core.channel.commands.shouldHandleTextCommands({
cfg: config as OpenClawConfig,
surface: CHANNEL_ID,
});
const useAccessGroups =
(config.commands as Record<string, unknown> | undefined)?.useAccessGroups !== false;
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
const access = resolveDmGroupAccessWithCommandGate({
isGroup,
dmPolicy,
groupPolicy,
allowFrom: configAllowFrom,
groupAllowFrom: configGroupAllowFrom,
storeAllowFrom: storeAllowList,
isSenderAllowed: (allowFrom) =>
resolveNextcloudTalkAllowlistMatch({
allowFrom,
senderId,
}).allowed,
command: {
useAccessGroups,
allowTextCommands,
hasControlCommand,
},
});
const commandAuthorized = access.commandAuthorized;
const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom;
if (isGroup) {
if (access.decision !== "allow") {
runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (reason=${access.reason})`);
return;
}
const groupAllow = resolveNextcloudTalkGroupAllow({
groupPolicy,
outerAllowFrom: effectiveGroupAllowFrom,
innerAllowFrom: roomAllowFrom,
senderId,
});
if (!groupAllow.allowed) {
runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (policy=${groupPolicy})`);
return;
}
} else {
if (access.decision !== "allow") {
if (access.decision === "pairing") {
await pairing.issueChallenge({
senderId,
senderIdLine: `Your Nextcloud user id: ${senderId}`,
meta: { name: senderName || undefined },
sendPairingReply: async (text) => {
await sendMessageNextcloudTalk(roomToken, text, { accountId: account.accountId });
statusSink?.({ lastOutboundAt: Date.now() });
},
onReplyError: (err) => {
runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`);
},
});
}
runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${access.reason})`);
return;
}
}
if (access.shouldBlockControlCommand) {
logInboundDrop({
log: (message) => runtime.log?.(message),
channel: CHANNEL_ID,
reason: "control command (unauthorized)",
target: senderId,
});
return;
}
const mentionRegexes = core.channel.mentions.buildMentionRegexes(config as OpenClawConfig);
const wasMentioned = mentionRegexes.length
? core.channel.mentions.matchesMentionPatterns(rawBody, mentionRegexes)
: false;
const shouldRequireMention = isGroup
? resolveNextcloudTalkRequireMention({
roomConfig,
wildcardConfig: roomMatch.wildcardConfig,
})
: false;
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 OpenClawConfig,
channel: CHANNEL_ID,
accountId: account.accountId,
peer: {
kind: isGroup ? "group" : "direct",
id: isGroup ? roomToken : senderId,
},
});
const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`;
const storePath = core.channel.session.resolveStorePath(
(config.session as Record<string, unknown> | undefined)?.store as string | undefined,
{
agentId: route.agentId,
},
);
const envelopeOptions = core.channel.reply.resolveEnvelopeFormatOptions(config as OpenClawConfig);
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 = normalizeOptionalString(roomConfig?.systemPrompt);
const ctxPayload = core.channel.reply.finalizeInboundContext({
Body: body,
BodyForAgent: rawBody,
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,
});
await dispatchInboundReplyWithBase({
cfg: config as OpenClawConfig,
channel: CHANNEL_ID,
accountId: account.accountId,
route,
storePath,
ctxPayload,
core,
deliver: async (payload) => {
await deliverNextcloudTalkReply({
payload,
roomToken,
accountId: account.accountId,
statusSink,
});
},
onRecordError: (err) => {
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
},
onDispatchError: (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,
},
});
}

View file

@ -0,0 +1,138 @@
import os from "node:os";
import { resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared";
import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
import { resolveNextcloudTalkAccount } from "./accounts.js";
import { handleNextcloudTalkInbound } from "./inbound.js";
import {
createNextcloudTalkWebhookServer,
processNextcloudTalkReplayGuardedMessage,
} from "./monitor.js";
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
import { getNextcloudTalkRuntime } from "./runtime.js";
import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
const DEFAULT_WEBHOOK_PORT = 8788;
const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
function normalizeOrigin(value: string): string | null {
try {
return normalizeLowercaseStringOrEmpty(new URL(value).origin);
} catch {
return null;
}
}
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 = resolveLoggerBackedRuntime(
opts.runtime,
core.logging.getChildLogger(),
);
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 expectedBackendOrigin = normalizeOrigin(account.baseUrl);
const replayGuard = createNextcloudTalkReplayGuard({
stateDir: core.state.resolveStateDir(process.env, os.homedir),
onDiskError: (error) => {
logger.warn(
`[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`,
);
},
});
const { start, stop } = createNextcloudTalkWebhookServer({
port,
host,
path,
secret: account.secret,
isBackendAllowed: (backend) => {
if (!expectedBackendOrigin) {
return true;
}
const backendOrigin = normalizeOrigin(backend);
return backendOrigin === expectedBackendOrigin;
},
processMessage: async (message) => {
const result = await processNextcloudTalkReplayGuardedMessage({
replayGuard,
accountId: account.accountId,
message,
handleMessage: async () => {
core.channel.activity.record({
channel: "nextcloud-talk",
accountId: account.accountId,
direction: "inbound",
at: message.timestamp,
});
if (opts.onMessage) {
await opts.onMessage(message);
} else {
await handleNextcloudTalkInbound({
message,
account,
config: cfg,
runtime,
statusSink: opts.statusSink,
});
}
},
});
if (result === "duplicate") {
logger.warn(
`[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`,
);
return;
}
},
onMessage: async () => {},
onError: (error) => {
logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`);
},
abortSignal: opts.abortSignal,
});
if (opts.abortSignal?.aborted) {
return { stop };
}
await start();
if (opts.abortSignal?.aborted) {
stop();
return { stop };
}
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 };
}

View file

@ -0,0 +1,279 @@
import { describe, expect, it, vi } from "vitest";
import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js";
import {
NextcloudTalkRetryableWebhookError,
processNextcloudTalkReplayGuardedMessage,
readNextcloudTalkWebhookBody,
} from "./monitor.js";
import { createSignedCreateMessageRequest } from "./monitor.test-fixtures.js";
import { startWebhookServer } from "./monitor.test-harness.js";
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
import { generateNextcloudTalkSignature } from "./signature.js";
import type { NextcloudTalkInboundMessage } from "./types.js";
describe("readNextcloudTalkWebhookBody", () => {
it("reads valid body within max bytes", async () => {
const req = createMockIncomingRequest(['{"type":"Create"}']);
const body = await readNextcloudTalkWebhookBody(req, 1024);
expect(body).toBe('{"type":"Create"}');
});
it("rejects when payload exceeds max bytes", async () => {
const req = createMockIncomingRequest(["x".repeat(300)]);
await expect(readNextcloudTalkWebhookBody(req, 128)).rejects.toThrow("PayloadTooLarge");
});
});
describe("createNextcloudTalkWebhookServer auth order", () => {
it("rejects missing signature headers before reading request body", async () => {
const readBody = vi.fn(async () => {
throw new Error("should not be called for missing signature headers");
});
const harness = await startWebhookServer({
path: "/nextcloud-auth-order",
maxBodyBytes: 128,
readBody,
onMessage: vi.fn(),
});
const response = await fetch(harness.webhookUrl, {
method: "POST",
headers: {
"content-type": "application/json",
},
body: "{}",
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: "Missing signature headers" });
expect(readBody).not.toHaveBeenCalled();
});
});
describe("createNextcloudTalkWebhookServer backend allowlist", () => {
it("rejects requests from unexpected backend origins", async () => {
const onMessage = vi.fn(async () => {});
const harness = await startWebhookServer({
path: "/nextcloud-backend-check",
isBackendAllowed: (backend) => backend === "https://nextcloud.expected",
onMessage,
});
const { body, headers } = createSignedCreateMessageRequest({
backend: "https://nextcloud.unexpected",
});
const response = await fetch(harness.webhookUrl, {
method: "POST",
headers,
body,
});
expect(response.status).toBe(401);
expect(await response.json()).toEqual({ error: "Invalid backend" });
expect(onMessage).not.toHaveBeenCalled();
});
});
describe("createNextcloudTalkWebhookServer replay handling", () => {
function createReplayGuardedProcess(params: {
stateDir?: string;
accountId?: string;
handleMessage: () => Promise<void>;
}) {
const replayGuard = createNextcloudTalkReplayGuard(
params.stateDir ? { stateDir: params.stateDir } : {},
);
return (message: NextcloudTalkInboundMessage) =>
processNextcloudTalkReplayGuardedMessage({
replayGuard,
accountId: params.accountId ?? "acct",
message,
handleMessage: params.handleMessage,
});
}
function buildInboundMessage(): NextcloudTalkInboundMessage {
return {
messageId: "msg-1",
roomToken: "room-token",
roomName: "Room 1",
senderId: "alice",
senderName: "Alice",
text: "hello",
mediaType: "text/plain",
timestamp: 1_700_000_000_000,
isGroupChat: true,
};
}
it("acknowledges replayed requests and skips onMessage side effects", async () => {
const seen = new Set<string>();
const onMessage = vi.fn(async () => {});
const shouldProcessMessage = vi.fn(async (message: NextcloudTalkInboundMessage) => {
if (seen.has(message.messageId)) {
return false;
}
seen.add(message.messageId);
return true;
});
const harness = await startWebhookServer({
path: "/nextcloud-replay",
shouldProcessMessage,
onMessage,
});
const { body, headers } = createSignedCreateMessageRequest();
const first = await fetch(harness.webhookUrl, {
method: "POST",
headers,
body,
});
const second = await fetch(harness.webhookUrl, {
method: "POST",
headers,
body,
});
expect(first.status).toBe(200);
expect(second.status).toBe(200);
expect(shouldProcessMessage).toHaveBeenCalledTimes(2);
expect(onMessage).toHaveBeenCalledTimes(1);
});
it("allows a retry after replay-guarded processing fails before commit", async () => {
let attempts = 0;
const handleMessage = vi.fn(async () => {
attempts += 1;
if (attempts === 1) {
throw new NextcloudTalkRetryableWebhookError("transient nextcloud failure");
}
});
const processMessage = createReplayGuardedProcess({
handleMessage,
});
const message = buildInboundMessage();
await expect(processMessage(message)).rejects.toThrow("transient nextcloud failure");
await expect(processMessage(message)).resolves.toBe("processed");
expect(handleMessage).toHaveBeenCalledTimes(2);
});
it("keeps replay committed after a non-retryable replay-guarded processing failure", async () => {
const visibleSideEffect = vi.fn();
const handleMessage = vi.fn(async () => {
visibleSideEffect();
throw new Error("post-send failure");
});
const processMessage = createReplayGuardedProcess({
handleMessage,
});
const message = buildInboundMessage();
await expect(processMessage(message)).rejects.toThrow("post-send failure");
await expect(processMessage(message)).resolves.toBe("duplicate");
expect(handleMessage).toHaveBeenCalledTimes(1);
expect(visibleSideEffect).toHaveBeenCalledTimes(1);
});
});
describe("createNextcloudTalkWebhookServer payload validation", () => {
it("rejects malformed webhook payloads after signature verification", async () => {
const payload = {
type: "Create",
actor: { type: "Person", id: "alice", name: "Alice" },
object: {
type: "Note",
id: "msg-1",
name: "hello",
content: "hello",
mediaType: "text/plain",
},
target: { type: "Collection", id: "", name: "Room 1" },
};
const body = JSON.stringify(payload);
const { random, signature } = generateNextcloudTalkSignature({
body,
secret: "nextcloud-secret", // pragma: allowlist secret
});
const harness = await startWebhookServer({
path: "/nextcloud-invalid-payload",
onMessage: vi.fn(),
});
const response = await fetch(harness.webhookUrl, {
method: "POST",
headers: {
"content-type": "application/json",
"x-nextcloud-talk-random": random,
"x-nextcloud-talk-signature": signature,
"x-nextcloud-talk-backend": "https://nextcloud.example",
},
body,
});
expect(response.status).toBe(400);
expect(await response.json()).toEqual({ error: "Invalid payload format" });
});
});
describe("createNextcloudTalkWebhookServer auth rate limiting", () => {
it("rate limits repeated invalid signature attempts from the same source", async () => {
const maxRequests = 1;
const harness = await startWebhookServer({
path: "/nextcloud-auth-rate-limit",
authRateLimit: { maxRequests },
onMessage: vi.fn(),
});
const { body, headers } = createSignedCreateMessageRequest();
const invalidHeaders = {
...headers,
"x-nextcloud-talk-signature": "invalid-signature",
};
let firstResponse: Response | undefined;
let lastResponse: Response | undefined;
for (let attempt = 0; attempt <= maxRequests; attempt += 1) {
const response = await fetch(harness.webhookUrl, {
method: "POST",
headers: invalidHeaders,
body,
});
if (attempt === 0) {
firstResponse = response;
}
lastResponse = response;
}
expect(firstResponse).toBeDefined();
expect(firstResponse?.status).toBe(401);
expect(lastResponse).toBeDefined();
expect(lastResponse?.status).toBe(429);
expect(await lastResponse?.text()).toBe("Too Many Requests");
});
it("does not rate limit valid signed webhook bursts from the same source", async () => {
const maxRequests = 1;
const harness = await startWebhookServer({
path: "/nextcloud-auth-rate-limit-valid",
authRateLimit: { maxRequests },
onMessage: vi.fn(),
});
const { body, headers } = createSignedCreateMessageRequest();
let lastResponse: Response | undefined;
for (let attempt = 0; attempt <= maxRequests; attempt += 1) {
lastResponse = await fetch(harness.webhookUrl, {
method: "POST",
headers,
body,
});
}
expect(lastResponse).toBeDefined();
expect(lastResponse?.status).toBe(200);
});
});

View file

@ -0,0 +1,30 @@
import { generateNextcloudTalkSignature } from "./signature.js";
export function createSignedCreateMessageRequest(params?: { backend?: string }) {
const payload = {
type: "Create",
actor: { type: "Person", id: "alice", name: "Alice" },
object: {
type: "Note",
id: "msg-1",
name: "hello",
content: "hello",
mediaType: "text/plain",
},
target: { type: "Collection", id: "room-1", name: "Room 1" },
};
const body = JSON.stringify(payload);
const { random, signature } = generateNextcloudTalkSignature({
body,
secret: "nextcloud-secret", // pragma: allowlist secret
});
return {
body,
headers: {
"content-type": "application/json",
"x-nextcloud-talk-random": random,
"x-nextcloud-talk-signature": signature,
"x-nextcloud-talk-backend": params?.backend ?? "https://nextcloud.example",
},
};
}

View file

@ -0,0 +1,59 @@
import { type AddressInfo } from "node:net";
import { afterEach } from "vitest";
import { createNextcloudTalkWebhookServer } from "./monitor.js";
import type { NextcloudTalkWebhookServerOptions } from "./types.js";
export type WebhookHarness = {
webhookUrl: string;
stop: () => Promise<void>;
};
const cleanupFns: Array<() => Promise<void>> = [];
afterEach(async () => {
while (cleanupFns.length > 0) {
const cleanup = cleanupFns.pop();
if (cleanup) {
await cleanup();
}
}
});
export type StartWebhookServerParams = Omit<
NextcloudTalkWebhookServerOptions,
"port" | "host" | "path" | "secret"
> & {
path: string;
secret?: string;
host?: string;
port?: number;
};
export async function startWebhookServer(
params: StartWebhookServerParams,
): Promise<WebhookHarness> {
const host = params.host ?? "127.0.0.1";
const port = params.port ?? 0;
const secret = params.secret ?? "nextcloud-secret";
const { server, start } = createNextcloudTalkWebhookServer({
...params,
port,
host,
secret,
});
await start();
const address = server.address() as AddressInfo | null;
if (!address) {
throw new Error("missing server address");
}
const harness: WebhookHarness = {
webhookUrl: `http://${host}:${address.port}${params.path}`,
stop: () =>
new Promise<void>((resolve) => {
server.close(() => resolve());
}),
};
cleanupFns.push(harness.stop);
return harness;
}

View file

@ -0,0 +1,385 @@
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
import { safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared";
import {
WEBHOOK_RATE_LIMIT_DEFAULTS,
createAuthRateLimiter,
isRequestBodyLimitError,
readRequestBodyWithLimit,
requestBodyErrorToText,
} from "openclaw/plugin-sdk/webhook-ingress";
import { z } from "zod";
import type { NextcloudTalkReplayGuard } from "./replay-guard.js";
import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
import type {
NextcloudTalkInboundMessage,
NextcloudTalkWebhookHeaders,
NextcloudTalkWebhookPayload,
NextcloudTalkWebhookServerOptions,
} from "./types.js";
const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
const PREAUTH_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
const PREAUTH_WEBHOOK_BODY_TIMEOUT_MS = 5_000;
const HEALTH_PATH = "/healthz";
const WEBHOOK_AUTH_RATE_LIMIT_SCOPE = "nextcloud-talk-webhook-auth";
const NextcloudTalkWebhookPayloadSchema: z.ZodType<NextcloudTalkWebhookPayload> = z.object({
type: z.enum(["Create", "Update", "Delete"]),
actor: z.object({
type: z.literal("Person"),
id: z.string().min(1),
name: z.string(),
}),
object: z.object({
type: z.literal("Note"),
id: z.string().min(1),
name: z.string(),
content: z.string(),
mediaType: z.string(),
}),
target: z.object({
type: z.literal("Collection"),
id: z.string().min(1),
name: z.string(),
}),
});
const WEBHOOK_ERRORS = {
missingSignatureHeaders: "Missing signature headers",
invalidBackend: "Invalid backend",
invalidSignature: "Invalid signature",
invalidPayloadFormat: "Invalid payload format",
payloadTooLarge: "Payload too large",
internalServerError: "Internal server error",
} as const;
export class NextcloudTalkRetryableWebhookError extends Error {
constructor(message: string, options?: ErrorOptions) {
super(message, options);
this.name = "NextcloudTalkRetryableWebhookError";
}
}
export async function processNextcloudTalkReplayGuardedMessage(params: {
replayGuard: NextcloudTalkReplayGuard;
accountId: string;
message: NextcloudTalkInboundMessage;
handleMessage: () => Promise<void>;
}): Promise<"processed" | "duplicate"> {
const claim = await params.replayGuard.claimMessage({
accountId: params.accountId,
roomToken: params.message.roomToken,
messageId: params.message.messageId,
});
if (claim !== "claimed") {
return "duplicate";
}
try {
await params.handleMessage();
await params.replayGuard.commitMessage({
accountId: params.accountId,
roomToken: params.message.roomToken,
messageId: params.message.messageId,
});
return "processed";
} catch (error) {
if (error instanceof NextcloudTalkRetryableWebhookError) {
params.replayGuard.releaseMessage({
accountId: params.accountId,
roomToken: params.message.roomToken,
messageId: params.message.messageId,
error,
});
} else {
// Generic failures are treated as non-retryable because the handler may already
// have produced a visible side effect, and replaying the webhook would duplicate it.
await params.replayGuard.commitMessage({
accountId: params.accountId,
roomToken: params.message.roomToken,
messageId: params.message.messageId,
});
}
throw error;
}
}
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 {
return safeParseJsonWithSchema(NextcloudTalkWebhookPayloadSchema, body);
}
function writeJsonResponse(
res: ServerResponse,
status: number,
body?: Record<string, unknown>,
): void {
if (body) {
res.writeHead(status, { "Content-Type": "application/json" });
res.end(JSON.stringify(body));
return;
}
res.writeHead(status);
res.end();
}
function writeWebhookError(res: ServerResponse, status: number, error: string): void {
if (res.headersSent) {
return;
}
writeJsonResponse(res, status, { error });
}
function validateWebhookHeaders(params: {
req: IncomingMessage;
res: ServerResponse;
isBackendAllowed?: (backend: string) => boolean;
}): NextcloudTalkWebhookHeaders | null {
const headers = extractNextcloudTalkHeaders(
params.req.headers as Record<string, string | string[] | undefined>,
);
if (!headers) {
writeWebhookError(params.res, 400, WEBHOOK_ERRORS.missingSignatureHeaders);
return null;
}
if (params.isBackendAllowed && !params.isBackendAllowed(headers.backend)) {
writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidBackend);
return null;
}
return headers;
}
function verifyWebhookSignature(params: {
headers: NextcloudTalkWebhookHeaders;
body: string;
secret: string;
res: ServerResponse;
clientIp: string;
authRateLimiter: ReturnType<typeof createAuthRateLimiter>;
}): boolean {
const isValid = verifyNextcloudTalkSignature({
signature: params.headers.signature,
random: params.headers.random,
body: params.body,
secret: params.secret,
});
if (!isValid) {
params.authRateLimiter.recordFailure(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidSignature);
return false;
}
params.authRateLimiter.reset(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
return true;
}
function decodeWebhookCreateMessage(params: {
body: string;
res: ServerResponse;
}):
| { kind: "message"; message: NextcloudTalkInboundMessage }
| { kind: "ignore" }
| { kind: "invalid" } {
const payload = parseWebhookPayload(params.body);
if (!payload) {
writeWebhookError(params.res, 400, WEBHOOK_ERRORS.invalidPayloadFormat);
return { kind: "invalid" };
}
if (payload.type !== "Create") {
return { kind: "ignore" };
}
return { kind: "message", message: payloadToInboundMessage(payload) };
}
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: 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,
};
}
export function readNextcloudTalkWebhookBody(
req: IncomingMessage,
maxBodyBytes: number,
): Promise<string> {
return readRequestBodyWithLimit(req, {
// This read happens before signature verification, so keep the unauthenticated
// body budget bounded even if the operator-configured post-parse limit is larger.
maxBytes: Math.min(maxBodyBytes, PREAUTH_WEBHOOK_MAX_BODY_BYTES),
timeoutMs: PREAUTH_WEBHOOK_BODY_TIMEOUT_MS,
});
}
export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServerOptions): {
server: Server;
start: () => Promise<void>;
stop: () => void;
} {
const { port, host, path, secret, onMessage, onError, abortSignal } = opts;
const maxBodyBytes =
typeof opts.maxBodyBytes === "number" &&
Number.isFinite(opts.maxBodyBytes) &&
opts.maxBodyBytes > 0
? Math.floor(opts.maxBodyBytes)
: DEFAULT_WEBHOOK_MAX_BODY_BYTES;
const readBody = opts.readBody ?? readNextcloudTalkWebhookBody;
const isBackendAllowed = opts.isBackendAllowed;
const shouldProcessMessage = opts.shouldProcessMessage;
const processMessage = opts.processMessage;
const authRateLimitMaxRequests =
typeof opts.authRateLimit?.maxRequests === "number"
? opts.authRateLimit.maxRequests
: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests;
const authRateLimitWindowMs =
typeof opts.authRateLimit?.windowMs === "number"
? opts.authRateLimit.windowMs
: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs;
const webhookAuthRateLimiter = createAuthRateLimiter({
maxAttempts: authRateLimitMaxRequests,
windowMs: authRateLimitWindowMs,
lockoutMs: authRateLimitWindowMs,
exemptLoopback: false,
pruneIntervalMs: authRateLimitWindowMs,
});
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;
}
const clientIp = req.socket.remoteAddress ?? "unknown";
if (!webhookAuthRateLimiter.check(clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE).allowed) {
res.writeHead(429);
res.end("Too Many Requests");
return;
}
try {
const headers = validateWebhookHeaders({
req,
res,
isBackendAllowed,
});
if (!headers) {
return;
}
const body = await readBody(req, maxBodyBytes);
const hasValidSignature = verifyWebhookSignature({
headers,
body,
secret,
res,
clientIp,
authRateLimiter: webhookAuthRateLimiter,
});
if (!hasValidSignature) {
return;
}
const decoded = decodeWebhookCreateMessage({
body,
res,
});
if (decoded.kind === "invalid") {
return;
}
if (decoded.kind === "ignore") {
writeJsonResponse(res, 200);
return;
}
const message = decoded.message;
if (processMessage) {
writeJsonResponse(res, 200);
try {
await processMessage(message);
} catch (err) {
onError?.(err instanceof Error ? err : new Error(formatError(err)));
}
return;
}
if (shouldProcessMessage) {
const shouldProcess = await shouldProcessMessage(message);
if (!shouldProcess) {
writeJsonResponse(res, 200);
return;
}
}
writeJsonResponse(res, 200);
try {
await onMessage(message);
} catch (err) {
onError?.(err instanceof Error ? err : new Error(formatError(err)));
}
} catch (err) {
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
writeWebhookError(res, 413, WEBHOOK_ERRORS.payloadTooLarge);
return;
}
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
writeWebhookError(res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
return;
}
const error = err instanceof Error ? err : new Error(formatError(err));
onError?.(error);
writeWebhookError(res, 500, WEBHOOK_ERRORS.internalServerError);
}
});
const start = (): Promise<void> => {
return new Promise((resolve) => {
server.listen(port, host, () => resolve());
});
};
let stopped = false;
const stop = () => {
if (stopped) {
return;
}
stopped = true;
try {
server.close();
} catch {
// ignore close races while shutting down
}
};
if (abortSignal) {
if (abortSignal.aborted) {
stop();
} else {
abortSignal.addEventListener("abort", stop, { once: true });
}
}
return { server, start, stop };
}

View file

@ -0,0 +1,44 @@
export function stripNextcloudTalkTargetPrefix(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 normalized;
}
export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined {
const normalized = stripNextcloudTalkTargetPrefix(raw);
return normalized ? `nextcloud-talk:${normalized}`.toLowerCase() : undefined;
}
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);
}

View file

@ -0,0 +1,180 @@
import {
buildChannelKeyCandidates,
normalizeChannelSlug,
resolveChannelEntryMatchWithFallback,
resolveNestedAllowlistDecision,
} from "openclaw/plugin-sdk/channel-targets";
import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
import type {
AllowlistMatch,
ChannelGroupContext,
GroupPolicy,
GroupToolPolicyConfig,
} from "../runtime-api.js";
import type { NextcloudTalkRoomConfig } from "./types.js";
function normalizeAllowEntry(raw: string): string {
return raw
.trim()
.replace(/^(nextcloud-talk|nc-talk|nc):/i, "")
.toLowerCase();
}
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;
}): AllowlistMatch<"wildcard" | "id"> {
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" };
}
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;
}): NextcloudTalkRoomMatch {
const rooms = params.rooms ?? {};
const allowlistConfigured = Object.keys(rooms).length > 0;
const roomCandidates = buildChannelKeyCandidates(params.roomToken);
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 resolveNextcloudTalkGroupToolPolicy(
params: ChannelGroupContext,
): GroupToolPolicyConfig | undefined {
const cfg = params.cfg as {
channels?: { "nextcloud-talk"?: { rooms?: Record<string, NextcloudTalkRoomConfig> } };
};
const roomToken = params.groupId?.trim();
if (!roomToken) {
return undefined;
}
const match = resolveNextcloudTalkRoomMatch({
rooms: cfg.channels?.["nextcloud-talk"]?.rooms,
roomToken,
});
return match.roomConfig?.tools ?? match.wildcardConfig?.tools;
}
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;
}): { allowed: boolean; outerMatch: AllowlistMatch; innerMatch: AllowlistMatch } {
const outerAllow = normalizeNextcloudTalkAllowlist(params.outerAllowFrom);
const innerAllow = normalizeNextcloudTalkAllowlist(params.innerAllowFrom);
const outerMatch = resolveNextcloudTalkAllowlistMatch({
allowFrom: params.outerAllowFrom,
senderId: params.senderId,
});
const innerMatch = resolveNextcloudTalkAllowlistMatch({
allowFrom: params.innerAllowFrom,
senderId: params.senderId,
});
const access = evaluateMatchedGroupAccessForPolicy({
groupPolicy: params.groupPolicy,
allowlistConfigured: outerAllow.length > 0 || innerAllow.length > 0,
allowlistMatched: resolveNestedAllowlistDecision({
outerConfigured: outerAllow.length > 0 || innerAllow.length > 0,
outerMatched: outerAllow.length > 0 ? outerMatch.allowed : true,
innerConfigured: innerAllow.length > 0,
innerMatched: innerMatch.allowed,
}),
});
return {
allowed: access.allowed,
outerMatch:
params.groupPolicy === "open"
? { allowed: true }
: params.groupPolicy === "disabled"
? { allowed: false }
: outerMatch,
innerMatch:
params.groupPolicy === "open"
? { allowed: true }
: params.groupPolicy === "disabled"
? { allowed: false }
: innerMatch,
};
}
export function resolveNextcloudTalkMentionGate(params: {
isGroup: boolean;
requireMention: boolean;
wasMentioned: boolean;
allowTextCommands: boolean;
hasControlCommand: boolean;
commandAuthorized: boolean;
}): { shouldSkip: boolean; shouldBypassMention: boolean } {
const shouldBypassMention =
params.isGroup &&
params.requireMention &&
!params.wasMentioned &&
params.allowTextCommands &&
params.commandAuthorized &&
params.hasControlCommand;
return {
shouldBypassMention,
shouldSkip: params.requireMention && !params.wasMentioned && !shouldBypassMention,
};
}

View file

@ -0,0 +1,128 @@
import path from "node:path";
import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
const DEFAULT_REPLAY_TTL_MS = 24 * 60 * 60 * 1000;
const DEFAULT_MEMORY_MAX_SIZE = 1_000;
const DEFAULT_FILE_MAX_ENTRIES = 10_000;
function sanitizeSegment(value: string): string {
const trimmed = value.trim();
if (!trimmed) {
return "default";
}
return trimmed.replace(/[^a-zA-Z0-9_-]/g, "_");
}
function buildReplayKey(params: { roomToken: string; messageId: string }): string | null {
const roomToken = params.roomToken.trim();
const messageId = params.messageId.trim();
if (!roomToken || !messageId) {
return null;
}
return `${roomToken}:${messageId}`;
}
export type NextcloudTalkReplayGuardOptions = {
stateDir?: string;
ttlMs?: number;
memoryMaxSize?: number;
fileMaxEntries?: number;
onDiskError?: (error: unknown) => void;
};
export type NextcloudTalkReplayGuard = {
claimMessage: (params: {
accountId: string;
roomToken: string;
messageId: string;
}) => Promise<"claimed" | "duplicate" | "inflight" | "invalid">;
commitMessage: (params: {
accountId: string;
roomToken: string;
messageId: string;
}) => Promise<boolean>;
releaseMessage: (params: {
accountId: string;
roomToken: string;
messageId: string;
error?: unknown;
}) => void;
shouldProcessMessage: (params: {
accountId: string;
roomToken: string;
messageId: string;
}) => Promise<boolean>;
};
export function createNextcloudTalkReplayGuard(
options: NextcloudTalkReplayGuardOptions,
): NextcloudTalkReplayGuard {
const stateDir = options.stateDir?.trim();
const baseOptions = {
ttlMs: options.ttlMs ?? DEFAULT_REPLAY_TTL_MS,
memoryMaxSize: options.memoryMaxSize ?? DEFAULT_MEMORY_MAX_SIZE,
};
const dedupe = createClaimableDedupe(
stateDir
? {
...baseOptions,
fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES,
resolveFilePath: (namespace) =>
path.join(
stateDir,
"nextcloud-talk",
"replay-dedupe",
`${sanitizeSegment(namespace)}.json`,
),
onDiskError: options.onDiskError,
}
: baseOptions,
);
return {
claimMessage: async ({ accountId, roomToken, messageId }) => {
const replayKey = buildReplayKey({ roomToken, messageId });
if (!replayKey) {
return "invalid";
}
const result = await dedupe.claim(replayKey, {
namespace: accountId,
});
return result.kind;
},
commitMessage: async ({ accountId, roomToken, messageId }) => {
const replayKey = buildReplayKey({ roomToken, messageId });
if (!replayKey) {
return true;
}
return await dedupe.commit(replayKey, {
namespace: accountId,
});
},
releaseMessage: ({ accountId, roomToken, messageId, error }) => {
const replayKey = buildReplayKey({ roomToken, messageId });
if (!replayKey) {
return;
}
dedupe.release(replayKey, {
namespace: accountId,
error,
});
},
shouldProcessMessage: async ({ accountId, roomToken, messageId }) => {
const replayKey = buildReplayKey({ roomToken, messageId });
if (!replayKey) {
return true;
}
const result = await dedupe.claim(replayKey, {
namespace: accountId,
});
if (result.kind !== "claimed") {
return false;
}
return await dedupe.commit(replayKey, {
namespace: accountId,
});
},
};
}

View file

@ -0,0 +1,116 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveNextcloudTalkRoomKind, __testing } from "./room-info.js";
const fetchWithSsrFGuard = vi.hoisted(() => vi.fn());
const readFileSync = vi.hoisted(() => vi.fn());
vi.mock("../runtime-api.js", () => {
return vi
.importActual<typeof import("../runtime-api.js")>("../runtime-api.js")
.then((actual) => ({
...actual,
fetchWithSsrFGuard,
}));
});
vi.mock("node:fs", () => {
return vi.importActual<typeof import("node:fs")>("node:fs").then((actual) => ({
...actual,
readFileSync,
}));
});
afterEach(() => {
fetchWithSsrFGuard.mockReset();
readFileSync.mockReset();
__testing.resetRoomCache();
});
describe("nextcloud talk room info", () => {
it("resolves direct rooms from the room info endpoint", async () => {
const release = vi.fn(async () => {});
fetchWithSsrFGuard.mockResolvedValue({
response: {
ok: true,
json: async () => ({
ocs: {
data: {
type: 1,
},
},
}),
},
release,
});
const kind = await resolveNextcloudTalkRoomKind({
account: {
accountId: "acct-direct",
baseUrl: "https://nc.example.com",
config: {
apiUser: "bot",
apiPassword: "secret",
},
} as never,
roomToken: "room-direct",
});
expect(kind).toBe("direct");
expect(fetchWithSsrFGuard).toHaveBeenCalledWith(
expect.objectContaining({
url: "https://nc.example.com/ocs/v2.php/apps/spreed/api/v4/room/room-direct",
auditContext: "nextcloud-talk.room-info",
}),
);
expect(release).toHaveBeenCalledTimes(1);
});
it("reads the api password from a file and logs non-ok room info responses", async () => {
const release = vi.fn(async () => {});
const log = vi.fn();
const error = vi.fn();
const exit = vi.fn();
readFileSync.mockReturnValue("file-secret\n");
fetchWithSsrFGuard.mockResolvedValue({
response: {
ok: false,
status: 403,
json: async () => ({}),
},
release,
});
const kind = await resolveNextcloudTalkRoomKind({
account: {
accountId: "acct-group",
baseUrl: "https://nc.example.com",
config: {
apiUser: "bot",
apiPasswordFile: "/tmp/nextcloud-secret",
},
} as never,
roomToken: "room-group",
runtime: { log, error, exit },
});
expect(kind).toBeUndefined();
expect(readFileSync).toHaveBeenCalledWith("/tmp/nextcloud-secret", "utf-8");
expect(log).toHaveBeenCalledWith("nextcloud-talk: room lookup failed (403) token=room-group");
expect(release).toHaveBeenCalledTimes(1);
});
it("returns undefined from room info without credentials or base url", async () => {
await expect(
resolveNextcloudTalkRoomKind({
account: {
accountId: "acct-missing",
baseUrl: "",
config: {},
} as never,
roomToken: "room-missing",
}),
).resolves.toBeUndefined();
expect(fetchWithSsrFGuard).not.toHaveBeenCalled();
});
});

View file

@ -0,0 +1,148 @@
import { readFileSync } from "node:fs";
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
import { ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime";
import { fetchWithSsrFGuard, type RuntimeEnv } from "../runtime-api.js";
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
import { normalizeResolvedSecretInputString } from "./secret-input.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 }
>();
export const __testing = {
resetRoomCache() {
roomCache.clear();
},
};
function resolveRoomCacheKey(params: { accountId: string; roomToken: string }) {
return `${params.accountId}:${params.roomToken}`;
}
function readApiPassword(params: {
apiPassword?: unknown;
apiPasswordFile?: string;
}): string | undefined {
const inlinePassword = normalizeResolvedSecretInputString({
value: params.apiPassword,
path: "channels.nextcloud-talk.apiPassword",
});
if (inlinePassword) {
return inlinePassword;
}
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, release } = await fetchWithSsrFGuard({
url,
init: {
method: "GET",
headers: {
Authorization: `Basic ${auth}`,
"OCS-APIRequest": "true",
Accept: "application/json",
},
},
auditContext: "nextcloud-talk.room-info",
policy: ssrfPolicyFromPrivateNetworkOptIn(account.config),
});
try {
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;
} finally {
await release();
}
} catch (err) {
roomCache.set(key, {
fetchedAt: Date.now(),
error: formatErrorMessage(err),
});
runtime?.error?.(`nextcloud-talk: room lookup error: ${String(err)}`);
return undefined;
}
}

View file

@ -0,0 +1,9 @@
import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } =
createPluginRuntimeStore<PluginRuntime>({
pluginId: "nextcloud-talk",
errorMessage: "Nextcloud Talk runtime not initialized",
});
export { getNextcloudTalkRuntime, setNextcloudTalkRuntime };

View file

@ -0,0 +1,103 @@
import {
collectConditionalChannelFieldAssignments,
getChannelSurface,
hasOwnProperty,
type ChannelAccountEntry,
type ResolverContext,
type SecretDefaults,
type SecretTargetRegistryEntry,
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
export const secretTargetRegistryEntries = [
{
id: "channels.nextcloud-talk.accounts.*.apiPassword",
targetType: "channels.nextcloud-talk.accounts.*.apiPassword",
configFile: "openclaw.json",
pathPattern: "channels.nextcloud-talk.accounts.*.apiPassword",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.nextcloud-talk.accounts.*.botSecret",
targetType: "channels.nextcloud-talk.accounts.*.botSecret",
configFile: "openclaw.json",
pathPattern: "channels.nextcloud-talk.accounts.*.botSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.nextcloud-talk.apiPassword",
targetType: "channels.nextcloud-talk.apiPassword",
configFile: "openclaw.json",
pathPattern: "channels.nextcloud-talk.apiPassword",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
{
id: "channels.nextcloud-talk.botSecret",
targetType: "channels.nextcloud-talk.botSecret",
configFile: "openclaw.json",
pathPattern: "channels.nextcloud-talk.botSecret",
secretShape: "secret_input",
expectedResolvedValue: "string",
includeInPlan: true,
includeInConfigure: true,
includeInAudit: true,
},
] satisfies SecretTargetRegistryEntry[];
export function collectRuntimeConfigAssignments(params: {
config: { channels?: Record<string, unknown> };
defaults?: SecretDefaults;
context: ResolverContext;
}): void {
const resolved = getChannelSurface(params.config, "nextcloud-talk");
if (!resolved) {
return;
}
const { channel: nextcloudTalk, surface } = resolved;
const inheritsField =
(field: string) =>
({ account, enabled }: ChannelAccountEntry) =>
enabled && !hasOwnProperty(account, field);
collectConditionalChannelFieldAssignments({
channelKey: "nextcloud-talk",
field: "botSecret",
channel: nextcloudTalk,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: true,
topLevelInheritedAccountActive: inheritsField("botSecret"),
accountActive: ({ enabled }) => enabled,
topInactiveReason: "no enabled Nextcloud Talk surface inherits this top-level botSecret.",
accountInactiveReason: "Nextcloud Talk account is disabled.",
});
collectConditionalChannelFieldAssignments({
channelKey: "nextcloud-talk",
field: "apiPassword",
channel: nextcloudTalk,
surface,
defaults: params.defaults,
context: params.context,
topLevelActiveWithoutAccounts: true,
topLevelInheritedAccountActive: inheritsField("apiPassword"),
accountActive: ({ enabled }) => enabled,
topInactiveReason: "no enabled Nextcloud Talk surface inherits this top-level apiPassword.",
accountInactiveReason: "Nextcloud Talk account is disabled.",
});
}
export const channelSecrets = {
secretTargetRegistryEntries,
collectRuntimeConfigAssignments,
};

View file

@ -0,0 +1,6 @@
export {
buildSecretInputSchema,
hasConfiguredSecretInput,
normalizeResolvedSecretInputString,
normalizeSecretInputString,
} from "openclaw/plugin-sdk/secret-input";

View file

@ -0,0 +1,153 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
createSendCfgThreadingRuntime,
expectProvidedCfgSkipsRuntimeLoad,
expectRuntimeCfgFallback,
} from "../../../test/helpers/plugins/send-config.js";
const hoisted = vi.hoisted(() => ({
loadConfig: vi.fn(),
resolveMarkdownTableMode: vi.fn(() => "preserve"),
convertMarkdownTables: vi.fn((text: string) => text),
record: vi.fn(),
resolveNextcloudTalkAccount: vi.fn(),
ssrfPolicyFromPrivateNetworkOptIn: vi.fn(() => undefined),
generateNextcloudTalkSignature: vi.fn(() => ({
random: "r",
signature: "s",
})),
mockFetchGuard: vi.fn(),
}));
vi.mock("./send.runtime.js", () => {
return {
convertMarkdownTables: hoisted.convertMarkdownTables,
fetchWithSsrFGuard: hoisted.mockFetchGuard,
generateNextcloudTalkSignature: hoisted.generateNextcloudTalkSignature,
getNextcloudTalkRuntime: () => createSendCfgThreadingRuntime(hoisted),
resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount,
resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode,
ssrfPolicyFromPrivateNetworkOptIn: hoisted.ssrfPolicyFromPrivateNetworkOptIn,
};
});
const { sendMessageNextcloudTalk, sendReactionNextcloudTalk } = await import("./send.js");
function expectProvidedMessageCfgThreading(cfg: unknown): void {
expectProvidedCfgSkipsRuntimeLoad({
loadConfig: hoisted.loadConfig,
resolveAccount: hoisted.resolveNextcloudTalkAccount,
cfg,
accountId: "work",
});
expect(hoisted.resolveMarkdownTableMode).toHaveBeenCalledWith({
cfg,
channel: "nextcloud-talk",
accountId: "default",
});
expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith("hello", "preserve");
}
describe("nextcloud-talk send cfg threading", () => {
const fetchMock = vi.fn<typeof fetch>();
const defaultAccount = {
accountId: "default",
baseUrl: "https://nextcloud.example.com",
secret: "secret-value",
};
function mockNextcloudMessageResponse(messageId: number, timestamp: number): void {
fetchMock.mockResolvedValueOnce(
new Response(
JSON.stringify({
ocs: { data: { id: messageId, timestamp } },
}),
{ status: 200, headers: { "content-type": "application/json" } },
),
);
}
beforeEach(() => {
vi.stubGlobal("fetch", fetchMock);
// Route the SSRF guard mock through the global fetch mock.
hoisted.mockFetchGuard.mockImplementation(async (p: { url: string; init?: RequestInit }) => {
const response = await globalThis.fetch(p.url, p.init);
return { response, release: async () => {}, finalUrl: p.url };
});
hoisted.loadConfig.mockReset();
hoisted.resolveMarkdownTableMode.mockClear();
hoisted.convertMarkdownTables.mockClear();
hoisted.record.mockReset();
hoisted.ssrfPolicyFromPrivateNetworkOptIn.mockClear();
hoisted.generateNextcloudTalkSignature.mockClear();
hoisted.resolveNextcloudTalkAccount.mockReset();
hoisted.resolveNextcloudTalkAccount.mockReturnValue(defaultAccount);
});
afterEach(() => {
fetchMock.mockReset();
hoisted.mockFetchGuard.mockReset();
vi.unstubAllGlobals();
});
it("uses provided cfg for sendMessage and skips runtime loadConfig", async () => {
const cfg = { source: "provided" } as const;
mockNextcloudMessageResponse(12345, 1_706_000_000);
const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
cfg,
accountId: "work",
});
expectProvidedMessageCfgThreading(cfg);
expect(hoisted.record).toHaveBeenCalledWith({
channel: "nextcloud-talk",
accountId: "default",
direction: "outbound",
});
expect(fetchMock).toHaveBeenCalledTimes(1);
expect(result).toEqual({
messageId: "12345",
roomToken: "abc123",
timestamp: 1_706_000_000,
});
});
it("sends with provided cfg even when the runtime store is not initialized", async () => {
const cfg = { source: "provided" } as const;
hoisted.record.mockImplementation(() => {
throw new Error("Nextcloud Talk runtime not initialized");
});
mockNextcloudMessageResponse(12346, 1_706_000_001);
const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
cfg,
accountId: "work",
});
expectProvidedMessageCfgThreading(cfg);
expect(result).toEqual({
messageId: "12346",
roomToken: "abc123",
timestamp: 1_706_000_001,
});
});
it("falls back to runtime cfg for sendReaction when cfg is omitted", async () => {
const runtimeCfg = { source: "runtime" } as const;
hoisted.loadConfig.mockReturnValueOnce(runtimeCfg);
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 }));
const result = await sendReactionNextcloudTalk("room:ops", "m-1", "👍", {
accountId: "default",
});
expect(result).toEqual({ ok: true });
expectRuntimeCfgFallback({
loadConfig: hoisted.loadConfig,
resolveAccount: hoisted.resolveNextcloudTalkAccount,
cfg: runtimeCfg,
accountId: "default",
});
});
});

View file

@ -0,0 +1,7 @@
export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/config-runtime";
export { ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime";
export { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
export { fetchWithSsrFGuard } from "../runtime-api.js";
export { resolveNextcloudTalkAccount } from "./accounts.js";
export { getNextcloudTalkRuntime } from "./runtime.js";
export { generateNextcloudTalkSignature } from "./signature.js";

View file

@ -0,0 +1,235 @@
import { stripNextcloudTalkTargetPrefix } from "./normalize.js";
import {
convertMarkdownTables,
fetchWithSsrFGuard,
generateNextcloudTalkSignature,
getNextcloudTalkRuntime,
resolveMarkdownTableMode,
resolveNextcloudTalkAccount,
ssrfPolicyFromPrivateNetworkOptIn,
} from "./send.runtime.js";
import type { CoreConfig, NextcloudTalkSendResult } from "./types.js";
type NextcloudTalkSendOpts = {
baseUrl?: string;
secret?: string;
accountId?: string;
replyTo?: string;
verbose?: boolean;
cfg?: CoreConfig;
};
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 normalized = stripNextcloudTalkTargetPrefix(to);
if (!normalized) {
throw new Error("Room token is required for Nextcloud Talk sends");
}
return normalized;
}
function resolveNextcloudTalkSendContext(opts: NextcloudTalkSendOpts): {
cfg: CoreConfig;
account: ReturnType<typeof resolveNextcloudTalkAccount>;
baseUrl: string;
secret: string;
} {
const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig;
const account = resolveNextcloudTalkAccount({
cfg,
accountId: opts.accountId,
});
const { baseUrl, secret } = resolveCredentials(
{ baseUrl: opts.baseUrl, secret: opts.secret },
account,
);
return { cfg, account, baseUrl, secret };
}
function recordNextcloudTalkOutboundActivity(accountId: string): void {
try {
getNextcloudTalkRuntime().channel.activity.record({
channel: "nextcloud-talk",
accountId,
direction: "outbound",
});
} catch (error) {
if (!(error instanceof Error) || error.message !== "Nextcloud Talk runtime not initialized") {
throw error;
}
}
}
export async function sendMessageNextcloudTalk(
to: string,
text: string,
opts: NextcloudTalkSendOpts = {},
): Promise<NextcloudTalkSendResult> {
const { cfg, account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts);
const roomToken = normalizeRoomToken(to);
if (!text?.trim()) {
throw new Error("Message must be non-empty for Nextcloud Talk sends");
}
const tableMode = resolveMarkdownTableMode({
cfg,
channel: "nextcloud-talk",
accountId: account.accountId,
});
const message = convertMarkdownTables(text.trim(), tableMode);
const body: Record<string, unknown> = {
message,
};
if (opts.replyTo) {
body.replyTo = opts.replyTo;
}
const bodyStr = JSON.stringify(body);
// Nextcloud Talk verifies signature against the extracted message text,
// not the full JSON body. See ChecksumVerificationService.php:
// hash_hmac('sha256', $random . $data, $secret)
// where $data is the "message" parameter, not the raw request body.
const { random, signature } = generateNextcloudTalkSignature({
body: message,
secret,
});
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${roomToken}/message`;
const { response, release } = await fetchWithSsrFGuard({
url,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
"OCS-APIRequest": "true",
"X-Nextcloud-Talk-Bot-Random": random,
"X-Nextcloud-Talk-Bot-Signature": signature,
},
body: bodyStr,
},
auditContext: "nextcloud-talk-send",
policy: ssrfPolicyFromPrivateNetworkOptIn(account.config),
});
try {
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}`);
}
recordNextcloudTalkOutboundActivity(account.accountId);
return { messageId, roomToken, timestamp };
} finally {
await release();
}
}
export async function sendReactionNextcloudTalk(
roomToken: string,
messageId: string,
reaction: string,
opts: Omit<NextcloudTalkSendOpts, "replyTo"> = {},
): Promise<{ ok: true }> {
const { account, baseUrl, secret } = resolveNextcloudTalkSendContext(opts);
const normalizedToken = normalizeRoomToken(roomToken);
const body = JSON.stringify({ reaction });
// Sign only the reaction string, not the full JSON body
const { random, signature } = generateNextcloudTalkSignature({
body: reaction,
secret,
});
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/${normalizedToken}/reaction/${messageId}`;
const { response, release } = await fetchWithSsrFGuard({
url,
init: {
method: "POST",
headers: {
"Content-Type": "application/json",
"OCS-APIRequest": "true",
"X-Nextcloud-Talk-Bot-Random": random,
"X-Nextcloud-Talk-Bot-Signature": signature,
},
body,
},
auditContext: "nextcloud-talk-reaction",
policy: ssrfPolicyFromPrivateNetworkOptIn(account.config),
});
try {
if (!response.ok) {
const errorBody = await response.text().catch(() => "");
throw new Error(`Nextcloud Talk reaction failed: ${response.status} ${errorBody}`.trim());
}
return { ok: true };
} finally {
await release();
}
}

View file

@ -0,0 +1,40 @@
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { buildOutboundBaseSessionKey } from "openclaw/plugin-sdk/routing";
import { stripNextcloudTalkTargetPrefix } from "./normalize.js";
type NextcloudTalkOutboundSessionRouteParams = {
cfg: OpenClawConfig;
agentId: string;
accountId?: string | null;
target: string;
};
export function resolveNextcloudTalkOutboundSessionRoute(
params: NextcloudTalkOutboundSessionRouteParams,
) {
const roomId = stripNextcloudTalkTargetPrefix(params.target);
if (!roomId) {
return null;
}
const baseSessionKey = buildOutboundBaseSessionKey({
cfg: params.cfg,
agentId: params.agentId,
channel: "nextcloud-talk",
accountId: params.accountId,
peer: {
kind: "group",
id: roomId,
},
});
return {
sessionKey: baseSessionKey,
baseSessionKey,
peer: {
kind: "group" as const,
id: roomId,
},
chatType: "group" as const,
from: `nextcloud-talk:room:${roomId}`,
to: `nextcloud-talk:${roomId}`,
};
}

View file

@ -0,0 +1,251 @@
import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup";
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-runtime";
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
import {
applyAccountNameToChannelSection,
patchScopedAccountConfig,
} from "openclaw/plugin-sdk/setup";
import {
createSetupInputPresenceValidator,
mergeAllowFromEntries,
promptParsedAllowFromForAccount,
resolveSetupAccountId,
type ChannelSetupDmPolicy,
type WizardPrompter,
} from "openclaw/plugin-sdk/setup-runtime";
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
import { resolveDefaultNextcloudTalkAccountId, resolveNextcloudTalkAccount } from "./accounts.js";
import type { CoreConfig } from "./types.js";
const channel = "nextcloud-talk" as const;
type NextcloudSetupInput = ChannelSetupInput & {
baseUrl?: string;
secret?: string;
secretFile?: string;
};
type NextcloudTalkSection = NonNullable<CoreConfig["channels"]>["nextcloud-talk"];
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
function addWildcardAllowFrom(allowFrom?: Array<string | number> | null): string[] {
return mergeAllowFromEntries(allowFrom, ["*"]);
}
export function normalizeNextcloudTalkBaseUrl(value: string | undefined): string {
return value?.trim().replace(/\/+$/, "") ?? "";
}
export function validateNextcloudTalkBaseUrl(value: string): string | undefined {
if (!value) {
return "Required";
}
if (!value.startsWith("http://") && !value.startsWith("https://")) {
return "URL must start with http:// or https://";
}
return undefined;
}
export function setNextcloudTalkAccountConfig(
cfg: CoreConfig,
accountId: string,
updates: Record<string, unknown>,
): CoreConfig {
return patchScopedAccountConfig({
cfg,
channelKey: channel,
accountId,
patch: updates,
}) as CoreConfig;
}
export function clearNextcloudTalkAccountFields(
cfg: CoreConfig,
accountId: string,
fields: string[],
): CoreConfig {
const section = cfg.channels?.["nextcloud-talk"];
if (!section) {
return cfg;
}
if (accountId === DEFAULT_ACCOUNT_ID) {
const nextSection = { ...section } as Record<string, unknown>;
for (const field of fields) {
delete nextSection[field];
}
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": nextSection as NextcloudTalkSection,
},
} as CoreConfig;
}
const currentAccount = section.accounts?.[accountId];
if (!currentAccount) {
return cfg;
}
const nextAccount = { ...currentAccount } as Record<string, unknown>;
for (const field of fields) {
delete nextAccount[field];
}
return {
...cfg,
channels: {
...cfg.channels,
"nextcloud-talk": {
...section,
accounts: {
...section.accounts,
[accountId]: nextAccount as NonNullable<typeof section.accounts>[string],
},
},
},
} as CoreConfig;
}
async function promptNextcloudTalkAllowFrom(params: {
cfg: CoreConfig;
prompter: WizardPrompter;
accountId: string;
}): Promise<CoreConfig> {
return await promptParsedAllowFromForAccount({
cfg: params.cfg,
accountId: params.accountId,
defaultAccountId: params.accountId,
prompter: params.prompter,
noteTitle: "Nextcloud Talk user id",
noteLines: [
"1) Check the Nextcloud admin panel for user IDs",
"2) Or look at the webhook payload logs when someone messages",
"3) User IDs are typically lowercase usernames in Nextcloud",
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "nextcloud-talk")}`,
],
message: "Nextcloud Talk allowFrom (user id)",
placeholder: "username",
parseEntries: (raw) => ({
entries: raw
.split(/[\n,;]+/g)
.map(normalizeLowercaseStringOrEmpty)
.filter(Boolean),
}),
getExistingAllowFrom: ({ cfg, accountId }) =>
resolveNextcloudTalkAccount({ cfg, accountId }).config.allowFrom ?? [],
mergeEntries: ({ existing, parsed }) =>
mergeAllowFromEntries(
existing.map((value) => normalizeLowercaseStringOrEmpty(String(value))),
parsed,
),
applyAllowFrom: ({ cfg, accountId, allowFrom }) =>
setNextcloudTalkAccountConfig(cfg, accountId, {
dmPolicy: "allowlist",
allowFrom,
}),
});
}
async function promptNextcloudTalkAllowFromForAccount(params: {
cfg: OpenClawConfig;
prompter: WizardPrompter;
accountId?: string;
}): Promise<OpenClawConfig> {
const accountId = resolveSetupAccountId({
accountId: params.accountId,
defaultAccountId: resolveDefaultNextcloudTalkAccountId(params.cfg as CoreConfig),
});
return await promptNextcloudTalkAllowFrom({
cfg: params.cfg as CoreConfig,
prompter: params.prompter,
accountId,
});
}
export const nextcloudTalkDmPolicy: ChannelSetupDmPolicy = {
label: "Nextcloud Talk",
channel,
policyKey: "channels.nextcloud-talk.dmPolicy",
allowFromKey: "channels.nextcloud-talk.allowFrom",
resolveConfigKeys: (cfg, accountId) =>
(accountId ?? resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig)) !== DEFAULT_ACCOUNT_ID
? {
policyKey: `channels.nextcloud-talk.accounts.${accountId ?? resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig)}.dmPolicy`,
allowFromKey: `channels.nextcloud-talk.accounts.${accountId ?? resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig)}.allowFrom`,
}
: {
policyKey: "channels.nextcloud-talk.dmPolicy",
allowFromKey: "channels.nextcloud-talk.allowFrom",
},
getCurrent: (cfg, accountId) =>
resolveNextcloudTalkAccount({
cfg: cfg as CoreConfig,
accountId: accountId ?? resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig),
}).config.dmPolicy ?? "pairing",
setPolicy: (cfg, policy, accountId) => {
const resolvedAccountId = accountId ?? resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig);
const resolved = resolveNextcloudTalkAccount({
cfg: cfg as CoreConfig,
accountId: resolvedAccountId,
});
return setNextcloudTalkAccountConfig(cfg as CoreConfig, resolvedAccountId, {
dmPolicy: policy,
...(policy === "open" ? { allowFrom: addWildcardAllowFrom(resolved.config.allowFrom) } : {}),
});
},
promptAllowFrom: promptNextcloudTalkAllowFromForAccount,
};
export const nextcloudTalkSetupAdapter: ChannelSetupAdapter = {
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId),
applyAccountName: ({ cfg, accountId, name }) =>
applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name,
}),
validateInput: createSetupInputPresenceValidator({
defaultAccountOnlyEnvError:
"NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account.",
validate: ({ input }) => {
const setupInput = input as NextcloudSetupInput;
if (!setupInput.useEnv && !setupInput.secret && !setupInput.secretFile) {
return "Nextcloud Talk requires bot secret or --secret-file (or --use-env).";
}
if (!setupInput.baseUrl) {
return "Nextcloud Talk requires --base-url.";
}
return null;
},
}),
applyAccountConfig: ({ cfg, accountId, input }) => {
const setupInput = input as NextcloudSetupInput;
const namedConfig = applyAccountNameToChannelSection({
cfg,
channelKey: channel,
accountId,
name: setupInput.name,
});
const next = setupInput.useEnv
? clearNextcloudTalkAccountFields(namedConfig as CoreConfig, accountId, [
"botSecret",
"botSecretFile",
])
: namedConfig;
const patch = {
baseUrl: normalizeNextcloudTalkBaseUrl(setupInput.baseUrl),
...(setupInput.useEnv
? {}
: setupInput.secretFile
? { botSecretFile: setupInput.secretFile }
: setupInput.secret
? { botSecret: setupInput.secret }
: {}),
};
return setNextcloudTalkAccountConfig(next as CoreConfig, accountId, patch);
},
};

View file

@ -0,0 +1,200 @@
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
import {
createStandardChannelSetupStatus,
formatDocsLink,
setSetupChannelEnabled,
type ChannelSetupWizard,
} from "openclaw/plugin-sdk/setup";
import { resolveNextcloudTalkAccount } from "./accounts.js";
import {
clearNextcloudTalkAccountFields,
nextcloudTalkDmPolicy,
nextcloudTalkSetupAdapter,
normalizeNextcloudTalkBaseUrl,
setNextcloudTalkAccountConfig,
validateNextcloudTalkBaseUrl,
} from "./setup-core.js";
import type { CoreConfig } from "./types.js";
const channel = "nextcloud-talk" as const;
const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials";
function normalizeOptionalString(value: unknown): string | undefined {
if (typeof value !== "string") {
return undefined;
}
const trimmed = value.trim();
return trimmed || undefined;
}
export const nextcloudTalkSetupWizard: ChannelSetupWizard = {
channel,
stepOrder: "text-first",
status: createStandardChannelSetupStatus({
channelLabel: "Nextcloud Talk",
configuredLabel: "configured",
unconfiguredLabel: "needs setup",
configuredHint: "configured",
unconfiguredHint: "self-hosted chat",
configuredScore: 1,
unconfiguredScore: 5,
resolveConfigured: ({ cfg, accountId }) => {
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
return Boolean(account.secret && account.baseUrl);
},
}),
introNote: {
title: "Nextcloud Talk bot setup",
lines: [
"1) SSH into your Nextcloud server",
'2) Run: ./occ talk:bot:install "OpenClaw" "<shared-secret>" "<webhook-url>" --feature reaction',
"3) Copy the shared secret you used in the command",
"4) Enable the bot in your Nextcloud Talk room settings",
"Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.",
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
],
shouldShow: ({ cfg, accountId }) => {
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
return !account.secret || !account.baseUrl;
},
},
prepare: async ({ cfg, accountId, credentialValues, prompter }) => {
const resolvedAccount = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
const hasApiCredentials = Boolean(
resolvedAccount.config.apiUser?.trim() &&
(hasConfiguredSecretInput(resolvedAccount.config.apiPassword) ||
resolvedAccount.config.apiPasswordFile),
);
const configureApiCredentials = await prompter.confirm({
message: "Configure optional Nextcloud Talk API credentials for room lookups?",
initialValue: hasApiCredentials,
});
if (!configureApiCredentials) {
return undefined;
}
return {
credentialValues: {
...credentialValues,
[CONFIGURE_API_FLAG]: "1",
},
};
},
credentials: [
{
inputKey: "token",
providerHint: channel,
credentialLabel: "bot secret",
preferredEnvVar: "NEXTCLOUD_TALK_BOT_SECRET",
envPrompt: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?",
keepPrompt: "Nextcloud Talk bot secret already configured. Keep it?",
inputPrompt: "Enter Nextcloud Talk bot secret",
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
inspect: ({ cfg, accountId }) => {
const resolvedAccount = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
return {
accountConfigured: Boolean(resolvedAccount.secret && resolvedAccount.baseUrl),
hasConfiguredValue: Boolean(
hasConfiguredSecretInput(resolvedAccount.config.botSecret) ||
resolvedAccount.config.botSecretFile,
),
resolvedValue: resolvedAccount.secret || undefined,
envValue:
accountId === DEFAULT_ACCOUNT_ID
? normalizeOptionalString(process.env.NEXTCLOUD_TALK_BOT_SECRET)
: undefined,
};
},
applyUseEnv: async (params) => {
const resolvedAccount = resolveNextcloudTalkAccount({
cfg: params.cfg as CoreConfig,
accountId: params.accountId,
});
const cleared = clearNextcloudTalkAccountFields(
params.cfg as CoreConfig,
params.accountId,
["botSecret", "botSecretFile"],
);
return setNextcloudTalkAccountConfig(cleared, params.accountId, {
baseUrl: resolvedAccount.baseUrl,
});
},
applySet: async (params) =>
setNextcloudTalkAccountConfig(
clearNextcloudTalkAccountFields(params.cfg as CoreConfig, params.accountId, [
"botSecret",
"botSecretFile",
]),
params.accountId,
{
botSecret: params.value,
},
),
},
{
inputKey: "password",
providerHint: "nextcloud-talk-api",
credentialLabel: "API password",
preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD",
envPrompt: "",
keepPrompt: "Nextcloud Talk API password already configured. Keep it?",
inputPrompt: "Enter Nextcloud Talk API password",
inspect: ({ cfg, accountId }) => {
const resolvedAccount = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
const apiUser = resolvedAccount.config.apiUser?.trim();
const apiPasswordConfigured = Boolean(
hasConfiguredSecretInput(resolvedAccount.config.apiPassword) ||
resolvedAccount.config.apiPasswordFile,
);
return {
accountConfigured: Boolean(apiUser && apiPasswordConfigured),
hasConfiguredValue: apiPasswordConfigured,
};
},
shouldPrompt: ({ credentialValues }) => credentialValues[CONFIGURE_API_FLAG] === "1",
applySet: async (params) =>
setNextcloudTalkAccountConfig(
clearNextcloudTalkAccountFields(params.cfg as CoreConfig, params.accountId, [
"apiPassword",
"apiPasswordFile",
]),
params.accountId,
{
apiPassword: params.value,
},
),
},
],
textInputs: [
{
inputKey: "httpUrl",
message: "Enter Nextcloud instance URL (e.g., https://cloud.example.com)",
currentValue: ({ cfg, accountId }) =>
resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).baseUrl || undefined,
shouldPrompt: ({ currentValue }) => !currentValue,
validate: ({ value }) => validateNextcloudTalkBaseUrl(value),
normalizeValue: ({ value }) => normalizeNextcloudTalkBaseUrl(value),
applySet: async (params) =>
setNextcloudTalkAccountConfig(params.cfg as CoreConfig, params.accountId, {
baseUrl: params.value,
}),
},
{
inputKey: "userId",
message: "Nextcloud Talk API user",
currentValue: ({ cfg, accountId }) =>
resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.apiUser?.trim() ||
undefined,
shouldPrompt: ({ credentialValues }) => credentialValues[CONFIGURE_API_FLAG] === "1",
validate: ({ value }) => (value ? undefined : "Required"),
applySet: async (params) =>
setNextcloudTalkAccountConfig(params.cfg as CoreConfig, params.accountId, {
apiUser: params.value,
}),
},
],
dmPolicy: nextcloudTalkDmPolicy,
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
};
export { nextcloudTalkSetupAdapter };

View file

@ -0,0 +1,422 @@
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
import { describe, expect, it } from "vitest";
import { resolveNextcloudTalkAccount } from "./accounts.js";
import {
clearNextcloudTalkAccountFields,
nextcloudTalkDmPolicy,
nextcloudTalkSetupAdapter,
normalizeNextcloudTalkBaseUrl,
setNextcloudTalkAccountConfig,
validateNextcloudTalkBaseUrl,
} from "./setup-core.js";
import { nextcloudTalkSetupWizard } from "./setup-surface.js";
import type { CoreConfig } from "./types.js";
describe("nextcloud talk setup", () => {
it("normalizes and validates base urls", () => {
expect(normalizeNextcloudTalkBaseUrl(" https://cloud.example.com/// ")).toBe(
"https://cloud.example.com",
);
expect(normalizeNextcloudTalkBaseUrl(undefined)).toBe("");
expect(validateNextcloudTalkBaseUrl("")).toBe("Required");
expect(validateNextcloudTalkBaseUrl("cloud.example.com")).toBe(
"URL must start with http:// or https://",
);
expect(validateNextcloudTalkBaseUrl("https://cloud.example.com")).toBeUndefined();
});
it("patches scoped account config and clears selected fields", () => {
const cfg: CoreConfig = {
channels: {
"nextcloud-talk": {
baseUrl: "https://cloud.example.com",
botSecret: "top-secret",
accounts: {
work: {
botSecret: "work-secret",
botSecretFile: "/tmp/work-secret",
apiPassword: "api-secret",
},
},
},
},
};
expect(
setNextcloudTalkAccountConfig(cfg, DEFAULT_ACCOUNT_ID, {
apiUser: "bot",
}),
).toMatchObject({
channels: {
"nextcloud-talk": {
apiUser: "bot",
},
},
});
expect(clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"])).toMatchObject({
channels: {
"nextcloud-talk": {
baseUrl: "https://cloud.example.com",
},
},
});
expect(
clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"]),
).not.toMatchObject({
channels: {
"nextcloud-talk": {
botSecret: expect.anything(),
},
},
});
expect(
clearNextcloudTalkAccountFields(cfg, "work", ["botSecret", "botSecretFile"]),
).toMatchObject({
channels: {
"nextcloud-talk": {
accounts: {
work: {
apiPassword: "api-secret",
},
},
},
},
});
});
it("sets top-level DM policy state", async () => {
const base: CoreConfig = {
channels: {
"nextcloud-talk": {},
},
};
expect(nextcloudTalkDmPolicy.getCurrent(base)).toBe("pairing");
expect(nextcloudTalkDmPolicy.setPolicy(base, "open")).toMatchObject({
channels: {
"nextcloud-talk": {
dmPolicy: "open",
},
},
});
});
it("honors named-account DM policy state and config keys", () => {
const base: CoreConfig = {
channels: {
"nextcloud-talk": {
dmPolicy: "disabled",
accounts: {
work: {
baseUrl: "https://cloud.example.com",
botSecret: "work-secret",
dmPolicy: "allowlist",
},
},
},
},
};
expect(nextcloudTalkDmPolicy.getCurrent(base, "work")).toBe("allowlist");
expect(nextcloudTalkDmPolicy.resolveConfigKeys?.(base, "work")).toEqual({
policyKey: "channels.nextcloud-talk.accounts.work.dmPolicy",
allowFromKey: "channels.nextcloud-talk.accounts.work.allowFrom",
});
});
it("uses configured defaultAccount for omitted DM policy account context", () => {
const base: CoreConfig = {
channels: {
"nextcloud-talk": {
defaultAccount: "work",
dmPolicy: "disabled",
accounts: {
work: {
baseUrl: "https://cloud.example.com",
botSecret: "work-secret",
dmPolicy: "allowlist",
},
},
},
},
};
expect(nextcloudTalkDmPolicy.getCurrent(base)).toBe("allowlist");
expect(nextcloudTalkDmPolicy.resolveConfigKeys?.(base)).toEqual({
policyKey: "channels.nextcloud-talk.accounts.work.dmPolicy",
allowFromKey: "channels.nextcloud-talk.accounts.work.allowFrom",
});
const next = nextcloudTalkDmPolicy.setPolicy(base, "open");
expect(next.channels?.["nextcloud-talk"]?.dmPolicy).toBe("disabled");
const workAccount = next.channels?.["nextcloud-talk"]?.accounts?.work as
| { dmPolicy?: string; allowFrom?: Array<string | number> }
| undefined;
expect(workAccount?.dmPolicy).toBe("open");
});
it('writes open DM policy to the named account and preserves inherited allowFrom with "*"', () => {
const next = nextcloudTalkDmPolicy.setPolicy(
{
channels: {
"nextcloud-talk": {
allowFrom: ["alice"],
accounts: {
work: {
baseUrl: "https://cloud.example.com",
botSecret: "work-secret",
},
},
},
},
},
"open",
"work",
);
expect(next.channels?.["nextcloud-talk"]?.dmPolicy).toBeUndefined();
const workAccount = next.channels?.["nextcloud-talk"]?.accounts?.work as
| { dmPolicy?: string; allowFrom?: Array<string | number> }
| undefined;
expect(workAccount?.dmPolicy).toBe("open");
expect(workAccount?.allowFrom).toEqual(["alice", "*"]);
});
it("validates env/default-account constraints and applies config patches", () => {
const validateInput = nextcloudTalkSetupAdapter.validateInput;
const applyAccountConfig = nextcloudTalkSetupAdapter.applyAccountConfig;
expect(validateInput).toBeTypeOf("function");
expect(applyAccountConfig).toBeTypeOf("function");
expect(
validateInput!({
accountId: "work",
input: { useEnv: true },
} as never),
).toBe("NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account.");
expect(
validateInput!({
accountId: DEFAULT_ACCOUNT_ID,
input: { useEnv: false, baseUrl: "", secret: "" },
} as never),
).toBe("Nextcloud Talk requires bot secret or --secret-file (or --use-env).");
expect(
validateInput!({
accountId: DEFAULT_ACCOUNT_ID,
input: { useEnv: false, secret: "secret", baseUrl: "" },
} as never),
).toBe("Nextcloud Talk requires --base-url.");
expect(
applyAccountConfig({
cfg: {
channels: {
"nextcloud-talk": {},
},
},
accountId: DEFAULT_ACCOUNT_ID,
input: {
name: "Default",
baseUrl: "https://cloud.example.com///",
secret: "bot-secret",
},
} as never),
).toEqual({
channels: {
"nextcloud-talk": {
enabled: true,
name: "Default",
baseUrl: "https://cloud.example.com",
botSecret: "bot-secret",
},
},
});
expect(
applyAccountConfig({
cfg: {
channels: {
"nextcloud-talk": {
accounts: {
work: {
botSecret: "old-secret",
},
},
},
},
},
accountId: "work",
input: {
name: "Work",
useEnv: true,
baseUrl: "https://cloud.example.com",
},
} as never),
).toMatchObject({
channels: {
"nextcloud-talk": {
accounts: {
work: {
enabled: true,
name: "Work",
baseUrl: "https://cloud.example.com",
},
},
},
},
});
});
it("clears stored bot secret fields when switching the default account to env", () => {
type ApplyAccountConfigContext = Parameters<
typeof nextcloudTalkSetupAdapter.applyAccountConfig
>[0];
const next = nextcloudTalkSetupAdapter.applyAccountConfig({
cfg: {
channels: {
"nextcloud-talk": {
enabled: true,
baseUrl: "https://cloud.old.example",
botSecret: "stored-secret",
botSecretFile: "/tmp/secret.txt",
},
},
},
accountId: DEFAULT_ACCOUNT_ID,
input: {
baseUrl: "https://cloud.example.com",
useEnv: true,
},
} as unknown as ApplyAccountConfigContext);
expect(next.channels?.["nextcloud-talk"]?.baseUrl).toBe("https://cloud.example.com");
expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret");
expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile");
});
it("clears stored bot secret fields when the wizard switches to env", async () => {
const credential = nextcloudTalkSetupWizard.credentials[0];
const next = await credential.applyUseEnv?.({
cfg: {
channels: {
"nextcloud-talk": {
enabled: true,
baseUrl: "https://cloud.example.com",
botSecret: "stored-secret",
botSecretFile: "/tmp/secret.txt",
},
},
},
accountId: DEFAULT_ACCOUNT_ID,
});
expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret");
expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile");
});
});
describe("resolveNextcloudTalkAccount", () => {
it("matches normalized configured account ids", () => {
const account = resolveNextcloudTalkAccount({
cfg: {
channels: {
"nextcloud-talk": {
accounts: {
"Ops Team": {
baseUrl: "https://cloud.example.com",
botSecret: "bot-secret",
},
},
},
},
} as CoreConfig,
accountId: "ops-team",
});
expect(account.accountId).toBe("ops-team");
expect(account.baseUrl).toBe("https://cloud.example.com");
expect(account.secret).toBe("bot-secret");
expect(account.secretSource).toBe("config");
});
it.runIf(process.platform !== "win32")("rejects symlinked botSecretFile paths", () => {
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-nextcloud-talk-"));
const secretFile = path.join(dir, "secret.txt");
const secretLink = path.join(dir, "secret-link.txt");
fs.writeFileSync(secretFile, "bot-secret\n", "utf8");
fs.symlinkSync(secretFile, secretLink);
const cfg = {
channels: {
"nextcloud-talk": {
baseUrl: "https://cloud.example.com",
botSecretFile: secretLink,
},
},
} as CoreConfig;
const account = resolveNextcloudTalkAccount({ cfg });
expect(account.secret).toBe("");
expect(account.secretSource).toBe("none");
fs.rmSync(dir, { recursive: true, force: true });
});
it("uses configured defaultAccount when accountId is omitted", () => {
const account = resolveNextcloudTalkAccount({
cfg: {
channels: {
"nextcloud-talk": {
defaultAccount: "work",
botSecret: "top-secret",
accounts: {
work: {
baseUrl: "https://cloud.example.com",
botSecret: "work-secret",
},
},
},
},
} as CoreConfig,
});
expect(account.accountId).toBe("work");
expect(account.baseUrl).toBe("https://cloud.example.com");
expect(account.secret).toBe("work-secret");
expect(account.secretSource).toBe("config");
});
it("uses configured defaultAccount for omitted setup configured state", () => {
const configured = nextcloudTalkSetupWizard.status.resolveConfigured({
cfg: {
channels: {
"nextcloud-talk": {
defaultAccount: "work",
baseUrl: "https://root.example.com",
botSecret: "root-secret",
accounts: {
alerts: {
baseUrl: "https://alerts.example.com",
botSecret: "alerts-secret",
},
work: {
baseUrl: "",
botSecret: "",
},
},
},
},
} as CoreConfig,
});
expect(configured).toBe(false);
});
});

View file

@ -0,0 +1,76 @@
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";
function normalizeLowercaseStringOrEmpty(value: unknown): string {
return typeof value === "string" ? value.trim().toLowerCase() : "";
}
/**
* 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[normalizeLowercaseStringOrEmpty(name)];
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 };
}

View file

@ -0,0 +1,204 @@
import type {
BlockStreamingCoalesceConfig,
DmConfig,
DmPolicy,
GroupPolicy,
SecretInput,
} from "../runtime-api.js";
export type { DmPolicy, GroupPolicy };
export type NextcloudTalkRoomConfig = {
requireMention?: boolean;
/** Optional tool policy overrides for this room. */
tools?: { allow?: string[]; deny?: string[] };
/** 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 NextcloudTalkNetworkConfig = {
/** Dangerous opt-in for self-hosted Nextcloud Talk on trusted private/internal hosts. */
dangerouslyAllowPrivateNetwork?: boolean;
};
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?: SecretInput;
/** 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?: SecretInput;
/** 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;
/** Chunking mode: "length" (default) splits by size; "newline" splits on every newline. */
chunkMode?: "length" | "newline";
/** Disable block streaming for this account. */
blockStreaming?: boolean;
/** Merge streamed block replies before sending. */
blockStreamingCoalesce?: BlockStreamingCoalesceConfig;
/** Outbound response prefix override for this channel/account. */
responsePrefix?: string;
/** Media upload max size in MB. */
mediaMaxMb?: number;
/** Network policy overrides for self-hosted Nextcloud Talk on trusted private/internal hosts. */
network?: NextcloudTalkNetworkConfig;
};
export type NextcloudTalkConfig = {
/** Optional per-account Nextcloud Talk configuration (multi-account). */
accounts?: Record<string, NextcloudTalkAccountConfig>;
/** Optional default account id when multiple accounts are configured. */
defaultAccount?: string;
} & 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;
maxBodyBytes?: number;
authRateLimit?: {
maxRequests?: number;
windowMs?: number;
};
readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise<string>;
isBackendAllowed?: (backend: string) => boolean;
shouldProcessMessage?: (message: NextcloudTalkInboundMessage) => boolean | Promise<boolean>;
processMessage?: (
message: NextcloudTalkInboundMessage,
) => void | "processed" | "duplicate" | Promise<void | "processed" | "duplicate">;
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;
};

View file

@ -0,0 +1,16 @@
{
"extends": "../tsconfig.package-boundary.base.json",
"compilerOptions": {
"rootDir": "."
},
"include": ["./*.ts", "./src/**/*.ts"],
"exclude": [
"./**/*.test.ts",
"./dist/**",
"./node_modules/**",
"./src/test-support/**",
"./src/**/*test-helpers.ts",
"./src/**/*test-harness.ts",
"./src/**/*test-support.ts"
]
}