@openclaw/nextcloud-talk 2026.3.13 → 2026.5.2-beta.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) 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.startup.test.ts → channel.lifecycle.test.ts} +29 -27
  18. package/src/channel.ts +158 -385
  19. package/src/config-schema.ts +24 -18
  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 -153
  32. package/src/policy.ts +23 -31
  33. package/src/replay-guard.ts +74 -11
  34. package/src/room-info.test.ts +116 -0
  35. package/src/room-info.ts +11 -3
  36. package/src/runtime.ts +6 -3
  37. package/src/secret-contract.ts +103 -0
  38. package/src/secret-input.ts +1 -10
  39. package/src/send.cfg-threading.test.ts +153 -0
  40. package/src/send.runtime.ts +8 -0
  41. package/src/send.ts +109 -77
  42. package/src/session-route.ts +40 -0
  43. package/src/setup-core.ts +248 -0
  44. package/src/setup-surface.ts +190 -0
  45. package/src/setup.test.ts +422 -0
  46. package/src/signature.ts +20 -10
  47. package/src/types.ts +19 -16
  48. package/tsconfig.json +16 -0
  49. package/src/accounts.test.ts +0 -30
  50. package/src/config-schema.test.ts +0 -36
  51. package/src/format.ts +0 -79
  52. package/src/monitor.auth-order.test.ts +0 -28
  53. package/src/monitor.backend.test.ts +0 -27
  54. package/src/monitor.read-body.test.ts +0 -16
  55. package/src/normalize.test.ts +0 -28
  56. package/src/onboarding.ts +0 -302
  57. package/src/policy.test.ts +0 -138
  58. package/src/replay-guard.test.ts +0 -70
  59. package/src/send.test.ts +0 -98
package/src/policy.ts CHANGED
@@ -1,24 +1,23 @@
1
- import type {
2
- AllowlistMatch,
3
- ChannelGroupContext,
4
- GroupPolicy,
5
- GroupToolPolicyConfig,
6
- } from "openclaw/plugin-sdk/nextcloud-talk";
7
1
  import {
8
2
  buildChannelKeyCandidates,
9
- evaluateMatchedGroupAccessForPolicy,
10
3
  normalizeChannelSlug,
11
4
  resolveChannelEntryMatchWithFallback,
12
- resolveMentionGatingWithBypass,
13
5
  resolveNestedAllowlistDecision,
14
- } from "openclaw/plugin-sdk/nextcloud-talk";
6
+ } from "openclaw/plugin-sdk/channel-targets";
7
+ import { evaluateMatchedGroupAccessForPolicy } from "openclaw/plugin-sdk/group-access";
8
+ import type {
9
+ AllowlistMatch,
10
+ ChannelGroupContext,
11
+ GroupPolicy,
12
+ GroupToolPolicyConfig,
13
+ } from "../runtime-api.js";
15
14
  import type { NextcloudTalkRoomConfig } from "./types.js";
16
15
 
17
16
  function normalizeAllowEntry(raw: string): string {
18
17
  return raw
19
18
  .trim()
20
- .toLowerCase()
21
- .replace(/^(nextcloud-talk|nc-talk|nc):/i, "");
19
+ .replace(/^(nextcloud-talk|nc-talk|nc):/i, "")
20
+ .toLowerCase();
22
21
  }
23
22
 
