@openclaw/nextcloud-talk 2026.3.2 → 2026.3.7

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/index.ts CHANGED
@@ -1,5 +1,5 @@
1
- import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
2
- import { emptyPluginConfigSchema } from "openclaw/plugin-sdk";
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/nextcloud-talk";
2
+ import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/nextcloud-talk";
3
3
  import { nextcloudTalkPlugin } from "./src/channel.js";
4
4
  import { setNextcloudTalkRuntime } from "./src/runtime.js";
5
5
 
package/package.json CHANGED
@@ -1,8 +1,11 @@
1
1
  {
2
2
  "name": "@openclaw/nextcloud-talk",
3
- "version": "2026.3.2",
3
+ "version": "2026.3.7",
4
4
  "description": "OpenClaw Nextcloud Talk channel plugin",
5
5
  "type": "module",
6
+ "dependencies": {
7
+ "zod": "^4.3.6"
8
+ },
6
9
  "openclaw": {
7
10
  "extensions": [
8
11
  "./index.ts"
package/src/accounts.ts CHANGED
@@ -1,13 +1,10 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import {
3
- listConfiguredAccountIds as listConfiguredAccountIdsFromSection,
4
- resolveAccountWithDefaultFallback,
5
- } from "openclaw/plugin-sdk";
6
- import {
3
+ createAccountListHelpers,
7
4
  DEFAULT_ACCOUNT_ID,
8
5
  normalizeAccountId,
9
- normalizeOptionalAccountId,
10
- } from "openclaw/plugin-sdk/account-id";
6
+ resolveAccountWithDefaultFallback,
7
+ } from "openclaw/plugin-sdk/nextcloud-talk";
11
8
  import { normalizeResolvedSecretInputString } from "./secret-input.js";
12
9
  import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
13
10
 
@@ -32,37 +29,18 @@ export type ResolvedNextcloudTalkAccount = {
32
29
  config: NextcloudTalkAccountConfig;
33
30
  };
34
31
 
35
- function listConfiguredAccountIds(cfg: CoreConfig): string[] {
36
- return listConfiguredAccountIdsFromSection({
37
- accounts: cfg.channels?.["nextcloud-talk"]?.accounts as Record<string, unknown> | undefined,
38
- normalizeAccountId,
39
- });
40
- }
32
+ const {
33
+ listAccountIds: listNextcloudTalkAccountIdsInternal,
34
+ resolveDefaultAccountId: resolveDefaultNextcloudTalkAccountId,
35
+ } = createAccountListHelpers("nextcloud-talk", {
36
+ normalizeAccountId,
37
+ });
38
+ export { resolveDefaultNextcloudTalkAccountId };
41
39
 
42
40
  export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] {
43
- const ids = listConfiguredAccountIds(cfg);
41
+ const ids = listNextcloudTalkAccountIdsInternal(cfg);
44
42
  debugAccounts("listNextcloudTalkAccountIds", ids);
45
- if (ids.length === 0) {
46
- return [DEFAULT_ACCOUNT_ID];
47
- }
48
- return ids.toSorted((a, b) => a.localeCompare(b));
49
- }
50
-
51
- export function resolveDefaultNextcloudTalkAccountId(cfg: CoreConfig): string {
52
- const preferred = normalizeOptionalAccountId(cfg.channels?.["nextcloud-talk"]?.defaultAccount);
53
- if (
54
- preferred &&
55
- listNextcloudTalkAccountIds(cfg).some(
56
- (accountId) => normalizeAccountId(accountId) === preferred,
57
- )
58
- ) {
59
- return preferred;
60
- }
61
- const ids = listNextcloudTalkAccountIds(cfg);
62
- if (ids.includes(DEFAULT_ACCOUNT_ID)) {
63
- return DEFAULT_ACCOUNT_ID;
64
- }
65
- return ids[0] ?? DEFAULT_ACCOUNT_ID;
43
+ return ids;
66
44
  }
67
45
 
68
46
  function resolveAccountConfig(
@@ -21,11 +21,11 @@ function buildAccount(): ResolvedNextcloudTalkAccount {
21
21
  accountId: "default",
22
22
  enabled: true,
23
23
  baseUrl: "https://nextcloud.example.com",
24
- secret: "secret",
25
- secretSource: "config",
24
+ secret: "secret", // pragma: allowlist secret
25
+ secretSource: "config", // pragma: allowlist secret
26
26
  config: {
27
27
  baseUrl: "https://nextcloud.example.com",
28
- botSecret: "secret",
28
+ botSecret: "secret", // pragma: allowlist secret
29
29
  webhookPath: "/nextcloud-talk-webhook",
30
30
  webhookPort: 8788,
31
31
  },
package/src/channel.ts CHANGED
@@ -1,17 +1,24 @@
1
+ import {
2
+ buildAccountScopedDmSecurityPolicy,
3
+ collectAllowlistProviderGroupPolicyWarnings,
4
+ collectOpenGroupPolicyRouteAllowlistWarnings,
5
+ formatAllowFromLowercase,
6
+ mapAllowFromEntries,
7
+ } from "openclaw/plugin-sdk/compat";
1
8
  import {
2
9
  applyAccountNameToChannelSection,
10
+ buildBaseChannelStatusSummary,
3
11
  buildChannelConfigSchema,
12
+ buildRuntimeAccountStatusSnapshot,
13
+ clearAccountEntryFields,
4
14
  DEFAULT_ACCOUNT_ID,
5
15
  deleteAccountFromConfigSection,
6
- formatPairingApproveHint,
7
16
  normalizeAccountId,
8
- resolveAllowlistProviderRuntimeGroupPolicy,
9
- resolveDefaultGroupPolicy,
10
17
  setAccountEnabledInConfigSection,
11
18
  type ChannelPlugin,
12
19
  type OpenClawConfig,
13
20
  type ChannelSetupInput,
14
- } from "openclaw/plugin-sdk";
21
+ } from "openclaw/plugin-sdk/nextcloud-talk";
15
22
  import { waitForAbortSignal } from "../../../src/infra/abort-signal.js";
16
23
  import {
17
24
  listNextcloudTalkAccountIds,
@@ -102,55 +109,55 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
102
109
  baseUrl: account.baseUrl ? "[set]" : "[missing]",
103
110
  }),
104
111
  resolveAllowFrom: ({ cfg, accountId }) =>
105
- (
106
- resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom ?? []
107
- ).map((entry) => String(entry).toLowerCase()),
112
+ mapAllowFromEntries(
113
+ resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId }).config.allowFrom,
114
+ ).map((entry) => entry.toLowerCase()),
108
115
  formatAllowFrom: ({ allowFrom }) =>
