@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/core.test.ts
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
+
import os from "node:os";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
5
|
+
import {
|
|
6
|
+
looksLikeNextcloudTalkTargetId,
|
|
7
|
+
normalizeNextcloudTalkMessagingTarget,
|
|
8
|
+
stripNextcloudTalkTargetPrefix,
|
|
9
|
+
} from "./normalize.js";
|
|
10
|
+
import { resolveNextcloudTalkAllowlistMatch, resolveNextcloudTalkGroupAllow } from "./policy.js";
|
|
11
|
+
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
|
|
12
|
+
import { resolveNextcloudTalkOutboundSessionRoute } from "./session-route.js";
|
|
13
|
+
import {
|
|
14
|
+
extractNextcloudTalkHeaders,
|
|
15
|
+
generateNextcloudTalkSignature,
|
|
16
|
+
verifyNextcloudTalkSignature,
|
|
17
|
+
} from "./signature.js";
|
|
18
|
+
|
|
19
|
+
const tempDirs: string[] = [];
|
|
20
|
+
|
|
21
|
+
afterEach(async () => {
|
|
22
|
+
while (tempDirs.length > 0) {
|
|
23
|
+
const dir = tempDirs.pop();
|
|
24
|
+
if (dir) {
|
|
25
|
+
await rm(dir, { recursive: true, force: true });
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
async function makeTempDir(): Promise<string> {
|
|
31
|
+
const dir = await mkdtemp(path.join(os.tmpdir(), "nextcloud-talk-replay-"));
|
|
32
|
+
tempDirs.push(dir);
|
|
33
|
+
return dir;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
describe("nextcloud talk core", () => {
|
|
37
|
+
it("builds an outbound session route for normalized room targets", () => {
|
|
38
|
+
const route = resolveNextcloudTalkOutboundSessionRoute({
|
|
39
|
+
cfg: {},
|
|
40
|
+
agentId: "main",
|
|
41
|
+
accountId: "acct-1",
|
|
42
|
+
target: "nextcloud-talk:room-123",
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
expect(route).toMatchObject({
|
|
46
|
+
peer: {
|
|
47
|
+
kind: "group",
|
|
48
|
+
id: "room-123",
|
|
49
|
+
},
|
|
50
|
+
from: "nextcloud-talk:room:room-123",
|
|
51
|
+
to: "nextcloud-talk:room-123",
|
|
52
|
+
});
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("returns null when the target cannot be normalized to a room id", () => {
|
|
56
|
+
expect(
|
|
57
|
+
resolveNextcloudTalkOutboundSessionRoute({
|
|
58
|
+
cfg: {},
|
|
59
|
+
agentId: "main",
|
|
60
|
+
accountId: "acct-1",
|
|
61
|
+
target: "",
|
|
62
|
+
}),
|
|
63
|
+
).toBeNull();
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("normalizes and recognizes supported room target formats", () => {
|
|
67
|
+
expect(stripNextcloudTalkTargetPrefix(" room:abc123 ")).toBe("abc123");
|
|
68
|
+
expect(stripNextcloudTalkTargetPrefix("nextcloud-talk:room:AbC123")).toBe("AbC123");
|
|
69
|
+
expect(stripNextcloudTalkTargetPrefix("nc-talk:room:ops")).toBe("ops");
|
|
70
|
+
expect(stripNextcloudTalkTargetPrefix("nc:room:ops")).toBe("ops");
|
|
71
|
+
expect(stripNextcloudTalkTargetPrefix("room: ")).toBeUndefined();
|
|
72
|
+
|
|
73
|
+
expect(normalizeNextcloudTalkMessagingTarget("room:AbC123")).toBe("nextcloud-talk:abc123");
|
|
74
|
+
expect(normalizeNextcloudTalkMessagingTarget("nc-talk:room:Ops")).toBe("nextcloud-talk:ops");
|
|
75
|
+
|
|
76
|
+
expect(looksLikeNextcloudTalkTargetId("nextcloud-talk:room:abc12345")).toBe(true);
|
|
77
|
+
expect(looksLikeNextcloudTalkTargetId("nc:opsroom1")).toBe(true);
|
|
78
|
+
expect(looksLikeNextcloudTalkTargetId("abc12345")).toBe(true);
|
|
79
|
+
expect(looksLikeNextcloudTalkTargetId("")).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("verifies generated signatures and extracts normalized headers", () => {
|
|
83
|
+
const body = JSON.stringify({ hello: "world" });
|
|
84
|
+
const generated = generateNextcloudTalkSignature({
|
|
85
|
+
body,
|
|
86
|
+
secret: "secret-123",
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
expect(generated.random).toMatch(/^[0-9a-f]{64}$/);
|
|
90
|
+
expect(generated.signature).toMatch(/^[0-9a-f]{64}$/);
|
|
91
|
+
expect(
|
|
92
|
+
verifyNextcloudTalkSignature({
|
|
93
|
+
signature: generated.signature,
|
|
94
|
+
random: generated.random,
|
|
95
|
+
body,
|
|
96
|
+
secret: "secret-123",
|
|
97
|
+
}),
|
|
98
|
+
).toBe(true);
|
|
99
|
+
expect(
|
|
100
|
+
verifyNextcloudTalkSignature({
|
|
101
|
+
signature: "",
|
|
102
|
+
random: "abc",
|
|
103
|
+
body: "body",
|
|
104
|
+
secret: "secret",
|
|
105
|
+
}),
|
|
106
|
+
).toBe(false);
|
|
107
|
+
expect(
|
|
108
|
+
verifyNextcloudTalkSignature({
|
|
109
|
+
signature: "deadbeef",
|
|
110
|
+
random: "abc",
|
|
111
|
+
body: "body",
|
|
112
|
+
secret: "secret",
|
|
113
|
+
}),
|
|
114
|
+
).toBe(false);
|
|
115
|
+
|
|
116
|
+
expect(
|
|
117
|
+
extractNextcloudTalkHeaders({
|
|
118
|
+
"x-nextcloud-talk-signature": "sig",
|
|
119
|
+
"x-nextcloud-talk-random": "rand",
|
|
120
|
+
"x-nextcloud-talk-backend": "backend",
|
|
121
|
+
}),
|
|
122
|
+
).toEqual({
|
|
123
|
+
signature: "sig",
|
|
124
|
+
random: "rand",
|
|
125
|
+
backend: "backend",
|
|
126
|
+
});
|
|
127
|
+
expect(
|
|
128
|
+
extractNextcloudTalkHeaders({
|
|
129
|
+
"X-Nextcloud-Talk-Signature": "sig",
|
|
130
|
+
}),
|
|
131
|
+
).toBeNull();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("rejects tampered bodies, wrong secrets, and tampered signatures", () => {
|
|
135
|
+
const body = JSON.stringify({ hello: "world" });
|
|
136
|
+
const generated = generateNextcloudTalkSignature({
|
|
137
|
+
body,
|
|
138
|
+
secret: "secret-123",
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(
|
|
142
|
+
verifyNextcloudTalkSignature({
|
|
143
|
+
signature: generated.signature,
|
|
144
|
+
random: generated.random,
|
|
145
|
+
body: JSON.stringify({ hello: "tampered" }),
|
|
146
|
+
secret: "secret-123",
|
|
147
|
+
}),
|
|
148
|
+
).toBe(false);
|
|
149
|
+
expect(
|
|
150
|
+
verifyNextcloudTalkSignature({
|
|
151
|
+
signature: generated.signature,
|
|
152
|
+
random: generated.random,
|
|
153
|
+
body,
|
|
154
|
+
secret: "wrong-secret",
|
|
155
|
+
}),
|
|
156
|
+
).toBe(false);
|
|
157
|
+
expect(
|
|
158
|
+
verifyNextcloudTalkSignature({
|
|
159
|
+
signature: "a".repeat(generated.signature.length),
|
|
160
|
+
random: generated.random,
|
|
161
|
+
body,
|
|
162
|
+
secret: "secret-123",
|
|
163
|
+
}),
|
|
164
|
+
).toBe(false);
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("takes the first value from array-backed headers", () => {
|
|
168
|
+
expect(
|
|
169
|
+
extractNextcloudTalkHeaders({
|
|
170
|
+
"x-nextcloud-talk-signature": ["sig1", "sig2"],
|
|
171
|
+
"x-nextcloud-talk-random": ["rand1", "rand2"],
|
|
172
|
+
"x-nextcloud-talk-backend": ["backend1", "backend2"],
|
|
173
|
+
}),
|
|
174
|
+
).toEqual({
|
|
175
|
+
signature: "sig1",
|
|
176
|
+
random: "rand1",
|
|
177
|
+
backend: "backend1",
|
|
178
|
+
});
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("still runs timingSafeEqual when the supplied signature length mismatches", async () => {
|
|
182
|
+
const timingSafeEqualMock = vi.fn();
|
|
183
|
+
|
|
184
|
+
vi.resetModules();
|
|
185
|
+
vi.doMock("node:crypto", async (importOriginal) => {
|
|
186
|
+
const actual = await importOriginal<typeof import("node:crypto")>();
|
|
187
|
+
return {
|
|
188
|
+
...actual,
|
|
189
|
+
timingSafeEqual: vi.fn((left: NodeJS.ArrayBufferView, right: NodeJS.ArrayBufferView) => {
|
|
190
|
+
timingSafeEqualMock(left, right);
|
|
191
|
+
return actual.timingSafeEqual(left, right);
|
|
192
|
+
}),
|
|
193
|
+
};
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
const { generateNextcloudTalkSignature, verifyNextcloudTalkSignature } =
|
|
197
|
+
await import("./signature.js");
|
|
198
|
+
const body = JSON.stringify({ hello: "world" });
|
|
199
|
+
const generated = generateNextcloudTalkSignature({
|
|
200
|
+
body,
|
|
201
|
+
secret: "secret-123",
|
|
202
|
+
});
|
|
203
|
+
const shortSignature = generated.signature.slice(0, 12);
|
|
204
|
+
|
|
205
|
+
expect(
|
|
206
|
+
verifyNextcloudTalkSignature({
|
|
207
|
+
signature: shortSignature,
|
|
208
|
+
random: generated.random,
|
|
209
|
+
body,
|
|
210
|
+
secret: "secret-123",
|
|
211
|
+
}),
|
|
212
|
+
).toBe(false);
|
|
213
|
+
|
|
214
|
+
expect(timingSafeEqualMock).toHaveBeenCalledOnce();
|
|
215
|
+
const [leftBuffer, rightBuffer] = timingSafeEqualMock.mock.calls[0] ?? [];
|
|
216
|
+
expect(Buffer.isBuffer(leftBuffer)).toBe(true);
|
|
217
|
+
expect(Buffer.isBuffer(rightBuffer)).toBe(true);
|
|
218
|
+
if (!Buffer.isBuffer(leftBuffer) || !Buffer.isBuffer(rightBuffer)) {
|
|
219
|
+
throw new TypeError("Expected timingSafeEqual to receive Buffer arguments");
|
|
220
|
+
}
|
|
221
|
+
expect(leftBuffer).toHaveLength(rightBuffer.length);
|
|
222
|
+
|
|
223
|
+
vi.doUnmock("node:crypto");
|
|
224
|
+
vi.resetModules();
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("persists replay decisions across guard instances and scopes account namespaces", async () => {
|
|
228
|
+
const stateDir = await makeTempDir();
|
|
229
|
+
|
|
230
|
+
const firstGuard = createNextcloudTalkReplayGuard({ stateDir });
|
|
231
|
+
const firstAttempt = await firstGuard.shouldProcessMessage({
|
|
232
|
+
accountId: "account-a",
|
|
233
|
+
roomToken: "room-1",
|
|
234
|
+
messageId: "msg-1",
|
|
235
|
+
});
|
|
236
|
+
const replayAttempt = await firstGuard.shouldProcessMessage({
|
|
237
|
+
accountId: "account-a",
|
|
238
|
+
roomToken: "room-1",
|
|
239
|
+
messageId: "msg-1",
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
const secondGuard = createNextcloudTalkReplayGuard({ stateDir });
|
|
243
|
+
const restartReplayAttempt = await secondGuard.shouldProcessMessage({
|
|
244
|
+
accountId: "account-a",
|
|
245
|
+
roomToken: "room-1",
|
|
246
|
+
messageId: "msg-1",
|
|
247
|
+
});
|
|
248
|
+
const otherAccountFirstAttempt = await secondGuard.shouldProcessMessage({
|
|
249
|
+
accountId: "account-b",
|
|
250
|
+
roomToken: "room-1",
|
|
251
|
+
messageId: "msg-1",
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
expect(firstAttempt).toBe(true);
|
|
255
|
+
expect(replayAttempt).toBe(false);
|
|
256
|
+
expect(restartReplayAttempt).toBe(false);
|
|
257
|
+
expect(otherAccountFirstAttempt).toBe(true);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
it("releases in-flight replay claims when processing fails", async () => {
|
|
261
|
+
const guard = createNextcloudTalkReplayGuard({});
|
|
262
|
+
|
|
263
|
+
const firstClaim = await guard.claimMessage({
|
|
264
|
+
accountId: "account-a",
|
|
265
|
+
roomToken: "room-1",
|
|
266
|
+
messageId: "msg-claim",
|
|
267
|
+
});
|
|
268
|
+
const secondClaim = await guard.claimMessage({
|
|
269
|
+
accountId: "account-a",
|
|
270
|
+
roomToken: "room-1",
|
|
271
|
+
messageId: "msg-claim",
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
expect(firstClaim).toBe("claimed");
|
|
275
|
+
expect(secondClaim).toBe("inflight");
|
|
276
|
+
|
|
277
|
+
guard.releaseMessage({
|
|
278
|
+
accountId: "account-a",
|
|
279
|
+
roomToken: "room-1",
|
|
280
|
+
messageId: "msg-claim",
|
|
281
|
+
error: new Error("transient"),
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
const retryClaim = await guard.claimMessage({
|
|
285
|
+
accountId: "account-a",
|
|
286
|
+
roomToken: "room-1",
|
|
287
|
+
messageId: "msg-claim",
|
|
288
|
+
});
|
|
289
|
+
expect(retryClaim).toBe("claimed");
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
it("resolves allowlist matches and group policy decisions", () => {
|
|
293
|
+
expect(
|
|
294
|
+
resolveNextcloudTalkAllowlistMatch({
|
|
295
|
+
allowFrom: ["*"],
|
|
296
|
+
senderId: "user-id",
|
|
297
|
+
}).allowed,
|
|
298
|
+
).toBe(true);
|
|
299
|
+
expect(
|
|
300
|
+
resolveNextcloudTalkAllowlistMatch({
|
|
301
|
+
allowFrom: ["nc:User-Id"],
|
|
302
|
+
senderId: "user-id",
|
|
303
|
+
}),
|
|
304
|
+
).toEqual({ allowed: true, matchKey: "user-id", matchSource: "id" });
|
|
305
|
+
expect(
|
|
306
|
+
resolveNextcloudTalkAllowlistMatch({
|
|
307
|
+
allowFrom: ["allowed"],
|
|
308
|
+
senderId: "other",
|
|
309
|
+
}).allowed,
|
|
310
|
+
).toBe(false);
|
|
311
|
+
|
|
312
|
+
expect(
|
|
313
|
+
resolveNextcloudTalkGroupAllow({
|
|
314
|
+
groupPolicy: "disabled",
|
|
315
|
+
outerAllowFrom: ["owner"],
|
|
316
|
+
innerAllowFrom: ["room-user"],
|
|
317
|
+
senderId: "owner",
|
|
318
|
+
}),
|
|
319
|
+
).toEqual({
|
|
320
|
+
allowed: false,
|
|
321
|
+
outerMatch: { allowed: false },
|
|
322
|
+
innerMatch: { allowed: false },
|
|
323
|
+
});
|
|
324
|
+
expect(
|
|
325
|
+
resolveNextcloudTalkGroupAllow({
|
|
326
|
+
groupPolicy: "open",
|
|
327
|
+
outerAllowFrom: [],
|
|
328
|
+
innerAllowFrom: [],
|
|
329
|
+
senderId: "owner",
|
|
330
|
+
}),
|
|
331
|
+
).toEqual({
|
|
332
|
+
allowed: true,
|
|
333
|
+
outerMatch: { allowed: true },
|
|
334
|
+
innerMatch: { allowed: true },
|
|
335
|
+
});
|
|
336
|
+
expect(
|
|
337
|
+
resolveNextcloudTalkGroupAllow({
|
|
338
|
+
groupPolicy: "allowlist",
|
|
339
|
+
outerAllowFrom: [],
|
|
340
|
+
innerAllowFrom: [],
|
|
341
|
+
senderId: "owner",
|
|
342
|
+
}),
|
|
343
|
+
).toEqual({
|
|
344
|
+
allowed: false,
|
|
345
|
+
outerMatch: { allowed: false },
|
|
346
|
+
innerMatch: { allowed: false },
|
|
347
|
+
});
|
|
348
|
+
expect(
|
|
349
|
+
resolveNextcloudTalkGroupAllow({
|
|
350
|
+
groupPolicy: "allowlist",
|
|
351
|
+
outerAllowFrom: [],
|
|
352
|
+
innerAllowFrom: ["room-user"],
|
|
353
|
+
senderId: "room-user",
|
|
354
|
+
}),
|
|
355
|
+
).toEqual({
|
|
356
|
+
allowed: true,
|
|
357
|
+
outerMatch: { allowed: false },
|
|
358
|
+
innerMatch: { allowed: true, matchKey: "room-user", matchSource: "id" },
|
|
359
|
+
});
|
|
360
|
+
expect(
|
|
361
|
+
resolveNextcloudTalkGroupAllow({
|
|
362
|
+
groupPolicy: "allowlist",
|
|
363
|
+
outerAllowFrom: ["team-owner"],
|
|
364
|
+
innerAllowFrom: ["room-user"],
|
|
365
|
+
senderId: "room-user",
|
|
366
|
+
}),
|
|
367
|
+
).toEqual({
|
|
368
|
+
allowed: false,
|
|
369
|
+
outerMatch: { allowed: false },
|
|
370
|
+
innerMatch: { allowed: true, matchKey: "room-user", matchSource: "id" },
|
|
371
|
+
});
|
|
372
|
+
expect(
|
|
373
|
+
resolveNextcloudTalkGroupAllow({
|
|
374
|
+
groupPolicy: "allowlist",
|
|
375
|
+
outerAllowFrom: ["team-owner"],
|
|
376
|
+
innerAllowFrom: ["room-user"],
|
|
377
|
+
senderId: "team-owner",
|
|
378
|
+
}),
|
|
379
|
+
).toEqual({
|
|
380
|
+
allowed: false,
|
|
381
|
+
outerMatch: { allowed: true, matchKey: "team-owner", matchSource: "id" },
|
|
382
|
+
innerMatch: { allowed: false },
|
|
383
|
+
});
|
|
384
|
+
expect(
|
|
385
|
+
resolveNextcloudTalkGroupAllow({
|
|
386
|
+
groupPolicy: "allowlist",
|
|
387
|
+
outerAllowFrom: ["shared-user"],
|
|
388
|
+
innerAllowFrom: ["shared-user"],
|
|
389
|
+
senderId: "shared-user",
|
|
390
|
+
}),
|
|
391
|
+
).toEqual({
|
|
392
|
+
allowed: true,
|
|
393
|
+
outerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" },
|
|
394
|
+
innerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" },
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
});
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { createLegacyPrivateNetworkDoctorContract } from "openclaw/plugin-sdk/ssrf-runtime";
|
|
2
|
+
|
|
3
|
+
const contract = createLegacyPrivateNetworkDoctorContract({
|
|
4
|
+
channelKey: "nextcloud-talk",
|
|
5
|
+
});
|
|
6
|
+
|
|
7
|
+
export const legacyConfigRules = contract.legacyConfigRules;
|
|
8
|
+
|
|
9
|
+
export const normalizeCompatibilityConfig = contract.normalizeCompatibilityConfig;
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { nextcloudTalkDoctor } from "./doctor.js";
|
|
3
|
+
|
|
4
|
+
describe("nextcloud-talk doctor", () => {
|
|
5
|
+
it("normalizes legacy private-network aliases", () => {
|
|
6
|
+
const normalize = nextcloudTalkDoctor.normalizeCompatibilityConfig;
|
|
7
|
+
expect(normalize).toBeDefined();
|
|
8
|
+
if (!normalize) {
|
|
9
|
+
return;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const result = normalize({
|
|
13
|
+
cfg: {
|
|
14
|
+
channels: {
|
|
15
|
+
"nextcloud-talk": {
|
|
16
|
+
allowPrivateNetwork: true,
|
|
17
|
+
accounts: {
|
|
18
|
+
work: {
|
|
19
|
+
allowPrivateNetwork: false,
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
},
|
|
23
|
+
},
|
|
24
|
+
} as never,
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
expect(result.config.channels?.["nextcloud-talk"]?.network).toEqual({
|
|
28
|
+
dangerouslyAllowPrivateNetwork: true,
|
|
29
|
+
});
|
|
30
|
+
expect(
|
|
31
|
+
(
|
|
32
|
+
result.config.channels?.["nextcloud-talk"]?.accounts?.work as
|
|
33
|
+
| { network?: Record<string, unknown> }
|
|
34
|
+
| undefined
|
|
35
|
+
)?.network,
|
|
36
|
+
).toEqual({
|
|
37
|
+
dangerouslyAllowPrivateNetwork: false,
|
|
38
|
+
});
|
|
39
|
+
});
|
|
40
|
+
});
|
package/src/doctor.ts
ADDED
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import type { ChannelDoctorAdapter } from "openclaw/plugin-sdk/channel-contract";
|
|
2
|
+
import {
|
|
3
|
+
legacyConfigRules as NEXTCLOUD_TALK_LEGACY_CONFIG_RULES,
|
|
4
|
+
normalizeCompatibilityConfig as normalizeNextcloudTalkCompatibilityConfig,
|
|
5
|
+
} from "./doctor-contract.js";
|
|
6
|
+
|
|
7
|
+
export const nextcloudTalkDoctor: ChannelDoctorAdapter = {
|
|
8
|
+
legacyConfigRules: NEXTCLOUD_TALK_LEGACY_CONFIG_RULES,
|
|
9
|
+
normalizeCompatibilityConfig: normalizeNextcloudTalkCompatibilityConfig,
|
|
10
|
+
};
|
package/src/gateway.ts
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { createAccountStatusSink } from "openclaw/plugin-sdk/channel-lifecycle";
|
|
2
|
+
import { runStoppablePassiveMonitor } from "openclaw/plugin-sdk/extension-shared";
|
|
3
|
+
import { resolveNextcloudTalkAccount, type ResolvedNextcloudTalkAccount } from "./accounts.js";
|
|
4
|
+
import {
|
|
5
|
+
clearAccountEntryFields,
|
|
6
|
+
DEFAULT_ACCOUNT_ID,
|
|
7
|
+
type ChannelPlugin,
|
|
8
|
+
type OpenClawConfig,
|
|
9
|
+
} from "./channel-api.js";
|
|
10
|
+
import { monitorNextcloudTalkProvider } from "./monitor-runtime.js";
|
|
11
|
+
import { getNextcloudTalkRuntime } from "./runtime.js";
|
|
12
|
+
import type { CoreConfig } from "./types.js";
|
|
13
|
+
|
|
14
|
+
export const nextcloudTalkGatewayAdapter: NonNullable<
|
|
15
|
+
ChannelPlugin<ResolvedNextcloudTalkAccount>["gateway"]
|
|
16
|
+
> = {
|
|
17
|
+
startAccount: async (ctx) => {
|
|
18
|
+
const account = ctx.account;
|
|
19
|
+
if (!account.secret || !account.baseUrl) {
|
|
20
|
+
throw new Error(
|
|
21
|
+
`Nextcloud Talk not configured for account "${account.accountId}" (missing secret or baseUrl)`,
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
ctx.log?.info(`[${account.accountId}] starting Nextcloud Talk webhook server`);
|
|
26
|
+
|
|
27
|
+
const statusSink = createAccountStatusSink({
|
|
28
|
+
accountId: ctx.accountId,
|
|
29
|
+
setStatus: ctx.setStatus,
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
await runStoppablePassiveMonitor({
|
|
33
|
+
abortSignal: ctx.abortSignal,
|
|
34
|
+
start: async () =>
|
|
35
|
+
await monitorNextcloudTalkProvider({
|
|
36
|
+
accountId: account.accountId,
|
|
37
|
+
config: ctx.cfg as CoreConfig,
|
|
38
|
+
runtime: ctx.runtime,
|
|
39
|
+
abortSignal: ctx.abortSignal,
|
|
40
|
+
statusSink,
|
|
41
|
+
}),
|
|
42
|
+
});
|
|
43
|
+
},
|
|
44
|
+
logoutAccount: async ({ accountId, cfg }) => {
|
|
45
|
+
const nextCfg = { ...cfg } as OpenClawConfig;
|
|
46
|
+
const nextSection = cfg.channels?.["nextcloud-talk"]
|
|
47
|
+
? { ...cfg.channels["nextcloud-talk"] }
|
|
48
|
+
: undefined;
|
|
49
|
+
let cleared = false;
|
|
50
|
+
let changed = false;
|
|
51
|
+
|
|
52
|
+
if (nextSection) {
|
|
53
|
+
if (accountId === DEFAULT_ACCOUNT_ID && nextSection.botSecret) {
|
|
54
|
+
delete nextSection.botSecret;
|
|
55
|
+
cleared = true;
|
|
56
|
+
changed = true;
|
|
57
|
+
}
|
|
58
|
+
const accountCleanup = clearAccountEntryFields({
|
|
59
|
+
accounts: nextSection.accounts as Record<string, object> | undefined,
|
|
60
|
+
accountId,
|
|
61
|
+
fields: ["botSecret"],
|
|
62
|
+
});
|
|
63
|
+
if (accountCleanup.changed) {
|
|
64
|
+
changed = true;
|
|
65
|
+
if (accountCleanup.cleared) {
|
|
66
|
+
cleared = true;
|
|
67
|
+
}
|
|
68
|
+
if (accountCleanup.nextAccounts) {
|
|
69
|
+
nextSection.accounts = accountCleanup.nextAccounts as Record<string, unknown>;
|
|
70
|
+
} else {
|
|
71
|
+
delete nextSection.accounts;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (changed) {
|
|
77
|
+
if (nextSection && Object.keys(nextSection).length > 0) {
|
|
78
|
+
nextCfg.channels = { ...nextCfg.channels, "nextcloud-talk": nextSection };
|
|
79
|
+
} else {
|
|
80
|
+
const nextChannels = { ...nextCfg.channels } as Record<string, unknown>;
|
|
81
|
+
delete nextChannels["nextcloud-talk"];
|
|
82
|
+
if (Object.keys(nextChannels).length > 0) {
|
|
83
|
+
nextCfg.channels = nextChannels as OpenClawConfig["channels"];
|
|
84
|
+
} else {
|
|
85
|
+
delete nextCfg.channels;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const resolved = resolveNextcloudTalkAccount({
|
|
91
|
+
cfg: changed ? (nextCfg as CoreConfig) : (cfg as CoreConfig),
|
|
92
|
+
accountId,
|
|
93
|
+
});
|
|
94
|
+
const loggedOut = resolved.secretSource === "none";
|
|
95
|
+
|
|
96
|
+
if (changed) {
|
|
97
|
+
await getNextcloudTalkRuntime().config.replaceConfigFile({
|
|
98
|
+
nextConfig: nextCfg,
|
|
99
|
+
afterWrite: { mode: "auto" },
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
return {
|
|
104
|
+
cleared,
|
|
105
|
+
envSecret: Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim()),
|
|
106
|
+
loggedOut,
|
|
107
|
+
};
|
|
108
|
+
},
|
|
109
|
+
};
|
|
@@ -1,32 +1,46 @@
|
|
|
1
|
-
import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/nextcloud-talk";
|
|
2
1
|
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js";
|
|
3
3
|
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
|
4
4
|
import { handleNextcloudTalkInbound } from "./inbound.js";
|
|
5
5
|
import { setNextcloudTalkRuntime } from "./runtime.js";
|
|
6
6
|
import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
|
|
7
7
|
|
|
8
|
+
function installInboundAuthzRuntime(params: {
|
|
9
|
+
readAllowFromStore: () => Promise<string[]>;
|
|
10
|
+
buildMentionRegexes: () => RegExp[];
|
|
11
|
+
}) {
|
|
12
|
+
setNextcloudTalkRuntime({
|
|
13
|
+
channel: {
|
|
14
|
+
pairing: {
|
|
15
|
+
readAllowFromStore: params.readAllowFromStore,
|
|
16
|
+
},
|
|
17
|
+
commands: {
|
|
18
|
+
shouldHandleTextCommands: () => false,
|
|
19
|
+
},
|
|
20
|
+
text: {
|
|
21
|
+
hasControlCommand: () => false,
|
|
22
|
+
},
|
|
23
|
+
mentions: {
|
|
24
|
+
buildMentionRegexes: params.buildMentionRegexes,
|
|
25
|
+
matchesMentionPatterns: () => false,
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
} as unknown as PluginRuntime);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function createTestRuntimeEnv(): RuntimeEnv {
|
|
32
|
+
return {
|
|
33
|
+
log: vi.fn(),
|
|
34
|
+
error: vi.fn(),
|
|
35
|
+
} as unknown as RuntimeEnv;
|
|
36
|
+
}
|
|
37
|
+
|
|
8
38
|
describe("nextcloud-talk inbound authz", () => {
|
|
9
39
|
it("does not treat DM pairing-store entries as group allowlist entries", async () => {
|
|
10
40
|
const readAllowFromStore = vi.fn(async () => ["attacker"]);
|
|
11
41
|
const buildMentionRegexes = vi.fn(() => [/@openclaw/i]);
|
|
12
42
|
|
|
13
|
-
|
|
14
|
-
channel: {
|
|
15
|
-
pairing: {
|
|
16
|
-
readAllowFromStore,
|
|
17
|
-
},
|
|
18
|
-
commands: {
|
|
19
|
-
shouldHandleTextCommands: () => false,
|
|
20
|
-
},
|
|
21
|
-
text: {
|
|
22
|
-
hasControlCommand: () => false,
|
|
23
|
-
},
|
|
24
|
-
mentions: {
|
|
25
|
-
buildMentionRegexes,
|
|
26
|
-
matchesMentionPatterns: () => false,
|
|
27
|
-
},
|
|
28
|
-
},
|
|
29
|
-
} as unknown as PluginRuntime);
|
|
43
|
+
installInboundAuthzRuntime({ readAllowFromStore, buildMentionRegexes });
|
|
30
44
|
|
|
31
45
|
const message: NextcloudTalkInboundMessage = {
|
|
32
46
|
messageId: "m-1",
|
|
@@ -69,10 +83,7 @@ describe("nextcloud-talk inbound authz", () => {
|
|
|
69
83
|
message,
|
|
70
84
|
account,
|
|
71
85
|
config,
|
|
72
|
-
runtime:
|
|
73
|
-
log: vi.fn(),
|
|
74
|
-
error: vi.fn(),
|
|
75
|
-
} as unknown as RuntimeEnv,
|
|
86
|
+
runtime: createTestRuntimeEnv(),
|
|
76
87
|
});
|
|
77
88
|
|
|
78
89
|
expect(readAllowFromStore).toHaveBeenCalledWith({
|
|
@@ -81,4 +92,58 @@ describe("nextcloud-talk inbound authz", () => {
|
|
|
81
92
|
});
|
|
82
93
|
expect(buildMentionRegexes).not.toHaveBeenCalled();
|
|
83
94
|
});
|
|
95
|
+
|
|
96
|
+
it("matches group rooms by token instead of colliding room names", async () => {
|
|
97
|
+
const readAllowFromStore = vi.fn(async () => []);
|
|
98
|
+
const buildMentionRegexes = vi.fn(() => [/@openclaw/i]);
|
|
99
|
+
|
|
100
|
+
installInboundAuthzRuntime({ readAllowFromStore, buildMentionRegexes });
|
|
101
|
+
|
|
102
|
+
const message: NextcloudTalkInboundMessage = {
|
|
103
|
+
messageId: "m-2",
|
|
104
|
+
roomToken: "room-attacker",
|
|
105
|
+
roomName: "Room Trusted",
|
|
106
|
+
senderId: "trusted-user",
|
|
107
|
+
senderName: "Trusted User",
|
|
108
|
+
text: "hello",
|
|
109
|
+
mediaType: "text/plain",
|
|
110
|
+
timestamp: Date.now(),
|
|
111
|
+
isGroupChat: true,
|
|
112
|
+
};
|
|
113
|
+
|
|
114
|
+
const account: ResolvedNextcloudTalkAccount = {
|
|
115
|
+
accountId: "default",
|
|
116
|
+
enabled: true,
|
|
117
|
+
baseUrl: "",
|
|
118
|
+
secret: "",
|
|
119
|
+
secretSource: "none",
|
|
120
|
+
config: {
|
|
121
|
+
dmPolicy: "pairing",
|
|
122
|
+
allowFrom: [],
|
|
123
|
+
groupPolicy: "allowlist",
|
|
124
|
+
groupAllowFrom: ["trusted-user"],
|
|
125
|
+
rooms: {
|
|
126
|
+
"room-trusted": {
|
|
127
|
+
enabled: true,
|
|
128
|
+
},
|
|
129
|
+
},
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
await handleNextcloudTalkInbound({
|
|
134
|
+
message,
|
|
135
|
+
account,
|
|
136
|
+
config: {
|
|
137
|
+
channels: {
|
|
138
|
+
"nextcloud-talk": {
|
|
139
|
+
groupPolicy: "allowlist",
|
|
140
|
+
groupAllowFrom: ["trusted-user"],
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
runtime: createTestRuntimeEnv(),
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
expect(buildMentionRegexes).not.toHaveBeenCalled();
|
|
148
|
+
});
|
|
84
149
|
});
|