24
23
  export function normalizeNextcloudTalkAllowlist(
@@ -45,7 +44,7 @@ export function resolveNextcloudTalkAllowlistMatch(params: {
45
44
  return { allowed: false };
46
45
  }
47
46
 
48
- export type NextcloudTalkRoomMatch = {
47
+ type NextcloudTalkRoomMatch = {
49
48
  roomConfig?: NextcloudTalkRoomConfig;
50
49
  wildcardConfig?: NextcloudTalkRoomConfig;
51
50
  roomKey?: string;
@@ -57,16 +56,10 @@ export type NextcloudTalkRoomMatch = {
57
56
  export function resolveNextcloudTalkRoomMatch(params: {
58
57
  rooms?: Record<string, NextcloudTalkRoomConfig>;
59
58
  roomToken: string;
60
- roomName?: string | null;
61
59
  }): NextcloudTalkRoomMatch {
62
60
  const rooms = params.rooms ?? {};
63
61
  const allowlistConfigured = Object.keys(rooms).length > 0;
64
- const roomName = params.roomName?.trim() || undefined;
65
- const roomCandidates = buildChannelKeyCandidates(
66
- params.roomToken,
67
- roomName,
68
- roomName ? normalizeChannelSlug(roomName) : undefined,
69
- );
62
+ const roomCandidates = buildChannelKeyCandidates(params.roomToken);
70
63
  const match = resolveChannelEntryMatchWithFallback({
71
64
  entries: rooms,
72
65
  keys: roomCandidates,
@@ -101,11 +94,9 @@ export function resolveNextcloudTalkGroupToolPolicy(
101
94
  if (!roomToken) {
102
95
  return undefined;
103
96
  }
104
- const roomName = params.groupChannel?.trim() || undefined;
105
97
  const match = resolveNextcloudTalkRoomMatch({
106
98
  rooms: cfg.channels?.["nextcloud-talk"]?.rooms,
107
99
  roomToken,
108
- roomName,
109
100
  });
110
101
  return match.roomConfig?.tools ?? match.wildcardConfig?.tools;
111
102
  }
@@ -175,14 +166,15 @@ export function resolveNextcloudTalkMentionGate(params: {
175
166
  hasControlCommand: boolean;
176
167
  commandAuthorized: boolean;
177
168
  }): { shouldSkip: boolean; shouldBypassMention: boolean } {
178
- const result = resolveMentionGatingWithBypass({
179
- isGroup: params.isGroup,
180
- requireMention: params.requireMention,
181
- canDetectMention: true,
182
- wasMentioned: params.wasMentioned,
183
- allowTextCommands: params.allowTextCommands,
184
- hasControlCommand: params.hasControlCommand,
185
- commandAuthorized: params.commandAuthorized,
186
- });
187
- return { shouldSkip: result.shouldSkip, shouldBypassMention: result.shouldBypassMention };
169
+ const shouldBypassMention =
170
+ params.isGroup &&
171
+ params.requireMention &&
172
+ !params.wasMentioned &&
173
+ params.allowTextCommands &&
174
+ params.commandAuthorized &&
175
+ params.hasControlCommand;
176
+ return {
177
+ shouldBypassMention,
178
+ shouldSkip: params.requireMention && !params.wasMentioned && !shouldBypassMention,
179
+ };
188
180
  }
@@ -1,5 +1,5 @@
1
1
  import path from "node:path";
2
- import { createPersistentDedupe } from "openclaw/plugin-sdk/nextcloud-talk";
2
+ import { createClaimableDedupe } from "openclaw/plugin-sdk/persistent-dedupe";
3
3
 
4
4
  const DEFAULT_REPLAY_TTL_MS = 24 * 60 * 60 * 1000;
5
5
  const DEFAULT_MEMORY_MAX_SIZE = 1_000;
@@ -22,8 +22,8 @@ function buildReplayKey(params: { roomToken: string; messageId: string }): strin
22
22
  return `${roomToken}:${messageId}`;
23
23
  }
24
24
 
25
- export type NextcloudTalkReplayGuardOptions = {
26
- stateDir: string;
25
+ type NextcloudTalkReplayGuardOptions = {
26
+ stateDir?: string;
27
27
  ttlMs?: number;
28
28
  memoryMaxSize?: number;
29
29
  fileMaxEntries?: number;
@@ -31,6 +31,22 @@ export type NextcloudTalkReplayGuardOptions = {
31
31
  };
32
32
 
33
33
  export type NextcloudTalkReplayGuard = {
34
+ claimMessage: (params: {
35
+ accountId: string;
36
+ roomToken: string;
37
+ messageId: string;
38
+ }) => Promise<"claimed" | "duplicate" | "inflight" | "invalid">;
39
+ commitMessage: (params: {
40
+ accountId: string;
41
+ roomToken: string;
42
+ messageId: string;
43
+ }) => Promise<boolean>;
44
+ releaseMessage: (params: {
45
+ accountId: string;
46
+ roomToken: string;
47
+ messageId: string;
48
+ error?: unknown;
49
+ }) => void;
34
50
  shouldProcessMessage: (params: {
35
51
  accountId: string;
36
52
  roomToken: string;
@@ -41,24 +57,71 @@ export type NextcloudTalkReplayGuard = {
41
57
  export function createNextcloudTalkReplayGuard(
42
58
  options: NextcloudTalkReplayGuardOptions,
43
59
  ): NextcloudTalkReplayGuard {
44
- const stateDir = options.stateDir.trim();
45
- const persistentDedupe = createPersistentDedupe({
60
+ const stateDir = options.stateDir?.trim();
61
+ const baseOptions = {
46
62
  ttlMs: options.ttlMs ?? DEFAULT_REPLAY_TTL_MS,
47
63
  memoryMaxSize: options.memoryMaxSize ?? DEFAULT_MEMORY_MAX_SIZE,
48
- fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES,
49
- resolveFilePath: (namespace) =>
50
- path.join(stateDir, "nextcloud-talk", "replay-dedupe", `${sanitizeSegment(namespace)}.json`),
51
- });
64
+ };
65
+ const dedupe = createClaimableDedupe(
66
+ stateDir
67
+ ? {
68
+ ...baseOptions,
69
+ fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES,
70
+ resolveFilePath: (namespace) =>
71
+ path.join(
72
+ stateDir,
73
+ "nextcloud-talk",
74
+ "replay-dedupe",
75
+ `${sanitizeSegment(namespace)}.json`,
76
+ ),
77
+ onDiskError: options.onDiskError,
78
+ }
79
+ : baseOptions,
80
+ );
52
81
 
53
82
  return {
83
+ claimMessage: async ({ accountId, roomToken, messageId }) => {
84
+ const replayKey = buildReplayKey({ roomToken, messageId });
85
+ if (!replayKey) {
86
+ return "invalid";
87
+ }
88
+ const result = await dedupe.claim(replayKey, {
89
+ namespace: accountId,
90
+ });
91
+ return result.kind;
92
+ },
93
+ commitMessage: async ({ accountId, roomToken, messageId }) => {
94
+ const replayKey = buildReplayKey({ roomToken, messageId });
95
+ if (!replayKey) {
96
+ return true;
97
+ }
98
+ return await dedupe.commit(replayKey, {
99
+ namespace: accountId,
100
+ });
101
+ },
102
+ releaseMessage: ({ accountId, roomToken, messageId, error }) => {
103
+ const replayKey = buildReplayKey({ roomToken, messageId });
104
+ if (!replayKey) {
105
+ return;
106
+ }
107
+ dedupe.release(replayKey, {
108
+ namespace: accountId,
109
+ error,
110
+ });
111
+ },
54
112
  shouldProcessMessage: async ({ accountId, roomToken, messageId }) => {
55
113
  const replayKey = buildReplayKey({ roomToken, messageId });
56
114
  if (!replayKey) {
57
115
  return true;
58
116
  }
59
- return await persistentDedupe.checkAndRecord(replayKey, {
117
+ const result = await dedupe.claim(replayKey, {
118
+ namespace: accountId,
119
+ });
120
+ if (result.kind !== "claimed") {
121
+ return false;
122
+ }
123
+ return await dedupe.commit(replayKey, {
60
124
  namespace: accountId,
61
- onDiskError: options.onDiskError,
62
125
  });
63
126
  },
64
127
  };
@@ -0,0 +1,116 @@
1
+ import { afterEach, describe, expect, it, vi } from "vitest";
2
+ import { resolveNextcloudTalkRoomKind, __testing } from "./room-info.js";
3
+
4
+ const fetchWithSsrFGuard = vi.hoisted(() => vi.fn());
5
+ const readFileSync = vi.hoisted(() => vi.fn());
6
+
7
+ vi.mock("../runtime-api.js", () => {
8
+ return vi
9
+ .importActual<typeof import("../runtime-api.js")>("../runtime-api.js")
10
+ .then((actual) => ({
11
+ ...actual,
12
+ fetchWithSsrFGuard,
13
+ }));
14
+ });
15
+
16
+ vi.mock("node:fs", () => {
17
+ return vi.importActual<typeof import("node:fs")>("node:fs").then((actual) => ({
18
+ ...actual,
19
+ readFileSync,
20
+ }));
21
+ });
22
+
23
+ afterEach(() => {
24
+ fetchWithSsrFGuard.mockReset();
25
+ readFileSync.mockReset();
26
+ __testing.resetRoomCache();
27
+ });
28
+
29
+ describe("nextcloud talk room info", () => {
30
+ it("resolves direct rooms from the room info endpoint", async () => {
31
+ const release = vi.fn(async () => {});
32
+ fetchWithSsrFGuard.mockResolvedValue({
33
+ response: {
34
+ ok: true,
35
+ json: async () => ({
36
+ ocs: {
37
+ data: {
38
+ type: 1,
39
+ },
40
+ },
41
+ }),
42
+ },
43
+ release,
44
+ });
45
+
46
+ const kind = await resolveNextcloudTalkRoomKind({
47
+ account: {
48
+ accountId: "acct-direct",
49
+ baseUrl: "https://nc.example.com",
50
+ config: {
51
+ apiUser: "bot",
52
+ apiPassword: "secret",
53
+ },
54
+ } as never,
55
+ roomToken: "room-direct",
56
+ });
57
+
58
+ expect(kind).toBe("direct");
59
+ expect(fetchWithSsrFGuard).toHaveBeenCalledWith(
60
+ expect.objectContaining({
61
+ url: "https://nc.example.com/ocs/v2.php/apps/spreed/api/v4/room/room-direct",
62
+ auditContext: "nextcloud-talk.room-info",
63
+ }),
64
+ );
65
+ expect(release).toHaveBeenCalledTimes(1);
66
+ });
67
+
68
+ it("reads the api password from a file and logs non-ok room info responses", async () => {
69
+ const release = vi.fn(async () => {});
70
+ const log = vi.fn();
71
+ const error = vi.fn();
72
+ const exit = vi.fn();
73
+ readFileSync.mockReturnValue("file-secret\n");
74
+ fetchWithSsrFGuard.mockResolvedValue({
75
+ response: {
76
+ ok: false,
77
+ status: 403,
78
+ json: async () => ({}),
79
+ },
80
+ release,
81
+ });
82
+
83
+ const kind = await resolveNextcloudTalkRoomKind({
84
+ account: {
85
+ accountId: "acct-group",
86
+ baseUrl: "https://nc.example.com",
87
+ config: {
88
+ apiUser: "bot",
89
+ apiPasswordFile: "/tmp/nextcloud-secret",
90
+ },
91
+ } as never,
92
+ roomToken: "room-group",
93
+ runtime: { log, error, exit },
94
+ });
95
+
96
+ expect(kind).toBeUndefined();
97
+ expect(readFileSync).toHaveBeenCalledWith("/tmp/nextcloud-secret", "utf-8");
98
+ expect(log).toHaveBeenCalledWith("nextcloud-talk: room lookup failed (403) token=room-group");
99
+ expect(release).toHaveBeenCalledTimes(1);
100
+ });
101
+
102
+ it("returns undefined from room info without credentials or base url", async () => {
103
+ await expect(
104
+ resolveNextcloudTalkRoomKind({
105
+ account: {
106
+ accountId: "acct-missing",
107
+ baseUrl: "",
108
+ config: {},
109
+ } as never,
110
+ roomToken: "room-missing",
111
+ }),
112
+ ).resolves.toBeUndefined();
113
+
114
+ expect(fetchWithSsrFGuard).not.toHaveBeenCalled();
115
+ });
116
+ });
package/src/room-info.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFileSync } from "node:fs";
2
- import { fetchWithSsrFGuard } from "openclaw/plugin-sdk/nextcloud-talk";
3
- import type { RuntimeEnv } from "openclaw/plugin-sdk/nextcloud-talk";
2
+ import { formatErrorMessage } from "openclaw/plugin-sdk/error-runtime";
3
+ import { ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime";
4
+ import { fetchWithSsrFGuard, type RuntimeEnv } from "../runtime-api.js";
4
5
  import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
5
6
  import { normalizeResolvedSecretInputString } from "./secret-input.js";
6
7
 
@@ -12,6 +13,12 @@ const roomCache = new Map<
12
13
  { kind?: "direct" | "group"; fetchedAt: number; error?: string }
13
14
  >();
14
15
 
16
+ export const __testing = {
17
+ resetRoomCache() {
18
+ roomCache.clear();
19
+ },
20
+ };
21
+
15
22
  function resolveRoomCacheKey(params: { accountId: string; roomToken: string }) {
16
23
  return `${params.accountId}:${params.roomToken}`;
17
24
  }
@@ -106,6 +113,7 @@ export async function resolveNextcloudTalkRoomKind(params: {
106
113
  },
107
114
  },
108
115
  auditContext: "nextcloud-talk.room-info",
116
+ policy: ssrfPolicyFromPrivateNetworkOptIn(account.config),
109
117
  });
110
118
  try {
111
119
  if (!response.ok) {
@@ -132,7 +140,7 @@ export async function resolveNextcloudTalkRoomKind(params: {
132
140
  } catch (err) {
133
141
  roomCache.set(key, {
134
142
  fetchedAt: Date.now(),
135
- error: err instanceof Error ? err.message : String(err),
143
+ error: formatErrorMessage(err),
136
144
  });
137
145
  runtime?.error?.(`nextcloud-talk: room lookup error: ${String(err)}`);
138
146
  return undefined;
package/src/runtime.ts CHANGED
@@ -1,6 +1,9 @@
1
- import { createPluginRuntimeStore } from "openclaw/plugin-sdk/compat";
2
- import type { PluginRuntime } from "openclaw/plugin-sdk/nextcloud-talk";
1
+ import { createPluginRuntimeStore } from "openclaw/plugin-sdk/runtime-store";
2
+ import type { PluginRuntime } from "openclaw/plugin-sdk/runtime-store";
3
3
 
4
4
  const { setRuntime: setNextcloudTalkRuntime, getRuntime: getNextcloudTalkRuntime } =
5
- createPluginRuntimeStore<PluginRuntime>("Nextcloud Talk runtime not initialized");
5
+ createPluginRuntimeStore<PluginRuntime>({
6
+ pluginId: "nextcloud-talk",
7
+ errorMessage: "Nextcloud Talk runtime not initialized",
8
+ });
6
9
  export { getNextcloudTalkRuntime, setNextcloudTalkRuntime };
@@ -0,0 +1,103 @@
1
+ import {
2
+ collectConditionalChannelFieldAssignments,
3
+ getChannelSurface,
4
+ hasOwnProperty,
5
+ type ChannelAccountEntry,
6
+ type ResolverContext,
7
+ type SecretDefaults,
8
+ } from "openclaw/plugin-sdk/channel-secret-basic-runtime";
9
+
10
+ export const secretTargetRegistryEntries: import("openclaw/plugin-sdk/channel-secret-basic-runtime").SecretTargetRegistryEntry[] =
11
+ [
12
+ {
13
+ id: "channels.nextcloud-talk.accounts.*.apiPassword",
14
+ targetType: "channels.nextcloud-talk.accounts.*.apiPassword",
15
+ configFile: "openclaw.json",
16
+ pathPattern: "channels.nextcloud-talk.accounts.*.apiPassword",
17
+ secretShape: "secret_input",
18
+ expectedResolvedValue: "string",
19
+ includeInPlan: true,
20
+ includeInConfigure: true,
21
+ includeInAudit: true,
22
+ },
23
+ {
24
+ id: "channels.nextcloud-talk.accounts.*.botSecret",
25
+ targetType: "channels.nextcloud-talk.accounts.*.botSecret",
26
+ configFile: "openclaw.json",
27
+ pathPattern: "channels.nextcloud-talk.accounts.*.botSecret",
28
+ secretShape: "secret_input",
29
+ expectedResolvedValue: "string",
30
+ includeInPlan: true,
31
+ includeInConfigure: true,
32
+ includeInAudit: true,
33
+ },
34
+ {
35
+ id: "channels.nextcloud-talk.apiPassword",
36
+ targetType: "channels.nextcloud-talk.apiPassword",
37
+ configFile: "openclaw.json",
38
+ pathPattern: "channels.nextcloud-talk.apiPassword",
39
+ secretShape: "secret_input",
40
+ expectedResolvedValue: "string",
41
+ includeInPlan: true,
42
+ includeInConfigure: true,
43
+ includeInAudit: true,
44
+ },
45
+ {
46
+ id: "channels.nextcloud-talk.botSecret",
47
+ targetType: "channels.nextcloud-talk.botSecret",
48
+ configFile: "openclaw.json",
49
+ pathPattern: "channels.nextcloud-talk.botSecret",
50
+ secretShape: "secret_input",
51
+ expectedResolvedValue: "string",
52
+ includeInPlan: true,
53
+ includeInConfigure: true,
54
+ includeInAudit: true,
55
+ },
56
+ ];
57
+
58
+ export function collectRuntimeConfigAssignments(params: {
59
+ config: { channels?: Record<string, unknown> };
60
+ defaults?: SecretDefaults;
61
+ context: ResolverContext;
62
+ }): void {
63
+ const resolved = getChannelSurface(params.config, "nextcloud-talk");
64
+ if (!resolved) {
65
+ return;
66
+ }
67
+ const { channel: nextcloudTalk, surface } = resolved;
68
+ const inheritsField =
69
+ (field: string) =>
70
+ ({ account, enabled }: ChannelAccountEntry) =>
71
+ enabled && !hasOwnProperty(account, field);
72
+ collectConditionalChannelFieldAssignments({
73
+ channelKey: "nextcloud-talk",
74
+ field: "botSecret",
75
+ channel: nextcloudTalk,
76
+ surface,
77
+ defaults: params.defaults,
78
+ context: params.context,
79
+ topLevelActiveWithoutAccounts: true,
80
+ topLevelInheritedAccountActive: inheritsField("botSecret"),
81
+ accountActive: ({ enabled }) => enabled,
82
+ topInactiveReason: "no enabled Nextcloud Talk surface inherits this top-level botSecret.",
83
+ accountInactiveReason: "Nextcloud Talk account is disabled.",
84
+ });
85
+ collectConditionalChannelFieldAssignments({
86
+ channelKey: "nextcloud-talk",
87
+ field: "apiPassword",
88
+ channel: nextcloudTalk,
89
+ surface,
90
+ defaults: params.defaults,
91
+ context: params.context,
92
+ topLevelActiveWithoutAccounts: true,
93
+ topLevelInheritedAccountActive: inheritsField("apiPassword"),
94
+ accountActive: ({ enabled }) => enabled,
95
+ topInactiveReason: "no enabled Nextcloud Talk surface inherits this top-level apiPassword.",
96
+ accountInactiveReason: "Nextcloud Talk account is disabled.",
97
+ });
98
+ }
99
+
100
+ export const channelSecrets = {
101
+ secretTargetRegistryEntries,
102
+ collectRuntimeConfigAssignments,
103
+ };
@@ -1,13 +1,4 @@
1
- import {
2
- buildSecretInputSchema,
3
- hasConfiguredSecretInput,
4
- normalizeResolvedSecretInputString,
5
- normalizeSecretInputString,
6
- } from "openclaw/plugin-sdk/nextcloud-talk";
7
-
8
1
  export {
9
2
  buildSecretInputSchema,
10
- hasConfiguredSecretInput,
11
3
  normalizeResolvedSecretInputString,
12
- normalizeSecretInputString,
13
- };
4
+ } from "openclaw/plugin-sdk/secret-input";
@@ -0,0 +1,153 @@
1
+ import {
2
+ createSendCfgThreadingRuntime,
3
+ expectProvidedCfgSkipsRuntimeLoad,
4
+ } from "openclaw/plugin-sdk/channel-test-helpers";
5
+ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
6
+
7
+ const hoisted = vi.hoisted(() => ({
8
+ loadConfig: vi.fn(),
9
+ resolveMarkdownTableMode: vi.fn(() => "preserve"),
10
+ convertMarkdownTables: vi.fn((text: string) => text),
11
+ record: vi.fn(),
12
+ resolveNextcloudTalkAccount: vi.fn(),
13
+ ssrfPolicyFromPrivateNetworkOptIn: vi.fn(() => undefined),
14
+ generateNextcloudTalkSignature: vi.fn(() => ({
15
+ random: "r",
16
+ signature: "s",
17
+ })),
18
+ mockFetchGuard: vi.fn(),
19
+ }));
20
+
21
+ vi.mock("./send.runtime.js", () => {
22
+ return {
23
+ convertMarkdownTables: hoisted.convertMarkdownTables,
24
+ fetchWithSsrFGuard: hoisted.mockFetchGuard,
25
+ generateNextcloudTalkSignature: hoisted.generateNextcloudTalkSignature,
26
+ getNextcloudTalkRuntime: () => createSendCfgThreadingRuntime(hoisted),
27
+ requireRuntimeConfig: (cfg: unknown, context: string) => {
28
+ if (cfg) {
29
+ return cfg;
30
+ }
31
+ throw new Error(`${context} requires a resolved runtime config`);
32
+ },
33
+ resolveNextcloudTalkAccount: hoisted.resolveNextcloudTalkAccount,
34
+ resolveMarkdownTableMode: hoisted.resolveMarkdownTableMode,
35
+ ssrfPolicyFromPrivateNetworkOptIn: hoisted.ssrfPolicyFromPrivateNetworkOptIn,
36
+ };
37
+ });
38
+
39
+ const { sendMessageNextcloudTalk, sendReactionNextcloudTalk } = await import("./send.js");
40
+
41
+ function expectProvidedMessageCfgThreading(cfg: unknown): void {
42
+ expectProvidedCfgSkipsRuntimeLoad({
43
+ loadConfig: hoisted.loadConfig,
44
+ resolveAccount: hoisted.resolveNextcloudTalkAccount,
45
+ cfg,
46
+ accountId: "work",
47
+ });
48
+ expect(hoisted.resolveMarkdownTableMode).toHaveBeenCalledWith({
49
+ cfg,
50
+ channel: "nextcloud-talk",
51
+ accountId: "default",
52
+ });
53
+ expect(hoisted.convertMarkdownTables).toHaveBeenCalledWith("hello", "preserve");
54
+ }
55
+
56
+ describe("nextcloud-talk send cfg threading", () => {
57
+ const fetchMock = vi.fn<typeof fetch>();
58
+ const defaultAccount = {
59
+ accountId: "default",
60
+ baseUrl: "https://nextcloud.example.com",
61
+ secret: "secret-value",
62
+ };
63
+
64
+ function mockNextcloudMessageResponse(messageId: number, timestamp: number): void {
65
+ fetchMock.mockResolvedValueOnce(
66
+ new Response(
67
+ JSON.stringify({
68
+ ocs: { data: { id: messageId, timestamp } },
69
+ }),
70
+ { status: 200, headers: { "content-type": "application/json" } },
71
+ ),
72
+ );
73
+ }
74
+
75
+ beforeEach(() => {
76
+ vi.stubGlobal("fetch", fetchMock);
77
+ // Route the SSRF guard mock through the global fetch mock.
78
+ hoisted.mockFetchGuard.mockImplementation(async (p: { url: string; init?: RequestInit }) => {
79
+ const response = await globalThis.fetch(p.url, p.init);
80
+ return { response, release: async () => {}, finalUrl: p.url };
81
+ });
82
+ hoisted.loadConfig.mockReset();
83
+ hoisted.resolveMarkdownTableMode.mockClear();
84
+ hoisted.convertMarkdownTables.mockClear();
85
+ hoisted.record.mockReset();
86
+ hoisted.ssrfPolicyFromPrivateNetworkOptIn.mockClear();
87
+ hoisted.generateNextcloudTalkSignature.mockClear();
88
+ hoisted.resolveNextcloudTalkAccount.mockReset();
89
+ hoisted.resolveNextcloudTalkAccount.mockReturnValue(defaultAccount);
90
+ });
91
+
92
+ afterEach(() => {
93
+ fetchMock.mockReset();
94
+ hoisted.mockFetchGuard.mockReset();
95
+ vi.unstubAllGlobals();
96
+ });
97
+
98
+ it("uses provided cfg for sendMessage and skips runtime loadConfig", async () => {
99
+ const cfg = { source: "provided" } as const;
100
+ mockNextcloudMessageResponse(12345, 1_706_000_000);
101
+
102
+ const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
103
+ cfg,
104
+ accountId: "work",
105
+ });
106
+
107
+ expectProvidedMessageCfgThreading(cfg);
108
+ expect(hoisted.record).toHaveBeenCalledWith({
109
+ channel: "nextcloud-talk",
110
+ accountId: "default",
111
+ direction: "outbound",
112
+ });
113
+ expect(fetchMock).toHaveBeenCalledTimes(1);
114
+ expect(result).toEqual({
115
+ messageId: "12345",
116
+ roomToken: "abc123",
117
+ timestamp: 1_706_000_000,
118
+ });
119
+ });
120
+
121
+ it("sends with provided cfg even when the runtime store is not initialized", async () => {
122
+ const cfg = { source: "provided" } as const;
123
+ hoisted.record.mockImplementation(() => {
124
+ throw new Error("Nextcloud Talk runtime not initialized");
125
+ });
126
+ mockNextcloudMessageResponse(12346, 1_706_000_001);
127
+
128
+ const result = await sendMessageNextcloudTalk("room:abc123", "hello", {
129
+ cfg,
130
+ accountId: "work",
131
+ });
132
+
133
+ expectProvidedMessageCfgThreading(cfg);
134
+ expect(result).toEqual({
135
+ messageId: "12346",
136
+ roomToken: "abc123",
137
+ timestamp: 1_706_000_001,
138
+ });
139
+ });
140
+
141
+ it("fails hard for sendReaction when cfg is omitted", async () => {
142
+ fetchMock.mockResolvedValueOnce(new Response("{}", { status: 200 }));
143
+
144
+ await expect(
145
+ sendReactionNextcloudTalk("room:ops", "m-1", "👍", {
146
+ accountId: "default",
147
+ } as never),
148
+ ).rejects.toThrow("Nextcloud Talk send requires a resolved runtime config");
149
+
150
+ expect(hoisted.loadConfig).not.toHaveBeenCalled();
151
+ expect(hoisted.resolveNextcloudTalkAccount).not.toHaveBeenCalled();
152
+ });
153
+ });
@@ -0,0 +1,8 @@
1
+ export { requireRuntimeConfig } from "openclaw/plugin-sdk/plugin-config-runtime";
2
+ export { resolveMarkdownTableMode } from "openclaw/plugin-sdk/markdown-table-runtime";
3
+ export { ssrfPolicyFromPrivateNetworkOptIn } from "openclaw/plugin-sdk/ssrf-runtime";
4
+ export { convertMarkdownTables } from "openclaw/plugin-sdk/text-runtime";
5
+ export { fetchWithSsrFGuard } from "../runtime-api.js";
6
+ export { resolveNextcloudTalkAccount } from "./accounts.js";
7
+ export { getNextcloudTalkRuntime } from "./runtime.js";
8
+ export { generateNextcloudTalkSignature } from "./signature.js";