@openclaw/nextcloud-talk 2026.3.12 → 2026.5.1-beta.2

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.
Files changed (60) hide show
  1. package/api.ts +1 -0
  2. package/channel-plugin-api.ts +1 -0
  3. package/contract-api.ts +4 -0
  4. package/doctor-contract-api.ts +1 -0
  5. package/index.ts +15 -12
  6. package/openclaw.plugin.json +804 -1
  7. package/package.json +31 -4
  8. package/runtime-api.ts +33 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/setup-entry.ts +13 -0
  11. package/src/accounts.ts +25 -42
  12. package/src/approval-auth.test.ts +17 -0
  13. package/src/approval-auth.ts +27 -0
  14. package/src/channel-api.ts +5 -0
  15. package/src/channel.adapters.ts +52 -0
  16. package/src/channel.core.test.ts +75 -0
  17. package/src/channel.lifecycle.test.ts +81 -0
  18. package/src/channel.ts +157 -388
  19. package/src/config-schema.ts +27 -22
  20. package/src/core.test.ts +397 -0
  21. package/src/doctor-contract.ts +9 -0
  22. package/src/doctor.test.ts +40 -0
  23. package/src/doctor.ts +10 -0
  24. package/src/gateway.ts +109 -0
  25. package/src/inbound.authz.test.ts +87 -22
  26. package/src/inbound.behavior.test.ts +202 -0
  27. package/src/inbound.ts +28 -26
  28. package/src/monitor-runtime.ts +138 -0
  29. package/src/monitor.replay.test.ts +238 -0
  30. package/src/monitor.test-harness.ts +2 -2
  31. package/src/monitor.ts +125 -155
  32. package/src/normalize.ts +7 -2
  33. package/src/policy.ts +23 -31
  34. package/src/replay-guard.ts +74 -11
  35. package/src/room-info.test.ts +116 -0
  36. package/src/room-info.ts +11 -3
  37. package/src/runtime.ts +6 -3
  38. package/src/secret-contract.ts +103 -0
  39. package/src/secret-input.ts +1 -10
  40. package/src/send.cfg-threading.test.ts +153 -0
  41. package/src/send.runtime.ts +8 -0
  42. package/src/send.ts +126 -106
  43. package/src/session-route.ts +40 -0
  44. package/src/setup-core.ts +248 -0
  45. package/src/setup-surface.ts +190 -0
  46. package/src/setup.test.ts +422 -0
  47. package/src/signature.ts +20 -10
  48. package/src/types.ts +19 -16
  49. package/tsconfig.json +16 -0
  50. package/src/accounts.test.ts +0 -30
  51. package/src/channel.startup.test.ts +0 -83
  52. package/src/config-schema.test.ts +0 -36
  53. package/src/format.ts +0 -79
  54. package/src/monitor.auth-order.test.ts +0 -28
  55. package/src/monitor.backend.test.ts +0 -27
  56. package/src/monitor.read-body.test.ts +0 -16
  57. package/src/onboarding.ts +0 -302
  58. package/src/policy.test.ts +0 -138
  59. package/src/replay-guard.test.ts +0 -70
  60. package/src/send.test.ts +0 -104
