Vendor OpenClaw source as Adolf fork baseline
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Some checks failed
ClawSweeper Dispatch / dispatch (push) Has been cancelled
CodeQL / Security High (actions) (push) Has been cancelled
CodeQL / Security High (channel-runtime-boundary) (push) Has been cancelled
CodeQL / Security High (core-auth-secrets) (push) Has been cancelled
CodeQL / Security High (mcp-process-tool-boundary) (push) Has been cancelled
CodeQL / Security High (network-ssrf-boundary) (push) Has been cancelled
CodeQL / Security High (plugin-trust-boundary) (push) Has been cancelled
CodeQL / Security High (process-exec-boundary) (push) Has been cancelled
Docs Sync Publish Repo / sync-publish-repo (push) Has been cancelled
Docs / docs (push) Has been cancelled
OpenClaw Stable Main Closeout / Resolve stable release closeout inputs (push) Has been cancelled
OpenClaw Stable Main Closeout / Verify stable main closeout (push) Has been cancelled
Workflow Sanity / no-tabs (push) Has been cancelled
Workflow Sanity / actionlint (push) Has been cancelled
Workflow Sanity / generated-doc-baselines (push) Has been cancelled
CI / runner-admission (push) Has been cancelled
CI / preflight (push) Has been cancelled
CI / security-fast (push) Has been cancelled
CI / pnpm-store-warmup (push) Has been cancelled
CI / build-artifacts (push) Has been cancelled
CI / native-i18n (push) Has been cancelled
CI / ${{ matrix.check_name }} (push) Has been cancelled
CI / ${{ matrix.checkName }} (push) Has been cancelled
CI / checks-node-compat-node22 (push) Has been cancelled
CI / check-bundled-channel-config-metadata (push) Has been cancelled
CI / check-dependencies (push) Has been cancelled
CI / check-guards (push) Has been cancelled
CI / check-lint (push) Has been cancelled
CI / check-prod-types (push) Has been cancelled
CI / check-shrinkwrap (push) Has been cancelled
CI / check-test-types (push) Has been cancelled
CI / check-additional-boundaries-a (push) Has been cancelled
CI / check-additional-boundaries-bcd (push) Has been cancelled
CI / check-additional-extension-bundled (push) Has been cancelled
CI / check-additional-extension-channels (push) Has been cancelled
CI / check-additional-extension-package-boundary (push) Has been cancelled
CI / check-additional-runtime-topology-architecture (push) Has been cancelled
CI / check-session-accessor-boundary (push) Has been cancelled
CI / check-session-transcript-reader-boundary (push) Has been cancelled
CI / check-docs (push) Has been cancelled
CI / skills-python (push) Has been cancelled
CI / macos-swift (push) Has been cancelled
CI / ios-build (push) Has been cancelled
CI / ci-timings-summary (push) Has been cancelled
Native App Locale Refresh / Refresh native fa (push) Has been cancelled
Native App Locale Refresh / Refresh native fr (push) Has been cancelled
Native App Locale Refresh / Refresh native hi (push) Has been cancelled
Native App Locale Refresh / Refresh native id (push) Has been cancelled
Native App Locale Refresh / Refresh native it (push) Has been cancelled
Native App Locale Refresh / Refresh native ja-JP (push) Has been cancelled
Control UI Locale Refresh / plan (push) Has been cancelled
Control UI Locale Refresh / Refresh ${{ matrix.locale }} (push) Has been cancelled
Control UI Locale Refresh / Commit control UI locale refresh (push) Has been cancelled
Live Media Runner Image / Build live media runner image (push) Has been cancelled
Native App Locale Refresh / Refresh native ar (push) Has been cancelled
Native App Locale Refresh / Refresh native de (push) Has been cancelled
Native App Locale Refresh / Refresh native es (push) Has been cancelled
Native App Locale Refresh / Refresh native ko (push) Has been cancelled
Native App Locale Refresh / Refresh native nl (push) Has been cancelled
Native App Locale Refresh / Refresh native pl (push) Has been cancelled
Native App Locale Refresh / Refresh native pt-BR (push) Has been cancelled
Native App Locale Refresh / Refresh native ru (push) Has been cancelled
Native App Locale Refresh / Refresh native sv (push) Has been cancelled
Native App Locale Refresh / Refresh native th (push) Has been cancelled
Native App Locale Refresh / Refresh native tr (push) Has been cancelled
Native App Locale Refresh / Refresh native uk (push) Has been cancelled
Native App Locale Refresh / Refresh native vi (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-CN (push) Has been cancelled
Native App Locale Refresh / Refresh native zh-TW (push) Has been cancelled
Native App Locale Refresh / Commit native locale refresh (push) Has been cancelled
Plugin Init Scaffold Validation / Validate provider scaffold (push) Has been cancelled
Plugin NPM Release / preview_plugins_npm (push) Has been cancelled
Plugin NPM Release / Validate release publish approval (push) Has been cancelled
Plugin NPM Release / preview_plugin_pack (push) Has been cancelled
Plugin NPM Release / publish_plugins_npm (push) Has been cancelled
Sandbox Common Smoke / sandbox-common-smoke (push) Has been cancelled
Website Installer Sync / static (push) Has been cancelled
Website Installer Sync / linux-docker (push) Has been cancelled
Website Installer Sync / macos-installer (push) Has been cancelled
Website Installer Sync / windows-installer (push) Has been cancelled
Website Installer Sync / sync-website (push) Has been cancelled
Adolf is a fork/vendored clone of github.com/openclaw/openclaw (v2026.6.11), free to diverge. Tree copied sans upstream .git; upstream remote added for future syncs. Node pinned to 24 (.nvmrc); engines already require >=22.19. Preserves docs/ARCHITECTURE.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LeqyaxJF2nbRXJtae2kNB2
This commit is contained in:
32
extensions/nextcloud-talk/src/accounts.test.ts
Normal file
32
extensions/nextcloud-talk/src/accounts.test.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Nextcloud Talk tests cover accounts plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
listNextcloudTalkAccountIds,
|
||||
resolveDefaultNextcloudTalkAccountId,
|
||||
resolveNextcloudTalkAccount,
|
||||
} from "./accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
describe("Nextcloud Talk account resolution", () => {
|
||||
it("preserves top-level default account when named accounts are configured", () => {
|
||||
const cfg = {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "shared-secret",
|
||||
accounts: {
|
||||
work: { enabled: false },
|
||||
},
|
||||
},
|
||||
},
|
||||
} satisfies CoreConfig;
|
||||
|
||||
expect(listNextcloudTalkAccountIds(cfg)).toEqual(["default", "work"]);
|
||||
expect(resolveDefaultNextcloudTalkAccountId(cfg)).toBe("default");
|
||||
expect(resolveNextcloudTalkAccount({ cfg })).toMatchObject({
|
||||
accountId: "default",
|
||||
baseUrl: "https://cloud.example.com",
|
||||
secret: "shared-secret",
|
||||
});
|
||||
});
|
||||
});
|
||||
150
extensions/nextcloud-talk/src/accounts.ts
Normal file
150
extensions/nextcloud-talk/src/accounts.ts
Normal file
@@ -0,0 +1,150 @@
|
||||
// Nextcloud Talk plugin module implements accounts behavior.
|
||||
import {
|
||||
createAccountListHelpers,
|
||||
DEFAULT_ACCOUNT_ID,
|
||||
hasConfiguredAccountValue,
|
||||
normalizeAccountId,
|
||||
resolveAccountWithDefaultFallback,
|
||||
resolveMergedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/account-core";
|
||||
import { tryReadSecretFileSync } from "openclaw/plugin-sdk/secret-file-runtime";
|
||||
import {
|
||||
normalizeLowercaseStringOrEmpty,
|
||||
normalizeOptionalString,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { normalizeResolvedSecretInputString } from "./secret-input.js";
|
||||
import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
|
||||
|
||||
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,
|
||||
hasImplicitDefaultAccount: (cfg) => {
|
||||
const channel = cfg.channels?.["nextcloud-talk"];
|
||||
return Boolean(
|
||||
channel?.baseUrl?.trim() &&
|
||||
(hasConfiguredAccountValue(channel.botSecret) ||
|
||||
channel.botSecretFile?.trim() ||
|
||||
process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()),
|
||||
);
|
||||
},
|
||||
});
|
||||
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),
|
||||
});
|
||||
}
|
||||
32
extensions/nextcloud-talk/src/api-credentials.ts
Normal file
32
extensions/nextcloud-talk/src/api-credentials.ts
Normal file
@@ -0,0 +1,32 @@
|
||||
// Nextcloud Talk plugin module implements api credentials behavior.
|
||||
import { readFileSync } from "node:fs";
|
||||
import { normalizeResolvedSecretInputString } from "./secret-input.js";
|
||||
|
||||
export function resolveNextcloudTalkApiCredentials(params: {
|
||||
apiUser?: string;
|
||||
apiPassword?: unknown;
|
||||
apiPasswordFile?: string;
|
||||
}): { apiUser: string; apiPassword: string } | undefined {
|
||||
const apiUser = params.apiUser?.trim();
|
||||
if (!apiUser) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const inlinePassword = normalizeResolvedSecretInputString({
|
||||
value: params.apiPassword,
|
||||
path: "channels.nextcloud-talk.apiPassword",
|
||||
});
|
||||
if (inlinePassword) {
|
||||
return { apiUser, apiPassword: inlinePassword };
|
||||
}
|
||||
|
||||
if (!params.apiPasswordFile) {
|
||||
return undefined;
|
||||
}
|
||||
try {
|
||||
const filePassword = readFileSync(params.apiPasswordFile, "utf-8").trim();
|
||||
return filePassword ? { apiUser, apiPassword: filePassword } : undefined;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
18
extensions/nextcloud-talk/src/approval-auth.test.ts
Normal file
18
extensions/nextcloud-talk/src/approval-auth.test.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
// Nextcloud Talk tests cover approval auth plugin behavior.
|
||||
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 });
|
||||
});
|
||||
});
|
||||
28
extensions/nextcloud-talk/src/approval-auth.ts
Normal file
28
extensions/nextcloud-talk/src/approval-auth.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Nextcloud Talk plugin module implements approval auth behavior.
|
||||
import {
|
||||
createResolvedApproverActionAuthAdapter,
|
||||
resolveApprovalApprovers,
|
||||
} from "openclaw/plugin-sdk/approval-auth-runtime";
|
||||
import { normalizeOptionalLowercaseString } from "openclaw/plugin-sdk/string-coerce-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),
|
||||
});
|
||||
221
extensions/nextcloud-talk/src/bot-preflight.test.ts
Normal file
221
extensions/nextcloud-talk/src/bot-preflight.test.ts
Normal file
@@ -0,0 +1,221 @@
|
||||
// Nextcloud Talk tests cover bot preflight plugin behavior.
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
fetchWithSsrFGuard: vi.fn(),
|
||||
ssrfPolicyFromPrivateNetworkOptIn: vi.fn(() => undefined),
|
||||
}));
|
||||
|
||||
vi.mock("../runtime-api.js", () => ({
|
||||
fetchWithSsrFGuard: hoisted.fetchWithSsrFGuard,
|
||||
}));
|
||||
|
||||
vi.mock("./send.runtime.js", () => ({
|
||||
ssrfPolicyFromPrivateNetworkOptIn: hoisted.ssrfPolicyFromPrivateNetworkOptIn,
|
||||
}));
|
||||
|
||||
const { probeNextcloudTalkBotResponseFeature } = await import("./bot-preflight.js");
|
||||
|
||||
function account(
|
||||
overrides: Partial<ResolvedNextcloudTalkAccount> = {},
|
||||
): ResolvedNextcloudTalkAccount {
|
||||
return {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
baseUrl: "https://cloud.example.com",
|
||||
secret: "secret",
|
||||
secretSource: "config",
|
||||
config: {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "secret",
|
||||
apiUser: "admin",
|
||||
apiPassword: "app-password",
|
||||
webhookPublicUrl: "https://bot.example.com/nextcloud-talk-webhook",
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function cancelTrackedResponse(
|
||||
text: string,
|
||||
init: ResponseInit,
|
||||
): {
|
||||
response: Response;
|
||||
wasCanceled: () => boolean;
|
||||
} {
|
||||
let canceled = false;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
start(controller) {
|
||||
controller.enqueue(new TextEncoder().encode(text));
|
||||
},
|
||||
cancel() {
|
||||
canceled = true;
|
||||
},
|
||||
});
|
||||
return {
|
||||
response: new Response(stream, init),
|
||||
wasCanceled: () => canceled,
|
||||
};
|
||||
}
|
||||
|
||||
function mockBotAdmin(features: number | string): void {
|
||||
hoisted.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: new Response(
|
||||
JSON.stringify({
|
||||
ocs: {
|
||||
data: [
|
||||
{
|
||||
id: 7,
|
||||
name: "OpenClaw",
|
||||
url: "https://bot.example.com/nextcloud-talk-webhook",
|
||||
features,
|
||||
},
|
||||
],
|
||||
},
|
||||
}),
|
||||
{ status: 200, headers: { "content-type": "application/json" } },
|
||||
),
|
||||
release: async () => {},
|
||||
finalUrl: "https://cloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/admin",
|
||||
});
|
||||
}
|
||||
|
||||
describe("probeNextcloudTalkBotResponseFeature", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.fetchWithSsrFGuard.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
hoisted.fetchWithSsrFGuard.mockReset();
|
||||
});
|
||||
|
||||
it("passes when the matching bot has the response feature bit", async () => {
|
||||
mockBotAdmin(1 | 2 | 8);
|
||||
|
||||
await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
|
||||
ok: true,
|
||||
code: "ok",
|
||||
botId: "7",
|
||||
botName: "OpenClaw",
|
||||
features: 11,
|
||||
message: 'Nextcloud Talk bot "OpenClaw" has the response feature.',
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes signed decimal bot feature strings through the shared parser", async () => {
|
||||
mockBotAdmin("+011");
|
||||
|
||||
await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
|
||||
ok: true,
|
||||
code: "ok",
|
||||
botId: "7",
|
||||
botName: "OpenClaw",
|
||||
features: 11,
|
||||
message: 'Nextcloud Talk bot "OpenClaw" has the response feature.',
|
||||
});
|
||||
});
|
||||
|
||||
it("reports missing response feature for the matching webhook bot", async () => {
|
||||
mockBotAdmin(1 | 8);
|
||||
|
||||
await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "missing_response_feature",
|
||||
botId: "7",
|
||||
botName: "OpenClaw",
|
||||
features: 9,
|
||||
message:
|
||||
'Nextcloud Talk bot "OpenClaw" (7) is missing the response feature (features=9); outbound replies will fail. Run ./occ talk:bot:state --feature webhook --feature response --feature reaction 7 1 or reinstall the bot with --feature response.',
|
||||
});
|
||||
});
|
||||
|
||||
it("does not coerce partial bot feature strings", async () => {
|
||||
mockBotAdmin("2response");
|
||||
|
||||
await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "missing_response_feature",
|
||||
botId: "7",
|
||||
botName: "OpenClaw",
|
||||
message:
|
||||
'Nextcloud Talk bot "OpenClaw" (7) is missing the response feature; outbound replies will fail. Run ./occ talk:bot:state --feature webhook --feature response --feature reaction 7 1 or reinstall the bot with --feature response.',
|
||||
});
|
||||
});
|
||||
|
||||
it("does not treat negative feature masks as having every feature", async () => {
|
||||
mockBotAdmin(-1);
|
||||
|
||||
await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "missing_response_feature",
|
||||
botId: "7",
|
||||
botName: "OpenClaw",
|
||||
message:
|
||||
'Nextcloud Talk bot "OpenClaw" (7) is missing the response feature; outbound replies will fail. Run ./occ talk:bot:state --feature webhook --feature response --feature reaction 7 1 or reinstall the bot with --feature response.',
|
||||
});
|
||||
});
|
||||
|
||||
it("reports malformed bot admin JSON with a stable channel error", async () => {
|
||||
hoisted.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: new Response("{ nope", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release: async () => {},
|
||||
finalUrl: "https://cloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/admin",
|
||||
});
|
||||
|
||||
await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "request_failed",
|
||||
message:
|
||||
"Nextcloud Talk bot response feature probe failed: Nextcloud Talk bot response feature probe failed: malformed JSON response",
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds bot admin error bodies without using response.text()", async () => {
|
||||
const tracked = cancelTrackedResponse(`${"nextcloud bot admin failure ".repeat(1024)}tail`, {
|
||||
status: 503,
|
||||
headers: { "content-type": "text/plain" },
|
||||
});
|
||||
const textSpy = vi.spyOn(tracked.response, "text").mockRejectedValue(new Error("unbounded"));
|
||||
hoisted.fetchWithSsrFGuard.mockResolvedValueOnce({
|
||||
response: tracked.response,
|
||||
release: async () => {},
|
||||
finalUrl: "https://cloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/admin",
|
||||
});
|
||||
|
||||
await expect(probeNextcloudTalkBotResponseFeature({ account: account() })).resolves.toEqual({
|
||||
ok: false,
|
||||
code: "api_error",
|
||||
status: 503,
|
||||
message: expect.stringContaining(
|
||||
"Nextcloud Talk bot response feature probe failed (503): nextcloud bot admin failure",
|
||||
),
|
||||
});
|
||||
expect(textSpy).not.toHaveBeenCalled();
|
||||
expect(tracked.wasCanceled()).toBe(true);
|
||||
});
|
||||
|
||||
it("skips when API credentials are absent", async () => {
|
||||
await expect(
|
||||
probeNextcloudTalkBotResponseFeature({
|
||||
account: account({
|
||||
config: {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "secret",
|
||||
webhookPublicUrl: "https://bot.example.com/nextcloud-talk-webhook",
|
||||
},
|
||||
}),
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
ok: true,
|
||||
skipped: true,
|
||||
code: "missing_api_credentials",
|
||||
message:
|
||||
"Nextcloud Talk bot response feature probe skipped: apiUser/apiPassword are not configured.",
|
||||
});
|
||||
expect(hoisted.fetchWithSsrFGuard).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
188
extensions/nextcloud-talk/src/bot-preflight.ts
Normal file
188
extensions/nextcloud-talk/src/bot-preflight.ts
Normal file
@@ -0,0 +1,188 @@
|
||||
// Nextcloud Talk plugin module implements bot preflight behavior.
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { parseStrictNonNegativeInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { fetchWithSsrFGuard } from "../runtime-api.js";
|
||||
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
||||
import { resolveNextcloudTalkApiCredentials } from "./api-credentials.js";
|
||||
import { ssrfPolicyFromPrivateNetworkOptIn } from "./send.runtime.js";
|
||||
|
||||
const BOT_FEATURE_RESPONSE = 2;
|
||||
const BOT_PREFLIGHT_ERROR_BODY_LIMIT_BYTES = 8 * 1024;
|
||||
|
||||
type NextcloudTalkBotAdminEntry = {
|
||||
id?: number | string;
|
||||
name?: string;
|
||||
url?: string;
|
||||
features?: number | string;
|
||||
};
|
||||
|
||||
export type NextcloudTalkBotResponseFeatureProbe = {
|
||||
ok: boolean;
|
||||
skipped?: boolean;
|
||||
code:
|
||||
| "ok"
|
||||
| "missing_api_credentials"
|
||||
| "missing_webhook_url"
|
||||
| "missing_base_url"
|
||||
| "bot_not_found"
|
||||
| "missing_response_feature"
|
||||
| "api_error"
|
||||
| "request_failed";
|
||||
message: string;
|
||||
botId?: string;
|
||||
botName?: string;
|
||||
features?: number;
|
||||
status?: number;
|
||||
};
|
||||
|
||||
function normalizeUrlForMatch(value: string | undefined): string {
|
||||
if (!value?.trim()) {
|
||||
return "";
|
||||
}
|
||||
try {
|
||||
const url = new URL(value.trim());
|
||||
url.hash = "";
|
||||
return url.toString().replace(/\/$/, "");
|
||||
} catch {
|
||||
return value.trim().replace(/\/$/, "");
|
||||
}
|
||||
}
|
||||
|
||||
function coerceFeatureMask(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isSafeInteger(value) && value >= 0) {
|
||||
return value;
|
||||
}
|
||||
return parseStrictNonNegativeInteger(value);
|
||||
}
|
||||
|
||||
function formatMissingResponseFeatureMessage(bot: NextcloudTalkBotAdminEntry, features?: number) {
|
||||
const id = bot.id == null ? "unknown" : String(bot.id);
|
||||
const name = bot.name?.trim() || "matching bot";
|
||||
const featureText = typeof features === "number" ? ` (features=${features})` : "";
|
||||
return `Nextcloud Talk bot "${name}" (${id}) is missing the response feature${featureText}; outbound replies will fail. Run ./occ talk:bot:state --feature webhook --feature response --feature reaction ${id} 1 or reinstall the bot with --feature response.`;
|
||||
}
|
||||
|
||||
export async function probeNextcloudTalkBotResponseFeature(params: {
|
||||
account: ResolvedNextcloudTalkAccount;
|
||||
timeoutMs?: number;
|
||||
}): Promise<NextcloudTalkBotResponseFeatureProbe> {
|
||||
const { account, timeoutMs } = params;
|
||||
const baseUrl = account.baseUrl?.trim();
|
||||
if (!baseUrl) {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
code: "missing_base_url",
|
||||
message: "Nextcloud Talk bot response feature probe skipped: baseUrl is not configured.",
|
||||
};
|
||||
}
|
||||
|
||||
const webhookUrl = normalizeUrlForMatch(account.config.webhookPublicUrl);
|
||||
if (!webhookUrl) {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
code: "missing_webhook_url",
|
||||
message:
|
||||
"Nextcloud Talk bot response feature probe skipped: webhookPublicUrl is not configured.",
|
||||
};
|
||||
}
|
||||
|
||||
const credentials = resolveNextcloudTalkApiCredentials({
|
||||
apiUser: account.config.apiUser,
|
||||
apiPassword: account.config.apiPassword,
|
||||
apiPasswordFile: account.config.apiPasswordFile,
|
||||
});
|
||||
if (!credentials) {
|
||||
return {
|
||||
ok: true,
|
||||
skipped: true,
|
||||
code: "missing_api_credentials",
|
||||
message:
|
||||
"Nextcloud Talk bot response feature probe skipped: apiUser/apiPassword are not configured.",
|
||||
};
|
||||
}
|
||||
|
||||
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v1/bot/admin`;
|
||||
const auth = Buffer.from(`${credentials.apiUser}:${credentials.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.bot-response-preflight",
|
||||
policy: ssrfPolicyFromPrivateNetworkOptIn(account.config),
|
||||
timeoutMs,
|
||||
});
|
||||
try {
|
||||
if (!response.ok) {
|
||||
const body = await readResponseTextLimited(
|
||||
response,
|
||||
BOT_PREFLIGHT_ERROR_BODY_LIMIT_BYTES,
|
||||
).catch(() => "");
|
||||
return {
|
||||
ok: false,
|
||||
code: "api_error",
|
||||
status: response.status,
|
||||
message: `Nextcloud Talk bot response feature probe failed (${response.status})${body ? `: ${body}` : ""}`,
|
||||
};
|
||||
}
|
||||
|
||||
const payload = await readProviderJsonResponse<{
|
||||
ocs?: { data?: NextcloudTalkBotAdminEntry[] };
|
||||
}>(response, "Nextcloud Talk bot response feature probe failed");
|
||||
const bots = Array.isArray(payload.ocs?.data) ? payload.ocs.data : [];
|
||||
const bot = bots.find((entry) => normalizeUrlForMatch(entry.url) === webhookUrl);
|
||||
if (!bot) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "bot_not_found",
|
||||
message: `Nextcloud Talk bot response feature probe could not find a bot with webhook URL ${webhookUrl}.`,
|
||||
};
|
||||
}
|
||||
|
||||
const features = coerceFeatureMask(bot.features);
|
||||
if (features == null || (features & BOT_FEATURE_RESPONSE) !== BOT_FEATURE_RESPONSE) {
|
||||
return {
|
||||
ok: false,
|
||||
code: "missing_response_feature",
|
||||
botId: bot.id == null ? undefined : String(bot.id),
|
||||
botName: bot.name,
|
||||
features,
|
||||
message: formatMissingResponseFeatureMessage(bot, features),
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
code: "ok",
|
||||
botId: bot.id == null ? undefined : String(bot.id),
|
||||
botName: bot.name,
|
||||
features,
|
||||
message: `Nextcloud Talk bot "${bot.name ?? bot.id ?? "matching bot"}" has the response feature.`,
|
||||
};
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
} catch (error) {
|
||||
const detail = error instanceof Error ? error.message : formatErrorMessage(error);
|
||||
return {
|
||||
ok: false,
|
||||
code: "request_failed",
|
||||
message: `Nextcloud Talk bot response feature probe failed: ${detail}`,
|
||||
};
|
||||
}
|
||||
}
|
||||
6
extensions/nextcloud-talk/src/channel-api.ts
Normal file
6
extensions/nextcloud-talk/src/channel-api.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Nextcloud Talk API module exposes the plugin public contract.
|
||||
export type { ChannelPlugin } from "openclaw/plugin-sdk/channel-plugin-common";
|
||||
export type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
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";
|
||||
53
extensions/nextcloud-talk/src/channel.adapters.ts
Normal file
53
extensions/nextcloud-talk/src/channel.adapters.ts
Normal file
@@ -0,0 +1,53 @@
|
||||
// Nextcloud Talk plugin module implements channel.adapters behavior.
|
||||
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 { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
listNextcloudTalkAccountIds,
|
||||
resolveDefaultNextcloudTalkAccountId,
|
||||
resolveNextcloudTalkAccount,
|
||||
type ResolvedNextcloudTalkAccount,
|
||||
} from "./accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
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),
|
||||
),
|
||||
};
|
||||
76
extensions/nextcloud-talk/src/channel.core.test.ts
Normal file
76
extensions/nextcloud-talk/src/channel.core.test.ts
Normal file
@@ -0,0 +1,76 @@
|
||||
// Nextcloud Talk tests cover channel.core plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import {
|
||||
nextcloudTalkConfigAdapter,
|
||||
nextcloudTalkPairingTextAdapter,
|
||||
nextcloudTalkSecurityAdapter,
|
||||
} from "./channel.adapters.js";
|
||||
import { NextcloudTalkConfigSchema } from "./config-schema.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
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",
|
||||
);
|
||||
});
|
||||
});
|
||||
92
extensions/nextcloud-talk/src/channel.lifecycle.test.ts
Normal file
92
extensions/nextcloud-talk/src/channel.lifecycle.test.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
// Nextcloud Talk tests cover channel.lifecycle plugin behavior.
|
||||
import { createStartAccountContext } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import {
|
||||
expectStopPendingUntilAbort,
|
||||
startAccountAndTrackLifecycle,
|
||||
waitForStartedMocks,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
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");
|
||||
|
||||
type NextcloudTalkStartAccount = NonNullable<typeof nextcloudTalkGatewayAdapter.startAccount>;
|
||||
|
||||
function requireStartAccount(): NextcloudTalkStartAccount {
|
||||
const startAccount = nextcloudTalkGatewayAdapter.startAccount;
|
||||
if (!startAccount) {
|
||||
throw new Error("Expected Nextcloud Talk gateway startAccount");
|
||||
}
|
||||
return startAccount;
|
||||
}
|
||||
|
||||
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 requireStartAccount()(
|
||||
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: requireStartAccount(),
|
||||
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();
|
||||
});
|
||||
});
|
||||
29
extensions/nextcloud-talk/src/channel.status.test.ts
Normal file
29
extensions/nextcloud-talk/src/channel.status.test.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Nextcloud Talk tests cover channel.status plugin behavior.
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { nextcloudTalkPlugin } from "./channel.js";
|
||||
|
||||
describe("nextcloud-talk channel status", () => {
|
||||
it("surfaces missing response feature probes as config issues", () => {
|
||||
const issues = nextcloudTalkPlugin.status?.collectStatusIssues?.([
|
||||
{
|
||||
accountId: "default",
|
||||
configured: true,
|
||||
probe: {
|
||||
ok: false,
|
||||
code: "missing_response_feature",
|
||||
message: "Nextcloud Talk bot is missing --feature response.",
|
||||
},
|
||||
},
|
||||
]);
|
||||
|
||||
expect(issues).toEqual([
|
||||
{
|
||||
channel: "nextcloud-talk",
|
||||
accountId: "default",
|
||||
kind: "config",
|
||||
message: "Nextcloud Talk bot is missing --feature response.",
|
||||
fix: "Add --feature response to the Talk bot.",
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
226
extensions/nextcloud-talk/src/channel.ts
Normal file
226
extensions/nextcloud-talk/src/channel.ts
Normal file
@@ -0,0 +1,226 @@
|
||||
// Nextcloud Talk plugin module implements channel behavior.
|
||||
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 { probeNextcloudTalkBotResponseFeature } from "./bot-preflight.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 { nextcloudTalkMessageActions } from "./message-actions.js";
|
||||
import { nextcloudTalkMessageAdapter } from "./message-adapter.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 { 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: {
|
||||
targetPrefixes: ["nextcloud-talk", "nc-talk", "nc"],
|
||||
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",
|
||||
}),
|
||||
collectStatusIssues: (accounts) =>
|
||||
accounts.flatMap((account) => {
|
||||
const probe = account.probe as
|
||||
| { ok?: boolean; code?: string; message?: string }
|
||||
| undefined;
|
||||
if (
|
||||
!probe ||
|
||||
probe.ok !== false ||
|
||||
probe.code !== "missing_response_feature" ||
|
||||
!probe.message
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
return [
|
||||
{
|
||||
channel: "nextcloud-talk",
|
||||
accountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
|
||||
kind: "config",
|
||||
message: probe.message,
|
||||
fix: "Add --feature response to the Talk bot.",
|
||||
} as const,
|
||||
];
|
||||
}),
|
||||
probeAccount: async ({ account, timeoutMs }) =>
|
||||
await probeNextcloudTalkBotResponseFeature({ account, timeoutMs }),
|
||||
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,
|
||||
message: nextcloudTalkMessageAdapter,
|
||||
actions: nextcloudTalkMessageActions,
|
||||
},
|
||||
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 nextcloudTalkMessageAdapter.send.text({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
accountId,
|
||||
replyToId,
|
||||
}),
|
||||
sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) =>
|
||||
await nextcloudTalkMessageAdapter.send.media({
|
||||
cfg,
|
||||
to,
|
||||
text,
|
||||
mediaUrl: mediaUrl ?? "",
|
||||
accountId,
|
||||
replyToId,
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
80
extensions/nextcloud-talk/src/config-schema.ts
Normal file
80
extensions/nextcloud-talk/src/config-schema.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
// Nextcloud Talk helper module supports config schema behavior.
|
||||
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 "zod";
|
||||
import { buildSecretInputSchema } from "./secret-input.js";
|
||||
|
||||
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();
|
||||
|
||||
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();
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
328
extensions/nextcloud-talk/src/core.test.ts
Normal file
328
extensions/nextcloud-talk/src/core.test.ts
Normal file
@@ -0,0 +1,328 @@
|
||||
// Nextcloud Talk tests cover core plugin behavior.
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
looksLikeNextcloudTalkTargetId,
|
||||
normalizeNextcloudTalkMessagingTarget,
|
||||
stripNextcloudTalkTargetPrefix,
|
||||
} from "./normalize.js";
|
||||
import { resolveNextcloudTalkAllowlistMatch } 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;
|
||||
}
|
||||
|
||||
function requireFirstTimingSafeEqualCall(mock: ReturnType<typeof vi.fn>): [unknown, unknown] {
|
||||
const [call] = mock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected timingSafeEqual call");
|
||||
}
|
||||
return call as [unknown, unknown];
|
||||
}
|
||||
|
||||
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).toEqual({
|
||||
sessionKey: "agent:main:nextcloud-talk:group:room-123",
|
||||
baseSessionKey: "agent:main:nextcloud-talk:group:room-123",
|
||||
peer: {
|
||||
kind: "group",
|
||||
id: "room-123",
|
||||
},
|
||||
chatType: "group",
|
||||
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("rejects tampered bodies, wrong secrets, and tampered signatures", () => {
|
||||
const body = JSON.stringify({ hello: "world" });
|
||||
const generated = generateNextcloudTalkSignature({
|
||||
body,
|
||||
secret: "secret-123",
|
||||
});
|
||||
|
||||
expect(
|
||||
verifyNextcloudTalkSignature({
|
||||
signature: generated.signature,
|
||||
random: generated.random,
|
||||
body: JSON.stringify({ hello: "tampered" }),
|
||||
secret: "secret-123",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
verifyNextcloudTalkSignature({
|
||||
signature: generated.signature,
|
||||
random: generated.random,
|
||||
body,
|
||||
secret: "wrong-secret",
|
||||
}),
|
||||
).toBe(false);
|
||||
expect(
|
||||
verifyNextcloudTalkSignature({
|
||||
signature: "a".repeat(generated.signature.length),
|
||||
random: generated.random,
|
||||
body,
|
||||
secret: "secret-123",
|
||||
}),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
it("takes the first value from array-backed headers", () => {
|
||||
expect(
|
||||
extractNextcloudTalkHeaders({
|
||||
"x-nextcloud-talk-signature": ["sig1", "sig2"],
|
||||
"x-nextcloud-talk-random": ["rand1", "rand2"],
|
||||
"x-nextcloud-talk-backend": ["backend1", "backend2"],
|
||||
}),
|
||||
).toEqual({
|
||||
signature: "sig1",
|
||||
random: "rand1",
|
||||
backend: "backend1",
|
||||
});
|
||||
});
|
||||
|
||||
it("still runs timingSafeEqual when the supplied signature length mismatches", async () => {
|
||||
const timingSafeEqualMock = vi.fn();
|
||||
|
||||
vi.resetModules();
|
||||
vi.doMock("node:crypto", async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import("node:crypto")>();
|
||||
return {
|
||||
...actual,
|
||||
timingSafeEqual: vi.fn((left: NodeJS.ArrayBufferView, right: NodeJS.ArrayBufferView) => {
|
||||
timingSafeEqualMock(left, right);
|
||||
return actual.timingSafeEqual(left, right);
|
||||
}),
|
||||
};
|
||||
});
|
||||
|
||||
try {
|
||||
const {
|
||||
generateNextcloudTalkSignature: generateNextcloudTalkSignatureLocal,
|
||||
verifyNextcloudTalkSignature: verifyNextcloudTalkSignatureLocal,
|
||||
} = await import("./signature.js");
|
||||
const body = JSON.stringify({ hello: "world" });
|
||||
const generated = generateNextcloudTalkSignatureLocal({
|
||||
body,
|
||||
secret: "secret-123",
|
||||
});
|
||||
const shortSignature = generated.signature.slice(0, 12);
|
||||
|
||||
expect(
|
||||
verifyNextcloudTalkSignatureLocal({
|
||||
signature: shortSignature,
|
||||
random: generated.random,
|
||||
body,
|
||||
secret: "secret-123",
|
||||
}),
|
||||
).toBe(false);
|
||||
|
||||
expect(timingSafeEqualMock).toHaveBeenCalledOnce();
|
||||
const [leftBuffer, rightBuffer] = requireFirstTimingSafeEqualCall(timingSafeEqualMock);
|
||||
expect(Buffer.isBuffer(leftBuffer)).toBe(true);
|
||||
expect(Buffer.isBuffer(rightBuffer)).toBe(true);
|
||||
if (!Buffer.isBuffer(leftBuffer) || !Buffer.isBuffer(rightBuffer)) {
|
||||
throw new TypeError("Expected timingSafeEqual to receive Buffer arguments");
|
||||
}
|
||||
expect(leftBuffer).toHaveLength(rightBuffer.length);
|
||||
} finally {
|
||||
vi.doUnmock("node:crypto");
|
||||
vi.resetModules();
|
||||
}
|
||||
});
|
||||
|
||||
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", () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
10
extensions/nextcloud-talk/src/doctor-contract.ts
Normal file
10
extensions/nextcloud-talk/src/doctor-contract.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Nextcloud Talk plugin module implements doctor contract behavior.
|
||||
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
|
||||
const contract = createLegacyPrivateNetworkDoctorContract({
|
||||
channelKey: "nextcloud-talk",
|
||||
});
|
||||
|
||||
export const legacyConfigRules = contract.legacyConfigRules;
|
||||
|
||||
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;
|
||||
138
extensions/nextcloud-talk/src/doctor.test.ts
Normal file
138
extensions/nextcloud-talk/src/doctor.test.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// Nextcloud Talk tests cover doctor plugin behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import { resetPluginStateStoreForTests } from "openclaw/plugin-sdk/plugin-state-test-runtime";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
probeNextcloudTalkBotResponseFeature: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./bot-preflight.js", () => ({
|
||||
probeNextcloudTalkBotResponseFeature: hoisted.probeNextcloudTalkBotResponseFeature,
|
||||
}));
|
||||
|
||||
const { nextcloudTalkDoctor } = await import("./doctor.js");
|
||||
|
||||
function getNextcloudTalkCompatibilityNormalizer(): NonNullable<
|
||||
typeof nextcloudTalkDoctor.normalizeCompatibilityConfig
|
||||
> {
|
||||
const normalize = nextcloudTalkDoctor.normalizeCompatibilityConfig;
|
||||
if (!normalize) {
|
||||
throw new Error("Expected nextcloud-talk doctor to expose normalizeCompatibilityConfig");
|
||||
}
|
||||
return normalize;
|
||||
}
|
||||
|
||||
describe("nextcloud-talk doctor", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.probeNextcloudTalkBotResponseFeature.mockReset();
|
||||
resetPluginStateStoreForTests();
|
||||
});
|
||||
|
||||
it("normalizes legacy private-network aliases", () => {
|
||||
const normalize = getNextcloudTalkCompatibilityNormalizer();
|
||||
|
||||
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,
|
||||
});
|
||||
});
|
||||
|
||||
it("warns when the configured bot is missing the response feature", async () => {
|
||||
hoisted.probeNextcloudTalkBotResponseFeature.mockResolvedValueOnce({
|
||||
ok: false,
|
||||
code: "missing_response_feature",
|
||||
message:
|
||||
'Nextcloud Talk bot "OpenClaw" (1) is missing the response feature (features=9); outbound replies will fail.',
|
||||
});
|
||||
|
||||
await expect(
|
||||
nextcloudTalkDoctor.collectPreviewWarnings?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "secret",
|
||||
apiUser: "admin",
|
||||
apiPassword: "app-password",
|
||||
webhookPublicUrl: "https://gateway.example.com/nextcloud-talk-webhook",
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
}),
|
||||
).resolves.toEqual([
|
||||
'- channels.nextcloud-talk.default: Nextcloud Talk bot "OpenClaw" (1) is missing the response feature (features=9); outbound replies will fail.',
|
||||
]);
|
||||
});
|
||||
|
||||
it("migrates legacy replay dedupe JSON into SQLite during doctor repair", async () => {
|
||||
const stateDir = await fs.mkdtemp(path.join(os.tmpdir(), "openclaw-nextcloud-doctor-"));
|
||||
const legacyDir = path.join(stateDir, "nextcloud-talk", "replay-dedupe");
|
||||
const legacyPath = path.join(legacyDir, "account-a.json");
|
||||
await fs.mkdir(legacyDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
legacyPath,
|
||||
JSON.stringify({
|
||||
"room-1:msg-1": Date.now(),
|
||||
}),
|
||||
);
|
||||
|
||||
const mutation = await nextcloudTalkDoctor.repairConfig?.({
|
||||
cfg: {
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
accounts: {
|
||||
"account-a": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
} as never,
|
||||
doctorFixCommand: "openclaw doctor --fix",
|
||||
env: { ...process.env, OPENCLAW_STATE_DIR: stateDir },
|
||||
});
|
||||
|
||||
expect(mutation?.changes.join("\n")).toContain(
|
||||
'Migrated Nextcloud Talk replay dedupe cache for account "account-a" to SQLite',
|
||||
);
|
||||
await expect(fs.access(legacyPath)).rejects.toThrow();
|
||||
|
||||
const guard = createNextcloudTalkReplayGuard({ stateDir });
|
||||
await expect(
|
||||
guard.shouldProcessMessage({
|
||||
accountId: "account-a",
|
||||
roomToken: "room-1",
|
||||
messageId: "msg-1",
|
||||
}),
|
||||
).resolves.toBe(false);
|
||||
});
|
||||
});
|
||||
120
extensions/nextcloud-talk/src/doctor.ts
Normal file
120
extensions/nextcloud-talk/src/doctor.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
// Nextcloud Talk plugin module implements doctor behavior.
|
||||
import fs from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
|
||||
import { migratePersistentDedupeLegacyJsonFile } from "openclaw/plugin-sdk/persistent-dedupe";
|
||||
import { resolveStateDir } from "openclaw/plugin-sdk/state-paths";
|
||||
import { listNextcloudTalkAccountIds, resolveNextcloudTalkAccount } from "./accounts.js";
|
||||
import { probeNextcloudTalkBotResponseFeature } from "./bot-preflight.js";
|
||||
import {
|
||||
legacyConfigRules as NEXTCLOUD_TALK_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig as normalizeNextcloudTalkCompatibilityConfig,
|
||||
} from "./doctor-contract.js";
|
||||
import {
|
||||
NEXTCLOUD_TALK_PLUGIN_ID,
|
||||
NEXTCLOUD_TALK_REPLAY_DEDUPE_NAMESPACE_PREFIX,
|
||||
} from "./replay-guard.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const REPLAY_DEDUPE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const REPLAY_DEDUPE_MAX_ENTRIES = 10_000;
|
||||
|
||||
function sanitizeLegacyReplaySegment(value: string): string {
|
||||
const trimmed = value.trim();
|
||||
if (!trimmed) {
|
||||
return "default";
|
||||
}
|
||||
return trimmed.replace(/[^a-zA-Z0-9_-]/g, "_");
|
||||
}
|
||||
|
||||
async function fileExists(filePath: string): Promise<boolean> {
|
||||
try {
|
||||
await fs.access(filePath);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function collectNextcloudTalkBotResponseWarnings(params: {
|
||||
cfg: CoreConfig;
|
||||
}): Promise<string[]> {
|
||||
const warnings: string[] = [];
|
||||
for (const accountId of listNextcloudTalkAccountIds(params.cfg)) {
|
||||
const account = resolveNextcloudTalkAccount({ cfg: params.cfg, accountId });
|
||||
if (!account.enabled || !account.secret || !account.baseUrl) {
|
||||
continue;
|
||||
}
|
||||
const result = await probeNextcloudTalkBotResponseFeature({
|
||||
account,
|
||||
timeoutMs: 5_000,
|
||||
});
|
||||
if (
|
||||
result.code === "missing_response_feature" ||
|
||||
result.code === "bot_not_found" ||
|
||||
result.code === "api_error" ||
|
||||
result.code === "request_failed"
|
||||
) {
|
||||
warnings.push(`- channels.nextcloud-talk.${account.accountId}: ${result.message}`);
|
||||
}
|
||||
}
|
||||
return warnings;
|
||||
}
|
||||
|
||||
async function repairNextcloudTalkReplayDedupeState(params: {
|
||||
cfg: CoreConfig;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): Promise<{ changes: string[]; warnings: string[] }> {
|
||||
const changes: string[] = [];
|
||||
const warnings: string[] = [];
|
||||
const env = params.env ?? process.env;
|
||||
const stateDir = resolveStateDir(env, os.homedir);
|
||||
const replayDir = path.join(stateDir, "nextcloud-talk", "replay-dedupe");
|
||||
|
||||
for (const accountId of listNextcloudTalkAccountIds(params.cfg)) {
|
||||
const legacyPath = path.join(replayDir, `${sanitizeLegacyReplaySegment(accountId)}.json`);
|
||||
if (!(await fileExists(legacyPath))) {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
const result = await migratePersistentDedupeLegacyJsonFile({
|
||||
filePath: legacyPath,
|
||||
namespace: accountId,
|
||||
ttlMs: REPLAY_DEDUPE_TTL_MS,
|
||||
memoryMaxSize: 0,
|
||||
pluginId: NEXTCLOUD_TALK_PLUGIN_ID,
|
||||
namespacePrefix: NEXTCLOUD_TALK_REPLAY_DEDUPE_NAMESPACE_PREFIX,
|
||||
stateMaxEntries: REPLAY_DEDUPE_MAX_ENTRIES,
|
||||
env,
|
||||
});
|
||||
changes.push(
|
||||
`Migrated Nextcloud Talk replay dedupe cache for account "${accountId}" to SQLite (${result.imported} imported, ${result.skippedExpired} expired, ${result.skippedExisting} already current).`,
|
||||
);
|
||||
} catch (error) {
|
||||
warnings.push(
|
||||
`Skipped Nextcloud Talk replay dedupe cache for account "${accountId}": ${String(error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return { changes, warnings };
|
||||
}
|
||||
|
||||
export const nextcloudTalkDoctor: ChannelDoctorAdapter = {
|
||||
legacyConfigRules: NEXTCLOUD_TALK_LEGACY_CONFIG_RULES,
|
||||
normalizeCompatibilityConfig: normalizeNextcloudTalkCompatibilityConfig,
|
||||
collectPreviewWarnings: async ({ cfg }) =>
|
||||
await collectNextcloudTalkBotResponseWarnings({ cfg: cfg as CoreConfig }),
|
||||
repairConfig: async ({ cfg, env }) => {
|
||||
const repair = await repairNextcloudTalkReplayDedupeState({
|
||||
cfg: cfg as CoreConfig,
|
||||
...(env ? { env } : {}),
|
||||
});
|
||||
return {
|
||||
config: cfg,
|
||||
changes: repair.changes,
|
||||
warnings: repair.warnings,
|
||||
};
|
||||
},
|
||||
};
|
||||
110
extensions/nextcloud-talk/src/gateway.ts
Normal file
110
extensions/nextcloud-talk/src/gateway.ts
Normal file
@@ -0,0 +1,110 @@
|
||||
// Nextcloud Talk plugin module implements gateway behavior.
|
||||
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-outbound";
|
||||
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.replaceConfigFile({
|
||||
nextConfig: nextCfg,
|
||||
afterWrite: { mode: "auto" },
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
cleared,
|
||||
envSecret: Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()),
|
||||
loggedOut,
|
||||
};
|
||||
},
|
||||
};
|
||||
147
extensions/nextcloud-talk/src/inbound.authz.test.ts
Normal file
147
extensions/nextcloud-talk/src/inbound.authz.test.ts
Normal file
@@ -0,0 +1,147 @@
|
||||
// Nextcloud Talk tests cover inbound.authz plugin behavior.
|
||||
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).not.toHaveBeenCalled();
|
||||
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();
|
||||
});
|
||||
});
|
||||
310
extensions/nextcloud-talk/src/inbound.behavior.test.ts
Normal file
310
extensions/nextcloud-talk/src/inbound.behavior.test.ts
Normal file
@@ -0,0 +1,310 @@
|
||||
// Nextcloud Talk tests cover inbound.behavior plugin behavior.
|
||||
import { createPluginRuntimeMock } from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
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,
|
||||
resolveAllowlistProviderRuntimeGroupPolicyMock,
|
||||
resolveDefaultGroupPolicyMock,
|
||||
warnMissingProviderGroupPolicyFallbackOnceMock,
|
||||
} = vi.hoisted(() => {
|
||||
return {
|
||||
createChannelPairingControllerMock: 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,
|
||||
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[];
|
||||
hasControlCommand?: (body: string) => boolean;
|
||||
matchesMentionPatterns?: (body: string, regexes: RegExp[]) => boolean;
|
||||
shouldHandleTextCommands?: () => boolean;
|
||||
}) {
|
||||
const runtime = {
|
||||
channel: {
|
||||
inbound: {
|
||||
dispatchReply: vi.fn(async () => undefined),
|
||||
},
|
||||
pairing: {
|
||||
readAllowFromStore: vi.fn(async () => []),
|
||||
upsertPairingRequest: vi.fn(async () => ({ code: "123456", created: true })),
|
||||
},
|
||||
commands: {
|
||||
shouldHandleTextCommands: params?.shouldHandleTextCommands ?? vi.fn(() => false),
|
||||
},
|
||||
text: {
|
||||
hasControlCommand: params?.hasControlCommand ?? vi.fn(() => false),
|
||||
},
|
||||
mentions: {
|
||||
buildMentionRegexes: params?.buildMentionRegexes ?? vi.fn(() => []),
|
||||
matchesMentionPatterns: params?.matchesMentionPatterns ?? vi.fn(() => false),
|
||||
},
|
||||
},
|
||||
};
|
||||
setNextcloudTalkRuntime(runtime as unknown as PluginRuntime);
|
||||
return runtime;
|
||||
}
|
||||
|
||||
function createRuntimeEnv() {
|
||||
return {
|
||||
log: vi.fn(),
|
||||
error: vi.fn(),
|
||||
} as unknown as RuntimeEnv;
|
||||
}
|
||||
|
||||
function requireFirstMockArg(mock: ReturnType<typeof vi.fn>, label: string): unknown {
|
||||
const [call] = mock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error(`expected ${label}`);
|
||||
}
|
||||
return call[0];
|
||||
}
|
||||
|
||||
function requireFirstSendMessageCall(): [unknown, unknown, unknown] {
|
||||
const [call] = sendMessageNextcloudTalkMock.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected Nextcloud Talk send call");
|
||||
}
|
||||
return call as [unknown, unknown, unknown];
|
||||
}
|
||||
|
||||
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);
|
||||
});
|
||||
|
||||
it("issues a DM pairing challenge and sends the challenge text", async () => {
|
||||
const issueChallenge = vi.fn(
|
||||
async (params: { sendPairingReply: (text: string) => Promise<void> }) => {
|
||||
await params.sendPairingReply("Pair with code 123456");
|
||||
},
|
||||
);
|
||||
createChannelPairingControllerMock.mockReturnValue({
|
||||
readStoreForDmPolicy: vi.fn(),
|
||||
issueChallenge,
|
||||
});
|
||||
sendMessageNextcloudTalkMock.mockResolvedValue(undefined);
|
||||
|
||||
const statusSink = vi.fn();
|
||||
await handleNextcloudTalkInbound({
|
||||
message: createMessage({ timestamp: 1_736_380_800_000 }),
|
||||
account: createAccount(),
|
||||
config: { channels: { "nextcloud-talk": {} } } as CoreConfig,
|
||||
runtime: createRuntimeEnv(),
|
||||
statusSink,
|
||||
});
|
||||
|
||||
const challengeParams = requireFirstMockArg(
|
||||
issueChallenge,
|
||||
"Nextcloud Talk pairing challenge",
|
||||
) as {
|
||||
meta?: { name?: string };
|
||||
senderId?: string;
|
||||
senderIdLine?: string;
|
||||
};
|
||||
expect(challengeParams.senderId).toBe("user-1");
|
||||
expect(challengeParams.senderIdLine).toBe("Your Nextcloud user id: user-1");
|
||||
expect(challengeParams.meta).toEqual({ name: "Alice" });
|
||||
expect(sendMessageNextcloudTalkMock).toHaveBeenCalledTimes(1);
|
||||
const sendArgs = requireFirstSendMessageCall();
|
||||
expect(sendArgs[0]).toBe("room-1");
|
||||
expect(sendArgs[1]).toBe("Pair with code 123456");
|
||||
expect(sendArgs[2]).toEqual({
|
||||
cfg: { channels: { "nextcloud-talk": {} } },
|
||||
accountId: "default",
|
||||
});
|
||||
expect(statusSink).toHaveBeenCalledWith({ lastInboundAt: 1_736_380_800_000 });
|
||||
const outboundStatus = statusSink.mock.calls
|
||||
.map(([status]) => status as { lastOutboundAt?: unknown })
|
||||
.find((status) => status.lastOutboundAt !== undefined);
|
||||
expect(typeof outboundStatus?.lastOutboundAt).toBe("number");
|
||||
expect(outboundStatus?.lastOutboundAt).toBeGreaterThanOrEqual(1_736_380_800_000);
|
||||
expect(sendMessageNextcloudTalkMock).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
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");
|
||||
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(sendMessageNextcloudTalkMock).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith("nextcloud-talk: drop room room-group (no mention)");
|
||||
});
|
||||
|
||||
it("blocks unauthorized group text control commands even when room sender access allows chat", async () => {
|
||||
const buildMentionRegexes = vi.fn(() => [/@openclaw/i]);
|
||||
const coreRuntime = installRuntime({
|
||||
buildMentionRegexes,
|
||||
hasControlCommand: vi.fn(() => true),
|
||||
shouldHandleTextCommands: vi.fn(() => true),
|
||||
});
|
||||
createChannelPairingControllerMock.mockReturnValue({
|
||||
readStoreForDmPolicy: vi.fn(),
|
||||
issueChallenge: vi.fn(),
|
||||
});
|
||||
resolveNextcloudTalkRoomKindMock.mockResolvedValue("group");
|
||||
const runtime = createRuntimeEnv();
|
||||
|
||||
await handleNextcloudTalkInbound({
|
||||
message: createMessage({
|
||||
roomToken: "room-group",
|
||||
roomName: "Ops",
|
||||
isGroupChat: true,
|
||||
text: "/openclaw reload",
|
||||
}),
|
||||
account: createAccount({
|
||||
config: {
|
||||
dmPolicy: "pairing",
|
||||
allowFrom: [],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
rooms: {
|
||||
"room-group": {
|
||||
allowFrom: ["user-1"],
|
||||
requireMention: false,
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
config: { channels: { "nextcloud-talk": {} } } as CoreConfig,
|
||||
runtime,
|
||||
});
|
||||
|
||||
expect(coreRuntime.channel.inbound.dispatchReply).not.toHaveBeenCalled();
|
||||
expect(buildMentionRegexes).not.toHaveBeenCalled();
|
||||
expect(runtime.log).toHaveBeenCalledWith(
|
||||
"nextcloud-talk: drop control command (unauthorized) target=user-1",
|
||||
);
|
||||
});
|
||||
|
||||
it("passes the shared reply pipeline for dispatched replies", async () => {
|
||||
const coreRuntime = createPluginRuntimeMock();
|
||||
setNextcloudTalkRuntime(coreRuntime as unknown as PluginRuntime);
|
||||
createChannelPairingControllerMock.mockReturnValue({
|
||||
readStoreForDmPolicy: vi.fn(async () => []),
|
||||
issueChallenge: vi.fn(),
|
||||
});
|
||||
|
||||
await handleNextcloudTalkInbound({
|
||||
message: createMessage(),
|
||||
account: createAccount({
|
||||
config: {
|
||||
dmPolicy: "allowlist",
|
||||
allowFrom: ["user-1"],
|
||||
groupPolicy: "allowlist",
|
||||
groupAllowFrom: [],
|
||||
},
|
||||
}),
|
||||
config: { channels: { "nextcloud-talk": {} } } as CoreConfig,
|
||||
runtime: createRuntimeEnv(),
|
||||
});
|
||||
|
||||
const assembledRequest = requireFirstMockArg(
|
||||
coreRuntime.channel.inbound.dispatchReply as ReturnType<typeof vi.fn>,
|
||||
"Nextcloud Talk assembled request",
|
||||
) as { replyPipeline?: unknown };
|
||||
expect(assembledRequest.replyPipeline).toEqual({});
|
||||
});
|
||||
});
|
||||
391
extensions/nextcloud-talk/src/inbound.ts
Normal file
391
extensions/nextcloud-talk/src/inbound.ts
Normal file
@@ -0,0 +1,391 @@
|
||||
// Nextcloud Talk plugin module implements inbound behavior.
|
||||
import {
|
||||
channelIngressRoutes,
|
||||
resolveStableChannelMessageIngress,
|
||||
} from "openclaw/plugin-sdk/channel-ingress-runtime";
|
||||
import { resolveInboundRouteEnvelopeBuilderWithRuntime } from "openclaw/plugin-sdk/inbound-envelope";
|
||||
import {
|
||||
normalizeOptionalString,
|
||||
normalizeStringEntries,
|
||||
} from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import {
|
||||
GROUP_POLICY_BLOCKED_LABEL,
|
||||
resolveAllowlistProviderRuntimeGroupPolicy,
|
||||
createChannelPairingController,
|
||||
deliverFormattedTextWithAttachments,
|
||||
logInboundDrop,
|
||||
resolveDefaultGroupPolicy,
|
||||
warnMissingProviderGroupPolicyFallbackOnce,
|
||||
type GroupPolicy,
|
||||
type OpenClawConfig,
|
||||
type OutboundReplyPayload,
|
||||
type RuntimeEnv,
|
||||
} from "../runtime-api.js";
|
||||
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
||||
import {
|
||||
normalizeNextcloudTalkAllowEntry,
|
||||
normalizeNextcloudTalkAllowlist,
|
||||
resolveNextcloudTalkAllowlistMatch,
|
||||
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, NextcloudTalkRoomConfig } from "./types.js";
|
||||
|
||||
const CHANNEL_ID = "nextcloud-talk" as const;
|
||||
|
||||
type NextcloudTalkRoomMatch = ReturnType<typeof resolveNextcloudTalkRoomMatch>;
|
||||
|
||||
function hasAllowEntries(entries: string[]): boolean {
|
||||
return normalizeNextcloudTalkAllowlist(entries).length > 0;
|
||||
}
|
||||
|
||||
function roomRoutes(params: {
|
||||
isGroup: boolean;
|
||||
groupPolicy: GroupPolicy;
|
||||
roomMatch: NextcloudTalkRoomMatch;
|
||||
roomConfig?: NextcloudTalkRoomConfig;
|
||||
senderId: string;
|
||||
outerGroupAllowFrom: string[];
|
||||
roomAllowFrom: string[];
|
||||
}) {
|
||||
if (!params.isGroup) {
|
||||
return [];
|
||||
}
|
||||
const roomSenderConfigured =
|
||||
params.groupPolicy === "allowlist" && hasAllowEntries(params.roomAllowFrom);
|
||||
return channelIngressRoutes(
|
||||
params.roomMatch.allowlistConfigured && {
|
||||
id: "nextcloud-talk:room",
|
||||
allowed: params.roomMatch.allowed,
|
||||
precedence: 0,
|
||||
matchId: "nextcloud-talk-room",
|
||||
blockReason: "room_not_allowlisted",
|
||||
},
|
||||
params.roomConfig?.enabled === false && {
|
||||
id: "nextcloud-talk:room-enabled",
|
||||
enabled: false,
|
||||
precedence: 10,
|
||||
blockReason: "room_disabled",
|
||||
},
|
||||
roomSenderConfigured && {
|
||||
id: "nextcloud-talk:room-sender",
|
||||
kind: "nestedAllowlist",
|
||||
precedence: 20,
|
||||
blockReason: "room_sender_not_allowlisted",
|
||||
...(!hasAllowEntries(params.outerGroupAllowFrom)
|
||||
? {
|
||||
senderPolicy: "replace" as const,
|
||||
senderAllowFrom: params.roomAllowFrom,
|
||||
}
|
||||
: {
|
||||
allowed: resolveNextcloudTalkAllowlistMatch({
|
||||
allowFrom: params.roomAllowFrom,
|
||||
senderId: params.senderId,
|
||||
}).allowed,
|
||||
matchId: "nextcloud-talk-room-sender",
|
||||
}),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async function deliverNextcloudTalkReply(params: {
|
||||
cfg: CoreConfig;
|
||||
payload: OutboundReplyPayload;
|
||||
roomToken: string;
|
||||
accountId: string;
|
||||
statusSink?: (patch: { lastOutboundAt?: number }) => void;
|
||||
}): Promise<void> {
|
||||
const { cfg, payload, roomToken, accountId, statusSink } = params;
|
||||
await deliverFormattedTextWithAttachments({
|
||||
payload,
|
||||
send: async ({ text, replyToId }) => {
|
||||
await sendMessageNextcloudTalk(roomToken, text, {
|
||||
cfg,
|
||||
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 roomMatch = resolveNextcloudTalkRoomMatch({
|
||||
rooms: account.config.rooms,
|
||||
roomToken,
|
||||
});
|
||||
const roomConfig = roomMatch.roomConfig;
|
||||
const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
|
||||
cfg: config as OpenClawConfig,
|
||||
surface: CHANNEL_ID,
|
||||
});
|
||||
const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
|
||||
const shouldRequireMention = isGroup
|
||||
? resolveNextcloudTalkRequireMention({
|
||||
roomConfig,
|
||||
wildcardConfig: roomMatch.wildcardConfig,
|
||||
})
|
||||
: false;
|
||||
const { groupPolicy, providerMissingFallbackApplied } =
|
||||
resolveAllowlistProviderRuntimeGroupPolicy({
|
||||
providerConfigPresent:
|
||||
((config.channels as Record<string, unknown> | undefined)?.[CHANNEL_ID] ?? undefined) !==
|
||||
undefined,
|
||||
groupPolicy: account.config.groupPolicy,
|
||||
defaultGroupPolicy: resolveDefaultGroupPolicy(config as OpenClawConfig),
|
||||
});
|
||||
const allowFrom = normalizeStringEntries(account.config.allowFrom);
|
||||
const outerGroupAllowFrom = account.config.groupAllowFrom?.length
|
||||
? normalizeStringEntries(account.config.groupAllowFrom)
|
||||
: allowFrom;
|
||||
const roomAllowFrom = normalizeStringEntries(roomConfig?.allowFrom);
|
||||
const resolveAccess = async (wasMentioned?: boolean) =>
|
||||
await resolveStableChannelMessageIngress({
|
||||
channelId: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
identity: {
|
||||
key: "nextcloud-talk-user-id",
|
||||
normalize: (value) => normalizeNextcloudTalkAllowEntry(value) || null,
|
||||
sensitivity: "pii",
|
||||
entryIdPrefix: "nextcloud-talk-entry",
|
||||
},
|
||||
cfg: config as OpenClawConfig,
|
||||
readStoreAllowFrom: async () =>
|
||||
await pairing.readStoreForDmPolicy(CHANNEL_ID, account.accountId),
|
||||
subject: { stableId: senderId },
|
||||
conversation: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: isGroup ? roomToken : senderId,
|
||||
},
|
||||
route: roomRoutes({
|
||||
isGroup,
|
||||
groupPolicy,
|
||||
roomMatch,
|
||||
roomConfig,
|
||||
senderId,
|
||||
outerGroupAllowFrom,
|
||||
roomAllowFrom,
|
||||
}),
|
||||
dmPolicy: account.config.dmPolicy ?? "pairing",
|
||||
groupPolicy,
|
||||
policy: {
|
||||
groupAllowFromFallbackToAllowFrom: true,
|
||||
activation: {
|
||||
requireMention: isGroup && shouldRequireMention,
|
||||
allowTextCommands,
|
||||
},
|
||||
},
|
||||
mentionFacts:
|
||||
isGroup && wasMentioned !== undefined
|
||||
? {
|
||||
canDetectMention: true,
|
||||
wasMentioned,
|
||||
hasAnyMention: wasMentioned,
|
||||
}
|
||||
: undefined,
|
||||
allowFrom,
|
||||
groupAllowFrom: account.config.groupAllowFrom,
|
||||
command: {
|
||||
allowTextCommands,
|
||||
hasControlCommand,
|
||||
},
|
||||
});
|
||||
let access = await resolveAccess();
|
||||
warnMissingProviderGroupPolicyFallbackOnce({
|
||||
providerMissingFallbackApplied,
|
||||
providerKey: "nextcloud-talk",
|
||||
accountId: account.accountId,
|
||||
blockedLabel: GROUP_POLICY_BLOCKED_LABEL.room,
|
||||
log: (messageValue) => runtime.log?.(messageValue),
|
||||
});
|
||||
const commandAuthorized = access.commandAccess.authorized;
|
||||
const accessReason =
|
||||
access.ingress.reasonCode === "route_blocked"
|
||||
? "route blocked"
|
||||
: access.senderAccess.reasonCode;
|
||||
|
||||
if (isGroup) {
|
||||
if (access.routeAccess.reason === "room_not_allowlisted") {
|
||||
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (not allowlisted)`);
|
||||
return;
|
||||
}
|
||||
if (access.routeAccess.reason === "room_disabled") {
|
||||
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (disabled)`);
|
||||
return;
|
||||
}
|
||||
if (access.routeAccess.reason === "room_sender_not_allowlisted") {
|
||||
runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (policy=${groupPolicy})`);
|
||||
return;
|
||||
}
|
||||
if (access.senderAccess.decision !== "allow") {
|
||||
runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (reason=${accessReason})`);
|
||||
return;
|
||||
}
|
||||
} else if (access.senderAccess.decision !== "allow") {
|
||||
if (access.senderAccess.decision === "pairing") {
|
||||
await pairing.issueChallenge({
|
||||
senderId,
|
||||
senderIdLine: `Your Nextcloud user id: ${senderId}`,
|
||||
meta: { name: senderName || undefined },
|
||||
sendPairingReply: async (text) => {
|
||||
await sendMessageNextcloudTalk(roomToken, text, {
|
||||
cfg: config,
|
||||
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=${accessReason})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (access.commandAccess.shouldBlockControlCommand) {
|
||||
logInboundDrop({
|
||||
log: (messageLocal) => runtime.log?.(messageLocal),
|
||||
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;
|
||||
if (isGroup) {
|
||||
access = await resolveAccess(wasMentioned);
|
||||
}
|
||||
|
||||
if (isGroup && access.activationAccess.shouldSkip) {
|
||||
runtime.log?.(`nextcloud-talk: drop room ${roomToken} (no mention)`);
|
||||
return;
|
||||
}
|
||||
const { route, buildEnvelope } = resolveInboundRouteEnvelopeBuilderWithRuntime({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
peer: {
|
||||
kind: isGroup ? "group" : "direct",
|
||||
id: isGroup ? roomToken : senderId,
|
||||
},
|
||||
runtime: core.channel,
|
||||
sessionStore: (config.session as Record<string, unknown> | undefined)?.store as
|
||||
| string
|
||||
| undefined,
|
||||
});
|
||||
|
||||
const fromLabel = isGroup ? `room:${roomName || roomToken}` : senderName || `user:${senderId}`;
|
||||
const { storePath, body } = buildEnvelope({
|
||||
channel: "Nextcloud Talk",
|
||||
from: fromLabel,
|
||||
timestamp: message.timestamp,
|
||||
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 core.channel.inbound.dispatchReply({
|
||||
cfg: config as OpenClawConfig,
|
||||
channel: CHANNEL_ID,
|
||||
accountId: account.accountId,
|
||||
agentId: route.agentId,
|
||||
routeSessionKey: route.sessionKey,
|
||||
storePath,
|
||||
ctxPayload,
|
||||
recordInboundSession: core.channel.session.recordInboundSession,
|
||||
dispatchReplyWithBufferedBlockDispatcher:
|
||||
core.channel.reply.dispatchReplyWithBufferedBlockDispatcher,
|
||||
delivery: {
|
||||
deliver: async (payload) => {
|
||||
await deliverNextcloudTalkReply({
|
||||
cfg: config,
|
||||
payload,
|
||||
roomToken,
|
||||
accountId: account.accountId,
|
||||
statusSink,
|
||||
});
|
||||
},
|
||||
onError: (err, info) => {
|
||||
runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
replyPipeline: {},
|
||||
replyOptions: {
|
||||
skillFilter: roomConfig?.skills,
|
||||
disableBlockStreaming:
|
||||
typeof account.config.blockStreaming === "boolean"
|
||||
? !account.config.blockStreaming
|
||||
: undefined,
|
||||
},
|
||||
record: {
|
||||
onRecordError: (err) => {
|
||||
runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
271
extensions/nextcloud-talk/src/message-actions.test.ts
Normal file
271
extensions/nextcloud-talk/src/message-actions.test.ts
Normal file
@@ -0,0 +1,271 @@
|
||||
// Nextcloud Talk tests cover message actions plugin behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const hoisted = vi.hoisted(() => ({
|
||||
sendReactionNextcloudTalk: vi.fn(),
|
||||
sendMessageNextcloudTalk: vi.fn(),
|
||||
listNextcloudTalkAccountIds: vi.fn(),
|
||||
resolveNextcloudTalkAccount: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("./send.js", () => ({
|
||||
sendReactionNextcloudTalk: hoisted.sendReactionNextcloudTalk,
|
||||
sendMessageNextcloudTalk: hoisted.sendMessageNextcloudTalk,
|
||||
}));
|
||||
|
||||
vi.mock("./accounts.js", () => ({
|
||||
listNextcloudTalkAccountIds: hoisted.listNextcloudTalkAccountIds,
|
||||
resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount,
|
||||
}));
|
||||
|
||||
const { nextcloudTalkMessageActions } = await import("./message-actions.js");
|
||||
|
||||
const configuredAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
baseUrl: "https://nc.example.com",
|
||||
secret: "bot-secret",
|
||||
} as const;
|
||||
|
||||
const unconfiguredAccount = {
|
||||
accountId: "default",
|
||||
enabled: true,
|
||||
baseUrl: "",
|
||||
secret: null,
|
||||
} as const;
|
||||
|
||||
const disabledAccount = {
|
||||
accountId: "default",
|
||||
enabled: false,
|
||||
baseUrl: "https://nc.example.com",
|
||||
secret: "bot-secret",
|
||||
} as const;
|
||||
|
||||
describe("nextcloudTalkMessageActions", () => {
|
||||
beforeEach(() => {
|
||||
hoisted.sendReactionNextcloudTalk.mockReset();
|
||||
hoisted.sendReactionNextcloudTalk.mockResolvedValue({ ok: true });
|
||||
hoisted.sendMessageNextcloudTalk.mockReset();
|
||||
hoisted.listNextcloudTalkAccountIds.mockReset();
|
||||
hoisted.resolveNextcloudTalkAccount.mockReset();
|
||||
});
|
||||
|
||||
describe("describeMessageTool", () => {
|
||||
it("returns null when no accounts are configured", () => {
|
||||
hoisted.listNextcloudTalkAccountIds.mockReturnValue([]);
|
||||
|
||||
const result = nextcloudTalkMessageActions.describeMessageTool?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when configured account has no secret/baseUrl", () => {
|
||||
hoisted.listNextcloudTalkAccountIds.mockReturnValue([unconfiguredAccount.accountId]);
|
||||
hoisted.resolveNextcloudTalkAccount.mockReturnValue(unconfiguredAccount);
|
||||
|
||||
const result = nextcloudTalkMessageActions.describeMessageTool?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("returns null when the only listed account is disabled", () => {
|
||||
hoisted.listNextcloudTalkAccountIds.mockReturnValue([disabledAccount.accountId]);
|
||||
hoisted.resolveNextcloudTalkAccount.mockReturnValue(disabledAccount);
|
||||
|
||||
const result = nextcloudTalkMessageActions.describeMessageTool?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it("advertises send + react when an account is configured", () => {
|
||||
hoisted.listNextcloudTalkAccountIds.mockReturnValue([configuredAccount.accountId]);
|
||||
hoisted.resolveNextcloudTalkAccount.mockReturnValue(configuredAccount);
|
||||
|
||||
const result = nextcloudTalkMessageActions.describeMessageTool?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
});
|
||||
|
||||
expect(result?.actions).toEqual(["send", "react"]);
|
||||
});
|
||||
|
||||
it("scopes discovery to a specific accountId when provided", () => {
|
||||
hoisted.resolveNextcloudTalkAccount.mockReturnValue(configuredAccount);
|
||||
|
||||
const result = nextcloudTalkMessageActions.describeMessageTool?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(hoisted.resolveNextcloudTalkAccount).toHaveBeenCalledWith({
|
||||
cfg: {},
|
||||
accountId: "work",
|
||||
});
|
||||
expect(hoisted.listNextcloudTalkAccountIds).not.toHaveBeenCalled();
|
||||
expect(result?.actions).toEqual(["send", "react"]);
|
||||
});
|
||||
|
||||
it("returns null when the targeted account is disabled", () => {
|
||||
hoisted.resolveNextcloudTalkAccount.mockReturnValue(disabledAccount);
|
||||
|
||||
const result = nextcloudTalkMessageActions.describeMessageTool?.({
|
||||
cfg: {} as OpenClawConfig,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("supportsAction", () => {
|
||||
it("delegates send back to outbound", () => {
|
||||
expect(nextcloudTalkMessageActions.supportsAction?.({ action: "send" })).toBe(false);
|
||||
});
|
||||
|
||||
it("handles react locally", () => {
|
||||
expect(nextcloudTalkMessageActions.supportsAction?.({ action: "react" })).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("handleAction", () => {
|
||||
const cfg = {} as CoreConfig;
|
||||
|
||||
it("invokes sendReactionNextcloudTalk with normalized params for the react action", async () => {
|
||||
const result = await nextcloudTalkMessageActions.handleAction?.({
|
||||
channel: "nextcloud-talk",
|
||||
action: "react",
|
||||
params: { to: "room:abc123", messageId: "42", emoji: "👍" },
|
||||
cfg,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledTimes(1);
|
||||
expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledWith("room:abc123", "42", "👍", {
|
||||
accountId: "work",
|
||||
cfg,
|
||||
});
|
||||
expect(result).toMatchObject({
|
||||
details: { ok: true, added: "👍" },
|
||||
});
|
||||
});
|
||||
|
||||
it("uses toolContext.currentMessageId when params.messageId is missing", async () => {
|
||||
await nextcloudTalkMessageActions.handleAction?.({
|
||||
channel: "nextcloud-talk",
|
||||
action: "react",
|
||||
params: { to: "room:abc123", emoji: "✅" },
|
||||
cfg,
|
||||
accountId: null,
|
||||
toolContext: { currentMessageId: 99 },
|
||||
});
|
||||
|
||||
expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledWith("room:abc123", "99", "✅", {
|
||||
accountId: undefined,
|
||||
cfg,
|
||||
});
|
||||
});
|
||||
|
||||
it("requires a target room token", async () => {
|
||||
await expect(
|
||||
nextcloudTalkMessageActions.handleAction?.({
|
||||
channel: "nextcloud-talk",
|
||||
action: "react",
|
||||
params: { messageId: "1", emoji: "👍" },
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(/to \(room token\) required/);
|
||||
expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires a messageId (explicit or via toolContext)", async () => {
|
||||
await expect(
|
||||
nextcloudTalkMessageActions.handleAction?.({
|
||||
channel: "nextcloud-talk",
|
||||
action: "react",
|
||||
params: { to: "room:abc123", emoji: "👍" },
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(/messageId required/);
|
||||
expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("requires an emoji", async () => {
|
||||
await expect(
|
||||
nextcloudTalkMessageActions.handleAction?.({
|
||||
channel: "nextcloud-talk",
|
||||
action: "react",
|
||||
params: { to: "room:abc123", messageId: "1" },
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(/emoji required/);
|
||||
expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("rejects send through the action handler (outbound owns send)", async () => {
|
||||
await expect(
|
||||
nextcloudTalkMessageActions.handleAction?.({
|
||||
channel: "nextcloud-talk",
|
||||
action: "send",
|
||||
params: { to: "room:abc123", text: "hi" },
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(/handled by outbound/);
|
||||
});
|
||||
|
||||
it("rejects unsupported actions", async () => {
|
||||
await expect(
|
||||
nextcloudTalkMessageActions.handleAction?.({
|
||||
channel: "nextcloud-talk",
|
||||
action: "delete",
|
||||
params: {},
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(/Action delete not supported for nextcloud-talk/);
|
||||
});
|
||||
|
||||
it("rejects reaction removal requests without calling the add-reaction sender", async () => {
|
||||
await expect(
|
||||
nextcloudTalkMessageActions.handleAction?.({
|
||||
channel: "nextcloud-talk",
|
||||
action: "react",
|
||||
params: { to: "room:abc123", messageId: "1", emoji: "👍", remove: true },
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(/removal is not supported/);
|
||||
expect(hoisted.sendReactionNextcloudTalk).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("still adds the reaction when remove is explicitly false", async () => {
|
||||
await nextcloudTalkMessageActions.handleAction?.({
|
||||
channel: "nextcloud-talk",
|
||||
action: "react",
|
||||
params: { to: "room:abc123", messageId: "1", emoji: "👍", remove: false },
|
||||
cfg,
|
||||
});
|
||||
|
||||
expect(hoisted.sendReactionNextcloudTalk).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("propagates errors from sendReactionNextcloudTalk", async () => {
|
||||
hoisted.sendReactionNextcloudTalk.mockRejectedValueOnce(
|
||||
new Error("Nextcloud Talk reaction failed: 403 forbidden"),
|
||||
);
|
||||
|
||||
await expect(
|
||||
nextcloudTalkMessageActions.handleAction?.({
|
||||
channel: "nextcloud-talk",
|
||||
action: "react",
|
||||
params: { to: "room:abc123", messageId: "1", emoji: "👍" },
|
||||
cfg,
|
||||
}),
|
||||
).rejects.toThrow(/403 forbidden/);
|
||||
});
|
||||
});
|
||||
});
|
||||
83
extensions/nextcloud-talk/src/message-actions.ts
Normal file
83
extensions/nextcloud-talk/src/message-actions.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// Nextcloud Talk plugin module implements message actions behavior.
|
||||
import {
|
||||
jsonResult,
|
||||
readStringParam,
|
||||
resolveReactionMessageId,
|
||||
} from "openclaw/plugin-sdk/channel-actions";
|
||||
import type {
|
||||
ChannelMessageActionAdapter,
|
||||
ChannelMessageActionName,
|
||||
} from "openclaw/plugin-sdk/channel-contract";
|
||||
import { listNextcloudTalkAccountIds, resolveNextcloudTalkAccount } from "./accounts.js";
|
||||
import { sendReactionNextcloudTalk } from "./send.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const providerId = "nextcloud-talk";
|
||||
|
||||
function isAccountConfigured(account: {
|
||||
enabled: boolean;
|
||||
secret: string | null;
|
||||
baseUrl?: string | null;
|
||||
}): boolean {
|
||||
return Boolean(account.enabled && account.secret?.trim() && account.baseUrl?.trim());
|
||||
}
|
||||
|
||||
function hasConfiguredAccount(cfg: CoreConfig, accountId: string | null | undefined): boolean {
|
||||
if (accountId) {
|
||||
const account = resolveNextcloudTalkAccount({ cfg, accountId });
|
||||
return isAccountConfigured(account);
|
||||
}
|
||||
return listNextcloudTalkAccountIds(cfg)
|
||||
.map((id) => resolveNextcloudTalkAccount({ cfg, accountId: id }))
|
||||
.some(isAccountConfigured);
|
||||
}
|
||||
|
||||
export const nextcloudTalkMessageActions: ChannelMessageActionAdapter = {
|
||||
describeMessageTool: ({ cfg, accountId }) => {
|
||||
if (!hasConfiguredAccount(cfg as CoreConfig, accountId)) {
|
||||
return null;
|
||||
}
|
||||
const actions: ChannelMessageActionName[] = ["send", "react"];
|
||||
return { actions };
|
||||
},
|
||||
|
||||
supportsAction: ({ action }) => action !== "send",
|
||||
|
||||
handleAction: async ({ action, params, cfg, accountId, toolContext }) => {
|
||||
if (action === "send") {
|
||||
throw new Error("Send should be handled by outbound, not actions handler.");
|
||||
}
|
||||
|
||||
if (action === "react") {
|
||||
const target = readStringParam(params, "to", {
|
||||
required: true,
|
||||
label: "to (room token)",
|
||||
});
|
||||
|
||||
const messageIdRaw = resolveReactionMessageId({ args: params, toolContext });
|
||||
if (messageIdRaw == null) {
|
||||
throw new Error("messageId required");
|
||||
}
|
||||
const messageId = String(messageIdRaw);
|
||||
|
||||
const emoji = readStringParam(params, "emoji", { required: true });
|
||||
|
||||
// Reaction removal is part of the shared `react` tool contract but is not
|
||||
// yet wired through to a Nextcloud Talk DELETE sender. Reject explicitly
|
||||
// so callers do not get the opposite of what they requested.
|
||||
if (params.remove === true) {
|
||||
throw new Error(
|
||||
"Nextcloud Talk reaction removal is not supported yet; only adding reactions is implemented.",
|
||||
);
|
||||
}
|
||||
|
||||
await sendReactionNextcloudTalk(target, messageId, emoji, {
|
||||
accountId: accountId ?? undefined,
|
||||
cfg: cfg as CoreConfig,
|
||||
});
|
||||
return jsonResult({ ok: true, added: emoji });
|
||||
}
|
||||
|
||||
throw new Error(`Action ${action} not supported for ${providerId}.`);
|
||||
},
|
||||
};
|
||||
29
extensions/nextcloud-talk/src/message-adapter.ts
Normal file
29
extensions/nextcloud-talk/src/message-adapter.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Nextcloud Talk plugin module implements message adapter behavior.
|
||||
import { defineChannelMessageAdapter } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import { sendMessageNextcloudTalk } from "./send.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
export const nextcloudTalkMessageAdapter = defineChannelMessageAdapter({
|
||||
id: "nextcloud-talk",
|
||||
durableFinal: {
|
||||
capabilities: {
|
||||
text: true,
|
||||
media: true,
|
||||
replyTo: true,
|
||||
},
|
||||
},
|
||||
send: {
|
||||
text: async ({ cfg, to, text, accountId, replyToId }) =>
|
||||
await sendMessageNextcloudTalk(to, text, {
|
||||
accountId: accountId ?? undefined,
|
||||
replyTo: replyToId ?? undefined,
|
||||
cfg: cfg as CoreConfig,
|
||||
}),
|
||||
media: 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,
|
||||
}),
|
||||
},
|
||||
});
|
||||
138
extensions/nextcloud-talk/src/monitor-runtime.ts
Normal file
138
extensions/nextcloud-talk/src/monitor-runtime.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
// Nextcloud Talk plugin module implements monitor runtime behavior.
|
||||
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/string-coerce-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;
|
||||
}
|
||||
}
|
||||
|
||||
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.current() 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}`,
|
||||
);
|
||||
}
|
||||
},
|
||||
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 };
|
||||
}
|
||||
348
extensions/nextcloud-talk/src/monitor.replay.test.ts
Normal file
348
extensions/nextcloud-talk/src/monitor.replay.test.ts
Normal file
@@ -0,0 +1,348 @@
|
||||
// Nextcloud Talk tests cover monitor.replay plugin behavior.
|
||||
import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env";
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
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("acknowledges signed non-message Create events instead of rejecting them", async () => {
|
||||
const payload = {
|
||||
type: "Create",
|
||||
actor: { type: "Person", id: "alice", name: "Alice" },
|
||||
object: {
|
||||
type: "Document",
|
||||
id: "file-1",
|
||||
name: "report.pdf",
|
||||
content: "",
|
||||
mediaType: "application/pdf",
|
||||
},
|
||||
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
|
||||
});
|
||||
const onMessage = vi.fn();
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-non-message-event",
|
||||
onMessage,
|
||||
});
|
||||
|
||||
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(200);
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("acknowledges signed non-Create Talk events instead of rejecting them", async () => {
|
||||
const payload = {
|
||||
type: "Join",
|
||||
actor: { type: "Application", id: "bots/bot-1", name: "Bot" },
|
||||
object: { type: "Collection", id: "room-1", name: "Room 1" },
|
||||
};
|
||||
const body = JSON.stringify(payload);
|
||||
const { random, signature } = generateNextcloudTalkSignature({
|
||||
body,
|
||||
secret: "nextcloud-secret", // pragma: allowlist secret
|
||||
});
|
||||
const onMessage = vi.fn();
|
||||
const harness = await startWebhookServer({
|
||||
path: "/nextcloud-lifecycle-event",
|
||||
onMessage,
|
||||
});
|
||||
|
||||
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(200);
|
||||
expect(onMessage).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
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?.status).toBe(401);
|
||||
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?.status).toBe(200);
|
||||
});
|
||||
});
|
||||
31
extensions/nextcloud-talk/src/monitor.test-fixtures.ts
Normal file
31
extensions/nextcloud-talk/src/monitor.test-fixtures.ts
Normal file
@@ -0,0 +1,31 @@
|
||||
// Nextcloud Talk plugin module implements monitor fixtures behavior.
|
||||
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",
|
||||
},
|
||||
};
|
||||
}
|
||||
60
extensions/nextcloud-talk/src/monitor.test-harness.ts
Normal file
60
extensions/nextcloud-talk/src/monitor.test-harness.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
// Nextcloud Talk plugin module implements monitor harness behavior.
|
||||
import type { AddressInfo } from "node:net";
|
||||
import { afterEach } from "vitest";
|
||||
import { createNextcloudTalkWebhookServer } from "./monitor.js";
|
||||
import type { NextcloudTalkWebhookServerOptions } from "./types.js";
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
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;
|
||||
}
|
||||
405
extensions/nextcloud-talk/src/monitor.ts
Normal file
405
extensions/nextcloud-talk/src/monitor.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
// Nextcloud Talk plugin module implements monitor behavior.
|
||||
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 NextcloudTalkWebhookEnvelopeSchema = z.object({
|
||||
type: z.string().min(1),
|
||||
object: z
|
||||
.object({
|
||||
type: z.string().min(1).optional(),
|
||||
})
|
||||
.passthrough()
|
||||
.optional(),
|
||||
});
|
||||
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 envelope = safeParseJsonWithSchema(NextcloudTalkWebhookEnvelopeSchema, params.body);
|
||||
if (!envelope) {
|
||||
writeWebhookError(params.res, 400, WEBHOOK_ERRORS.invalidPayloadFormat);
|
||||
return { kind: "invalid" };
|
||||
}
|
||||
if (envelope.type !== "Create") {
|
||||
return { kind: "ignore" };
|
||||
}
|
||||
if (envelope.object?.type && envelope.object.type !== "Note") {
|
||||
return { kind: "ignore" };
|
||||
}
|
||||
const payload = parseWebhookPayload(params.body);
|
||||
if (!payload) {
|
||||
writeWebhookError(params.res, 400, WEBHOOK_ERRORS.invalidPayloadFormat);
|
||||
return { kind: "invalid" };
|
||||
}
|
||||
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((req: IncomingMessage, res: ServerResponse) => {
|
||||
void (async () => {
|
||||
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 };
|
||||
}
|
||||
45
extensions/nextcloud-talk/src/normalize.ts
Normal file
45
extensions/nextcloud-talk/src/normalize.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
// Nextcloud Talk helper module supports normalize behavior.
|
||||
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);
|
||||
}
|
||||
112
extensions/nextcloud-talk/src/policy.ts
Normal file
112
extensions/nextcloud-talk/src/policy.ts
Normal file
@@ -0,0 +1,112 @@
|
||||
// Nextcloud Talk plugin module implements policy behavior.
|
||||
import {
|
||||
buildChannelKeyCandidates,
|
||||
normalizeChannelSlug,
|
||||
resolveChannelEntryMatchWithFallback,
|
||||
resolveNestedAllowlistDecision,
|
||||
} from "openclaw/plugin-sdk/channel-targets";
|
||||
import type { AllowlistMatch, ChannelGroupContext, GroupToolPolicyConfig } from "../runtime-api.js";
|
||||
import type { NextcloudTalkRoomConfig } from "./types.js";
|
||||
|
||||
export function normalizeNextcloudTalkAllowEntry(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) => normalizeNextcloudTalkAllowEntry(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 = normalizeNextcloudTalkAllowEntry(params.senderId);
|
||||
if (allowFrom.includes(senderId)) {
|
||||
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
||||
}
|
||||
return { allowed: false };
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
124
extensions/nextcloud-talk/src/replay-guard.ts
Normal file
124
extensions/nextcloud-talk/src/replay-guard.ts
Normal file
@@ -0,0 +1,124 @@
|
||||
// Nextcloud Talk plugin module implements replay guard behavior.
|
||||
import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
|
||||
|
||||
export const NEXTCLOUD_TALK_PLUGIN_ID = "nextcloud-talk";
|
||||
export const NEXTCLOUD_TALK_REPLAY_DEDUPE_NAMESPACE_PREFIX = "replay-dedupe";
|
||||
const DEFAULT_REPLAY_TTL_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_MEMORY_MAX_SIZE = 1_000;
|
||||
const DEFAULT_STATE_MAX_ENTRIES = 10_000;
|
||||
|
||||
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}`;
|
||||
}
|
||||
|
||||
type NextcloudTalkReplayGuardOptions = {
|
||||
stateDir?: string;
|
||||
ttlMs?: number;
|
||||
memoryMaxSize?: number;
|
||||
stateMaxEntries?: number;
|
||||
/** @deprecated Use stateMaxEntries. */
|
||||
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,
|
||||
pluginId: NEXTCLOUD_TALK_PLUGIN_ID,
|
||||
namespacePrefix: NEXTCLOUD_TALK_REPLAY_DEDUPE_NAMESPACE_PREFIX,
|
||||
stateMaxEntries:
|
||||
options.stateMaxEntries ?? options.fileMaxEntries ?? DEFAULT_STATE_MAX_ENTRIES,
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_STATE_DIR: stateDir,
|
||||
},
|
||||
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,
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
249
extensions/nextcloud-talk/src/room-info.test.ts
Normal file
249
extensions/nextcloud-talk/src/room-info.test.ts
Normal file
@@ -0,0 +1,249 @@
|
||||
// Nextcloud Talk tests cover room info plugin behavior.
|
||||
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { resolveNextcloudTalkRoomKind, testing } from "./room-info.js";
|
||||
|
||||
const fetchWithSsrFGuard = vi.hoisted(() => vi.fn());
|
||||
const tempDirs: string[] = [];
|
||||
|
||||
vi.mock("../runtime-api.js", () => {
|
||||
return { fetchWithSsrFGuard };
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchWithSsrFGuard.mockReset();
|
||||
testing.resetRoomCache();
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
rmSync(dir, { force: true, recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
function requireFirstFetchParams(): {
|
||||
auditContext?: string;
|
||||
init?: { headers?: { Authorization?: string } };
|
||||
url?: string;
|
||||
} {
|
||||
const [call] = fetchWithSsrFGuard.mock.calls;
|
||||
if (!call) {
|
||||
throw new Error("expected Nextcloud Talk room info fetch call");
|
||||
}
|
||||
const [fetchParams] = call;
|
||||
if (!fetchParams || typeof fetchParams !== "object" || Array.isArray(fetchParams)) {
|
||||
throw new Error("expected Nextcloud Talk room info fetch call");
|
||||
}
|
||||
return fetchParams as { auditContext?: string; url?: string };
|
||||
}
|
||||
|
||||
function jsonResponse(payload: unknown, init?: ResponseInit): Response {
|
||||
return new Response(JSON.stringify(payload), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
...init,
|
||||
});
|
||||
}
|
||||
|
||||
describe("nextcloud talk room info", () => {
|
||||
it("resolves direct rooms from the room info endpoint", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: jsonResponse({
|
||||
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");
|
||||
const fetchParams = requireFirstFetchParams();
|
||||
expect(fetchParams.url).toBe(
|
||||
"https://nc.example.com/ocs/v2.php/apps/spreed/api/v4/room/room-direct",
|
||||
);
|
||||
expect(fetchParams.auditContext).toBe("nextcloud-talk.room-info");
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("normalizes signed decimal room type strings through the shared parser", async () => {
|
||||
fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: jsonResponse({
|
||||
ocs: {
|
||||
data: {
|
||||
type: "+01",
|
||||
},
|
||||
},
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveNextcloudTalkRoomKind({
|
||||
account: {
|
||||
accountId: "acct-direct-string",
|
||||
baseUrl: "https://nc.example.com",
|
||||
config: {
|
||||
apiUser: "bot",
|
||||
apiPassword: "secret",
|
||||
},
|
||||
} as never,
|
||||
roomToken: "room-direct-string",
|
||||
}),
|
||||
).resolves.toBe("direct");
|
||||
});
|
||||
|
||||
it("does not coerce partial room type strings", async () => {
|
||||
fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: jsonResponse({
|
||||
ocs: {
|
||||
data: {
|
||||
type: "1direct",
|
||||
},
|
||||
},
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveNextcloudTalkRoomKind({
|
||||
account: {
|
||||
accountId: "acct-partial",
|
||||
baseUrl: "https://nc.example.com",
|
||||
config: {
|
||||
apiUser: "bot",
|
||||
apiPassword: "secret",
|
||||
},
|
||||
} as never,
|
||||
roomToken: "room-partial",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("does not classify negative room types as group rooms", async () => {
|
||||
fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: jsonResponse({
|
||||
ocs: {
|
||||
data: {
|
||||
type: -1,
|
||||
},
|
||||
},
|
||||
}),
|
||||
release: vi.fn(async () => {}),
|
||||
});
|
||||
|
||||
await expect(
|
||||
resolveNextcloudTalkRoomKind({
|
||||
account: {
|
||||
accountId: "acct-negative",
|
||||
baseUrl: "https://nc.example.com",
|
||||
config: {
|
||||
apiUser: "bot",
|
||||
apiPassword: "secret",
|
||||
},
|
||||
} as never,
|
||||
roomToken: "room-negative",
|
||||
}),
|
||||
).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
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();
|
||||
const tempDir = mkdtempSync(path.join(tmpdir(), "nextcloud-talk-room-info-"));
|
||||
tempDirs.push(tempDir);
|
||||
const passwordFile = path.join(tempDir, "secret");
|
||||
writeFileSync(passwordFile, "file-secret\n", "utf-8");
|
||||
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: passwordFile,
|
||||
},
|
||||
} as never,
|
||||
roomToken: "room-group",
|
||||
runtime: { log, error, exit },
|
||||
});
|
||||
|
||||
expect(kind).toBeUndefined();
|
||||
expect(requireFirstFetchParams().init?.headers?.Authorization).toBe(
|
||||
"Basic Ym90OmZpbGUtc2VjcmV0",
|
||||
);
|
||||
expect(log).toHaveBeenCalledWith("nextcloud-talk: room lookup failed (403) token=room-group");
|
||||
expect(release).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("reports malformed room info JSON with a stable channel error", async () => {
|
||||
const release = vi.fn(async () => {});
|
||||
const log = vi.fn();
|
||||
const error = vi.fn();
|
||||
const exit = vi.fn();
|
||||
fetchWithSsrFGuard.mockResolvedValue({
|
||||
response: new Response("{ nope", {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
release,
|
||||
});
|
||||
|
||||
const kind = await resolveNextcloudTalkRoomKind({
|
||||
account: {
|
||||
accountId: "acct-malformed",
|
||||
baseUrl: "https://nc.example.com",
|
||||
config: {
|
||||
apiUser: "bot",
|
||||
apiPassword: "secret",
|
||||
},
|
||||
} as never,
|
||||
roomToken: "room-malformed",
|
||||
runtime: { log, error, exit },
|
||||
});
|
||||
|
||||
expect(kind).toBeUndefined();
|
||||
expect(error).toHaveBeenCalledWith(
|
||||
"nextcloud-talk: room lookup error: Error: Nextcloud Talk room info failed: malformed JSON response",
|
||||
);
|
||||
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();
|
||||
});
|
||||
});
|
||||
128
extensions/nextcloud-talk/src/room-info.ts
Normal file
128
extensions/nextcloud-talk/src/room-info.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// Nextcloud Talk plugin module implements room info behavior.
|
||||
import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
|
||||
import { parseStrictPositiveInteger } from "openclaw/plugin-sdk/number-runtime";
|
||||
import { readProviderJsonResponse } from "openclaw/plugin-sdk/provider-http";
|
||||
import { ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
import { fetchWithSsrFGuard, type RuntimeEnv } from "../runtime-api.js";
|
||||
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
||||
import { resolveNextcloudTalkApiCredentials } from "./api-credentials.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 coerceRoomType(value: unknown): number | undefined {
|
||||
if (typeof value === "number" && Number.isSafeInteger(value) && value > 0) {
|
||||
return value;
|
||||
}
|
||||
return parseStrictPositiveInteger(value);
|
||||
}
|
||||
|
||||
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 apiCredentials = resolveNextcloudTalkApiCredentials({
|
||||
apiUser: account.config.apiUser,
|
||||
apiPassword: account.config.apiPassword,
|
||||
apiPasswordFile: account.config.apiPasswordFile,
|
||||
});
|
||||
if (!apiCredentials) {
|
||||
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(
|
||||
`${apiCredentials.apiUser}:${apiCredentials.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 readProviderJsonResponse<{
|
||||
ocs?: { data?: { type?: number | string } };
|
||||
}>(response, "Nextcloud Talk room info failed");
|
||||
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;
|
||||
}
|
||||
}
|
||||
export { testing as __testing };
|
||||
10
extensions/nextcloud-talk/src/runtime.ts
Normal file
10
extensions/nextcloud-talk/src/runtime.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
// Nextcloud Talk plugin module implements runtime behavior.
|
||||
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 };
|
||||
104
extensions/nextcloud-talk/src/secret-contract.ts
Normal file
104
extensions/nextcloud-talk/src/secret-contract.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
// Nextcloud Talk plugin module implements secret contract behavior.
|
||||
import {
|
||||
collectConditionalChannelFieldAssignments,
|
||||
getChannelSurface,
|
||||
hasOwnProperty,
|
||||
type ChannelAccountEntry,
|
||||
type ResolverContext,
|
||||
type SecretDefaults,
|
||||
} from "openclaw/plugin-sdk/channel-secret-basic-runtime";
|
||||
|
||||
export const secretTargetRegistryEntries: import("openclaw/plugin-sdk/channel-secret-basic-runtime").SecretTargetRegistryEntry[] =
|
||||
[
|
||||
{
|
||||
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,
|
||||
},
|
||||
];
|
||||
|
||||
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,
|
||||
};
|
||||
5
extensions/nextcloud-talk/src/secret-input.ts
Normal file
5
extensions/nextcloud-talk/src/secret-input.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// Nextcloud Talk plugin module implements secret input behavior.
|
||||
export {
|
||||
buildSecretInputSchema,
|
||||
normalizeResolvedSecretInputString,
|
||||
} from "openclaw/plugin-sdk/secret-input";
|
||||
486
extensions/nextcloud-talk/src/send.cfg-threading.test.ts
Normal file
486
extensions/nextcloud-talk/src/send.cfg-threading.test.ts
Normal file
@@ -0,0 +1,486 @@
|
||||
// Nextcloud Talk tests cover send.cfg threading plugin behavior.
|
||||
import { verifyChannelMessageAdapterCapabilityProofs } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
createSendCfgThreadingRuntime,
|
||||
expectProvidedCfgSkipsRuntimeLoad,
|
||||
} from "openclaw/plugin-sdk/channel-test-helpers";
|
||||
import type { OpenClawConfig as CoreConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
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),
|
||||
requireRuntimeConfig: (cfg: unknown, context: string) => {
|
||||
if (cfg) {
|
||||
return cfg;
|
||||
}
|
||||
throw new Error(`${context} requires a resolved runtime config`);
|
||||
},
|
||||
resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount,
|
||||
resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode,
|
||||
ssrfPolicyFromPrivateNetworkOptIn: hoisted.ssrfPolicyFromPrivateNetworkOptIn,
|
||||
};
|
||||
});
|
||||
|
||||
const { nextcloudTalkMessageAdapter } = await import("./message-adapter.js");
|
||||
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 fixedSentAt = 1_800_000_000_000;
|
||||
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.setSystemTime(fixedSentAt);
|
||||
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.useRealTimers();
|
||||
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",
|
||||
receipt: {
|
||||
platformMessageIds: ["12345"],
|
||||
primaryPlatformMessageId: "12345",
|
||||
parts: [
|
||||
{
|
||||
index: 0,
|
||||
kind: "text",
|
||||
platformMessageId: "12345",
|
||||
raw: {
|
||||
channel: "nextcloud-talk",
|
||||
conversationId: "abc123",
|
||||
messageId: "12345",
|
||||
},
|
||||
},
|
||||
],
|
||||
raw: [
|
||||
{
|
||||
channel: "nextcloud-talk",
|
||||
conversationId: "abc123",
|
||||
messageId: "12345",
|
||||
},
|
||||
],
|
||||
sentAt: fixedSentAt,
|
||||
},
|
||||
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",
|
||||
receipt: {
|
||||
platformMessageIds: ["12346"],
|
||||
primaryPlatformMessageId: "12346",
|
||||
parts: [
|
||||
{
|
||||
index: 0,
|
||||
kind: "text",
|
||||
platformMessageId: "12346",
|
||||
raw: {
|
||||
channel: "nextcloud-talk",
|
||||
conversationId: "abc123",
|
||||
messageId: "12346",
|
||||
},
|
||||
},
|
||||
],
|
||||
raw: [
|
||||
{
|
||||
channel: "nextcloud-talk",
|
||||
conversationId: "abc123",
|
||||
messageId: "12346",
|
||||
},
|
||||
],
|
||||
sentAt: fixedSentAt,
|
||||
},
|
||||
roomToken: "abc123",
|
||||
timestamp: 1_706_000_001,
|
||||
});
|
||||
});
|
||||
|
||||
it("preserves reply ids in receipts", async () => {
|
||||
const cfg = { source: "provided" } as const;
|
||||
mockNextcloudMessageResponse(12347, 1_706_000_002);
|
||||
|
||||
const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
|
||||
cfg,
|
||||
accountId: "work",
|
||||
replyTo: "parent-1",
|
||||
});
|
||||
|
||||
expect(result.receipt).toEqual({
|
||||
platformMessageIds: ["12347"],
|
||||
primaryPlatformMessageId: "12347",
|
||||
replyToId: "parent-1",
|
||||
parts: [
|
||||
{
|
||||
index: 0,
|
||||
kind: "text",
|
||||
replyToId: "parent-1",
|
||||
platformMessageId: "12347",
|
||||
raw: {
|
||||
channel: "nextcloud-talk",
|
||||
conversationId: "abc123",
|
||||
messageId: "12347",
|
||||
},
|
||||
},
|
||||
],
|
||||
raw: [
|
||||
{
|
||||
channel: "nextcloud-talk",
|
||||
conversationId: "abc123",
|
||||
messageId: "12347",
|
||||
},
|
||||
],
|
||||
sentAt: fixedSentAt,
|
||||
});
|
||||
});
|
||||
|
||||
it("explains that 401 sends can mean the response feature is missing", async () => {
|
||||
const cfg = { source: "provided" } as const;
|
||||
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 401 }));
|
||||
|
||||
await expect(
|
||||
sendMessageNextcloudTalk("room:abc123", "hello", {
|
||||
cfg,
|
||||
accountId: "work",
|
||||
}),
|
||||
).rejects.toThrow("--feature response");
|
||||
});
|
||||
|
||||
it("declares message adapter durable text, media, and reply with receipt proofs", async () => {
|
||||
const cfg = { source: "provided" } as const;
|
||||
mockNextcloudMessageResponse(22345, 1_706_000_003);
|
||||
mockNextcloudMessageResponse(22346, 1_706_000_004);
|
||||
mockNextcloudMessageResponse(22347, 1_706_000_005);
|
||||
|
||||
const proofResults = await verifyChannelMessageAdapterCapabilityProofs({
|
||||
adapterName: "nextcloud-talk",
|
||||
adapter: nextcloudTalkMessageAdapter,
|
||||
proofs: {
|
||||
text: async () => {
|
||||
const result = await nextcloudTalkMessageAdapter.send?.text?.({
|
||||
cfg: cfg as CoreConfig,
|
||||
to: "room:abc123",
|
||||
text: "hello",
|
||||
accountId: "work",
|
||||
});
|
||||
expect(result?.receipt.platformMessageIds).toEqual(["22345"]);
|
||||
},
|
||||
media: async () => {
|
||||
const result = await nextcloudTalkMessageAdapter.send?.media?.({
|
||||
cfg: cfg as CoreConfig,
|
||||
to: "room:abc123",
|
||||
text: "image",
|
||||
mediaUrl: "https://example.com/image.png",
|
||||
accountId: "work",
|
||||
});
|
||||
expect(result?.receipt.platformMessageIds).toEqual(["22346"]);
|
||||
const mediaSendCall = fetchMock.mock.calls.at(1);
|
||||
expect(mediaSendCall?.[0]).toBe(
|
||||
"https://nextcloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/abc123/message",
|
||||
);
|
||||
expect(mediaSendCall?.[1]?.body).toBe(
|
||||
JSON.stringify({
|
||||
message: "image\n\nAttachment: https://example.com/image.png",
|
||||
}),
|
||||
);
|
||||
},
|
||||
replyTo: async () => {
|
||||
const result = await nextcloudTalkMessageAdapter.send?.text?.({
|
||||
cfg: cfg as CoreConfig,
|
||||
to: "room:abc123",
|
||||
text: "threaded",
|
||||
replyToId: "parent-1",
|
||||
accountId: "work",
|
||||
});
|
||||
expect(result?.receipt.replyToId).toBe("parent-1");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(proofResults.find((result) => result.capability === "text")?.status).toBe("verified");
|
||||
expect(proofResults.find((result) => result.capability === "media")?.status).toBe("verified");
|
||||
expect(proofResults.find((result) => result.capability === "replyTo")?.status).toBe("verified");
|
||||
});
|
||||
|
||||
it("fails hard for sendReaction when cfg is omitted", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
||||
|
||||
await expect(
|
||||
sendReactionNextcloudTalk("room:ops", "m-1", "👍", {
|
||||
accountId: "default",
|
||||
} as never),
|
||||
).rejects.toThrow("Nextcloud Talk send requires a resolved runtime config");
|
||||
|
||||
expect(hoisted.loadConfig).not.toHaveBeenCalled();
|
||||
expect(hoisted.resolveNextcloudTalkAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("uses provided cfg for sendReaction and posts the reaction payload", async () => {
|
||||
const cfg = { source: "provided" } as const;
|
||||
fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 }));
|
||||
|
||||
const result = await sendReactionNextcloudTalk("room:ops", "m-1", "👍", {
|
||||
cfg,
|
||||
accountId: "work",
|
||||
});
|
||||
|
||||
expectProvidedCfgSkipsRuntimeLoad({
|
||||
loadConfig: hoisted.loadConfig,
|
||||
resolveAccount: hoisted.resolveNextcloudTalkAccount,
|
||||
cfg,
|
||||
accountId: "work",
|
||||
});
|
||||
expect(hoisted.generateNextcloudTalkSignature).toHaveBeenCalledWith({
|
||||
body: "👍",
|
||||
secret: "secret-value",
|
||||
});
|
||||
expect(fetchMock).toHaveBeenCalledWith(
|
||||
"https://nextcloud.example.com/ocs/v2.php/apps/spreed/api/v1/bot/ops/reaction/m-1",
|
||||
{
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"OCS-APIRequest": "true",
|
||||
"X-Nextcloud-Talk-Bot-Random": "r",
|
||||
"X-Nextcloud-Talk-Bot-Signature": "s",
|
||||
},
|
||||
body: JSON.stringify({ reaction: "👍" }),
|
||||
},
|
||||
);
|
||||
expect(result).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it("surfaces sendReaction HTTP failures", async () => {
|
||||
fetchMock.mockResolvedValueOnce(new Response("forbidden", { status: 403 }));
|
||||
|
||||
await expect(
|
||||
sendReactionNextcloudTalk("room:ops", "m-1", "👍", {
|
||||
cfg: { source: "provided" },
|
||||
accountId: "work",
|
||||
}),
|
||||
).rejects.toThrow("Nextcloud Talk reaction failed: 403 forbidden");
|
||||
});
|
||||
});
|
||||
|
||||
describe("nextcloud-talk send bounded response reads", () => {
|
||||
const fetchMock = vi.fn<typeof fetch>();
|
||||
const account = {
|
||||
accountId: "default",
|
||||
baseUrl: "https://nextcloud.example.com",
|
||||
secret: "secret-value",
|
||||
};
|
||||
|
||||
// Builds a streaming body with NO content-length so only the streaming byte
|
||||
// cap can stop it. `chunks` chunks of `chunkBytes` each => total may exceed cap.
|
||||
function streamingResponse(params: {
|
||||
status: number;
|
||||
chunkBytes: number;
|
||||
chunks: number;
|
||||
contentType: string;
|
||||
fill?: number;
|
||||
}): Response {
|
||||
let remaining = params.chunks;
|
||||
const stream = new ReadableStream<Uint8Array>({
|
||||
pull(controller) {
|
||||
if (remaining <= 0) {
|
||||
controller.close();
|
||||
return;
|
||||
}
|
||||
remaining -= 1;
|
||||
controller.enqueue(new Uint8Array(params.chunkBytes).fill(params.fill ?? 0x7b));
|
||||
},
|
||||
});
|
||||
return new Response(stream, {
|
||||
status: params.status,
|
||||
headers: { "content-type": params.contentType },
|
||||
});
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
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.resolveNextcloudTalkAccount.mockReset();
|
||||
hoisted.resolveNextcloudTalkAccount.mockReturnValue(account);
|
||||
hoisted.record.mockReset();
|
||||
hoisted.generateNextcloudTalkSignature.mockClear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
fetchMock.mockReset();
|
||||
hoisted.mockFetchGuard.mockReset();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("keeps the unknown receipt when a success body exceeds the JSON byte cap", async () => {
|
||||
// 17 MiB streamed as 200-OK JSON with no content-length: over the 16 MiB cap.
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
streamingResponse({
|
||||
status: 200,
|
||||
chunkBytes: 1024 * 1024,
|
||||
chunks: 17,
|
||||
contentType: "application/json",
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await sendMessageNextcloudTalk("room:abc", "hello", {
|
||||
cfg: { source: "provided" },
|
||||
});
|
||||
|
||||
// Over-limit success body must not throw and must fall back to the unknown receipt.
|
||||
expect(result.messageId).toBe("unknown");
|
||||
expect(result.timestamp).toBeUndefined();
|
||||
});
|
||||
|
||||
it("bounds an oversized error body into a short send-failure snippet", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
streamingResponse({
|
||||
status: 400,
|
||||
chunkBytes: 1024 * 1024,
|
||||
chunks: 17,
|
||||
contentType: "text/plain",
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(
|
||||
sendMessageNextcloudTalk("room:abc", "hello", { cfg: { source: "provided" } }),
|
||||
).rejects.toThrow(/Nextcloud Talk: bad request/);
|
||||
});
|
||||
|
||||
it("bounds an oversized reaction error body into a short snippet", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
streamingResponse({
|
||||
status: 500,
|
||||
chunkBytes: 1024 * 1024,
|
||||
chunks: 17,
|
||||
contentType: "text/plain",
|
||||
}),
|
||||
);
|
||||
|
||||
let caught: unknown;
|
||||
try {
|
||||
await sendReactionNextcloudTalk("room:abc", "m-1", "👍", { cfg: { source: "provided" } });
|
||||
} catch (error) {
|
||||
caught = error;
|
||||
}
|
||||
|
||||
expect(caught).toBeInstanceOf(Error);
|
||||
// The collapsed snippet caps the message far below the streamed 17 MiB body.
|
||||
expect((caught as Error).message.length).toBeLessThan(4_000);
|
||||
});
|
||||
|
||||
it("still parses a normal small success body", async () => {
|
||||
fetchMock.mockResolvedValueOnce(
|
||||
new Response(JSON.stringify({ ocs: { data: { id: 99, timestamp: 1_700_000_000 } } }), {
|
||||
status: 200,
|
||||
headers: { "content-type": "application/json" },
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await sendMessageNextcloudTalk("room:abc", "hello", {
|
||||
cfg: { source: "provided" },
|
||||
});
|
||||
|
||||
expect(result.messageId).toBe("99");
|
||||
expect(result.timestamp).toBe(1_700_000_000);
|
||||
});
|
||||
});
|
||||
9
extensions/nextcloud-talk/src/send.runtime.ts
Normal file
9
extensions/nextcloud-talk/src/send.runtime.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
// Nextcloud Talk plugin module implements send behavior.
|
||||
export { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
|
||||
export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
|
||||
export { ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime";
|
||||
export { convertMarkdownTables } from "openclaw/plugin-sdk/text-chunking";
|
||||
export { fetchWithSsrFGuard } from "../runtime-api.js";
|
||||
export { resolveNextcloudTalkAccount } from "./accounts.js";
|
||||
export { getNextcloudTalkRuntime } from "./runtime.js";
|
||||
export { generateNextcloudTalkSignature } from "./signature.js";
|
||||
305
extensions/nextcloud-talk/src/send.ts
Normal file
305
extensions/nextcloud-talk/src/send.ts
Normal file
@@ -0,0 +1,305 @@
|
||||
// Nextcloud Talk plugin module implements send behavior.
|
||||
import { createMessageReceiptFromOutboundResults } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import {
|
||||
readProviderJsonResponse,
|
||||
readResponseTextLimited,
|
||||
} from "openclaw/plugin-sdk/provider-http";
|
||||
import { stripNextcloudTalkTargetPrefix } from "./normalize.js";
|
||||
import {
|
||||
convertMarkdownTables,
|
||||
fetchWithSsrFGuard,
|
||||
generateNextcloudTalkSignature,
|
||||
getNextcloudTalkRuntime,
|
||||
requireRuntimeConfig,
|
||||
resolveMarkdownTableMode,
|
||||
resolveNextcloudTalkAccount,
|
||||
ssrfPolicyFromPrivateNetworkOptIn,
|
||||
} from "./send.runtime.js";
|
||||
import type { CoreConfig, NextcloudTalkSendResult } from "./types.js";
|
||||
|
||||
// Nextcloud Talk runs against self-hosted servers whose responses are not
|
||||
// trusted to be small. Cap error bodies so a hostile or misbehaving endpoint
|
||||
// cannot stream an unbounded body into memory. (Success JSON is bounded by the
|
||||
// shared readProviderJsonResponse helper.)
|
||||
const NEXTCLOUD_TALK_ERROR_SNIPPET_MAX_BYTES = 8 * 1024;
|
||||
const NEXTCLOUD_TALK_ERROR_SNIPPET_MAX_CHARS = 200;
|
||||
|
||||
/** Collapses whitespace and caps an error-body prefix to a short, log-safe snippet. */
|
||||
function collapseErrorSnippet(text: string): string {
|
||||
const collapsed = text.replace(/\s+/g, " ").trim();
|
||||
if (collapsed.length > NEXTCLOUD_TALK_ERROR_SNIPPET_MAX_CHARS) {
|
||||
return `${collapsed.slice(0, NEXTCLOUD_TALK_ERROR_SNIPPET_MAX_CHARS)}…`;
|
||||
}
|
||||
return collapsed;
|
||||
}
|
||||
|
||||
/** Reads a bounded, collapsed error-body snippet without buffering hostile responses. */
|
||||
async function readNextcloudTalkErrorSnippet(response: Response): Promise<string> {
|
||||
try {
|
||||
// readResponseTextLimited caps the read at the byte budget and cancels the
|
||||
// upstream stream once full, so a hostile endpoint cannot stream an
|
||||
// unbounded body into memory. Collapse the bounded prefix locally to keep a
|
||||
// short, log-safe error snippet (no new plugin SDK surface required).
|
||||
const text = await readResponseTextLimited(response, NEXTCLOUD_TALK_ERROR_SNIPPET_MAX_BYTES);
|
||||
return collapseErrorSnippet(text);
|
||||
} catch {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
type NextcloudTalkSendOpts = {
|
||||
cfg: CoreConfig;
|
||||
baseUrl?: string;
|
||||
secret?: string;
|
||||
accountId?: string;
|
||||
replyTo?: string;
|
||||
verbose?: boolean;
|
||||
};
|
||||
|
||||
function resolveCredentials(
|
||||
explicit: { baseUrl?: string; secret?: string },
|
||||
account: { baseUrl: string; secret: string; accountId: string },
|
||||
): { baseUrl: string; secret: string } {
|
||||
const baseUrl = explicit.baseUrl?.trim() ?? account.baseUrl;
|
||||
const secret = explicit.secret?.trim() ?? account.secret;
|
||||
|
||||
if (!baseUrl) {
|
||||
throw new Error(
|
||||
`Nextcloud Talk baseUrl missing for account "${account.accountId}" (set channels.nextcloud-talk.baseUrl).`,
|
||||
);
|
||||
}
|
||||
if (!secret) {
|
||||
throw new Error(
|
||||
`Nextcloud Talk bot secret missing for account "${account.accountId}" (set channels.nextcloud-talk.botSecret/botSecretFile or NEXTCLOUD_TALK_BOT_SECRET for default).`,
|
||||
);
|
||||
}
|
||||
|
||||
return { baseUrl, secret };
|
||||
}
|
||||
|
||||
function normalizeRoomToken(to: string): string {
|
||||
const 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 = requireRuntimeConfig(opts.cfg, "Nextcloud Talk send") 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createNextcloudTalkSendReceipt(params: {
|
||||
messageId: string;
|
||||
roomToken: string;
|
||||
replyTo?: string;
|
||||
}) {
|
||||
const messageId = params.messageId.trim();
|
||||
return createMessageReceiptFromOutboundResults({
|
||||
results:
|
||||
messageId && messageId !== "unknown"
|
||||
? [
|
||||
{
|
||||
channel: "nextcloud-talk",
|
||||
messageId,
|
||||
conversationId: params.roomToken,
|
||||
},
|
||||
]
|
||||
: [],
|
||||
kind: "text",
|
||||
...(params.replyTo ? { replyToId: params.replyTo } : {}),
|
||||
});
|
||||
}
|
||||
|
||||
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 readNextcloudTalkErrorSnippet(response);
|
||||
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: bot send was rejected - check the bot secret and ensure the bot was installed with --feature response";
|
||||
} 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 readProviderJsonResponse<{
|
||||
ocs?: {
|
||||
data?: {
|
||||
id?: number | string;
|
||||
timestamp?: number;
|
||||
};
|
||||
};
|
||||
}>(response, "Nextcloud Talk send");
|
||||
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 (including an over-limit body), but the message
|
||||
// was already accepted by the server, so keep the "unknown" receipt.
|
||||
}
|
||||
|
||||
if (opts.verbose) {
|
||||
console.log(`[nextcloud-talk] Sent message ${messageId} to room ${roomToken}`);
|
||||
}
|
||||
|
||||
recordNextcloudTalkOutboundActivity(account.accountId);
|
||||
|
||||
return {
|
||||
messageId,
|
||||
roomToken,
|
||||
receipt: createNextcloudTalkSendReceipt({
|
||||
messageId,
|
||||
roomToken,
|
||||
...(opts.replyTo ? { replyTo: opts.replyTo } : {}),
|
||||
}),
|
||||
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 readNextcloudTalkErrorSnippet(response);
|
||||
throw new Error(`Nextcloud Talk reaction failed: ${response.status} ${errorBody}`.trim());
|
||||
}
|
||||
|
||||
return { ok: true };
|
||||
} finally {
|
||||
await release();
|
||||
}
|
||||
}
|
||||
41
extensions/nextcloud-talk/src/session-route.ts
Normal file
41
extensions/nextcloud-talk/src/session-route.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
// Nextcloud Talk plugin module implements session route behavior.
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
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}`,
|
||||
};
|
||||
}
|
||||
254
extensions/nextcloud-talk/src/setup-core.ts
Normal file
254
extensions/nextcloud-talk/src/setup-core.ts
Normal file
@@ -0,0 +1,254 @@
|
||||
// Nextcloud Talk plugin module implements setup core behavior.
|
||||
import type { ChannelSetupAdapter, ChannelSetupInput } from "openclaw/plugin-sdk/channel-setup";
|
||||
import type { OpenClawConfig } from "openclaw/plugin-sdk/config-contracts";
|
||||
import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/routing";
|
||||
import {
|
||||
applyAccountNameToChannelSection,
|
||||
patchScopedAccountConfig,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import {
|
||||
createSetupInputPresenceValidator,
|
||||
mergeAllowFromEntries,
|
||||
promptParsedAllowFromForAccount,
|
||||
resolveSetupAccountId,
|
||||
createSetupTranslator,
|
||||
type ChannelSetupDmPolicy,
|
||||
type WizardPrompter,
|
||||
} from "openclaw/plugin-sdk/setup-runtime";
|
||||
import { formatDocsLink } from "openclaw/plugin-sdk/setup-tools";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveDefaultNextcloudTalkAccountId, resolveNextcloudTalkAccount } from "./accounts.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
const channel = "nextcloud-talk" as const;
|
||||
|
||||
type NextcloudSetupInput = ChannelSetupInput & {
|
||||
baseUrl?: string;
|
||||
secret?: string;
|
||||
secretFile?: string;
|
||||
};
|
||||
type NextcloudTalkSection = NonNullable<CoreConfig["channels"]>["nextcloud-talk"];
|
||||
|
||||
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: t("wizard.nextcloudTalk.userIdTitle"),
|
||||
noteLines: [
|
||||
t("wizard.nextcloudTalk.userIdHelpAdmin"),
|
||||
t("wizard.nextcloudTalk.userIdHelpLogs"),
|
||||
t("wizard.nextcloudTalk.userIdHelpLowercase"),
|
||||
t("wizard.channels.docs", {
|
||||
link: formatDocsLink("/channels/nextcloud-talk", "nextcloud-talk"),
|
||||
}),
|
||||
],
|
||||
message: t("wizard.nextcloudTalk.allowFromPrompt"),
|
||||
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);
|
||||
},
|
||||
};
|
||||
196
extensions/nextcloud-talk/src/setup-surface.ts
Normal file
196
extensions/nextcloud-talk/src/setup-surface.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// Nextcloud Talk plugin module implements setup surface behavior.
|
||||
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
|
||||
import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
|
||||
import {
|
||||
createStandardChannelSetupStatus,
|
||||
formatDocsLink,
|
||||
setSetupChannelEnabled,
|
||||
createSetupTranslator,
|
||||
type ChannelSetupWizard,
|
||||
} from "openclaw/plugin-sdk/setup";
|
||||
import { normalizeOptionalString } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import { resolveNextcloudTalkAccount } from "./accounts.js";
|
||||
import {
|
||||
clearNextcloudTalkAccountFields,
|
||||
nextcloudTalkDmPolicy,
|
||||
normalizeNextcloudTalkBaseUrl,
|
||||
setNextcloudTalkAccountConfig,
|
||||
validateNextcloudTalkBaseUrl,
|
||||
} from "./setup-core.js";
|
||||
import type { CoreConfig } from "./types.js";
|
||||
|
||||
const t = createSetupTranslator();
|
||||
|
||||
const channel = "nextcloud-talk" as const;
|
||||
const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials";
|
||||
|
||||
export const nextcloudTalkSetupWizard: ChannelSetupWizard = {
|
||||
channel,
|
||||
stepOrder: "text-first",
|
||||
status: createStandardChannelSetupStatus({
|
||||
channelLabel: "Nextcloud Talk",
|
||||
configuredLabel: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredLabel: t("wizard.channels.statusNeedsSetup"),
|
||||
configuredHint: t("wizard.channels.statusConfigured"),
|
||||
unconfiguredHint: t("wizard.channels.statusSelfHostedChat"),
|
||||
configuredScore: 1,
|
||||
unconfiguredScore: 5,
|
||||
resolveConfigured: ({ cfg, accountId }) => {
|
||||
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
|
||||
return Boolean(account.secret && account.baseUrl);
|
||||
},
|
||||
}),
|
||||
introNote: {
|
||||
title: t("wizard.nextcloudTalk.setupTitle"),
|
||||
lines: [
|
||||
t("wizard.nextcloudTalk.helpSsh"),
|
||||
t("wizard.nextcloudTalk.helpInstallCommand"),
|
||||
t("wizard.nextcloudTalk.helpCopySecret"),
|
||||
t("wizard.nextcloudTalk.helpEnableRoom"),
|
||||
t("wizard.nextcloudTalk.helpEnvTip"),
|
||||
t("wizard.channels.docs", {
|
||||
link: 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: t("wizard.nextcloudTalk.configureApiCredentials"),
|
||||
initialValue: hasApiCredentials,
|
||||
});
|
||||
if (!configureApiCredentials) {
|
||||
return undefined;
|
||||
}
|
||||
return {
|
||||
credentialValues: {
|
||||
...credentialValues,
|
||||
[CONFIGURE_API_FLAG]: "1",
|
||||
},
|
||||
};
|
||||
},
|
||||
credentials: [
|
||||
{
|
||||
inputKey: "token",
|
||||
providerHint: channel,
|
||||
credentialLabel: t("wizard.nextcloudTalk.botSecret"),
|
||||
preferredEnvVar: "NEXTCLOUD_TALK_BOT_SECRET",
|
||||
envPrompt: t("wizard.nextcloudTalk.botSecretEnvPrompt"),
|
||||
keepPrompt: t("wizard.nextcloudTalk.botSecretKeep"),
|
||||
inputPrompt: t("wizard.nextcloudTalk.botSecretInput"),
|
||||
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: t("wizard.nextcloudTalk.apiPassword"),
|
||||
preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD",
|
||||
envPrompt: "",
|
||||
keepPrompt: t("wizard.nextcloudTalk.apiPasswordKeep"),
|
||||
inputPrompt: t("wizard.nextcloudTalk.apiPasswordInput"),
|
||||
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: t("wizard.nextcloudTalk.instanceUrlPrompt"),
|
||||
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: t("wizard.nextcloudTalk.apiUserPrompt"),
|
||||
currentValue: ({ cfg, accountId }) =>
|
||||
resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.apiUser?.trim() ||
|
||||
undefined,
|
||||
shouldPrompt: ({ credentialValues }) => credentialValues[CONFIGURE_API_FLAG] === "1",
|
||||
validate: ({ value }) => (value ? undefined : t("common.required")),
|
||||
applySet: async (params) =>
|
||||
setNextcloudTalkAccountConfig(params.cfg as CoreConfig, params.accountId, {
|
||||
apiUser: params.value,
|
||||
}),
|
||||
},
|
||||
],
|
||||
dmPolicy: nextcloudTalkDmPolicy,
|
||||
disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
|
||||
};
|
||||
446
extensions/nextcloud-talk/src/setup.test.ts
Normal file
446
extensions/nextcloud-talk/src/setup.test.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
// Nextcloud Talk tests cover setup plugin behavior.
|
||||
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("shows a bot install command with webhook, response, and reaction features", () => {
|
||||
expect(nextcloudTalkSetupWizard.introNote?.lines.join("\n")).toContain(
|
||||
"--feature webhook --feature response --feature reaction",
|
||||
);
|
||||
});
|
||||
|
||||
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",
|
||||
}),
|
||||
).toEqual({
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
enabled: true,
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "top-secret",
|
||||
apiUser: "bot",
|
||||
accounts: {
|
||||
work: {
|
||||
botSecret: "work-secret",
|
||||
botSecretFile: "/tmp/work-secret",
|
||||
apiPassword: "api-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"])).toEqual({
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
accounts: {
|
||||
work: {
|
||||
botSecret: "work-secret",
|
||||
botSecretFile: "/tmp/work-secret",
|
||||
apiPassword: "api-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
expect(
|
||||
clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"]),
|
||||
).not.toHaveProperty(["channels", "nextcloud-talk", "botSecret"]);
|
||||
|
||||
expect(clearNextcloudTalkAccountFields(cfg, "work", ["botSecret", "botSecretFile"])).toEqual({
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
baseUrl: "https://cloud.example.com",
|
||||
botSecret: "top-secret",
|
||||
accounts: {
|
||||
work: {
|
||||
apiPassword: "api-secret",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("sets top-level DM policy state", () => {
|
||||
const base: CoreConfig = {
|
||||
channels: {
|
||||
"nextcloud-talk": {},
|
||||
},
|
||||
};
|
||||
|
||||
expect(nextcloudTalkDmPolicy.getCurrent(base)).toBe("pairing");
|
||||
expect(nextcloudTalkDmPolicy.setPolicy(base, "open")).toEqual({
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
enabled: true,
|
||||
dmPolicy: "open",
|
||||
allowFrom: ["*"],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
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");
|
||||
if (!validateInput) {
|
||||
throw new Error("Expected Nextcloud Talk setup validateInput");
|
||||
}
|
||||
|
||||
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),
|
||||
).toEqual({
|
||||
channels: {
|
||||
"nextcloud-talk": {
|
||||
enabled: true,
|
||||
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;
|
||||
|
||||
expect(() => resolveNextcloudTalkAccount({ cfg })).toThrow(
|
||||
/Nextcloud Talk bot secret file.*must not be a symlink/,
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
83
extensions/nextcloud-talk/src/signature.ts
Normal file
83
extensions/nextcloud-talk/src/signature.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
// Nextcloud Talk plugin module implements signature behavior.
|
||||
import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
|
||||
import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/string-coerce-runtime";
|
||||
import type { NextcloudTalkWebhookHeaders } from "./types.js";
|
||||
|
||||
const SIGNATURE_HEADER = "x-nextcloud-talk-signature";
|
||||
const RANDOM_HEADER = "x-nextcloud-talk-random";
|
||||
const BACKEND_HEADER = "x-nextcloud-talk-backend";
|
||||
|
||||
/**
|
||||
* Verify the HMAC-SHA256 signature of an incoming webhook request.
|
||||
* Signature is calculated as: HMAC-SHA256(random + body, secret)
|
||||
*/
|
||||
export function verifyNextcloudTalkSignature(params: {
|
||||
signature: string;
|
||||
random: string;
|
||||
body: string;
|
||||
secret: string;
|
||||
}): boolean {
|
||||
const { signature, random, body, secret } = params;
|
||||
if (!signature || !random || !secret) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expected = createHmac("sha256", secret)
|
||||
.update(random + body)
|
||||
.digest("hex");
|
||||
|
||||
const expectedBuf = Buffer.from(expected, "utf8");
|
||||
const signatureBuf = Buffer.from(signature, "utf8");
|
||||
|
||||
// Pad to equal length before constant-time comparison to prevent
|
||||
// leaking length information via early-return timing.
|
||||
// Note: digest("hex") always produces lowercase ASCII (64 bytes for SHA-256),
|
||||
// so expectedBuf is always 64 bytes — no variable-length concern on the expected side.
|
||||
const maxLen = Math.max(expectedBuf.length, signatureBuf.length);
|
||||
const paddedExpected = Buffer.alloc(maxLen);
|
||||
const paddedSignature = Buffer.alloc(maxLen);
|
||||
expectedBuf.copy(paddedExpected);
|
||||
signatureBuf.copy(paddedSignature);
|
||||
|
||||
// Use crypto.timingSafeEqual instead of manual XOR loop to avoid
|
||||
// potential JIT-optimisation timing leaks in the JavaScript engine.
|
||||
const timingResult = timingSafeEqual(paddedExpected, paddedSignature);
|
||||
return expectedBuf.length === signatureBuf.length && timingResult;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 };
|
||||
}
|
||||
196
extensions/nextcloud-talk/src/types.ts
Normal file
196
extensions/nextcloud-talk/src/types.ts
Normal file
@@ -0,0 +1,196 @@
|
||||
// Nextcloud Talk type declarations define plugin contracts.
|
||||
import type { MessageReceipt } from "openclaw/plugin-sdk/channel-outbound";
|
||||
import type {
|
||||
BlockStreamingCoalesceConfig,
|
||||
DmConfig,
|
||||
DmPolicy,
|
||||
GroupPolicy,
|
||||
SecretInput,
|
||||
} from "../runtime-api.js";
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
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). */
|
||||
type NextcloudTalkActor = {
|
||||
type: "Person";
|
||||
/** User ID in Nextcloud. */
|
||||
id: string;
|
||||
/** Display name of the user. */
|
||||
name: string;
|
||||
};
|
||||
|
||||
/** The message object in the activity. */
|
||||
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. */
|
||||
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;
|
||||
receipt: MessageReceipt;
|
||||
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;
|
||||
};
|
||||
Reference in New Issue
Block a user