@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,202 @@
1
+ import { beforeEach, describe, expect, it, vi } from "vitest";
2
+ import type { PluginRuntime, RuntimeEnv } from "../runtime-api.js";
3
+ import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
4
+ import { handleNextcloudTalkInbound } from "./inbound.js";
5
+ import { setNextcloudTalkRuntime } from "./runtime.js";
6
+ import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
7
+
8
+ const {
9
+ createChannelPairingControllerMock,
10
+ dispatchInboundReplyWithBaseMock,
11
+ readStoreAllowFromForDmPolicyMock,
12
+ resolveDmGroupAccessWithCommandGateMock,
13
+ resolveAllowlistProviderRuntimeGroupPolicyMock,
14
+ resolveDefaultGroupPolicyMock,
15
+ warnMissingProviderGroupPolicyFallbackOnceMock,
16
+ } = vi.hoisted(() => {
17
+ return {
18
+ createChannelPairingControllerMock: vi.fn(),
19
+ dispatchInboundReplyWithBaseMock: vi.fn(),
20
+ readStoreAllowFromForDmPolicyMock: vi.fn(),
21
+ resolveDmGroupAccessWithCommandGateMock: vi.fn(),
22
+ resolveAllowlistProviderRuntimeGroupPolicyMock: vi.fn(),
23
+ resolveDefaultGroupPolicyMock: vi.fn(),
24
+ warnMissingProviderGroupPolicyFallbackOnceMock: vi.fn(),
25
+ };
26
+ });
27
+
28
+ const sendMessageNextcloudTalkMock = vi.hoisted(() => vi.fn());
29
+ const resolveNextcloudTalkRoomKindMock = vi.hoisted(() => vi.fn());
30
+
31
+ vi.mock("../runtime-api.js", async () => {
32
+ const actual = await vi.importActual<typeof import("../runtime-api.js")>("../runtime-api.js");
33
+ return {
34
+ ...actual,
35
+ createChannelPairingController: createChannelPairingControllerMock,
36
+ dispatchInboundReplyWithBase: dispatchInboundReplyWithBaseMock,
37
+ readStoreAllowFromForDmPolicy: readStoreAllowFromForDmPolicyMock,
38
+ resolveDmGroupAccessWithCommandGate: resolveDmGroupAccessWithCommandGateMock,
39
+ resolveAllowlistProviderRuntimeGroupPolicy: resolveAllowlistProviderRuntimeGroupPolicyMock,
40
+ resolveDefaultGroupPolicy: resolveDefaultGroupPolicyMock,
41
+ warnMissingProviderGroupPolicyFallbackOnce: warnMissingProviderGroupPolicyFallbackOnceMock,
42
+ };
43
+ });
44
+
45
+ vi.mock("./send.js", () => ({
46
+ sendMessageNextcloudTalk: sendMessageNextcloudTalkMock,
47
+ }));
48
+
49
+ vi.mock("./room-info.js", async () => {
50
+ const actual = await vi.importActual<typeof import("./room-info.js")>("./room-info.js");
51
+ return {
52
+ ...actual,
53
+ resolveNextcloudTalkRoomKind: resolveNextcloudTalkRoomKindMock,
54
+ };
55
+ });
56
+
57
+ function installRuntime(params?: {
58
+ buildMentionRegexes?: () => RegExp[];
59
+ matchesMentionPatterns?: (body: string, regexes: RegExp[]) => boolean;
60
+ }) {
61
+ setNextcloudTalkRuntime({
62
+ channel: {
63
+ pairing: {
64
+ readAllowFromStore: vi.fn(async () => []),
65
+ upsertPairingRequest: vi.fn(async () => ({ code: "123456", created: true })),
66
+ },
67
+ commands: {
68
+ shouldHandleTextCommands: vi.fn(() => false),
69
+ },
70
+ text: {
71
+ hasControlCommand: vi.fn(() => false),
72
+ },
73
+ mentions: {
74
+ buildMentionRegexes: params?.buildMentionRegexes ?? vi.fn(() => []),
75
+ matchesMentionPatterns: params?.matchesMentionPatterns ?? vi.fn(() => false),
76
+ },
77
+ },
78
+ } as unknown as PluginRuntime);
79
+ }
80
+
81
+ function createRuntimeEnv() {
82
+ return {
83
+ log: vi.fn(),
84
+ error: vi.fn(),
85
+ } as unknown as RuntimeEnv;
86
+ }
87
+
88
+ function createAccount(
89
+ overrides?: Partial<ResolvedNextcloudTalkAccount>,
90
+ ): ResolvedNextcloudTalkAccount {
91
+ return {
92
+ accountId: "default",
93
+ enabled: true,
94
+ baseUrl: "https://cloud.example.com",
95
+ secret: "secret",
96
+ secretSource: "config",
97
+ config: {
98
+ dmPolicy: "pairing",
99
+ allowFrom: [],
100
+ groupPolicy: "allowlist",
101
+ groupAllowFrom: [],
102
+ },
103
+ ...overrides,
104
+ };
105
+ }
106
+
107
+ function createMessage(
108
+ overrides?: Partial<NextcloudTalkInboundMessage>,
109
+ ): NextcloudTalkInboundMessage {
110
+ return {
111
+ messageId: "msg-1",
112
+ roomToken: "room-1",
113
+ roomName: "Room 1",
114
+ senderId: "user-1",
115
+ senderName: "Alice",
116
+ text: "hello",
117
+ mediaType: "text/plain",
118
+ timestamp: Date.now(),
119
+ isGroupChat: false,
120
+ ...overrides,
121
+ };
122
+ }
123
+
124
+ describe("nextcloud-talk inbound behavior", () => {
125
+ beforeEach(() => {
126
+ vi.clearAllMocks();
127
+ installRuntime();
128
+ resolveNextcloudTalkRoomKindMock.mockResolvedValue("direct");
129
+ resolveDefaultGroupPolicyMock.mockReturnValue("allowlist");
130
+ resolveAllowlistProviderRuntimeGroupPolicyMock.mockReturnValue({
131
+ groupPolicy: "allowlist",
132
+ providerMissingFallbackApplied: false,
133
+ });
134
+ warnMissingProviderGroupPolicyFallbackOnceMock.mockReturnValue(undefined);
135
+ readStoreAllowFromForDmPolicyMock.mockResolvedValue([]);
136
+ });
137
+
138
+ // The DM pairing assertion currently depends on a mocked runtime barrel that Vitest
139
+ // does not bind reliably for this extension package.
140
+ it.skip("issues a DM pairing challenge and sends the challenge text", async () => {
141
+ createChannelPairingControllerMock.mockReturnValue({
142
+ readStoreForDmPolicy: vi.fn(),
143
+ issueChallenge: vi.fn(),
144
+ });
145
+ resolveDmGroupAccessWithCommandGateMock.mockReturnValue({
146
+ decision: "pairing",
147
+ reason: "pairing_required",
148
+ commandAuthorized: false,
149
+ effectiveGroupAllowFrom: [],
150
+ });
151
+ sendMessageNextcloudTalkMock.mockResolvedValue(undefined);
152
+
153
+ const statusSink = vi.fn();
154
+ await handleNextcloudTalkInbound({
155
+ message: createMessage(),
156
+ account: createAccount(),
157
+ config: { channels: { "nextcloud-talk": {} } } as CoreConfig,
158
+ runtime: createRuntimeEnv(),
159
+ statusSink,
160
+ });
161
+ });
162
+
163
+ it("drops unmentioned group traffic before dispatch", async () => {
164
+ installRuntime({
165
+ buildMentionRegexes: vi.fn(() => [/@openclaw/i]),
166
+ matchesMentionPatterns: vi.fn(() => false),
167
+ });
168
+ createChannelPairingControllerMock.mockReturnValue({
169
+ readStoreForDmPolicy: vi.fn(),
170
+ issueChallenge: vi.fn(),
171
+ });
172
+ resolveNextcloudTalkRoomKindMock.mockResolvedValue("group");
173
+ resolveDmGroupAccessWithCommandGateMock.mockReturnValue({
174
+ decision: "allow",
175
+ reason: "allow",
176
+ commandAuthorized: false,
177
+ effectiveGroupAllowFrom: ["user-1"],
178
+ });
179
+ const runtime = createRuntimeEnv();
180
+
181
+ await handleNextcloudTalkInbound({
182
+ message: createMessage({
183
+ roomToken: "room-group",
184
+ roomName: "Ops",
185
+ isGroupChat: true,
186
+ }),
187
+ account: createAccount({
188
+ config: {
189
+ dmPolicy: "pairing",
190
+ allowFrom: [],
191
+ groupPolicy: "allowlist",
192
+ groupAllowFrom: ["user-1"],
193
+ },
194
+ }),
195
+ config: { channels: { "nextcloud-talk": {} } } as CoreConfig,
196
+ runtime,
197
+ });
198
+
199
+ expect(dispatchInboundReplyWithBaseMock).not.toHaveBeenCalled();
200
+ expect(runtime.log).toHaveBeenCalledWith("nextcloud-talk: drop room room-group (no mention)");
201
+ });
202
+ });
package/src/inbound.ts CHANGED
@@ -1,20 +1,19 @@
1
+ import { normalizeOptionalString } from "openclaw/plugin-sdk/text-runtime";
1
2
  import {
2
3
  GROUP_POLICY_BLOCKED_LABEL,
3
- createScopedPairingAccess,
4
+ createChannelPairingController,
5
+ deliverFormattedTextWithAttachments,
4
6
  dispatchInboundReplyWithBase,
5
- formatTextWithAttachmentLinks,
6
- issuePairingChallenge,
7
7
  logInboundDrop,
8
8
  readStoreAllowFromForDmPolicy,
9
- resolveDmGroupAccessWithCommandGate,
10
- resolveOutboundMediaUrls,
11
9
  resolveAllowlistProviderRuntimeGroupPolicy,
12
10
  resolveDefaultGroupPolicy,
11
+ resolveDmGroupAccessWithCommandGate,
13
12
  warnMissingProviderGroupPolicyFallbackOnce,
14
- type OutboundReplyPayload,
15
13
  type OpenClawConfig,
14
+ type OutboundReplyPayload,
16
15
  type RuntimeEnv,
17
- } from "openclaw/plugin-sdk/nextcloud-talk";
16
+ } from "../runtime-api.js";
18
17
  import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
