@openclaw/nextcloud-talk 2026.2.25 → 2026.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@openclaw/nextcloud-talk",
3
- "version": "2026.2.25",
3
+ "version": "2026.3.1",
4
4
  "description": "OpenClaw Nextcloud Talk channel plugin",
5
5
  "type": "module",
6
6
  "openclaw": {
package/src/accounts.ts CHANGED
@@ -1,5 +1,9 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "openclaw/plugin-sdk/account-id";
2
+ import {
3
+ DEFAULT_ACCOUNT_ID,
4
+ normalizeAccountId,
5
+ normalizeOptionalAccountId,
6
+ } from "openclaw/plugin-sdk/account-id";
3
7
  import type { CoreConfig, NextcloudTalkAccountConfig } from "./types.js";
4
8
 
5
9
  function isTruthyEnvValue(value?: string): boolean {
@@ -48,6 +52,15 @@ export function listNextcloudTalkAccountIds(cfg: CoreConfig): string[] {
48
52
  }
49
53
 
50
54
  export function resolveDefaultNextcloudTalkAccountId(cfg: CoreConfig): string {
55
+ const preferred = normalizeOptionalAccountId(cfg.channels?.["nextcloud-talk"]?.defaultAccount);
56
+ if (
57
+ preferred &&
58
+ listNextcloudTalkAccountIds(cfg).some(
59
+ (accountId) => normalizeAccountId(accountId) === preferred,
60
+ )
61
+ ) {
62
+ return preferred;
63
+ }
51
64
  const ids = listNextcloudTalkAccountIds(cfg);
52
65
  if (ids.includes(DEFAULT_ACCOUNT_ID)) {
53
66
  return DEFAULT_ACCOUNT_ID;
@@ -76,8 +89,14 @@ function mergeNextcloudTalkAccountConfig(
76
89
  cfg: CoreConfig,
77
90
  accountId: string,
78
91
  ): NextcloudTalkAccountConfig {
79
- const { accounts: _ignored, ...base } = (cfg.channels?.["nextcloud-talk"] ??
80
- {}) as NextcloudTalkAccountConfig & { accounts?: unknown };
92
+ const {
93
+ accounts: _ignored,
94
+ defaultAccount: _ignoredDefaultAccount,
95
+ ...base
96
+ } = (cfg.channels?.["nextcloud-talk"] ?? {}) as NextcloudTalkAccountConfig & {
97
+ accounts?: unknown;
98
+ defaultAccount?: unknown;
99
+ };
81
100
  const account = resolveAccountConfig(cfg, accountId) ?? {};
82
101
  return { ...base, ...account };
83
102
  }
@@ -0,0 +1,115 @@
1
+ import type {
2
+ ChannelAccountSnapshot,
3
+ ChannelGatewayContext,
4
+ OpenClawConfig,
5
+ } from "openclaw/plugin-sdk";
6
+ import { afterEach, describe, expect, it, vi } from "vitest";
7
+ import { createRuntimeEnv } from "../../test-utils/runtime-env.js";
8
+ import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
9
+
10
+ const hoisted = vi.hoisted(() => ({
11
+ monitorNextcloudTalkProvider: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("./monitor.js", async () => {
15
+ const actual = await vi.importActual<typeof import("./monitor.js")>("./monitor.js");
16
+ return {
17
+ ...actual,
18
+ monitorNextcloudTalkProvider: hoisted.monitorNextcloudTalkProvider,
19
+ };
20
+ });
21
+
22
+ import { nextcloudTalkPlugin } from "./channel.js";
23
+
24
+ function createStartAccountCtx(params: {
25
+ account: ResolvedNextcloudTalkAccount;
26
+ abortSignal: AbortSignal;
27
+ }): ChannelGatewayContext<ResolvedNextcloudTalkAccount> {
28
+ const snapshot: ChannelAccountSnapshot = {
29
+ accountId: params.account.accountId,
30
+ configured: true,
31
+ enabled: true,
32
+ running: false,
33
+ };
34
+ return {
35
+ accountId: params.account.accountId,
36
+ account: params.account,
37
+ cfg: {} as OpenClawConfig,
38
+ runtime: createRuntimeEnv(),
39
+ abortSignal: params.abortSignal,
40
+ log: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
41
+ getStatus: () => snapshot,
42
+ setStatus: (next) => {
43
+ Object.assign(snapshot, next);
44
+ },
45
+ };
46
+ }
47
+
48
+ function buildAccount(): ResolvedNextcloudTalkAccount {
49
+ return {
50
+ accountId: "default",
51
+ enabled: true,
52
+ baseUrl: "https://nextcloud.example.com",
53
+ secret: "secret",
54
+ secretSource: "config",
55
+ config: {
56
+ baseUrl: "https://nextcloud.example.com",
57
+ botSecret: "secret",
58
+ webhookPath: "/nextcloud-talk-webhook",
59
+ webhookPort: 8788,
60
+ },
61
+ };
62
+ }
63
+
64
+ describe("nextcloudTalkPlugin gateway.startAccount", () => {
65
+ afterEach(() => {
66
+ vi.clearAllMocks();
67
+ });
68
+
69
+ it("keeps startAccount pending until abort, then stops the monitor", async () => {
70
+ const stop = vi.fn();
71
+ hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop });
72
+ const abort = new AbortController();
73
+
74
+ const task = nextcloudTalkPlugin.gateway!.startAccount!(
75
+ createStartAccountCtx({
76
+ account: buildAccount(),
77
+ abortSignal: abort.signal,
78
+ }),
79
+ );
80
+
81
+ await new Promise((resolve) => setTimeout(resolve, 20));
82
+
83
+ let settled = false;
84
+ void task.then(() => {
85
+ settled = true;
86
+ });
87
+
88
+ await new Promise((resolve) => setTimeout(resolve, 20));
89
+ expect(settled).toBe(false);
90
+ expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce();
91
+ expect(stop).not.toHaveBeenCalled();
92
+
93
+ abort.abort();
94
+ await task;
95
+
96
+ expect(stop).toHaveBeenCalledOnce();
97
+ });
98
+
99
+ it("stops immediately when startAccount receives an already-aborted signal", async () => {
100
+ const stop = vi.fn();
101
+ hoisted.monitorNextcloudTalkProvider.mockResolvedValue({ stop });
102
+ const abort = new AbortController();
103
+ abort.abort();
104
+
105
+ await nextcloudTalkPlugin.gateway!.startAccount!(
106
+ createStartAccountCtx({
107
+ account: buildAccount(),
108
+ abortSignal: abort.signal,
109
+ }),
110
+ );
111
+
112
+ expect(hoisted.monitorNextcloudTalkProvider).toHaveBeenCalledOnce();
113
+ expect(stop).toHaveBeenCalledOnce();
114
+ });
115
+ });
package/src/channel.ts CHANGED
@@ -12,6 +12,7 @@ import {
12
12
  type OpenClawConfig,
13
13
  type ChannelSetupInput,
14
14
  } from "openclaw/plugin-sdk";
15
+ import { waitForAbortSignal } from "../../../src/infra/abort-signal.js";
15
16
  import {
16
17
  listNextcloudTalkAccountIds,
17
18
  resolveDefaultNextcloudTalkAccountId,
@@ -332,7 +333,9 @@ export const nextcloudTalkPlugin: ChannelPlugin<ResolvedNextcloudTalkAccount> =
332
333
  statusSink: (patch) => ctx.setStatus({ accountId: ctx.accountId, ...patch }),
333
334
  });
334
335
 
335
- return { stop };
336
+ // Keep webhook channels pending for the account lifecycle.
337
+ await waitForAbortSignal(ctx.abortSignal);
338
+ stop();
336
339
  },
337
340
  logoutAccount: async ({ accountId, cfg }) => {
338
341
  const nextCfg = { ...cfg } as OpenClawConfig;
@@ -60,6 +60,7 @@ export const NextcloudTalkAccountSchema = NextcloudTalkAccountSchemaBase.superRe
60
60
 
61
61
  export const NextcloudTalkConfigSchema = NextcloudTalkAccountSchemaBase.extend({
62
62
  accounts: z.record(z.string(), NextcloudTalkAccountSchema.optional()).optional(),
63
+ defaultAccount: z.string().optional(),
63
64
  }).superRefine((value, ctx) => {
64
65
  requireOpenAllowFrom({
65
66
  policy: value.dmPolicy,
@@ -75,7 +75,10 @@ describe("nextcloud-talk inbound authz", () => {
75
75
  } as unknown as RuntimeEnv,
76
76
  });
77
77
 
78
- expect(readAllowFromStore).toHaveBeenCalledWith("nextcloud-talk");
78
+ expect(readAllowFromStore).toHaveBeenCalledWith({
79
+ channel: "nextcloud-talk",
80
+ accountId: "default",
81
+ });
79
82
  expect(buildMentionRegexes).not.toHaveBeenCalled();
80
83
  });
81
84
  });
package/src/inbound.ts CHANGED
@@ -1,10 +1,12 @@
1
1
  import {
2
2
  GROUP_POLICY_BLOCKED_LABEL,
3
+ createScopedPairingAccess,
3
4
  createNormalizedOutboundDeliverer,
4
5
  createReplyPrefixOptions,
5
6
  formatTextWithAttachmentLinks,
6
7
  logInboundDrop,
7
- resolveControlCommandGate,
8
+ readStoreAllowFromForDmPolicy,
9
+ resolveDmGroupAccessWithCommandGate,
8
10
  resolveOutboundMediaUrls,
9
11
  resolveAllowlistProviderRuntimeGroupPolicy,
10
12
  resolveDefaultGroupPolicy,
@@ -57,6 +59,11 @@ export async function handleNextcloudTalkInbound(params: {
57
59
  }): Promise<void> {
58
60
  const { message, account, config, runtime, statusSink } = params;
59
61
  const core = getNextcloudTalkRuntime();
62
+ const pairing = createScopedPairingAccess({
63
+ core,
64
+ channel: CHANNEL_ID,
65
+ accountId: account.accountId,
66
+ });
60
67
 
61
68
  const rawBody = message.text?.trim() ?? "";
62
69
  if (!rawBody) {
@@ -96,10 +103,12 @@ export async function handleNextcloudTalkInbound(params: {
96
103
 
97
104
  const configAllowFrom = normalizeNextcloudTalkAllowlist(account.config.allowFrom);
98
105
  const configGroupAllowFrom = normalizeNextcloudTalkAllowlist(account.config.groupAllowFrom);
99
- const storeAllowFrom =
100
- dmPolicy === "allowlist"
101
- ? []
102
- : await core.channel.pairing.readAllowFromStore(CHANNEL_ID).catch(() => []);
106
+ const storeAllowFrom = await readStoreAllowFromForDmPolicy({
107
+ provider: CHANNEL_ID,
108
+ accountId: account.accountId,
109
+ dmPolicy,
110
+ readStore: pairing.readStoreForDmPolicy,
111
+ });
103
112
  const storeAllowList = normalizeNextcloudTalkAllowlist(storeAllowFrom);
104
113
 
105
114
  const roomMatch = resolveNextcloudTalkRoomMatch({
@@ -118,11 +127,6 @@ export async function handleNextcloudTalkInbound(params: {
118
127
  }
119
128
 
120
129
  const roomAllowFrom = normalizeNextcloudTalkAllowlist(roomConfig?.allowFrom);
121
- const baseGroupAllowFrom =
122
- configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom;
123
-
124
- const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean);
125
- const effectiveGroupAllowFrom = [...baseGroupAllowFrom].filter(Boolean);
126
130
 
127
131
  const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
128
132
  cfg: config as OpenClawConfig,
@@ -130,25 +134,33 @@ export async function handleNextcloudTalkInbound(params: {
130
134
  });
131
135
  const useAccessGroups =
132
136
  (config.commands as Record<string, unknown> | undefined)?.useAccessGroups !== false;
133
- const senderAllowedForCommands = resolveNextcloudTalkAllowlistMatch({
134
- allowFrom: isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom,
135
- senderId,
136
- }).allowed;
137
137
  const hasControlCommand = core.channel.text.hasControlCommand(rawBody, config as OpenClawConfig);
138
- const commandGate = resolveControlCommandGate({
139
- useAccessGroups,
140
- authorizers: [
141
- {
142
- configured: (isGroup ? effectiveGroupAllowFrom : effectiveAllowFrom).length > 0,
143
- allowed: senderAllowedForCommands,
144
- },
145
- ],
146
- allowTextCommands,
147
- hasControlCommand,
138
+ const access = resolveDmGroupAccessWithCommandGate({
139
+ isGroup,
140
+ dmPolicy,
141
+ groupPolicy,
142
+ allowFrom: configAllowFrom,
143
+ groupAllowFrom: configGroupAllowFrom,
144
+ storeAllowFrom: storeAllowList,
145
+ isSenderAllowed: (allowFrom) =>
146
+ resolveNextcloudTalkAllowlistMatch({
147
+ allowFrom,
148
+ senderId,
149
+ }).allowed,
150
+ command: {
151
+ useAccessGroups,
152
+ allowTextCommands,
153
+ hasControlCommand,
154
+ },
148
155
  });
149
- const commandAuthorized = commandGate.commandAuthorized;
156
+ const commandAuthorized = access.commandAuthorized;
157
+ const effectiveGroupAllowFrom = access.effectiveGroupAllowFrom;
150
158
 
151
159
  if (isGroup) {
160
+ if (access.decision !== "allow") {
161
+ runtime.log?.(`nextcloud-talk: drop group sender ${senderId} (reason=${access.reason})`);
162
+ return;
163
+ }
152
164
  const groupAllow = resolveNextcloudTalkGroupAllow({
153
165
  groupPolicy,
154
166
  outerAllowFrom: effectiveGroupAllowFrom,
@@ -160,48 +172,35 @@ export async function handleNextcloudTalkInbound(params: {
160
172
  return;
161
173
  }
162
174
  } else {
163
- if (dmPolicy === "disabled") {
164
- runtime.log?.(`nextcloud-talk: drop DM sender=${senderId} (dmPolicy=disabled)`);
165
- return;
166
- }
167
- if (dmPolicy !== "open") {
168
- const dmAllowed = resolveNextcloudTalkAllowlistMatch({
169
- allowFrom: effectiveAllowFrom,
170
- senderId,
171
- }).allowed;
172
- if (!dmAllowed) {
173
- if (dmPolicy === "pairing") {
174
- const { code, created } = await core.channel.pairing.upsertPairingRequest({
175
- channel: CHANNEL_ID,
176
- id: senderId,
177
- meta: { name: senderName || undefined },
178
- });
179
- if (created) {
180
- try {
181
- await sendMessageNextcloudTalk(
182
- roomToken,
183
- core.channel.pairing.buildPairingReply({
184
- channel: CHANNEL_ID,
185
- idLine: `Your Nextcloud user id: ${senderId}`,
186
- code,
187
- }),
188
- { accountId: account.accountId },
189
- );
190
- statusSink?.({ lastOutboundAt: Date.now() });
191
- } catch (err) {
192
- runtime.error?.(
193
- `nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`,
194
- );
195
- }
175
+ if (access.decision !== "allow") {
176
+ if (access.decision === "pairing") {
177
+ const { code, created } = await pairing.upsertPairingRequest({
178
+ id: senderId,
179
+ 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
+ );
192
+ statusSink?.({ lastOutboundAt: Date.now() });
193
+ } catch (err) {
194
+ runtime.error?.(`nextcloud-talk: pairing reply failed for ${senderId}: ${String(err)}`);
196
195
  }
197
196
  }
198
- runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (dmPolicy=${dmPolicy})`);
199
- return;
200
197
  }
198
+ runtime.log?.(`nextcloud-talk: drop DM sender ${senderId} (reason=${access.reason})`);
199
+ return;
201
200
  }
202
201
  }
203
202
 
204
- if (isGroup && commandGate.shouldBlock) {
203
+ if (access.shouldBlockControlCommand) {
205
204
  logInboundDrop({
206
205
  log: (message) => runtime.log?.(message),
207
206
  channel: CHANNEL_ID,
package/src/monitor.ts CHANGED
@@ -276,12 +276,25 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
276
276
  });
277
277
  };
278
278
 
279
+ let stopped = false;
279
280
  const stop = () => {
280
- server.close();
281
+ if (stopped) {
282
+ return;
283
+ }
284
+ stopped = true;
285
+ try {
286
+ server.close();
287
+ } catch {
288
+ // ignore close races while shutting down
289
+ }
281
290
  };
282
291
 
283
292
  if (abortSignal) {
284
- abortSignal.addEventListener("abort", stop, { once: true });
293
+ if (abortSignal.aborted) {
294
+ stop();
295
+ } else {
296
+ abortSignal.addEventListener("abort", stop, { once: true });
297
+ }
285
298
  }
286
299
 
287
300
  return { server, start, stop };
@@ -384,7 +397,14 @@ export async function monitorNextcloudTalkProvider(
384
397
  abortSignal: opts.abortSignal,
385
398
  });
386
399
 
400
+ if (opts.abortSignal?.aborted) {
401
+ return { stop };
402
+ }
387
403
  await start();
404
+ if (opts.abortSignal?.aborted) {
405
+ stop();
406
+ return { stop };
407
+ }
388
408
 
389
409
  const publicUrl =
390
410
  account.config.webhookPublicUrl ??
package/src/types.ts CHANGED
@@ -79,6 +79,8 @@ export type NextcloudTalkAccountConfig = {
79
79
  export type NextcloudTalkConfig = {
80
80
  /** Optional per-account Nextcloud Talk configuration (multi-account). */
81
81
  accounts?: Record<string, NextcloudTalkAccountConfig>;
82
+ /** Optional default account id when multiple accounts are configured. */
83
+ defaultAccount?: string;
82
84
  } & NextcloudTalkAccountConfig;
83
85
 
84
86
  export type CoreConfig = {