@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.
- package/api.ts +1 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +4 -0
- package/doctor-contract-api.ts +1 -0
- package/index.ts +15 -12
- package/openclaw.plugin.json +804 -1
- package/package.json +31 -4
- package/runtime-api.ts +33 -0
- package/secret-contract-api.ts +5 -0
- package/setup-entry.ts +13 -0
- package/src/accounts.ts +25 -42
- package/src/approval-auth.test.ts +17 -0
- package/src/approval-auth.ts +27 -0
- package/src/channel-api.ts +5 -0
- package/src/channel.adapters.ts +52 -0
- package/src/channel.core.test.ts +75 -0
- package/src/{channel.startup.test.ts → channel.lifecycle.test.ts} +29 -27
- package/src/channel.ts +158 -385
- package/src/config-schema.ts +24 -18
- package/src/core.test.ts +397 -0
- package/src/doctor-contract.ts +9 -0
- package/src/doctor.test.ts +40 -0
- package/src/doctor.ts +10 -0
- package/src/gateway.ts +109 -0
- package/src/inbound.authz.test.ts +87 -22
- package/src/inbound.behavior.test.ts +202 -0
- package/src/inbound.ts +28 -26
- package/src/monitor-runtime.ts +138 -0
- package/src/monitor.replay.test.ts +238 -0
- package/src/monitor.test-harness.ts +2 -2
- package/src/monitor.ts +125 -153
- package/src/policy.ts +23 -31
- package/src/replay-guard.ts +74 -11
- package/src/room-info.test.ts +116 -0
- package/src/room-info.ts +11 -3
- package/src/runtime.ts +6 -3
- package/src/secret-contract.ts +103 -0
- package/src/secret-input.ts +1 -10
- package/src/send.cfg-threading.test.ts +153 -0
- package/src/send.runtime.ts +8 -0
- package/src/send.ts +109 -77
- package/src/session-route.ts +40 -0
- package/src/setup-core.ts +248 -0
- package/src/setup-surface.ts +190 -0
- package/src/setup.test.ts +422 -0
- package/src/signature.ts +20 -10
- package/src/types.ts +19 -16
- package/tsconfig.json +16 -0
- package/src/accounts.test.ts +0 -30
- package/src/config-schema.test.ts +0 -36
- package/src/format.ts +0 -79
- package/src/monitor.auth-order.test.ts +0 -28
- package/src/monitor.backend.test.ts +0 -27
- package/src/monitor.read-body.test.ts +0 -16
- package/src/normalize.test.ts +0 -28
- package/src/onboarding.ts +0 -302
- package/src/policy.test.ts +0 -138
- package/src/replay-guard.test.ts +0 -70
- 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/
|
|
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
|
-
.
|
|
21
|
-
.
|
|
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
|
-
|
|
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
|
|
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
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
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
|
}
|
package/src/replay-guard.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
|
-
import {
|
|
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
|
-
|
|
26
|
-
stateDir
|
|
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
|
|
45
|
-
const
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
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
|
-
|
|
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 {
|
|
3
|
-
import
|
|
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:
|
|
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/
|
|
2
|
-
import type { PluginRuntime } from "openclaw/plugin-sdk/
|
|
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>(
|
|
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
|
+
};
|
package/src/secret-input.ts
CHANGED
|
@@ -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
|
-
|
|
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";
|