19
18
  import {
20
19
  normalizeNextcloudTalkAllowlist,
@@ -27,27 +26,29 @@ import {
27
26
  import { resolveNextcloudTalkRoomKind } from "./room-info.js";
28
27
  import { getNextcloudTalkRuntime } from "./runtime.js";
29
28
  import { sendMessageNextcloudTalk } from "./send.js";
30
- import type { CoreConfig, GroupPolicy, NextcloudTalkInboundMessage } from "./types.js";
29
+ import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
31
30
 
32
31
  const CHANNEL_ID = "nextcloud-talk" as const;
33
32
 
34
33
  async function deliverNextcloudTalkReply(params: {
34
+ cfg: CoreConfig;
35
35
  payload: OutboundReplyPayload;
36
36
  roomToken: string;
37
37
  accountId: string;
38
38
  statusSink?: (patch: { lastOutboundAt?: number }) => void;
39
39
  }): Promise<void> {
40
- const { payload, roomToken, accountId, statusSink } = params;
41
- const combined = formatTextWithAttachmentLinks(payload.text, resolveOutboundMediaUrls(payload));
42
- if (!combined) {
43
- return;
44
- }
45
-
46
- await sendMessageNextcloudTalk(roomToken, combined, {
47
- accountId,
48
- replyTo: payload.replyToId,
40
+ const { cfg, payload, roomToken, accountId, statusSink } = params;
41
+ await deliverFormattedTextWithAttachments({
42
+ payload,
43
+ send: async ({ text, replyToId }) => {
44
+ await sendMessageNextcloudTalk(roomToken, text, {
45
+ cfg,
46
+ accountId,
47
+ replyTo: replyToId,
48
+ });
49
+ statusSink?.({ lastOutboundAt: Date.now() });
50
+ },
49
51
  });
50
- statusSink?.({ lastOutboundAt: Date.now() });
51
52
  }
52
53
 
53
54
  export async function handleNextcloudTalkInbound(params: {
@@ -59,7 +60,7 @@ export async function handleNextcloudTalkInbound(params: {
59
60
  }): Promise<void> {
60
61
  const { message, account, config, runtime, statusSink } = params;
61
62
  const core = getNextcloudTalkRuntime();
62
- const pairing = createScopedPairingAccess({
63
+ const pairing = createChannelPairingController({
63
64
  core,
64
65
  channel: CHANNEL_ID,
65
66
  accountId: account.accountId,
@@ -90,7 +91,7 @@ export async function handleNextcloudTalkInbound(params: {
90
91
  providerConfigPresent:
91
92
  ((config.channels as Record<string, unknown> | undefined)?.["nextcloud-talk"] ??
92
93
  undefined) !== undefined,
93
- groupPolicy: account.config.groupPolicy as GroupPolicy | undefined,
94
+ groupPolicy: account.config.groupPolicy,
94
95
  defaultGroupPolicy,
95
96
  });
96
97
  warnMissingProviderGroupPolicyFallbackOnce({
@@ -114,7 +115,6 @@ export async function handleNextcloudTalkInbound(params: {
114
115
  const roomMatch = resolveNextcloudTalkRoomMatch({
115
116
  rooms: account.config.rooms,
116
117
  roomToken,
117
- roomName,
118
118
  });
119
119
  const roomConfig = roomMatch.roomConfig;
120
120
  if (isGroup && !roomMatch.allowed) {
@@ -174,14 +174,15 @@ export async function handleNextcloudTalkInbound(params: {
174
174
  } else {
175
175
  if (access.decision !== "allow") {
176
176
  if (access.decision === "pairing") {
177
- await issuePairingChallenge({
178
- channel: CHANNEL_ID,
177
+ await pairing.issueChallenge({
179
178
  senderId,
180
179
  senderIdLine: `Your Nextcloud user id: ${senderId}`,
181
180
  meta: { name: senderName || undefined },
182
- upsertPairingRequest: pairing.upsertPairingRequest,
183
181
  sendPairingReply: async (text) => {
184
- await sendMessageNextcloudTalk(roomToken, text, { accountId: account.accountId });
182
+ await sendMessageNextcloudTalk(roomToken, text, {
183
+ cfg: config,
184
+ accountId: account.accountId,
185
+ });
185
186
  statusSink?.({ lastOutboundAt: Date.now() });
186
187
  },
187
188
  onReplyError: (err) => {
@@ -258,7 +259,7 @@ export async function handleNextcloudTalkInbound(params: {
258
259
  body: rawBody,
259
260
  });
260
261
 
261
- const groupSystemPrompt = roomConfig?.systemPrompt?.trim() || undefined;
262
+ const groupSystemPrompt = normalizeOptionalString(roomConfig?.systemPrompt);
262
263
 
263
264
  const ctxPayload = core.channel.reply.finalizeInboundContext({
264
265
  Body: body,
@@ -295,6 +296,7 @@ export async function handleNextcloudTalkInbound(params: {
295
296
  core,
296
297
  deliver: async (payload) => {
297
298
  await deliverNextcloudTalkReply({
299
+ cfg: config,
298
300
  payload,
299
301
  roomToken,
300
302
  accountId: account.accountId,
@@ -0,0 +1,138 @@
1
+ import os from "node:os";
2
+ import { resolveLoggerBackedRuntime } from "openclaw/plugin-sdk/extension-shared";
3
+ import type { RuntimeEnv } from "openclaw/plugin-sdk/runtime";
4
+ import { normalizeLowercaseStringOrEmpty } from "openclaw/plugin-sdk/text-runtime";
5
+ import { resolveNextcloudTalkAccount } from "./accounts.js";
6
+ import { handleNextcloudTalkInbound } from "./inbound.js";
7
+ import {
8
+ createNextcloudTalkWebhookServer,
9
+ processNextcloudTalkReplayGuardedMessage,
10
+ } from "./monitor.js";
11
+ import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
12
+ import { getNextcloudTalkRuntime } from "./runtime.js";
13
+ import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
14
+
15
+ const DEFAULT_WEBHOOK_PORT = 8788;
16
+ const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
17
+ const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
18
+
19
+ function normalizeOrigin(value: string): string | null {
20
+ try {
21
+ return normalizeLowercaseStringOrEmpty(new URL(value).origin);
22
+ } catch {
23
+ return null;
24
+ }
25
+ }
26
+
27
+ type NextcloudTalkMonitorOptions = {
28
+ accountId?: string;
29
+ config?: CoreConfig;
30
+ runtime?: RuntimeEnv;
31
+ abortSignal?: AbortSignal;
32
+ onMessage?: (message: NextcloudTalkInboundMessage) => void | Promise<void>;
33
+ statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
34
+ };
35
+
36
+ export async function monitorNextcloudTalkProvider(
37
+ opts: NextcloudTalkMonitorOptions,
38
+ ): Promise<{ stop: () => void }> {
39
+ const core = getNextcloudTalkRuntime();
40
+ const cfg = opts.config ?? (core.config.current() as CoreConfig);
41
+ const account = resolveNextcloudTalkAccount({
42
+ cfg,
43
+ accountId: opts.accountId,
44
+ });
45
+ const runtime: RuntimeEnv = resolveLoggerBackedRuntime(
46
+ opts.runtime,
47
+ core.logging.getChildLogger(),
48
+ );
49
+
50
+ if (!account.secret) {
51
+ throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`);
52
+ }
53
+
54
+ const port = account.config.webhookPort ?? DEFAULT_WEBHOOK_PORT;
55
+ const host = account.config.webhookHost ?? DEFAULT_WEBHOOK_HOST;
56
+ const path = account.config.webhookPath ?? DEFAULT_WEBHOOK_PATH;
57
+
58
+ const logger = core.logging.getChildLogger({
59
+ channel: "nextcloud-talk",
60
+ accountId: account.accountId,
61
+ });
62
+ const expectedBackendOrigin = normalizeOrigin(account.baseUrl);
63
+ const replayGuard = createNextcloudTalkReplayGuard({
64
+ stateDir: core.state.resolveStateDir(process.env, os.homedir),
65
+ onDiskError: (error) => {
66
+ logger.warn(
67
+ `[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`,
68
+ );
69
+ },
70
+ });
71
+
72
+ const { start, stop } = createNextcloudTalkWebhookServer({
73
+ port,
74
+ host,
75
+ path,
76
+ secret: account.secret,
77
+ isBackendAllowed: (backend) => {
78
+ if (!expectedBackendOrigin) {
79
+ return true;
80
+ }
81
+ const backendOrigin = normalizeOrigin(backend);
82
+ return backendOrigin === expectedBackendOrigin;
83
+ },
84
+ processMessage: async (message) => {
85
+ const result = await processNextcloudTalkReplayGuardedMessage({
86
+ replayGuard,
87
+ accountId: account.accountId,
88
+ message,
89
+ handleMessage: async () => {
90
+ core.channel.activity.record({
91
+ channel: "nextcloud-talk",
92
+ accountId: account.accountId,
93
+ direction: "inbound",
94
+ at: message.timestamp,
95
+ });
96
+ if (opts.onMessage) {
97
+ await opts.onMessage(message);
98
+ } else {
99
+ await handleNextcloudTalkInbound({
100
+ message,
101
+ account,
102
+ config: cfg,
103
+ runtime,
104
+ statusSink: opts.statusSink,
105
+ });
106
+ }
107
+ },
108
+ });
109
+ if (result === "duplicate") {
110
+ logger.warn(
111
+ `[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`,
112
+ );
113
+ return;
114
+ }
115
+ },
116
+ onMessage: async () => {},
117
+ onError: (error) => {
118
+ logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`);
119
+ },
120
+ abortSignal: opts.abortSignal,
121
+ });
122
+
123
+ if (opts.abortSignal?.aborted) {
124
+ return { stop };
125
+ }
126
+ await start();
127
+ if (opts.abortSignal?.aborted) {
128
+ stop();
129
+ return { stop };
130
+ }
131
+
132
+ const publicUrl =
133
+ account.config.webhookPublicUrl ??
134
+ `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`;
135
+ logger.info(`[nextcloud-talk:${account.accountId}] webhook listening on ${publicUrl}`);
136
+
137
+ return { stop };
138
+ }