@@ -0,0 +1,190 @@
1
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
2
+ import { hasConfiguredSecretInput } from "openclaw/plugin-sdk/secret-input";
3
+ import {
4
+ createStandardChannelSetupStatus,
5
+ formatDocsLink,
6
+ setSetupChannelEnabled,
7
+ type ChannelSetupWizard,
8
+ } from "openclaw/plugin-sdk/setup";
9
+ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
10
+ import { resolveNextcloudTalkAccount } from "./accounts.js";
11
+ import {
12
+ clearNextcloudTalkAccountFields,
13
+ nextcloudTalkDmPolicy,
14
+ normalizeNextcloudTalkBaseUrl,
15
+ setNextcloudTalkAccountConfig,
16
+ validateNextcloudTalkBaseUrl,
17
+ } from "./setup-core.js";
18
+ import type { CoreConfig } from "./types.js";
19
+
20
+ const channel = "nextcloud-talk" as const;
21
+ const CONFIGURE_API_FLAG = "__nextcloudTalkConfigureApiCredentials";
22
+
23
+ export const nextcloudTalkSetupWizard: ChannelSetupWizard = {
24
+ channel,
25
+ stepOrder: "text-first",
26
+ status: createStandardChannelSetupStatus({
27
+ channelLabel: "Nextcloud Talk",
28
+ configuredLabel: "configured",
29
+ unconfiguredLabel: "needs setup",
30
+ configuredHint: "configured",
31
+ unconfiguredHint: "self-hosted chat",
32
+ configuredScore: 1,
33
+ unconfiguredScore: 5,
34
+ resolveConfigured: ({ cfg, accountId }) => {
35
+ const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
36
+ return Boolean(account.secret && account.baseUrl);
37
+ },
38
+ }),
39
+ introNote: {
40
+ title: "Nextcloud Talk bot setup",
41
+ lines: [
42
+ "1) SSH into your Nextcloud server",
43
+ '2) Run: ./occ talk:bot:install "OpenClaw" "<shared-secret>" "<webhook-url>" --feature reaction',
44
+ "3) Copy the shared secret you used in the command",
45
+ "4) Enable the bot in your Nextcloud Talk room settings",
46
+ "Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.",
47
+ `Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
48
+ ],
49
+ shouldShow: ({ cfg, accountId }) => {
50
+ const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
51
+ return !account.secret || !account.baseUrl;
52
+ },
53
+ },
54
+ prepare: async ({ cfg, accountId, credentialValues, prompter }) => {
55
+ const resolvedAccount = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
56
+ const hasApiCredentials = Boolean(
57
+ resolvedAccount.config.apiUser?.trim() &&
58
+ (hasConfiguredSecretInput(resolvedAccount.config.apiPassword) ||
59
+ resolvedAccount.config.apiPasswordFile),
60
+ );
61
+ const configureApiCredentials = await prompter.confirm({
62
+ message: "Configure optional Nextcloud Talk API credentials for room lookups?",
63
+ initialValue: hasApiCredentials,
64
+ });
65
+ if (!configureApiCredentials) {
66
+ return undefined;
67
+ }
68
+ return {
69
+ credentialValues: {
70
+ ...credentialValues,
71
+ [CONFIGURE_API_FLAG]: "1",
72
+ },
73
+ };
74
+ },
75
+ credentials: [
76
+ {
77
+ inputKey: "token",
78
+ providerHint: channel,
79
+ credentialLabel: "bot secret",
80
+ preferredEnvVar: "NEXTCLOUD_TALK_BOT_SECRET",
81
+ envPrompt: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?",
82
+ keepPrompt: "Nextcloud Talk bot secret already configured. Keep it?",
83
+ inputPrompt: "Enter Nextcloud Talk bot secret",
84
+ allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
85
+ inspect: ({ cfg, accountId }) => {
86
+ const resolvedAccount = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
87
+ return {
88
+ accountConfigured: Boolean(resolvedAccount.secret && resolvedAccount.baseUrl),
89
+ hasConfiguredValue: Boolean(
90
+ hasConfiguredSecretInput(resolvedAccount.config.botSecret) ||
91
+ resolvedAccount.config.botSecretFile,
92
+ ),
93
+ resolvedValue: resolvedAccount.secret || undefined,
94
+ envValue:
95
+ accountId === DEFAULT_ACCOUNT_ID
96
+ ? normalizeOptionalString(process.env.NEXTCLOUD_TALK_BOT_SECRET)
97
+ : undefined,
98
+ };
99
+ },
100
+ applyUseEnv: async (params) => {
101
+ const resolvedAccount = resolveNextcloudTalkAccount({
102
+ cfg: params.cfg as CoreConfig,
103
+ accountId: params.accountId,
104
+ });
105
+ const cleared = clearNextcloudTalkAccountFields(
106
+ params.cfg as CoreConfig,
107
+ params.accountId,
108
+ ["botSecret", "botSecretFile"],
109
+ );
110
+ return setNextcloudTalkAccountConfig(cleared, params.accountId, {
111
+ baseUrl: resolvedAccount.baseUrl,
112
+ });
113
+ },
114
+ applySet: async (params) =>
115
+ setNextcloudTalkAccountConfig(
116
+ clearNextcloudTalkAccountFields(params.cfg as CoreConfig, params.accountId, [
117
+ "botSecret",
118
+ "botSecretFile",
119
+ ]),
120
+ params.accountId,
121
+ {
122
+ botSecret: params.value,
123
+ },
124
+ ),
125
+ },
126
+ {
127
+ inputKey: "password",
128
+ providerHint: "nextcloud-talk-api",
129
+ credentialLabel: "API password",
130
+ preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD",
131
+ envPrompt: "",
132
+ keepPrompt: "Nextcloud Talk API password already configured. Keep it?",
133
+ inputPrompt: "Enter Nextcloud Talk API password",
134
+ inspect: ({ cfg, accountId }) => {
135
+ const resolvedAccount = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
136
+ const apiUser = resolvedAccount.config.apiUser?.trim();
137
+ const apiPasswordConfigured = Boolean(
138
+ hasConfiguredSecretInput(resolvedAccount.config.apiPassword) ||
139
+ resolvedAccount.config.apiPasswordFile,
140
+ );
141
+ return {
142
+ accountConfigured: Boolean(apiUser && apiPasswordConfigured),
143
+ hasConfiguredValue: apiPasswordConfigured,
144
+ };
145
+ },
146
+ shouldPrompt: ({ credentialValues }) => credentialValues[CONFIGURE_API_FLAG] === "1",
147
+ applySet: async (params) =>
148
+ setNextcloudTalkAccountConfig(
149
+ clearNextcloudTalkAccountFields(params.cfg as CoreConfig, params.accountId, [
150
+ "apiPassword",
151
+ "apiPasswordFile",
152
+ ]),
153
+ params.accountId,
154
+ {
155
+ apiPassword: params.value,
156
+ },
157
+ ),
158
+ },
159
+ ],
160
+ textInputs: [
161
+ {
162
+ inputKey: "httpUrl",
163
+ message: "Enter Nextcloud instance URL (e.g., https://cloud.example.com)",
164
+ currentValue: ({ cfg, accountId }) =>
165
+ resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).baseUrl || undefined,
166
+ shouldPrompt: ({ currentValue }) => !currentValue,
167
+ validate: ({ value }) => validateNextcloudTalkBaseUrl(value),
168
+ normalizeValue: ({ value }) => normalizeNextcloudTalkBaseUrl(value),
169
+ applySet: async (params) =>
170
+ setNextcloudTalkAccountConfig(params.cfg as CoreConfig, params.accountId, {
171
+ baseUrl: params.value,
172
+ }),
173
+ },
174
+ {
175
+ inputKey: "userId",
176
+ message: "Nextcloud Talk API user",
177
+ currentValue: ({ cfg, accountId }) =>
178
+ resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.apiUser?.trim() ||
179
+ undefined,
180
+ shouldPrompt: ({ credentialValues }) => credentialValues[CONFIGURE_API_FLAG] === "1",
181
+ validate: ({ value }) => (value ? undefined : "Required"),
182
+ applySet: async (params) =>
183
+ setNextcloudTalkAccountConfig(params.cfg as CoreConfig, params.accountId, {
184
+ apiUser: params.value,
185
+ }),
186
+ },
187
+ ],
188
+ dmPolicy: nextcloudTalkDmPolicy,
189
+ disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
190
+ };
@@ -0,0 +1,422 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+ import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/routing";
5
+ import { describe, expect, it } from "vitest";
6
+ import { resolveNextcloudTalkAccount } from "./accounts.js";
7
+ import {
8
+ clearNextcloudTalkAccountFields,
9
+ nextcloudTalkDmPolicy,
10
+ nextcloudTalkSetupAdapter,
11
+ normalizeNextcloudTalkBaseUrl,
12
+ setNextcloudTalkAccountConfig,
13
+ validateNextcloudTalkBaseUrl,
14
+ } from "./setup-core.js";
15
+ import { nextcloudTalkSetupWizard } from "./setup-surface.js";
16
+ import type { CoreConfig } from "./types.js";
17
+
18
+ describe("nextcloud talk setup", () => {
19
+ it("normalizes and validates base urls", () => {
20
+ expect(normalizeNextcloudTalkBaseUrl(" https://cloud.example.com/// ")).toBe(
21
+ "https://cloud.example.com",
22
+ );
23
+ expect(normalizeNextcloudTalkBaseUrl(undefined)).toBe("");
24
+
25
+ expect(validateNextcloudTalkBaseUrl("")).toBe("Required");
26
+ expect(validateNextcloudTalkBaseUrl("cloud.example.com")).toBe(
27
+ "URL must start with http:// or https://",
28
+ );
29
+ expect(validateNextcloudTalkBaseUrl("https://cloud.example.com")).toBeUndefined();
30
+ });
31
+
32
+ it("patches scoped account config and clears selected fields", () => {
33
+ const cfg: CoreConfig = {
34
+ channels: {
35
+ "nextcloud-talk": {
36
+ baseUrl: "https://cloud.example.com",
37
+ botSecret: "top-secret",
38
+ accounts: {
39
+ work: {
40
+ botSecret: "work-secret",
41
+ botSecretFile: "/tmp/work-secret",
42
+ apiPassword: "api-secret",
43
+ },
44
+ },
45
+ },
46
+ },
47
+ };
48
+
49
+ expect(
50
+ setNextcloudTalkAccountConfig(cfg, DEFAULT_ACCOUNT_ID, {
51
+ apiUser: "bot",
52
+ }),
53
+ ).toMatchObject({
54
+ channels: {
55
+ "nextcloud-talk": {
56
+ apiUser: "bot",
57
+ },
58
+ },
59
+ });
60
+
61
+ expect(clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"])).toMatchObject({
62
+ channels: {
63
+ "nextcloud-talk": {
64
+ baseUrl: "https://cloud.example.com",
65
+ },
66
+ },
67
+ });
68
+ expect(
69
+ clearNextcloudTalkAccountFields(cfg, DEFAULT_ACCOUNT_ID, ["botSecret"]),
70
+ ).not.toMatchObject({
71
+ channels: {
72
+ "nextcloud-talk": {
73
+ botSecret: expect.anything(),
74
+ },
75
+ },
76
+ });
77
+
78
+ expect(
79
+ clearNextcloudTalkAccountFields(cfg, "work", ["botSecret", "botSecretFile"]),
80
+ ).toMatchObject({
81
+ channels: {
82
+ "nextcloud-talk": {
83
+ accounts: {
84
+ work: {
85
+ apiPassword: "api-secret",
86
+ },
87
+ },
88
+ },
89
+ },
90
+ });
91
+ });
92
+
93
+ it("sets top-level DM policy state", async () => {
94
+ const base: CoreConfig = {
95
+ channels: {
96
+ "nextcloud-talk": {},
97
+ },
98
+ };
99
+
100
+ expect(nextcloudTalkDmPolicy.getCurrent(base)).toBe("pairing");
101
+ expect(nextcloudTalkDmPolicy.setPolicy(base, "open")).toMatchObject({
102
+ channels: {
103
+ "nextcloud-talk": {
104
+ dmPolicy: "open",
105
+ },
106
+ },
107
+ });
108
+ });
109
+
110
+ it("honors named-account DM policy state and config keys", () => {
111
+ const base: CoreConfig = {
112
+ channels: {
113
+ "nextcloud-talk": {
114
+ dmPolicy: "disabled",
115
+ accounts: {
116
+ work: {
117
+ baseUrl: "https://cloud.example.com",
118
+ botSecret: "work-secret",
119
+ dmPolicy: "allowlist",
120
+ },
121
+ },
122
+ },
123
+ },
124
+ };
125
+
126
+ expect(nextcloudTalkDmPolicy.getCurrent(base, "work")).toBe("allowlist");
127
+ expect(nextcloudTalkDmPolicy.resolveConfigKeys?.(base, "work")).toEqual({
128
+ policyKey: "channels.nextcloud-talk.accounts.work.dmPolicy",
129
+ allowFromKey: "channels.nextcloud-talk.accounts.work.allowFrom",
130
+ });
131
+ });
132
+
133
+ it("uses configured defaultAccount for omitted DM policy account context", () => {
134
+ const base: CoreConfig = {
135
+ channels: {
136
+ "nextcloud-talk": {
137
+ defaultAccount: "work",
138
+ dmPolicy: "disabled",
139
+ accounts: {
140
+ work: {
141
+ baseUrl: "https://cloud.example.com",
142
+ botSecret: "work-secret",
143
+ dmPolicy: "allowlist",
144
+ },
145
+ },
146
+ },
147
+ },
148
+ };
149
+
150
+ expect(nextcloudTalkDmPolicy.getCurrent(base)).toBe("allowlist");
151
+ expect(nextcloudTalkDmPolicy.resolveConfigKeys?.(base)).toEqual({
152
+ policyKey: "channels.nextcloud-talk.accounts.work.dmPolicy",
153
+ allowFromKey: "channels.nextcloud-talk.accounts.work.allowFrom",
154
+ });
155
+
156
+ const next = nextcloudTalkDmPolicy.setPolicy(base, "open");
157
+ expect(next.channels?.["nextcloud-talk"]?.dmPolicy).toBe("disabled");
158
+ const workAccount = next.channels?.["nextcloud-talk"]?.accounts?.work as
159
+ | { dmPolicy?: string; allowFrom?: Array<string | number> }
160
+ | undefined;
161
+ expect(workAccount?.dmPolicy).toBe("open");
162
+ });
163
+
164
+ it('writes open DM policy to the named account and preserves inherited allowFrom with "*"', () => {
165
+ const next = nextcloudTalkDmPolicy.setPolicy(
166
+ {
167
+ channels: {
168
+ "nextcloud-talk": {
169
+ allowFrom: ["alice"],
170
+ accounts: {
171
+ work: {
172
+ baseUrl: "https://cloud.example.com",
173
+ botSecret: "work-secret",
174
+ },
175
+ },
176
+ },
177
+ },
178
+ },
179
+ "open",
180
+ "work",
181
+ );
182
+
183
+ expect(next.channels?.["nextcloud-talk"]?.dmPolicy).toBeUndefined();
184
+ const workAccount = next.channels?.["nextcloud-talk"]?.accounts?.work as
185
+ | { dmPolicy?: string; allowFrom?: Array<string | number> }
186
+ | undefined;
187
+ expect(workAccount?.dmPolicy).toBe("open");
188
+ expect(workAccount?.allowFrom).toEqual(["alice", "*"]);
189
+ });
190
+
191
+ it("validates env/default-account constraints and applies config patches", () => {
192
+ const validateInput = nextcloudTalkSetupAdapter.validateInput;
193
+ const applyAccountConfig = nextcloudTalkSetupAdapter.applyAccountConfig;
194
+ expect(validateInput).toBeTypeOf("function");
195
+ expect(applyAccountConfig).toBeTypeOf("function");
196
+
197
+ expect(
198
+ validateInput!({
199
+ accountId: "work",
200
+ input: { useEnv: true },
201
+ } as never),
202
+ ).toBe("NEXTCLOUD_TALK_BOT_SECRET can only be used for the default account.");
203
+
204
+ expect(
205
+ validateInput!({
206
+ accountId: DEFAULT_ACCOUNT_ID,
207
+ input: { useEnv: false, baseUrl: "", secret: "" },
208
+ } as never),
209
+ ).toBe("Nextcloud Talk requires bot secret or --secret-file (or --use-env).");
210
+
211
+ expect(
212
+ validateInput!({
213
+ accountId: DEFAULT_ACCOUNT_ID,
214
+ input: { useEnv: false, secret: "secret", baseUrl: "" },
215
+ } as never),
216
+ ).toBe("Nextcloud Talk requires --base-url.");
217
+
218
+ expect(
219
+ applyAccountConfig({
220
+ cfg: {
221
+ channels: {
222
+ "nextcloud-talk": {},
223
+ },
224
+ },
225
+ accountId: DEFAULT_ACCOUNT_ID,
226
+ input: {
227
+ name: "Default",
228
+ baseUrl: "https://cloud.example.com///",
229
+ secret: "bot-secret",
230
+ },
231
+ } as never),
232
+ ).toEqual({
233
+ channels: {
234
+ "nextcloud-talk": {
235
+ enabled: true,
236
+ name: "Default",
237
+ baseUrl: "https://cloud.example.com",
238
+ botSecret: "bot-secret",
239
+ },
240
+ },
241
+ });
242
+
243
+ expect(
244
+ applyAccountConfig({
245
+ cfg: {
246
+ channels: {
247
+ "nextcloud-talk": {
248
+ accounts: {
249
+ work: {
250
+ botSecret: "old-secret",
251
+ },
252
+ },
253
+ },
254
+ },
255
+ },
256
+ accountId: "work",
257
+ input: {
258
+ name: "Work",
259
+ useEnv: true,
260
+ baseUrl: "https://cloud.example.com",
261
+ },
262
+ } as never),
263
+ ).toMatchObject({
264
+ channels: {
265
+ "nextcloud-talk": {
266
+ accounts: {
267
+ work: {
268
+ enabled: true,
269
+ name: "Work",
270
+ baseUrl: "https://cloud.example.com",
271
+ },
272
+ },
273
+ },
274
+ },
275
+ });
276
+ });
277
+
278
+ it("clears stored bot secret fields when switching the default account to env", () => {
279
+ type ApplyAccountConfigContext = Parameters<
280
+ typeof nextcloudTalkSetupAdapter.applyAccountConfig
281
+ >[0];
282
+
283
+ const next = nextcloudTalkSetupAdapter.applyAccountConfig({
284
+ cfg: {
285
+ channels: {
286
+ "nextcloud-talk": {
287
+ enabled: true,
288
+ baseUrl: "https://cloud.old.example",
289
+ botSecret: "stored-secret",
290
+ botSecretFile: "/tmp/secret.txt",
291
+ },
292
+ },
293
+ },
294
+ accountId: DEFAULT_ACCOUNT_ID,
295
+ input: {
296
+ baseUrl: "https://cloud.example.com",
297
+ useEnv: true,
298
+ },
299
+ } as unknown as ApplyAccountConfigContext);
300
+
301
+ expect(next.channels?.["nextcloud-talk"]?.baseUrl).toBe("https://cloud.example.com");
302
+ expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret");
303
+ expect(next.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile");
304
+ });
305
+
306
+ it("clears stored bot secret fields when the wizard switches to env", async () => {
307
+ const credential = nextcloudTalkSetupWizard.credentials[0];
308
+ const next = await credential.applyUseEnv?.({
309
+ cfg: {
310
+ channels: {
311
+ "nextcloud-talk": {
312
+ enabled: true,
313
+ baseUrl: "https://cloud.example.com",
314
+ botSecret: "stored-secret",
315
+ botSecretFile: "/tmp/secret.txt",
316
+ },
317
+ },
318
+ },
319
+ accountId: DEFAULT_ACCOUNT_ID,
320
+ });
321
+
322
+ expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecret");
323
+ expect(next?.channels?.["nextcloud-talk"]).not.toHaveProperty("botSecretFile");
324
+ });
325
+ });
326
+
327
+ describe("resolveNextcloudTalkAccount", () => {
328
+ it("matches normalized configured account ids", () => {
329
+ const account = resolveNextcloudTalkAccount({
330
+ cfg: {
331
+ channels: {
332
+ "nextcloud-talk": {
333
+ accounts: {
334
+ "Ops Team": {
335
+ baseUrl: "https://cloud.example.com",
336
+ botSecret: "bot-secret",
337
+ },
338
+ },
339
+ },
340
+ },
341
+ } as CoreConfig,
342
+ accountId: "ops-team",
343
+ });
344
+
345
+ expect(account.accountId).toBe("ops-team");
346
+ expect(account.baseUrl).toBe("https://cloud.example.com");
347
+ expect(account.secret).toBe("bot-secret");
348
+ expect(account.secretSource).toBe("config");
349
+ });
350
+
351
+ it.runIf(process.platform !== "win32")("rejects symlinked botSecretFile paths", () => {
352
+ const dir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-nextcloud-talk-"));
353
+ const secretFile = path.join(dir, "secret.txt");
354
+ const secretLink = path.join(dir, "secret-link.txt");
355
+ fs.writeFileSync(secretFile, "bot-secret\n", "utf8");
356
+ fs.symlinkSync(secretFile, secretLink);
357
+
358
+ const cfg = {
359
+ channels: {
360
+ "nextcloud-talk": {
361
+ baseUrl: "https://cloud.example.com",
362
+ botSecretFile: secretLink,
363
+ },
364
+ },
365
+ } as CoreConfig;
366
+
367
+ const account = resolveNextcloudTalkAccount({ cfg });
368
+ expect(account.secret).toBe("");
369
+ expect(account.secretSource).toBe("none");
370
+ fs.rmSync(dir, { recursive: true, force: true });
371
+ });
372
+
373
+ it("uses configured defaultAccount when accountId is omitted", () => {
374
+ const account = resolveNextcloudTalkAccount({
375
+ cfg: {
376
+ channels: {
377
+ "nextcloud-talk": {
378
+ defaultAccount: "work",
379
+ botSecret: "top-secret",
380
+ accounts: {
381
+ work: {
382
+ baseUrl: "https://cloud.example.com",
383
+ botSecret: "work-secret",
384
+ },
385
+ },
386
+ },
387
+ },
388
+ } as CoreConfig,
389
+ });
390
+
391
+ expect(account.accountId).toBe("work");
392
+ expect(account.baseUrl).toBe("https://cloud.example.com");
393
+ expect(account.secret).toBe("work-secret");
394
+ expect(account.secretSource).toBe("config");
395
+ });
396
+
397
+ it("uses configured defaultAccount for omitted setup configured state", () => {
398
+ const configured = nextcloudTalkSetupWizard.status.resolveConfigured({
399
+ cfg: {
400
+ channels: {
401
+ "nextcloud-talk": {
402
+ defaultAccount: "work",
403
+ baseUrl: "https://root.example.com",
404
+ botSecret: "root-secret",
405
+ accounts: {
406
+ alerts: {
407
+ baseUrl: "https://alerts.example.com",
408
+ botSecret: "alerts-secret",
409
+ },
410
+ work: {
411
+ baseUrl: "",
412
+ botSecret: "",
413
+ },
414
+ },
415
+ },
416
+ },
417
+ } as CoreConfig,
418
+ });
419
+
420
+ expect(configured).toBe(false);
421
+ });
422
+ });
package/src/signature.ts CHANGED
@@ -1,4 +1,5 @@
1
- import { createHmac, randomBytes } from "node:crypto";
1
+ import { createHmac, randomBytes, timingSafeEqual } from "node:crypto";
2
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
2
3
  import type { NextcloudTalkWebhookHeaders } from "./types.js";
3
4
 
4
5
  const SIGNATURE_HEADER = "x-nextcloud-talk-signature";
@@ -24,14 +25,23 @@ export function verifyNextcloudTalkSignature(params: {
24
25
  .update(random + body)
25
26
  .digest("hex");
26
27
 
27
- if (signature.length !== expected.length) {
28
- return false;
29
- }
30
- let result = 0;
31
- for (let i = 0; i < signature.length; i++) {
32
- result |= signature.charCodeAt(i) ^ expected.charCodeAt(i);
33
- }
34
- return result === 0;
28
+ const expectedBuf = Buffer.from(expected, "utf8");
29
+ const signatureBuf = Buffer.from(signature, "utf8");
30
+
31
+ // Pad to equal length before constant-time comparison to prevent
32
+ // leaking length information via early-return timing.
33
+ // Note: digest("hex") always produces lowercase ASCII (64 bytes for SHA-256),
34
+ // so expectedBuf is always 64 bytes — no variable-length concern on the expected side.
35
+ const maxLen = Math.max(expectedBuf.length, signatureBuf.length);
36
+ const paddedExpected = Buffer.alloc(maxLen);
37
+ const paddedSignature = Buffer.alloc(maxLen);
38
+ expectedBuf.copy(paddedExpected);
39
+ signatureBuf.copy(paddedSignature);
40
+
41
+ // Use crypto.timingSafeEqual instead of manual XOR loop to avoid
42
+ // potential JIT-optimisation timing leaks in the JavaScript engine.
43
+ const timingResult = timingSafeEqual(paddedExpected, paddedSignature);
44
+ return expectedBuf.length === signatureBuf.length && timingResult;
35
45
  }
36
46
 
37
47
  /**
@@ -41,7 +51,7 @@ export function extractNextcloudTalkHeaders(
41
51
  headers: Record<string, string | string[] | undefined>,
42
52
  ): NextcloudTalkWebhookHeaders | null {
43
53
  const getHeader = (name: string): string | undefined => {
44
- const value = headers[name] ?? headers[name.toLowerCase()];
54
+ const value = headers[name] ?? headers[normalizeLowercaseStringOrEmpty(name)];
45
55
  return Array.isArray(value) ? value[0] : value;
46
56
  };
47
57