@openclaw/nextcloud-talk 2026.3.13 → 2026.5.2-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/api.ts +1 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +4 -0
- package/doctor-contract-api.ts +1 -0
- package/index.ts +15 -12
- package/openclaw.plugin.json +804 -1
- package/package.json +31 -4
- package/runtime-api.ts +33 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.ts +25 -42
- package/src/approval-auth.test.ts +17 -0
- package/src/approval-auth.ts +27 -0
- package/src/channel-api.ts +5 -0
- package/src/channel.adapters.ts +52 -0
- package/src/channel.core.test.ts +75 -0
- package/src/{channel.startup.test.ts → channel.lifecycle.test.ts} +29 -27
- package/src/channel.ts +158 -385
- package/src/config-schema.ts +24 -18
- package/src/core.test.ts +397 -0
- package/src/doctor-contract.ts +9 -0
- package/src/doctor.test.ts +40 -0
- package/src/doctor.ts +10 -0
- package/src/gateway.ts +109 -0
- package/src/inbound.authz.test.ts +87 -22
- package/src/inbound.behavior.test.ts +202 -0
- package/src/inbound.ts +28 -26
- package/src/monitor-runtime.ts +138 -0
- package/src/monitor.replay.test.ts +238 -0
- package/src/monitor.test-harness.ts +2 -2
- package/src/monitor.ts +125 -153
- package/src/policy.ts +23 -31
- package/src/replay-guard.ts +74 -11
- package/src/room-info.test.ts +116 -0
- package/src/room-info.ts +11 -3
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +103 -0
- package/src/secret-input.ts +1 -10
- package/src/send.cfg-threading.test.ts +153 -0
- package/src/send.runtime.ts +8 -0
- package/src/send.ts +109 -77
- package/src/session-route.ts +40 -0
- package/src/setup-core.ts +248 -0
- package/src/setup-surface.ts +190 -0
- package/src/setup.test.ts +422 -0
- package/src/signature.ts +20 -10
- package/src/types.ts +19 -16
- package/tsconfig.json +16 -0
- package/src/accounts.test.ts +0 -30
- package/src/config-schema.test.ts +0 -36
- package/src/format.ts +0 -79
- package/src/monitor.auth-order.test.ts +0 -28
- package/src/monitor.backend.test.ts +0 -27
- package/src/monitor.read-body.test.ts +0 -16
- package/src/normalize.test.ts +0 -28
- package/src/onboarding.ts +0 -302
- package/src/policy.test.ts +0 -138
- package/src/replay-guard.test.ts +0 -70
- package/src/send.test.ts +0 -98
package/src/types.ts
CHANGED
|
@@ -4,9 +4,7 @@ import type {
|
|
|
4
4
|
DmPolicy,
|
|
5
5
|
GroupPolicy,
|
|
6
6
|
SecretInput,
|
|
7
|
-
} from "
|
|
8
|
-
|
|
9
|
-
export type { DmPolicy, GroupPolicy };
|
|
7
|
+
} from "../runtime-api.js";
|
|
10
8
|
|
|
11
9
|
export type NextcloudTalkRoomConfig = {
|
|
12
10
|
requireMention?: boolean;
|
|
@@ -22,6 +20,11 @@ export type NextcloudTalkRoomConfig = {
|
|
|
22
20
|
systemPrompt?: string;
|
|
23
21
|
};
|
|
24
22
|
|
|
23
|
+
type NextcloudTalkNetworkConfig = {
|
|
24
|
+
/** Dangerous opt-in for self-hosted Nextcloud Talk on trusted private/internal hosts. */
|
|
25
|
+
dangerouslyAllowPrivateNetwork?: boolean;
|
|
26
|
+
};
|
|
27
|
+
|
|
25
28
|
export type NextcloudTalkAccountConfig = {
|
|
26
29
|
/** Optional display name for this account (used in CLI/UI lists). */
|
|
27
30
|
name?: string;
|
|
@@ -75,9 +78,11 @@ export type NextcloudTalkAccountConfig = {
|
|
|
75
78
|
responsePrefix?: string;
|
|
76
79
|
/** Media upload max size in MB. */
|
|
77
80
|
mediaMaxMb?: number;
|
|
81
|
+
/** Network policy overrides for self-hosted Nextcloud Talk on trusted private/internal hosts. */
|
|
82
|
+
network?: NextcloudTalkNetworkConfig;
|
|
78
83
|
};
|
|
79
84
|
|
|
80
|
-
|
|
85
|
+
type NextcloudTalkConfig = {
|
|
81
86
|
/** Optional per-account Nextcloud Talk configuration (multi-account). */
|
|
82
87
|
accounts?: Record<string, NextcloudTalkAccountConfig>;
|
|
83
88
|
/** Optional default account id when multiple accounts are configured. */
|
|
@@ -97,7 +102,7 @@ export type CoreConfig = {
|
|
|
97
102
|
*/
|
|
98
103
|
|
|
99
104
|
/** Actor in the activity (the message sender). */
|
|
100
|
-
|
|
105
|
+
type NextcloudTalkActor = {
|
|
101
106
|
type: "Person";
|
|
102
107
|
/** User ID in Nextcloud. */
|
|
103
108
|
id: string;
|
|
@@ -106,7 +111,7 @@ export type NextcloudTalkActor = {
|
|
|
106
111
|
};
|
|
107
112
|
|
|
108
113
|
/** The message object in the activity. */
|
|
109
|
-
|
|
114
|
+
type NextcloudTalkObject = {
|
|
110
115
|
type: "Note";
|
|
111
116
|
/** Message ID. */
|
|
112
117
|
id: string;
|
|
@@ -119,7 +124,7 @@ export type NextcloudTalkObject = {
|
|
|
119
124
|
};
|
|
120
125
|
|
|
121
126
|
/** Target conversation/room. */
|
|
122
|
-
|
|
127
|
+
type NextcloudTalkTarget = {
|
|
123
128
|
type: "Collection";
|
|
124
129
|
/** Room token. */
|
|
125
130
|
id: string;
|
|
@@ -172,19 +177,17 @@ export type NextcloudTalkWebhookServerOptions = {
|
|
|
172
177
|
path: string;
|
|
173
178
|
secret: string;
|
|
174
179
|
maxBodyBytes?: number;
|
|
180
|
+
authRateLimit?: {
|
|
181
|
+
maxRequests?: number;
|
|
182
|
+
windowMs?: number;
|
|
183
|
+
};
|
|
175
184
|
readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise<string>;
|
|
176
185
|
isBackendAllowed?: (backend: string) => boolean;
|
|
177
186
|
shouldProcessMessage?: (message: NextcloudTalkInboundMessage) => boolean | Promise<boolean>;
|
|
187
|
+
processMessage?: (
|
|
188
|
+
message: NextcloudTalkInboundMessage,
|
|
189
|
+
) => void | "processed" | "duplicate" | Promise<void | "processed" | "duplicate">;
|
|
178
190
|
onMessage: (message: NextcloudTalkInboundMessage) => void | Promise<void>;
|
|
179
191
|
onError?: (error: Error) => void;
|
|
180
192
|
abortSignal?: AbortSignal;
|
|
181
193
|
};
|
|
182
|
-
|
|
183
|
-
/** Options for sending a message. */
|
|
184
|
-
export type NextcloudTalkSendOptions = {
|
|
185
|
-
baseUrl: string;
|
|
186
|
-
secret: string;
|
|
187
|
-
roomToken: string;
|
|
188
|
-
message: string;
|
|
189
|
-
replyTo?: string;
|
|
190
|
-
};
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
{
|
|
2
|
+
"extends": "../tsconfig.package-boundary.base.json",
|
|
3
|
+
"compilerOptions": {
|
|
4
|
+
"rootDir": "."
|
|
5
|
+
},
|
|
6
|
+
"include": ["./*.ts", "./src/**/*.ts"],
|
|
7
|
+
"exclude": [
|
|
8
|
+
"./**/*.test.ts",
|
|
9
|
+
"./dist/**",
|
|
10
|
+
"./node_modules/**",
|
|
11
|
+
"./src/test-support/**",
|
|
12
|
+
"./src/**/*test-helpers.ts",
|
|
13
|
+
"./src/**/*test-harness.ts",
|
|
14
|
+
"./src/**/*test-support.ts"
|
|
15
|
+
]
|
|
16
|
+
}
|
package/src/accounts.test.ts
DELETED
|
@@ -1,30 +0,0 @@
|
|
|
1
|
-
import fs from "node:fs";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { describe, expect, it } from "vitest";
|
|
5
|
-
import { resolveNextcloudTalkAccount } from "./accounts.js";
|
|
6
|
-
import type { CoreConfig } from "./types.js";
|
|
7
|
-
|
|
8
|
-
describe("resolveNextcloudTalkAccount", () => {
|
|
9
|
-
it.runIf(process.platform !== "win32")("rejects symlinked botSecretFile paths", () => {
|
|
10
|
-
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-nextcloud-talk-"));
|
|
11
|
-
const secretFile = path.join(dir, "secret.txt");
|
|
12
|
-
const secretLink = path.join(dir, "secret-link.txt");
|
|
13
|
-
fs.writeFileSync(secretFile, "bot-secret\n", "utf8");
|
|
14
|
-
fs.symlinkSync(secretFile, secretLink);
|
|
15
|
-
|
|
16
|
-
const cfg = {
|
|
17
|
-
channels: {
|
|
18
|
-
"nextcloud-talk": {
|
|
19
|
-
baseUrl: "https://cloud.example.com",
|
|
20
|
-
botSecretFile: secretLink,
|
|
21
|
-
},
|
|
22
|
-
},
|
|
23
|
-
} as CoreConfig;
|
|
24
|
-
|
|
25
|
-
const account = resolveNextcloudTalkAccount({ cfg });
|
|
26
|
-
expect(account.secret).toBe("");
|
|
27
|
-
expect(account.secretSource).toBe("none");
|
|
28
|
-
fs.rmSync(dir, { recursive: true, force: true });
|
|
29
|
-
});
|
|
30
|
-
});
|
|
@@ -1,36 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { NextcloudTalkConfigSchema } from "./config-schema.js";
|
|
3
|
-
|
|
4
|
-
describe("NextcloudTalkConfigSchema SecretInput", () => {
|
|
5
|
-
it("accepts SecretRef botSecret and apiPassword at top-level", () => {
|
|
6
|
-
const result = NextcloudTalkConfigSchema.safeParse({
|
|
7
|
-
baseUrl: "https://cloud.example.com",
|
|
8
|
-
botSecret: { source: "env", provider: "default", id: "NEXTCLOUD_TALK_BOT_SECRET" },
|
|
9
|
-
apiUser: "bot",
|
|
10
|
-
apiPassword: { source: "env", provider: "default", id: "NEXTCLOUD_TALK_API_PASSWORD" },
|
|
11
|
-
});
|
|
12
|
-
expect(result.success).toBe(true);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("accepts SecretRef botSecret and apiPassword on account", () => {
|
|
16
|
-
const result = NextcloudTalkConfigSchema.safeParse({
|
|
17
|
-
accounts: {
|
|
18
|
-
main: {
|
|
19
|
-
baseUrl: "https://cloud.example.com",
|
|
20
|
-
botSecret: {
|
|
21
|
-
source: "env",
|
|
22
|
-
provider: "default",
|
|
23
|
-
id: "NEXTCLOUD_TALK_MAIN_BOT_SECRET",
|
|
24
|
-
},
|
|
25
|
-
apiUser: "bot",
|
|
26
|
-
apiPassword: {
|
|
27
|
-
source: "env",
|
|
28
|
-
provider: "default",
|
|
29
|
-
id: "NEXTCLOUD_TALK_MAIN_API_PASSWORD",
|
|
30
|
-
},
|
|
31
|
-
},
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
|
-
expect(result.success).toBe(true);
|
|
35
|
-
});
|
|
36
|
-
});
|
package/src/format.ts
DELETED
|
@@ -1,79 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Format utilities for Nextcloud Talk messages.
|
|
3
|
-
*
|
|
4
|
-
* Nextcloud Talk supports markdown natively, so most formatting passes through.
|
|
5
|
-
* This module handles any edge cases or transformations needed.
|
|
6
|
-
*/
|
|
7
|
-
|
|
8
|
-
/**
|
|
9
|
-
* Convert markdown to Nextcloud Talk compatible format.
|
|
10
|
-
* Nextcloud Talk supports standard markdown, so minimal transformation needed.
|
|
11
|
-
*/
|
|
12
|
-
export function markdownToNextcloudTalk(text: string): string {
|
|
13
|
-
return text.trim();
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
/**
|
|
17
|
-
* Escape special characters in text to prevent markdown interpretation.
|
|
18
|
-
*/
|
|
19
|
-
export function escapeNextcloudTalkMarkdown(text: string): string {
|
|
20
|
-
return text.replace(/([*_`~[\]()#>+\-=|{}!\\])/g, "\\$1");
|
|
21
|
-
}
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Format a mention for a Nextcloud user.
|
|
25
|
-
* Nextcloud Talk uses @user format for mentions.
|
|
26
|
-
*/
|
|
27
|
-
export function formatNextcloudTalkMention(userId: string): string {
|
|
28
|
-
return `@${userId.replace(/^@/, "")}`;
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* Format a code block for Nextcloud Talk.
|
|
33
|
-
*/
|
|
34
|
-
export function formatNextcloudTalkCodeBlock(code: string, language?: string): string {
|
|
35
|
-
const lang = language ?? "";
|
|
36
|
-
return `\`\`\`${lang}\n${code}\n\`\`\``;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Format inline code for Nextcloud Talk.
|
|
41
|
-
*/
|
|
42
|
-
export function formatNextcloudTalkInlineCode(code: string): string {
|
|
43
|
-
if (code.includes("`")) {
|
|
44
|
-
return `\`\` ${code} \`\``;
|
|
45
|
-
}
|
|
46
|
-
return `\`${code}\``;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/**
|
|
50
|
-
* Strip Nextcloud Talk specific formatting from text.
|
|
51
|
-
* Useful for extracting plain text content.
|
|
52
|
-
*/
|
|
53
|
-
export function stripNextcloudTalkFormatting(text: string): string {
|
|
54
|
-
return text
|
|
55
|
-
.replace(/```[\s\S]*?```/g, "")
|
|
56
|
-
.replace(/`[^`]+`/g, "")
|
|
57
|
-
.replace(/\*\*([^*]+)\*\*/g, "$1")
|
|
58
|
-
.replace(/\*([^*]+)\*/g, "$1")
|
|
59
|
-
.replace(/_([^_]+)_/g, "$1")
|
|
60
|
-
.replace(/~~([^~]+)~~/g, "$1")
|
|
61
|
-
.replace(/\[([^\]]+)\]\([^)]+\)/g, "$1")
|
|
62
|
-
.replace(/\s+/g, " ")
|
|
63
|
-
.trim();
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* Truncate text to a maximum length, preserving word boundaries.
|
|
68
|
-
*/
|
|
69
|
-
export function truncateNextcloudTalkText(text: string, maxLength: number, suffix = "..."): string {
|
|
70
|
-
if (text.length <= maxLength) {
|
|
71
|
-
return text;
|
|
72
|
-
}
|
|
73
|
-
const truncated = text.slice(0, maxLength - suffix.length);
|
|
74
|
-
const lastSpace = truncated.lastIndexOf(" ");
|
|
75
|
-
if (lastSpace > maxLength * 0.7) {
|
|
76
|
-
return truncated.slice(0, lastSpace) + suffix;
|
|
77
|
-
}
|
|
78
|
-
return truncated + suffix;
|
|
79
|
-
}
|
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { startWebhookServer } from "./monitor.test-harness.js";
|
|
3
|
-
|
|
4
|
-
describe("createNextcloudTalkWebhookServer auth order", () => {
|
|
5
|
-
it("rejects missing signature headers before reading request body", async () => {
|
|
6
|
-
const readBody = vi.fn(async () => {
|
|
7
|
-
throw new Error("should not be called for missing signature headers");
|
|
8
|
-
});
|
|
9
|
-
const harness = await startWebhookServer({
|
|
10
|
-
path: "/nextcloud-auth-order",
|
|
11
|
-
maxBodyBytes: 128,
|
|
12
|
-
readBody,
|
|
13
|
-
onMessage: vi.fn(),
|
|
14
|
-
});
|
|
15
|
-
|
|
16
|
-
const response = await fetch(harness.webhookUrl, {
|
|
17
|
-
method: "POST",
|
|
18
|
-
headers: {
|
|
19
|
-
"content-type": "application/json",
|
|
20
|
-
},
|
|
21
|
-
body: "{}",
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
expect(response.status).toBe(400);
|
|
25
|
-
expect(await response.json()).toEqual({ error: "Missing signature headers" });
|
|
26
|
-
expect(readBody).not.toHaveBeenCalled();
|
|
27
|
-
});
|
|
28
|
-
});
|
|
@@ -1,27 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it, vi } from "vitest";
|
|
2
|
-
import { createSignedCreateMessageRequest } from "./monitor.test-fixtures.js";
|
|
3
|
-
import { startWebhookServer } from "./monitor.test-harness.js";
|
|
4
|
-
|
|
5
|
-
describe("createNextcloudTalkWebhookServer backend allowlist", () => {
|
|
6
|
-
it("rejects requests from unexpected backend origins", async () => {
|
|
7
|
-
const onMessage = vi.fn(async () => {});
|
|
8
|
-
const harness = await startWebhookServer({
|
|
9
|
-
path: "/nextcloud-backend-check",
|
|
10
|
-
isBackendAllowed: (backend) => backend === "https://nextcloud.expected",
|
|
11
|
-
onMessage,
|
|
12
|
-
});
|
|
13
|
-
|
|
14
|
-
const { body, headers } = createSignedCreateMessageRequest({
|
|
15
|
-
backend: "https://nextcloud.unexpected",
|
|
16
|
-
});
|
|
17
|
-
const response = await fetch(harness.webhookUrl, {
|
|
18
|
-
method: "POST",
|
|
19
|
-
headers,
|
|
20
|
-
body,
|
|
21
|
-
});
|
|
22
|
-
|
|
23
|
-
expect(response.status).toBe(401);
|
|
24
|
-
expect(await response.json()).toEqual({ error: "Invalid backend" });
|
|
25
|
-
expect(onMessage).not.toHaveBeenCalled();
|
|
26
|
-
});
|
|
27
|
-
});
|
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { createMockIncomingRequest } from "../../../test/helpers/mock-incoming-request.js";
|
|
3
|
-
import { readNextcloudTalkWebhookBody } from "./monitor.js";
|
|
4
|
-
|
|
5
|
-
describe("readNextcloudTalkWebhookBody", () => {
|
|
6
|
-
it("reads valid body within max bytes", async () => {
|
|
7
|
-
const req = createMockIncomingRequest(['{"type":"Create"}']);
|
|
8
|
-
const body = await readNextcloudTalkWebhookBody(req, 1024);
|
|
9
|
-
expect(body).toBe('{"type":"Create"}');
|
|
10
|
-
});
|
|
11
|
-
|
|
12
|
-
it("rejects when payload exceeds max bytes", async () => {
|
|
13
|
-
const req = createMockIncomingRequest(["x".repeat(300)]);
|
|
14
|
-
await expect(readNextcloudTalkWebhookBody(req, 128)).rejects.toThrow("PayloadTooLarge");
|
|
15
|
-
});
|
|
16
|
-
});
|
package/src/normalize.test.ts
DELETED
|
@@ -1,28 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import {
|
|
3
|
-
looksLikeNextcloudTalkTargetId,
|
|
4
|
-
normalizeNextcloudTalkMessagingTarget,
|
|
5
|
-
stripNextcloudTalkTargetPrefix,
|
|
6
|
-
} from "./normalize.js";
|
|
7
|
-
|
|
8
|
-
describe("nextcloud-talk target normalization", () => {
|
|
9
|
-
it("strips supported prefixes to a room token", () => {
|
|
10
|
-
expect(stripNextcloudTalkTargetPrefix(" room:abc123 ")).toBe("abc123");
|
|
11
|
-
expect(stripNextcloudTalkTargetPrefix("nextcloud-talk:room:AbC123")).toBe("AbC123");
|
|
12
|
-
expect(stripNextcloudTalkTargetPrefix("nc-talk:room:ops")).toBe("ops");
|
|
13
|
-
expect(stripNextcloudTalkTargetPrefix("nc:room:ops")).toBe("ops");
|
|
14
|
-
expect(stripNextcloudTalkTargetPrefix("room: ")).toBeUndefined();
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
it("normalizes messaging targets to lowercase channel ids", () => {
|
|
18
|
-
expect(normalizeNextcloudTalkMessagingTarget("room:AbC123")).toBe("nextcloud-talk:abc123");
|
|
19
|
-
expect(normalizeNextcloudTalkMessagingTarget("nc-talk:room:Ops")).toBe("nextcloud-talk:ops");
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
it("detects prefixed and bare room ids", () => {
|
|
23
|
-
expect(looksLikeNextcloudTalkTargetId("nextcloud-talk:room:abc12345")).toBe(true);
|
|
24
|
-
expect(looksLikeNextcloudTalkTargetId("nc:opsroom1")).toBe(true);
|
|
25
|
-
expect(looksLikeNextcloudTalkTargetId("abc12345")).toBe(true);
|
|
26
|
-
expect(looksLikeNextcloudTalkTargetId("")).toBe(false);
|
|
27
|
-
});
|
|
28
|
-
});
|
package/src/onboarding.ts
DELETED
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
formatDocsLink,
|
|
3
|
-
hasConfiguredSecretInput,
|
|
4
|
-
mapAllowFromEntries,
|
|
5
|
-
mergeAllowFromEntries,
|
|
6
|
-
patchScopedAccountConfig,
|
|
7
|
-
runSingleChannelSecretStep,
|
|
8
|
-
resolveAccountIdForConfigure,
|
|
9
|
-
DEFAULT_ACCOUNT_ID,
|
|
10
|
-
normalizeAccountId,
|
|
11
|
-
setTopLevelChannelDmPolicyWithAllowFrom,
|
|
12
|
-
type ChannelOnboardingAdapter,
|
|
13
|
-
type ChannelOnboardingDmPolicy,
|
|
14
|
-
type OpenClawConfig,
|
|
15
|
-
type WizardPrompter,
|
|
16
|
-
} from "openclaw/plugin-sdk/nextcloud-talk";
|
|
17
|
-
import {
|
|
18
|
-
listNextcloudTalkAccountIds,
|
|
19
|
-
resolveDefaultNextcloudTalkAccountId,
|
|
20
|
-
resolveNextcloudTalkAccount,
|
|
21
|
-
} from "./accounts.js";
|
|
22
|
-
import type { CoreConfig, DmPolicy } from "./types.js";
|
|
23
|
-
|
|
24
|
-
const channel = "nextcloud-talk" as const;
|
|
25
|
-
|
|
26
|
-
function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
|
|
27
|
-
return setTopLevelChannelDmPolicyWithAllowFrom({
|
|
28
|
-
cfg,
|
|
29
|
-
channel: "nextcloud-talk",
|
|
30
|
-
dmPolicy,
|
|
31
|
-
getAllowFrom: (inputCfg) =>
|
|
32
|
-
mapAllowFromEntries(inputCfg.channels?.["nextcloud-talk"]?.allowFrom),
|
|
33
|
-
}) as CoreConfig;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function setNextcloudTalkAccountConfig(
|
|
37
|
-
cfg: CoreConfig,
|
|
38
|
-
accountId: string,
|
|
39
|
-
updates: Record<string, unknown>,
|
|
40
|
-
): CoreConfig {
|
|
41
|
-
return patchScopedAccountConfig({
|
|
42
|
-
cfg,
|
|
43
|
-
channelKey: channel,
|
|
44
|
-
accountId,
|
|
45
|
-
patch: updates,
|
|
46
|
-
}) as CoreConfig;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise<void> {
|
|
50
|
-
await prompter.note(
|
|
51
|
-
[
|
|
52
|
-
"1) SSH into your Nextcloud server",
|
|
53
|
-
'2) Run: ./occ talk:bot:install "OpenClaw" "<shared-secret>" "<webhook-url>" --feature reaction',
|
|
54
|
-
"3) Copy the shared secret you used in the command",
|
|
55
|
-
"4) Enable the bot in your Nextcloud Talk room settings",
|
|
56
|
-
"Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.",
|
|
57
|
-
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
|
|
58
|
-
].join("\n"),
|
|
59
|
-
"Nextcloud Talk bot setup",
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function noteNextcloudTalkUserIdHelp(prompter: WizardPrompter): Promise<void> {
|
|
64
|
-
await prompter.note(
|
|
65
|
-
[
|
|
66
|
-
"1) Check the Nextcloud admin panel for user IDs",
|
|
67
|
-
"2) Or look at the webhook payload logs when someone messages",
|
|
68
|
-
"3) User IDs are typically lowercase usernames in Nextcloud",
|
|
69
|
-
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
|
|
70
|
-
].join("\n"),
|
|
71
|
-
"Nextcloud Talk user id",
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function promptNextcloudTalkAllowFrom(params: {
|
|
76
|
-
cfg: CoreConfig;
|
|
77
|
-
prompter: WizardPrompter;
|
|
78
|
-
accountId: string;
|
|
79
|
-
}): Promise<CoreConfig> {
|
|
80
|
-
const { cfg, prompter, accountId } = params;
|
|
81
|
-
const resolved = resolveNextcloudTalkAccount({ cfg, accountId });
|
|
82
|
-
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
|
83
|
-
await noteNextcloudTalkUserIdHelp(prompter);
|
|
84
|
-
|
|
85
|
-
const parseInput = (value: string) =>
|
|
86
|
-
value
|
|
87
|
-
.split(/[\n,;]+/g)
|
|
88
|
-
.map((entry) => entry.trim().toLowerCase())
|
|
89
|
-
.filter(Boolean);
|
|
90
|
-
|
|
91
|
-
let resolvedIds: string[] = [];
|
|
92
|
-
while (resolvedIds.length === 0) {
|
|
93
|
-
const entry = await prompter.text({
|
|
94
|
-
message: "Nextcloud Talk allowFrom (user id)",
|
|
95
|
-
placeholder: "username",
|
|
96
|
-
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
|
97
|
-
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
98
|
-
});
|
|
99
|
-
resolvedIds = parseInput(String(entry));
|
|
100
|
-
if (resolvedIds.length === 0) {
|
|
101
|
-
await prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk allowlist");
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const merged = [
|
|
106
|
-
...existingAllowFrom.map((item) => String(item).trim().toLowerCase()).filter(Boolean),
|
|
107
|
-
...resolvedIds,
|
|
108
|
-
];
|
|
109
|
-
const unique = mergeAllowFromEntries(undefined, merged);
|
|
110
|
-
|
|
111
|
-
return setNextcloudTalkAccountConfig(cfg, accountId, {
|
|
112
|
-
dmPolicy: "allowlist",
|
|
113
|
-
allowFrom: unique,
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function promptNextcloudTalkAllowFromForAccount(params: {
|
|
118
|
-
cfg: CoreConfig;
|
|
119
|
-
prompter: WizardPrompter;
|
|
120
|
-
accountId?: string;
|
|
121
|
-
}): Promise<CoreConfig> {
|
|
122
|
-
const accountId =
|
|
123
|
-
params.accountId && normalizeAccountId(params.accountId)
|
|
124
|
-
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
|
|
125
|
-
: resolveDefaultNextcloudTalkAccountId(params.cfg);
|
|
126
|
-
return promptNextcloudTalkAllowFrom({
|
|
127
|
-
cfg: params.cfg,
|
|
128
|
-
prompter: params.prompter,
|
|
129
|
-
accountId,
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const dmPolicy: ChannelOnboardingDmPolicy = {
|
|
134
|
-
label: "Nextcloud Talk",
|
|
135
|
-
channel,
|
|
136
|
-
policyKey: "channels.nextcloud-talk.dmPolicy",
|
|
137
|
-
allowFromKey: "channels.nextcloud-talk.allowFrom",
|
|
138
|
-
getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing",
|
|
139
|
-
setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy),
|
|
140
|
-
promptAllowFrom: promptNextcloudTalkAllowFromForAccount as (params: {
|
|
141
|
-
cfg: OpenClawConfig;
|
|
142
|
-
prompter: WizardPrompter;
|
|
143
|
-
accountId?: string | undefined;
|
|
144
|
-
}) => Promise<OpenClawConfig>,
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
148
|
-
channel,
|
|
149
|
-
getStatus: async ({ cfg }) => {
|
|
150
|
-
const configured = listNextcloudTalkAccountIds(cfg as CoreConfig).some((accountId) => {
|
|
151
|
-
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
|
|
152
|
-
return Boolean(account.secret && account.baseUrl);
|
|
153
|
-
});
|
|
154
|
-
return {
|
|
155
|
-
channel,
|
|
156
|
-
configured,
|
|
157
|
-
statusLines: [`Nextcloud Talk: ${configured ? "configured" : "needs setup"}`],
|
|
158
|
-
selectionHint: configured ? "configured" : "self-hosted chat",
|
|
159
|
-
quickstartScore: configured ? 1 : 5,
|
|
160
|
-
};
|
|
161
|
-
},
|
|
162
|
-
configure: async ({
|
|
163
|
-
cfg,
|
|
164
|
-
prompter,
|
|
165
|
-
accountOverrides,
|
|
166
|
-
shouldPromptAccountIds,
|
|
167
|
-
forceAllowFrom,
|
|
168
|
-
}) => {
|
|
169
|
-
const defaultAccountId = resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig);
|
|
170
|
-
const accountId = await resolveAccountIdForConfigure({
|
|
171
|
-
cfg,
|
|
172
|
-
prompter,
|
|
173
|
-
label: "Nextcloud Talk",
|
|
174
|
-
accountOverride: accountOverrides["nextcloud-talk"],
|
|
175
|
-
shouldPromptAccountIds,
|
|
176
|
-
listAccountIds: listNextcloudTalkAccountIds as (cfg: OpenClawConfig) => string[],
|
|
177
|
-
defaultAccountId,
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
let next = cfg as CoreConfig;
|
|
181
|
-
const resolvedAccount = resolveNextcloudTalkAccount({
|
|
182
|
-
cfg: next,
|
|
183
|
-
accountId,
|
|
184
|
-
});
|
|
185
|
-
const accountConfigured = Boolean(resolvedAccount.secret && resolvedAccount.baseUrl);
|
|
186
|
-
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
|
187
|
-
const hasConfigSecret = Boolean(
|
|
188
|
-
hasConfiguredSecretInput(resolvedAccount.config.botSecret) ||
|
|
189
|
-
resolvedAccount.config.botSecretFile,
|
|
190
|
-
);
|
|
191
|
-
|
|
192
|
-
let baseUrl = resolvedAccount.baseUrl;
|
|
193
|
-
if (!baseUrl) {
|
|
194
|
-
baseUrl = String(
|
|
195
|
-
await prompter.text({
|
|
196
|
-
message: "Enter Nextcloud instance URL (e.g., https://cloud.example.com)",
|
|
197
|
-
validate: (value) => {
|
|
198
|
-
const v = String(value ?? "").trim();
|
|
199
|
-
if (!v) {
|
|
200
|
-
return "Required";
|
|
201
|
-
}
|
|
202
|
-
if (!v.startsWith("http://") && !v.startsWith("https://")) {
|
|
203
|
-
return "URL must start with http:// or https://";
|
|
204
|
-
}
|
|
205
|
-
return undefined;
|
|
206
|
-
},
|
|
207
|
-
}),
|
|
208
|
-
).trim();
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const secretStep = await runSingleChannelSecretStep({
|
|
212
|
-
cfg: next,
|
|
213
|
-
prompter,
|
|
214
|
-
providerHint: "nextcloud-talk",
|
|
215
|
-
credentialLabel: "bot secret",
|
|
216
|
-
accountConfigured,
|
|
217
|
-
hasConfigToken: hasConfigSecret,
|
|
218
|
-
allowEnv,
|
|
219
|
-
envValue: process.env.NEXTCLOUD_TALK_BOT_SECRET,
|
|
220
|
-
envPrompt: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?",
|
|
221
|
-
keepPrompt: "Nextcloud Talk bot secret already configured. Keep it?",
|
|
222
|
-
inputPrompt: "Enter Nextcloud Talk bot secret",
|
|
223
|
-
preferredEnvVar: "NEXTCLOUD_TALK_BOT_SECRET",
|
|
224
|
-
onMissingConfigured: async () => await noteNextcloudTalkSecretHelp(prompter),
|
|
225
|
-
applyUseEnv: async (cfg) =>
|
|
226
|
-
setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, {
|
|
227
|
-
baseUrl,
|
|
228
|
-
}),
|
|
229
|
-
applySet: async (cfg, value) =>
|
|
230
|
-
setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, {
|
|
231
|
-
baseUrl,
|
|
232
|
-
botSecret: value,
|
|
233
|
-
}),
|
|
234
|
-
});
|
|
235
|
-
next = secretStep.cfg as CoreConfig;
|
|
236
|
-
|
|
237
|
-
if (secretStep.action === "keep" && baseUrl !== resolvedAccount.baseUrl) {
|
|
238
|
-
next = setNextcloudTalkAccountConfig(next, accountId, {
|
|
239
|
-
baseUrl,
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const existingApiUser = resolvedAccount.config.apiUser?.trim();
|
|
244
|
-
const existingApiPasswordConfigured = Boolean(
|
|
245
|
-
hasConfiguredSecretInput(resolvedAccount.config.apiPassword) ||
|
|
246
|
-
resolvedAccount.config.apiPasswordFile,
|
|
247
|
-
);
|
|
248
|
-
const configureApiCredentials = await prompter.confirm({
|
|
249
|
-
message: "Configure optional Nextcloud Talk API credentials for room lookups?",
|
|
250
|
-
initialValue: Boolean(existingApiUser && existingApiPasswordConfigured),
|
|
251
|
-
});
|
|
252
|
-
if (configureApiCredentials) {
|
|
253
|
-
const apiUser = String(
|
|
254
|
-
await prompter.text({
|
|
255
|
-
message: "Nextcloud Talk API user",
|
|
256
|
-
initialValue: existingApiUser,
|
|
257
|
-
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
258
|
-
}),
|
|
259
|
-
).trim();
|
|
260
|
-
const apiPasswordStep = await runSingleChannelSecretStep({
|
|
261
|
-
cfg: next,
|
|
262
|
-
prompter,
|
|
263
|
-
providerHint: "nextcloud-talk-api",
|
|
264
|
-
credentialLabel: "API password",
|
|
265
|
-
accountConfigured: Boolean(existingApiUser && existingApiPasswordConfigured),
|
|
266
|
-
hasConfigToken: existingApiPasswordConfigured,
|
|
267
|
-
allowEnv: false,
|
|
268
|
-
envPrompt: "",
|
|
269
|
-
keepPrompt: "Nextcloud Talk API password already configured. Keep it?",
|
|
270
|
-
inputPrompt: "Enter Nextcloud Talk API password",
|
|
271
|
-
preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD",
|
|
272
|
-
applySet: async (cfg, value) =>
|
|
273
|
-
setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, {
|
|
274
|
-
apiUser,
|
|
275
|
-
apiPassword: value,
|
|
276
|
-
}),
|
|
277
|
-
});
|
|
278
|
-
next =
|
|
279
|
-
apiPasswordStep.action === "keep"
|
|
280
|
-
? setNextcloudTalkAccountConfig(next, accountId, { apiUser })
|
|
281
|
-
: (apiPasswordStep.cfg as CoreConfig);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
if (forceAllowFrom) {
|
|
285
|
-
next = await promptNextcloudTalkAllowFrom({
|
|
286
|
-
cfg: next,
|
|
287
|
-
prompter,
|
|
288
|
-
accountId,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
return { cfg: next, accountId };
|
|
293
|
-
},
|
|
294
|
-
dmPolicy,
|
|
295
|
-
disable: (cfg) => ({
|
|
296
|
-
...cfg,
|
|
297
|
-
channels: {
|
|
298
|
-
...cfg.channels,
|
|
299
|
-
"nextcloud-talk": { ...cfg.channels?.["nextcloud-talk"], enabled: false },
|
|
300
|
-
},
|
|
301
|
-
}),
|
|
302
|
-
};
|