109
- allowFrom
110
- .map((entry) => String(entry).trim())
111
- .filter(Boolean)
112
- .map((entry) => entry.replace(/^(nextcloud-talk|nc-talk|nc):/i, ""))
113
- .map((entry) => entry.toLowerCase()),
116
+ formatAllowFromLowercase({
117
+ allowFrom,
118
+ stripPrefixRe: /^(nextcloud-talk|nc-talk|nc):/i,
119
+ }),
114
120
  },
115
121
  security: {
116
122
  resolveDmPolicy: ({ cfg, accountId, account }) => {
117
- const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
118
- const useAccountPath = Boolean(
119
- cfg.channels?.["nextcloud-talk"]?.accounts?.[resolvedAccountId],
120
- );
121
- const basePath = useAccountPath
122
- ? `channels.nextcloud-talk.accounts.${resolvedAccountId}.`
123
- : "channels.nextcloud-talk.";
124
- return {
125
- policy: account.config.dmPolicy ?? "pairing",
123
+ return buildAccountScopedDmSecurityPolicy({
124
+ cfg,
125
+ channelKey: "nextcloud-talk",
126
+ accountId,
127
+ fallbackAccountId: account.accountId ?? DEFAULT_ACCOUNT_ID,
128
+ policy: account.config.dmPolicy,
126
129
  allowFrom: account.config.allowFrom ?? [],
127
- policyPath: `${basePath}dmPolicy`,
128
- allowFromPath: basePath,
129
- approveHint: formatPairingApproveHint("nextcloud-talk"),
130
+ policyPathSuffix: "dmPolicy",
130
131
  normalizeEntry: (raw) => raw.replace(/^(nextcloud-talk|nc-talk|nc):/i, "").toLowerCase(),
131
- };
132
+ });
132
133
  },
133
134
  collectWarnings: ({ account, cfg }) => {
134
- const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg);
135
- const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({
135
+ const roomAllowlistConfigured =
136
+ account.config.rooms && Object.keys(account.config.rooms).length > 0;
137
+ return collectAllowlistProviderGroupPolicyWarnings({
138
+ cfg,
136
139
  providerConfigPresent:
137
140
  (cfg.channels as Record<string, unknown> | undefined)?.["nextcloud-talk"] !== undefined,
138
- groupPolicy: account.config.groupPolicy,
139
- defaultGroupPolicy,
141
+ configuredGroupPolicy: account.config.groupPolicy,
142
+ collect: (groupPolicy) =>
143
+ collectOpenGroupPolicyRouteAllowlistWarnings({
144
+ groupPolicy,
145
+ routeAllowlistConfigured: Boolean(roomAllowlistConfigured),
146
+ restrictSenders: {
147
+ surface: "Nextcloud Talk rooms",
148
+ openScope: "any member in allowed rooms",
149
+ groupPolicyPath: "channels.nextcloud-talk.groupPolicy",
150
+ groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom",
151
+ },
152
+ noRouteAllowlist: {
153
+ surface: "Nextcloud Talk rooms",
154
+ routeAllowlistPath: "channels.nextcloud-talk.rooms",
155
+ routeScope: "room",
156
+ groupPolicyPath: "channels.nextcloud-talk.groupPolicy",
157
+ groupAllowFromPath: "channels.nextcloud-talk.groupAllowFrom",
158
+ },
159
+ }),
140
160
  });
141
- if (groupPolicy !== "open") {
142
- return [];
143
- }
144
- const roomAllowlistConfigured =
145
- account.config.rooms && Object.keys(account.config.rooms).length > 0;
146
- if (roomAllowlistConfigured) {
147
- return [
148
- `- Nextcloud Talk rooms: groupPolicy="open" allows any member in allowed rooms to trigger (mention-gated). Set channels.nextcloud-talk.groupPolicy="allowlist" + channels.nextcloud-talk.groupAllowFrom to restrict senders.`,
149
- ];
150
- }
151
- return [
152
- `- Nextcloud Talk rooms: groupPolicy="open" with no channels.nextcloud-talk.rooms allowlist; any room can add + ping (mention-gated). Set channels.nextcloud-talk.groupPolicy="allowlist" + channels.nextcloud-talk.groupAllowFrom or configure channels.nextcloud-talk.rooms.`,
153
- ];
154
161
  },
155
162
  },
156
163
  groups: {
@@ -262,18 +269,20 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
262
269
  chunker: (text, limit) => getNextcloudTalkRuntime().channel.text.chunkMarkdownText(text, limit),
263
270
  chunkerMode: "markdown",
264
271
  textChunkLimit: 4000,
265
- sendText: async ({ to, text, accountId, replyToId }) => {
272
+ sendText: async ({ cfg, to, text, accountId, replyToId }) => {
266
273
  const result = await sendMessageNextcloudTalk(to, text, {
267
274
  accountId: accountId ?? undefined,
268
275
  replyTo: replyToId ?? undefined,
276
+ cfg: cfg as CoreConfig,
269
277
  });
270
278
  return { channel: "nextcloud-talk", ...result };
271
279
  },
272
- sendMedia: async ({ to, text, mediaUrl, accountId, replyToId }) => {
280
+ sendMedia: async ({ cfg, to, text, mediaUrl, accountId, replyToId }) => {
273
281
  const messageWithMedia = mediaUrl ? `${text}\n\nAttachment: ${mediaUrl}` : text;
274
282
  const result = await sendMessageNextcloudTalk(to, messageWithMedia, {
275
283
  accountId: accountId ?? undefined,
276
284
  replyTo: replyToId ?? undefined,
285
+ cfg: cfg as CoreConfig,
277
286
  });
278
287
  return { channel: "nextcloud-talk", ...result };
279
288
  },
@@ -286,17 +295,21 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
286
295
  lastStopAt: null,
287
296
  lastError: null,
288
297
  },
289
- buildChannelSummary: ({ snapshot }) => ({
290
- configured: snapshot.configured ?? false,
291
- secretSource: snapshot.secretSource ?? "none",
292
- running: snapshot.running ?? false,
293
- mode: "webhook",
294
- lastStartAt: snapshot.lastStartAt ?? null,
295
- lastStopAt: snapshot.lastStopAt ?? null,
296
- lastError: snapshot.lastError ?? null,
297
- }),
298
+ buildChannelSummary: ({ snapshot }) => {
299
+ const base = buildBaseChannelStatusSummary(snapshot);
300
+ return {
301
+ configured: base.configured,
302
+ secretSource: snapshot.secretSource ?? "none",
303
+ running: base.running,
304
+ mode: "webhook",
305
+ lastStartAt: base.lastStartAt,
306
+ lastStopAt: base.lastStopAt,
307
+ lastError: base.lastError,
308
+ };
309
+ },
298
310
  buildAccountSnapshot: ({ account, runtime }) => {
299
311
  const configured = Boolean(account.secret?.trim() && account.baseUrl?.trim());
312
+ const runtimeSnapshot = buildRuntimeAccountStatusSnapshot({ runtime });
300
313
  return {
301
314
  accountId: account.accountId,
302
315
  name: account.name,
@@ -304,10 +317,10 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
304
317
  configured,
305
318
  secretSource: account.secretSource,
306
319
  baseUrl: account.baseUrl ? "[set]" : "[missing]",
307
- running: runtime?.running ?? false,
308
- lastStartAt: runtime?.lastStartAt ?? null,
309
- lastStopAt: runtime?.lastStopAt ?? null,
310
- lastError: runtime?.lastError ?? null,
320
+ running: runtimeSnapshot.running,
321
+ lastStartAt: runtimeSnapshot.lastStartAt,
322
+ lastStopAt: runtimeSnapshot.lastStopAt,
323
+ lastError: runtimeSnapshot.lastError,
311
324
  mode: "webhook",
312
325
  lastInboundAt: runtime?.lastInboundAt ?? null,
313
326
  lastOutboundAt: runtime?.lastOutboundAt ?? null,
@@ -351,36 +364,20 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
351
364
  cleared = true;
352
365
  changed = true;
353
366
  }
354
- const accounts =
355
- nextSection.accounts && typeof nextSection.accounts === "object"
356
- ? { ...nextSection.accounts }
357
- : undefined;
358
- if (accounts && accountId in accounts) {
359
- const entry = accounts[accountId];
360
- if (entry && typeof entry === "object") {
361
- const nextEntry = { ...entry } as Record<string, unknown>;
362
- if ("botSecret" in nextEntry) {
363
- const secret = nextEntry.botSecret;
364
- if (typeof secret === "string" ? secret.trim() : secret) {
365
- cleared = true;
366
- }
367
- delete nextEntry.botSecret;
368
- changed = true;
369
- }
370
- if (Object.keys(nextEntry).length === 0) {
371
- delete accounts[accountId];
372
- changed = true;
373
- } else {
374
- accounts[accountId] = nextEntry as typeof entry;
375
- }
367
+ const accountCleanup = clearAccountEntryFields({
368
+ accounts: nextSection.accounts,
369
+ accountId,
370
+ fields: ["botSecret"],
371
+ });
372
+ if (accountCleanup.changed) {
373
+ changed = true;
374
+ if (accountCleanup.cleared) {
375
+ cleared = true;
376
376
  }
377
- }
378
- if (accounts) {
379
- if (Object.keys(accounts).length === 0) {
380
- delete nextSection.accounts;
381
- changed = true;
377
+ if (accountCleanup.nextAccounts) {
378
+ nextSection.accounts = accountCleanup.nextAccounts;
382
379
  } else {
383
- nextSection.accounts = accounts;
380
+ delete nextSection.accounts;
384
381
  }
385
382
  }
386
383
  }
@@ -7,7 +7,7 @@ import {
7
7
  ReplyRuntimeConfigSchemaShape,
8
8
  ToolPolicySchema,
9
9
  requireOpenAllowFrom,
10
- } from "openclaw/plugin-sdk";
10
+ } from "openclaw/plugin-sdk/nextcloud-talk";
11
11
  import { z } from "zod";
12
12
  import { buildSecretInputSchema } from "./secret-input.js";
13
13
 
@@ -1,4 +1,4 @@
1
- import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
1
+ import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk/nextcloud-talk";
2
2
  import { describe, expect, it, vi } from "vitest";
3
3
  import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
4
4
  import { handleNextcloudTalkInbound } from "./inbound.js";
@@ -45,7 +45,7 @@ describe("nextcloud-talk inbound authz", () => {
45
45
  enabled: true,
46
46
  baseUrl: "",
47
47
  secret: "",
48
- secretSource: "none",
48
+ secretSource: "none", // pragma: allowlist secret
49
49
  config: {
50
50
  dmPolicy: "pairing",
51
51
  allowFrom: [],
package/src/inbound.ts CHANGED
@@ -1,9 +1,9 @@
1
1
  import {
2
2
  GROUP_POLICY_BLOCKED_LABEL,
3
3
  createScopedPairingAccess,
4
- createNormalizedOutboundDeliverer,
5
- createReplyPrefixOptions,
4
+ dispatchInboundReplyWithBase,
6
5
  formatTextWithAttachmentLinks,
6
+ issuePairingChallenge,
7
7
  logInboundDrop,
8
8
  readStoreAllowFromForDmPolicy,
9
9
  resolveDmGroupAccessWithCommandGate,
@@ -14,7 +14,7 @@ import {
14
14
  type OutboundReplyPayload,
15
15
  type OpenClawConfig,
16
16
  type RuntimeEnv,
17
- } from "openclaw/plugin-sdk";
17
+ } from "openclaw/plugin-sdk/nextcloud-talk";
18
18
  import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
19
19
  import {
20
20
  normalizeNextcloudTalkAllowlist,
@@ -174,26 +174,20 @@ export async function handleNextcloudTalkInbound(params: {
174
174
  } else {
175
175
  if (access.decision !== "allow") {
176
176
  if (access.decision === "pairing") {
177
- const { code, created } = await pairing.upsertPairingRequest({
178
- id: senderId,
177
+ await issuePairingChallenge({
178
+ channel: CHANNEL_ID,
179
+ senderId,
180
+ senderIdLine: `Your Nextcloud user id: ${senderId}`,
179
181
  meta: { name: senderName || undefined },
180
- });
181
- if (created) {
182
- try {
183
- await sendMessageNextcloudTalk(
184
- roomToken,
185
- core.channel.pairing.buildPairingReply({
186
- channel: CHANNEL_ID,
187
- idLine: `Your Nextcloud user id: ${senderId}`,
188
- code,
189
- }),
190
- { accountId: account.accountId },
191
- );
182
+ upsertPairingRequest: pairing.upsertPairingRequest,
183
+ sendPairingReply: async (text) => {
184
+ await sendMessageNextcloudTalk(roomToken, text, { accountId: account.accountId });
192
185
  statusSink?.({ lastOutboundAt: Date.now() });
193
- } catch (err) {
186
+ },
187
+ onReplyError: (err) => {
194
188
  runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`);
195
- }
196
- }
189
+ },
190
+ });
197
191
  }
198
192
  runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${access.reason})`);
199
193
  return;
@@ -291,43 +285,30 @@ export async function handleNextcloudTalkInbound(params: {
291
285
  CommandAuthorized: commandAuthorized,
292
286
  });
293
287
 
294
- await core.channel.session.recordInboundSession({
288
+ await dispatchInboundReplyWithBase({
289
+ cfg: config as OpenClawConfig,
290
+ channel: CHANNEL_ID,
291
+ accountId: account.accountId,
292
+ route,
295
293
  storePath,
296
- sessionKey: ctxPayload.SessionKey ?? route.sessionKey,
297
- ctx: ctxPayload,
294
+ ctxPayload,
295
+ core,
296
+ deliver: async (payload) => {
297
+ await deliverNextcloudTalkReply({
298
+ payload,
299
+ roomToken,
300
+ accountId: account.accountId,
301
+ statusSink,
302
+ });
303
+ },
298
304
  onRecordError: (err) => {
299
305
  runtime.error?.(`nextcloud-talk: failed updating session meta: ${String(err)}`);
300
306
  },
301
- });
302
-
303
- const { onModelSelected, ...prefixOptions } = createReplyPrefixOptions({
304
- cfg: config as OpenClawConfig,
305
- agentId: route.agentId,
306
- channel: CHANNEL_ID,
307
- accountId: account.accountId,
308
- });
309
- const deliverReply = createNormalizedOutboundDeliverer(async (payload) => {
310
- await deliverNextcloudTalkReply({
311
- payload,
312
- roomToken,
313
- accountId: account.accountId,
314
- statusSink,
315
- });
316
- });
317
-
318
- await core.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
319
- ctx: ctxPayload,
320
- cfg: config as OpenClawConfig,
321
- dispatcherOptions: {
322
- ...prefixOptions,
323
- deliver: deliverReply,
324
- onError: (err, info) => {
325
- runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`);
326
- },
307
+ onDispatchError: (err, info) => {
308
+ runtime.error?.(`nextcloud-talk ${info.kind} reply failed: ${String(err)}`);
327
309
  },
328
310
  replyOptions: {
329
311
  skillFilter: roomConfig?.skills,
330
- onModelSelected,
331
312
  disableBlockStreaming:
332
313
  typeof account.config.blockStreaming === "boolean"
333
314
  ? !account.config.blockStreaming
@@ -16,7 +16,7 @@ export function createSignedCreateMessageRequest(params?: { backend?: string })
16
16
  const body = JSON.stringify(payload);
17
17
  const { random, signature } = generateNextcloudTalkSignature({
18
18
  body,
19
- secret: "nextcloud-secret",
19
+ secret: "nextcloud-secret", // pragma: allowlist secret
20
20
  });
21
21
  return {
22
22
  body,
package/src/monitor.ts CHANGED
@@ -6,7 +6,7 @@ import {
6
6
  isRequestBodyLimitError,
7
7
  readRequestBodyWithLimit,
8
8
  requestBodyErrorToText,
9
- } from "openclaw/plugin-sdk";
9
+ } from "openclaw/plugin-sdk/nextcloud-talk";
10
10
  import { resolveNextcloudTalkAccount } from "./accounts.js";
11
11
  import { handleNextcloudTalkInbound } from "./inbound.js";
12
12
  import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
package/src/onboarding.ts CHANGED
@@ -1,18 +1,20 @@
1
1
  import {
2
- addWildcardAllowFrom,
2
+ buildSingleChannelSecretPromptState,
3
3
  formatDocsLink,
4
4
  hasConfiguredSecretInput,
5
+ mapAllowFromEntries,
5
6
  mergeAllowFromEntries,
6
7
  promptSingleChannelSecretInput,
7
- promptAccountId,
8
+ resolveAccountIdForConfigure,
8
9
  DEFAULT_ACCOUNT_ID,
9
10
  normalizeAccountId,
11
+ setTopLevelChannelDmPolicyWithAllowFrom,
10
12
  type SecretInput,
11
13
  type ChannelOnboardingAdapter,
12
14
  type ChannelOnboardingDmPolicy,
13
15
  type OpenClawConfig,
14
16
  type WizardPrompter,
15
- } from "openclaw/plugin-sdk";
17
+ } from "openclaw/plugin-sdk/nextcloud-talk";
16
18
  import {
17
19
  listNextcloudTalkAccountIds,
18
20
  resolveDefaultNextcloudTalkAccountId,
@@ -23,24 +25,52 @@ import type { CoreConfig, DmPolicy } from "./types.js";
23
25
  const channel = "nextcloud-talk" as const;
24
26
 
25
27
  function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
26
- const existingConfig = cfg.channels?.["nextcloud-talk"];
27
- const existingAllowFrom: string[] = (existingConfig?.allowFrom ?? []).map((x) => String(x));
28
- const allowFrom: string[] =
29
- dmPolicy === "open" ? (addWildcardAllowFrom(existingAllowFrom) as string[]) : existingAllowFrom;
30
-
31
- const newNextcloudTalkConfig = {
32
- ...existingConfig,
28
+ return setTopLevelChannelDmPolicyWithAllowFrom({
29
+ cfg,
30
+ channel: "nextcloud-talk",
33
31
  dmPolicy,
34
- allowFrom,
35
- };
32
+ getAllowFrom: (inputCfg) =>
33
+ mapAllowFromEntries(inputCfg.channels?.["nextcloud-talk"]?.allowFrom),
34
+ }) as CoreConfig;
35
+ }
36
+
37
+ function setNextcloudTalkAccountConfig(
38
+ cfg: CoreConfig,
39
+ accountId: string,
40
+ updates: Record<string, unknown>,
41
+ ): CoreConfig {
42
+ if (accountId === DEFAULT_ACCOUNT_ID) {
43
+ return {
44
+ ...cfg,
45
+ channels: {
46
+ ...cfg.channels,
47
+ "nextcloud-talk": {
48
+ ...cfg.channels?.["nextcloud-talk"],
49
+ enabled: true,
50
+ ...updates,
51
+ },
52
+ },
53
+ };
54
+ }
36
55
 
37
56
  return {
38
57
  ...cfg,
39
58
  channels: {
40
59
  ...cfg.channels,
41
- "nextcloud-talk": newNextcloudTalkConfig,
60
+ "nextcloud-talk": {
61
+ ...cfg.channels?.["nextcloud-talk"],
62
+ enabled: true,
63
+ accounts: {
64
+ ...cfg.channels?.["nextcloud-talk"]?.accounts,
65
+ [accountId]: {
66
+ ...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId],
67
+ enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
68
+ ...updates,
69
+ },
70
+ },
71
+ },
42
72
  },
43
- } as CoreConfig;
73
+ };
44
74
  }
45
75
 
46
76
  async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise<void> {
@@ -105,40 +135,10 @@ async function promptNextcloudTalkAllowFrom(params: {
105
135
  ];
106
136
  const unique = mergeAllowFromEntries(undefined, merged);
107
137
 
108
- if (accountId === DEFAULT_ACCOUNT_ID) {
109
- return {
110
- ...cfg,
111
- channels: {
112
- ...cfg.channels,
113
- "nextcloud-talk": {
114
- ...cfg.channels?.["nextcloud-talk"],
115
- enabled: true,
116
- dmPolicy: "allowlist",
117
- allowFrom: unique,
118
- },
119
- },
120
- };
121
- }
122
-
123
- return {
124
- ...cfg,
125
- channels: {
126
- ...cfg.channels,
127
- "nextcloud-talk": {
128
- ...cfg.channels?.["nextcloud-talk"],
129
- enabled: true,
130
- accounts: {
131
- ...cfg.channels?.["nextcloud-talk"]?.accounts,
132
- [accountId]: {
133
- ...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId],
134
- enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
135
- dmPolicy: "allowlist",
136
- allowFrom: unique,
137
- },
138
- },
139
- },
140
- },
141
- };
138
+ return setNextcloudTalkAccountConfig(cfg, accountId, {
139
+ dmPolicy: "allowlist",
140
+ allowFrom: unique,
141
+ });
142
142
  }
143
143
 
144
144
  async function promptNextcloudTalkAllowFromForAccount(params: {
@@ -193,22 +193,16 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
193
193
  shouldPromptAccountIds,
194
194
  forceAllowFrom,
195
195
  }) => {
196
- const nextcloudTalkOverride = accountOverrides["nextcloud-talk"]?.trim();
197
196
  const defaultAccountId = resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig);
198
- let accountId = nextcloudTalkOverride
199
- ? normalizeAccountId(nextcloudTalkOverride)
200
- : defaultAccountId;
201
-
202
- if (shouldPromptAccountIds && !nextcloudTalkOverride) {
203
- accountId = await promptAccountId({
204
- cfg: cfg as CoreConfig,
205
- prompter,
206
- label: "Nextcloud Talk",
207
- currentId: accountId,
208
- listAccountIds: listNextcloudTalkAccountIds as (cfg: OpenClawConfig) => string[],
209
- defaultAccountId,
210
- });
211
- }
197
+ const accountId = await resolveAccountIdForConfigure({
198
+ cfg,
199
+ prompter,
200
+ label: "Nextcloud Talk",
201
+ accountOverride: accountOverrides["nextcloud-talk"],
202
+ shouldPromptAccountIds,
203
+ listAccountIds: listNextcloudTalkAccountIds as (cfg: OpenClawConfig) => string[],
204
+ defaultAccountId,
205
+ });
212
206
 
213
207
  let next = cfg as CoreConfig;
214
208
  const resolvedAccount = resolveNextcloudTalkAccount({
@@ -217,11 +211,16 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
217
211
  });
218
212
  const accountConfigured = Boolean(resolvedAccount.secret && resolvedAccount.baseUrl);
219
213
  const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
220
- const canUseEnv = allowEnv && Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim());
221
214
  const hasConfigSecret = Boolean(
222
215
  hasConfiguredSecretInput(resolvedAccount.config.botSecret) ||
223
216
  resolvedAccount.config.botSecretFile,
224
217
  );
218
+ const secretPromptState = buildSingleChannelSecretPromptState({
219
+ accountConfigured,
220
+ hasConfigToken: hasConfigSecret,
221
+ allowEnv,
222
+ envValue: process.env.NEXTCLOUD_TALK_BOT_SECRET,
223
+ });
225
224
 
226
225
  let baseUrl = resolvedAccount.baseUrl;
227
226
  if (!baseUrl) {
@@ -252,9 +251,9 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
252
251
  prompter,
253
252
  providerHint: "nextcloud-talk",
254
253
  credentialLabel: "bot secret",
255
- accountConfigured,
256
- canUseEnv: canUseEnv && !hasConfigSecret,
257
- hasConfigToken: hasConfigSecret,
254
+ accountConfigured: secretPromptState.accountConfigured,
255
+ canUseEnv: secretPromptState.canUseEnv,
256
+ hasConfigToken: secretPromptState.hasConfigToken,
258
257
  envPrompt: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?",
259
258
  keepPrompt: "Nextcloud Talk bot secret already configured. Keep it?",
260
259
  inputPrompt: "Enter Nextcloud Talk bot secret",
@@ -265,41 +264,10 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
265
264
  }
266
265
 
267
266
  if (secretResult.action === "use-env" || secret || baseUrl !== resolvedAccount.baseUrl) {
268
- if (accountId === DEFAULT_ACCOUNT_ID) {
269
- next = {
270
- ...next,
271
- channels: {
272
- ...next.channels,
273
- "nextcloud-talk": {
274
- ...next.channels?.["nextcloud-talk"],
275
- enabled: true,
276
- baseUrl,
277
- ...(secret ? { botSecret: secret } : {}),
278
- },
279
- },
280
- };
281
- } else {
282
- next = {
283
- ...next,
284
- channels: {
285
- ...next.channels,
286
- "nextcloud-talk": {
287
- ...next.channels?.["nextcloud-talk"],
288
- enabled: true,
289
- accounts: {
290
- ...next.channels?.["nextcloud-talk"]?.accounts,
291
- [accountId]: {
292
- ...next.channels?.["nextcloud-talk"]?.accounts?.[accountId],
293
- enabled:
294
- next.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
295
- baseUrl,
296
- ...(secret ? { botSecret: secret } : {}),
297
- },
298
- },
299
- },
300
- },
301
- };
302
- }
267
+ next = setNextcloudTalkAccountConfig(next, accountId, {
268
+ baseUrl,
269
+ ...(secret ? { botSecret: secret } : {}),
270
+ });
303
271
  }
304
272
 
305
273
  const existingApiUser = resolvedAccount.config.apiUser?.trim();
@@ -324,50 +292,21 @@ export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
324
292
  prompter,
325
293
  providerHint: "nextcloud-talk-api",
326
294
  credentialLabel: "API password",
327
- accountConfigured: Boolean(existingApiUser && existingApiPasswordConfigured),
328
- canUseEnv: false,
329
- hasConfigToken: existingApiPasswordConfigured,
295
+ ...buildSingleChannelSecretPromptState({
296
+ accountConfigured: Boolean(existingApiUser && existingApiPasswordConfigured),
297
+ hasConfigToken: existingApiPasswordConfigured,
298
+ allowEnv: false,
299
+ }),
330
300
  envPrompt: "",
331
301
  keepPrompt: "Nextcloud Talk API password already configured. Keep it?",
332
302
  inputPrompt: "Enter Nextcloud Talk API password",
333
303
  preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD",
334
304
  });
335
305
  const apiPassword = apiPasswordResult.action === "set" ? apiPasswordResult.value : undefined;
336
- if (accountId === DEFAULT_ACCOUNT_ID) {
337
- next = {
338
- ...next,
339
- channels: {
340
- ...next.channels,
341
- "nextcloud-talk": {
342
- ...next.channels?.["nextcloud-talk"],
343
- enabled: true,
344
- apiUser,
345
- ...(apiPassword ? { apiPassword } : {}),
346
- },
347
- },
348
- };
349
- } else {
350
- next = {
351
- ...next,
352
- channels: {
353
- ...next.channels,
354
- "nextcloud-talk": {
355
- ...next.channels?.["nextcloud-talk"],
356
- enabled: true,
357
- accounts: {
358
- ...next.channels?.["nextcloud-talk"]?.accounts,
359
- [accountId]: {
360
- ...next.channels?.["nextcloud-talk"]?.accounts?.[accountId],
361
- enabled:
362
- next.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
363
- apiUser,
364
- ...(apiPassword ? { apiPassword } : {}),
365
- },
366
- },
367
- },
368
- },
369
- };
370
- }
306
+ next = setNextcloudTalkAccountConfig(next, accountId, {
307
+ apiUser,
308
+ ...(apiPassword ? { apiPassword } : {}),
309
+ });
371
310
  }
372
311
 
373
312
  if (forceAllowFrom) {
@@ -1,5 +1,5 @@
1
1
  import { describe, expect, it } from "vitest";
2
- import { resolveNextcloudTalkAllowlistMatch } from "./policy.js";
2
+ import { resolveNextcloudTalkAllowlistMatch, resolveNextcloudTalkGroupAllow } from "./policy.js";
3
3
 
4
4
  describe("nextcloud-talk policy", () => {
5
5
  describe("resolveNextcloudTalkAllowlistMatch", () => {
@@ -30,4 +30,109 @@ describe("nextcloud-talk policy", () => {
30
30
  ).toBe(false);
31
31
  });
32
32
  });
33
+
34
+ describe("resolveNextcloudTalkGroupAllow", () => {
35
+ it("blocks disabled policy", () => {
36
+ expect(
37
+ resolveNextcloudTalkGroupAllow({
38
+ groupPolicy: "disabled",
39
+ outerAllowFrom: ["owner"],
40
+ innerAllowFrom: ["room-user"],
41
+ senderId: "owner",
42
+ }),
43
+ ).toEqual({
44
+ allowed: false,
45
+ outerMatch: { allowed: false },
46
+ innerMatch: { allowed: false },
47
+ });
48
+ });
49
+
50
+ it("allows open policy", () => {
51
+ expect(
52
+ resolveNextcloudTalkGroupAllow({
53
+ groupPolicy: "open",
54
+ outerAllowFrom: [],
55
+ innerAllowFrom: [],
56
+ senderId: "owner",
57
+ }),
58
+ ).toEqual({
59
+ allowed: true,
60
+ outerMatch: { allowed: true },
61
+ innerMatch: { allowed: true },
62
+ });
63
+ });
64
+
65
+ it("blocks allowlist mode when both outer and inner allowlists are empty", () => {
66
+ expect(
67
+ resolveNextcloudTalkGroupAllow({
68
+ groupPolicy: "allowlist",
69
+ outerAllowFrom: [],
70
+ innerAllowFrom: [],
71
+ senderId: "owner",
72
+ }),
73
+ ).toEqual({
74
+ allowed: false,
75
+ outerMatch: { allowed: false },
76
+ innerMatch: { allowed: false },
77
+ });
78
+ });
79
+
80
+ it("requires inner match when only room-specific allowlist is configured", () => {
81
+ expect(
82
+ resolveNextcloudTalkGroupAllow({
83
+ groupPolicy: "allowlist",
84
+ outerAllowFrom: [],
85
+ innerAllowFrom: ["room-user"],
86
+ senderId: "room-user",
87
+ }),
88
+ ).toEqual({
89
+ allowed: true,
90
+ outerMatch: { allowed: false },
91
+ innerMatch: { allowed: true, matchKey: "room-user", matchSource: "id" },
92
+ });
93
+ });
94
+
95
+ it("blocks when outer allowlist misses even if inner allowlist matches", () => {
96
+ expect(
97
+ resolveNextcloudTalkGroupAllow({
98
+ groupPolicy: "allowlist",
99
+ outerAllowFrom: ["team-owner"],
100
+ innerAllowFrom: ["room-user"],
101
+ senderId: "room-user",
102
+ }),
103
+ ).toEqual({
104
+ allowed: false,
105
+ outerMatch: { allowed: false },
106
+ innerMatch: { allowed: true, matchKey: "room-user", matchSource: "id" },
107
+ });
108
+ });
109
+
110
+ it("allows when both outer and inner allowlists match", () => {
111
+ expect(
112
+ resolveNextcloudTalkGroupAllow({
113
+ groupPolicy: "allowlist",
114
+ outerAllowFrom: ["team-owner"],
115
+ innerAllowFrom: ["room-user"],
116
+ senderId: "team-owner",
117
+ }),
118
+ ).toEqual({
119
+ allowed: false,
120
+ outerMatch: { allowed: true, matchKey: "team-owner", matchSource: "id" },
121
+ innerMatch: { allowed: false },
122
+ });
123
+
124
+ expect(
125
+ resolveNextcloudTalkGroupAllow({
126
+ groupPolicy: "allowlist",
127
+ outerAllowFrom: ["shared-user"],
128
+ innerAllowFrom: ["shared-user"],
129
+ senderId: "shared-user",
130
+ }),
131
+ ).toEqual({
132
+ allowed: true,
133
+ outerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" },
134
+ innerMatch: { allowed: true, matchKey: "shared-user", matchSource: "id" },
135
+ });
136
+ });
137
+ });
33
138
  });
package/src/policy.ts CHANGED
@@ -3,14 +3,15 @@ import type {
3
3
  ChannelGroupContext,
4
4
  GroupPolicy,
5
5
  GroupToolPolicyConfig,
6
- } from "openclaw/plugin-sdk";
6
+ } from "openclaw/plugin-sdk/nextcloud-talk";
7
7
  import {
8
8
  buildChannelKeyCandidates,
9
+ evaluateMatchedGroupAccessForPolicy,
9
10
  normalizeChannelSlug,
10
11
  resolveChannelEntryMatchWithFallback,
11
12
  resolveMentionGatingWithBypass,
12
13
  resolveNestedAllowlistDecision,
13
- } from "openclaw/plugin-sdk";
14
+ } from "openclaw/plugin-sdk/nextcloud-talk";
14
15
  import type { NextcloudTalkRoomConfig } from "./types.js";
15
16
 
16
17
  function normalizeAllowEntry(raw: string): string {
@@ -128,19 +129,8 @@ export function resolveNextcloudTalkGroupAllow(params: {
128
129
  innerAllowFrom: Array<string | number> | undefined;
129
130
  senderId: string;
130
131
  }): { allowed: boolean; outerMatch: AllowlistMatch; innerMatch: AllowlistMatch } {
131
- if (params.groupPolicy === "disabled") {
132
- return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
133
- }
134
- if (params.groupPolicy === "open") {
135
- return { allowed: true, outerMatch: { allowed: true }, innerMatch: { allowed: true } };
136
- }
137
-
138
132
  const outerAllow = normalizeNextcloudTalkAllowlist(params.outerAllowFrom);
139
133
  const innerAllow = normalizeNextcloudTalkAllowlist(params.innerAllowFrom);
140
- if (outerAllow.length === 0 && innerAllow.length === 0) {
141
- return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
142
- }
143
-
144
134
  const outerMatch = resolveNextcloudTalkAllowlistMatch({
145
135
  allowFrom: params.outerAllowFrom,
146
136
  senderId: params.senderId,
@@ -149,14 +139,32 @@ export function resolveNextcloudTalkGroupAllow(params: {
149
139
  allowFrom: params.innerAllowFrom,
150
140
  senderId: params.senderId,
151
141
  });
152
- const allowed = resolveNestedAllowlistDecision({
153
- outerConfigured: outerAllow.length > 0 || innerAllow.length > 0,
154
- outerMatched: outerAllow.length > 0 ? outerMatch.allowed : true,
155
- innerConfigured: innerAllow.length > 0,
156
- innerMatched: innerMatch.allowed,
142
+ const access = evaluateMatchedGroupAccessForPolicy({
143
+ groupPolicy: params.groupPolicy,
144
+ allowlistConfigured: outerAllow.length > 0 || innerAllow.length > 0,
145
+ allowlistMatched: resolveNestedAllowlistDecision({
146
+ outerConfigured: outerAllow.length > 0 || innerAllow.length > 0,
147
+ outerMatched: outerAllow.length > 0 ? outerMatch.allowed : true,
148
+ innerConfigured: innerAllow.length > 0,
149
+ innerMatched: innerMatch.allowed,
150
+ }),
157
151
  });
158
152
 
159
- return { allowed, outerMatch, innerMatch };
153
+ return {
154
+ allowed: access.allowed,
155
+ outerMatch:
156
+ params.groupPolicy === "open"
157
+ ? { allowed: true }
158
+ : params.groupPolicy === "disabled"
159
+ ? { allowed: false }
160
+ : outerMatch,
161
+ innerMatch:
162
+ params.groupPolicy === "open"
163
+ ? { allowed: true }
164
+ : params.groupPolicy === "disabled"
165
+ ? { allowed: false }
166
+ : innerMatch,
167
+ };
160
168
  }
161
169
 
162
170
  export function resolveNextcloudTalkMentionGate(params: {
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { createPersistentDedupe } from "openclaw/plugin-sdk";
2
+ import { createPersistentDedupe } from "openclaw/plugin-sdk/nextcloud-talk";
3
3
 
4
4
  const DEFAULT_REPLAY_TTL_MS = 24 * 60 * 60 * 1000;
5
5
  const DEFAULT_MEMORY_MAX_SIZE = 1_000;
package/src/room-info.ts CHANGED
@@ -1,6 +1,6 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { fetchWithSsrFGuard } from "openclaw/plugin-sdk";
3
- import type { RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/nextcloud-talk";
3
+ import type { RuntimeEnv } from "openclaw/plugin-sdk/nextcloud-talk";
4
4
  import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
5
5
  import { normalizeResolvedSecretInputString } from "./secret-input.js";
6
6
 
package/src/runtime.ts CHANGED
@@ -1,4 +1,4 @@
1
- import type { PluginRuntime } from "openclaw/plugin-sdk";
1
+ import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk";
2
2
 
3
3
  let runtime: PluginRuntime | null = null;
4
4
 
@@ -1,19 +1,13 @@
1
1
  import {
2
+ buildSecretInputSchema,
2
3
  hasConfiguredSecretInput,
3
4
  normalizeResolvedSecretInputString,
4
5
  normalizeSecretInputString,
5
- } from "openclaw/plugin-sdk";
6
- import { z } from "zod";
6
+ } from "openclaw/plugin-sdk/nextcloud-talk";
7
7
 
8
- export { hasConfiguredSecretInput, normalizeResolvedSecretInputString, normalizeSecretInputString };
9
-
10
- export function buildSecretInputSchema() {
11
- return z.union([
12
- z.string(),
13
- z.object({
14
- source: z.enum(["env", "file", "exec"]),
15
- provider: z.string().min(1),
16
- id: z.string().min(1),
17
- }),
18
- ]);
19
- }
8
+ export {
9
+ buildSecretInputSchema,
10
+ hasConfiguredSecretInput,
11
+ normalizeResolvedSecretInputString,
12
+ normalizeSecretInputString,
13
+ };
@@ -0,0 +1,104 @@
1
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
2
+
3
+ const hoisted = vi.hoisted(() => ({
4
+ loadConfig: vi.fn(),
5
+ resolveMarkdownTableMode: vi.fn(() => "preserve"),
6
+ convertMarkdownTables: vi.fn((text: string) => text),
7
+ record: vi.fn(),
8
+ resolveNextcloudTalkAccount: vi.fn(() => ({
9
+ accountId: "default",
10
+ baseUrl: "https://nextcloud.example.com",
11
+ secret: "secret-value", // pragma: allowlist secret
12
+ })),
13
+ generateNextcloudTalkSignature: vi.fn(() => ({
14
+ random: "r",
15
+ signature: "s",
16
+ })),
17
+ }));
18
+
19
+ vi.mock("./runtime.js", () => ({
20
+ getNextcloudTalkRuntime: () => ({
21
+ config: {
22
+ loadConfig: hoisted.loadConfig,
23
+ },
24
+ channel: {
25
+ text: {
26
+ resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode,
27
+ convertMarkdownTables: hoisted.convertMarkdownTables,
28
+ },
29
+ activity: {
30
+ record: hoisted.record,
31
+ },
32
+ },
33
+ }),
34
+ }));
35
+
36
+ vi.mock("./accounts.js", () => ({
37
+ resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount,
38
+ }));
39
+
40
+ vi.mock("./signature.js", () => ({
41
+ generateNextcloudTalkSignature: hoisted.generateNextcloudTalkSignature,
42
+ }));
43
+
44
+ import { sendMessageNextcloudTalk, sendReactionNextcloudTalk } from "./send.js";
45
+
46
+ describe("nextcloud-talk send cfg threading", () => {
47
+ const fetchMock = vi.fn<typeof fetch>();
48
+
49
+ beforeEach(() => {
50
+ vi.clearAllMocks();
51
+ fetchMock.mockReset();
52
+ vi.stubGlobal("fetch", fetchMock);
53
+ });
54
+
55
+ afterEach(() => {
56
+ vi.unstubAllGlobals();
57
+ });
58
+
59
+ it("uses provided cfg for sendMessage and skips runtime loadConfig", async () => {
60
+ const cfg = { source: "provided" } as const;
61
+ fetchMock.mockResolvedValueOnce(
62
+ new Response(
63
+ JSON.stringify({
64
+ ocs: { data: { id: 12345, timestamp: 1_706_000_000 } },
65
+ }),
66
+ { status: 200, headers: { "content-type": "application/json" } },
67
+ ),
68
+ );
69
+
70
+ const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
71
+ cfg,
72
+ accountId: "work",
73
+ });
74
+
75
+ expect(hoisted.loadConfig).not.toHaveBeenCalled();
76
+ expect(hoisted.resolveNextcloudTalkAccount).toHaveBeenCalledWith({
77
+ cfg,
78
+ accountId: "work",
79
+ });
80
+ expect(fetchMock).toHaveBeenCalledTimes(1);
81
+ expect(result).toEqual({
82
+ messageId: "12345",
83
+ roomToken: "abc123",
84
+ timestamp: 1_706_000_000,
85
+ });
86
+ });
87
+
88
+ it("falls back to runtime cfg for sendReaction when cfg is omitted", async () => {
89
+ const runtimeCfg = { source: "runtime" } as const;
90
+ hoisted.loadConfig.mockReturnValueOnce(runtimeCfg);
91
+ fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 }));
92
+
93
+ const result = await sendReactionNextcloudTalk("room:ops", "m-1", "👍", {
94
+ accountId: "default",
95
+ });
96
+
97
+ expect(result).toEqual({ ok: true });
98
+ expect(hoisted.loadConfig).toHaveBeenCalledTimes(1);
99
+ expect(hoisted.resolveNextcloudTalkAccount).toHaveBeenCalledWith({
100
+ cfg: runtimeCfg,
101
+ accountId: "default",
102
+ });
103
+ });
104
+ });
package/src/send.ts CHANGED
@@ -9,6 +9,7 @@ type NextcloudTalkSendOpts = {
9
9
  accountId?: string;
10
10
  replyTo?: string;
11
11
  verbose?: boolean;
12
+ cfg?: CoreConfig;
12
13
  };
13
14
 
14
15
  function resolveCredentials(
@@ -60,7 +61,7 @@ export async function sendMessageNextcloudTalk(
60
61
  text: string,
61
62
  opts: NextcloudTalkSendOpts = {},
62
63
  ): Promise<NextcloudTalkSendResult> {
63
- const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig;
64
+ const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig;
64
65
  const account = resolveNextcloudTalkAccount({
65
66
  cfg,
66
67
  accountId: opts.accountId,
@@ -175,7 +176,7 @@ export async function sendReactionNextcloudTalk(
175
176
  reaction: string,
176
177
  opts: Omit<NextcloudTalkSendOpts, "replyTo"> = {},
177
178
  ): Promise<{ ok: true }> {
178
- const cfg = getNextcloudTalkRuntime().config.loadConfig() as CoreConfig;
179
+ const cfg = (opts.cfg ?? getNextcloudTalkRuntime().config.loadConfig()) as CoreConfig;
179
180
  const account = resolveNextcloudTalkAccount({
180
181
  cfg,
181
182
  accountId: opts.accountId,
package/src/types.ts CHANGED
@@ -4,7 +4,7 @@ import type {
4
4
  DmPolicy,
5
5
  GroupPolicy,
6
6
  SecretInput,
7
- } from "openclaw/plugin-sdk";
7
+ } from "openclaw/plugin-sdk/nextcloud-talk";
8
8
 
9
9
  export type { DmPolicy, GroupPolicy };
10
10