@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.
- 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.lifecycle.test.ts +81 -0
- package/src/channel.ts +157 -388
- package/src/config-schema.ts +27 -22
- 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 -155
- package/src/normalize.ts +7 -2
- 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 +126 -106
- 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/channel.startup.test.ts +0 -83
- 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/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 -104
package/src/onboarding.ts
DELETED
|
@@ -1,302 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
formatDocsLink,
|
|
3
|
-
hasConfiguredSecretInput,
|
|
4
|
-
mapAllowFromEntries,
|
|
5
|
-
mergeAllowFromEntries,
|
|
6
|
-
patchScopedAccountConfig,
|
|
7
|
-
runSingleChannelSecretStep,
|
|
8
|
-
resolveAccountIdForConfigure,
|
|
9
|
-
DEFAULT_ACCOUNT_ID,
|
|
10
|
-
normalizeAccountId,
|
|
11
|
-
setTopLevelChannelDmPolicyWithAllowFrom,
|
|
12
|
-
type ChannelOnboardingAdapter,
|
|
13
|
-
type ChannelOnboardingDmPolicy,
|
|
14
|
-
type OpenClawConfig,
|
|
15
|
-
type WizardPrompter,
|
|
16
|
-
} from "openclaw/plugin-sdk/nextcloud-talk";
|
|
17
|
-
import {
|
|
18
|
-
listNextcloudTalkAccountIds,
|
|
19
|
-
resolveDefaultNextcloudTalkAccountId,
|
|
20
|
-
resolveNextcloudTalkAccount,
|
|
21
|
-
} from "./accounts.js";
|
|
22
|
-
import type { CoreConfig, DmPolicy } from "./types.js";
|
|
23
|
-
|
|
24
|
-
const channel = "nextcloud-talk" as const;
|
|
25
|
-
|
|
26
|
-
function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
|
|
27
|
-
return setTopLevelChannelDmPolicyWithAllowFrom({
|
|
28
|
-
cfg,
|
|
29
|
-
channel: "nextcloud-talk",
|
|
30
|
-
dmPolicy,
|
|
31
|
-
getAllowFrom: (inputCfg) =>
|
|
32
|
-
mapAllowFromEntries(inputCfg.channels?.["nextcloud-talk"]?.allowFrom),
|
|
33
|
-
}) as CoreConfig;
|
|
34
|
-
}
|
|
35
|
-
|
|
36
|
-
function setNextcloudTalkAccountConfig(
|
|
37
|
-
cfg: CoreConfig,
|
|
38
|
-
accountId: string,
|
|
39
|
-
updates: Record<string, unknown>,
|
|
40
|
-
): CoreConfig {
|
|
41
|
-
return patchScopedAccountConfig({
|
|
42
|
-
cfg,
|
|
43
|
-
channelKey: channel,
|
|
44
|
-
accountId,
|
|
45
|
-
patch: updates,
|
|
46
|
-
}) as CoreConfig;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise<void> {
|
|
50
|
-
await prompter.note(
|
|
51
|
-
[
|
|
52
|
-
"1) SSH into your Nextcloud server",
|
|
53
|
-
'2) Run: ./occ talk:bot:install "OpenClaw" "<shared-secret>" "<webhook-url>" --feature reaction',
|
|
54
|
-
"3) Copy the shared secret you used in the command",
|
|
55
|
-
"4) Enable the bot in your Nextcloud Talk room settings",
|
|
56
|
-
"Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.",
|
|
57
|
-
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
|
|
58
|
-
].join("\n"),
|
|
59
|
-
"Nextcloud Talk bot setup",
|
|
60
|
-
);
|
|
61
|
-
}
|
|
62
|
-
|
|
63
|
-
async function noteNextcloudTalkUserIdHelp(prompter: WizardPrompter): Promise<void> {
|
|
64
|
-
await prompter.note(
|
|
65
|
-
[
|
|
66
|
-
"1) Check the Nextcloud admin panel for user IDs",
|
|
67
|
-
"2) Or look at the webhook payload logs when someone messages",
|
|
68
|
-
"3) User IDs are typically lowercase usernames in Nextcloud",
|
|
69
|
-
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
|
|
70
|
-
].join("\n"),
|
|
71
|
-
"Nextcloud Talk user id",
|
|
72
|
-
);
|
|
73
|
-
}
|
|
74
|
-
|
|
75
|
-
async function promptNextcloudTalkAllowFrom(params: {
|
|
76
|
-
cfg: CoreConfig;
|
|
77
|
-
prompter: WizardPrompter;
|
|
78
|
-
accountId: string;
|
|
79
|
-
}): Promise<CoreConfig> {
|
|
80
|
-
const { cfg, prompter, accountId } = params;
|
|
81
|
-
const resolved = resolveNextcloudTalkAccount({ cfg, accountId });
|
|
82
|
-
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
|
83
|
-
await noteNextcloudTalkUserIdHelp(prompter);
|
|
84
|
-
|
|
85
|
-
const parseInput = (value: string) =>
|
|
86
|
-
value
|
|
87
|
-
.split(/[\n,;]+/g)
|
|
88
|
-
.map((entry) => entry.trim().toLowerCase())
|
|
89
|
-
.filter(Boolean);
|
|
90
|
-
|
|
91
|
-
let resolvedIds: string[] = [];
|
|
92
|
-
while (resolvedIds.length === 0) {
|
|
93
|
-
const entry = await prompter.text({
|
|
94
|
-
message: "Nextcloud Talk allowFrom (user id)",
|
|
95
|
-
placeholder: "username",
|
|
96
|
-
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
|
97
|
-
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
98
|
-
});
|
|
99
|
-
resolvedIds = parseInput(String(entry));
|
|
100
|
-
if (resolvedIds.length === 0) {
|
|
101
|
-
await prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk allowlist");
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
const merged = [
|
|
106
|
-
...existingAllowFrom.map((item) => String(item).trim().toLowerCase()).filter(Boolean),
|
|
107
|
-
...resolvedIds,
|
|
108
|
-
];
|
|
109
|
-
const unique = mergeAllowFromEntries(undefined, merged);
|
|
110
|
-
|
|
111
|
-
return setNextcloudTalkAccountConfig(cfg, accountId, {
|
|
112
|
-
dmPolicy: "allowlist",
|
|
113
|
-
allowFrom: unique,
|
|
114
|
-
});
|
|
115
|
-
}
|
|
116
|
-
|
|
117
|
-
async function promptNextcloudTalkAllowFromForAccount(params: {
|
|
118
|
-
cfg: CoreConfig;
|
|
119
|
-
prompter: WizardPrompter;
|
|
120
|
-
accountId?: string;
|
|
121
|
-
}): Promise<CoreConfig> {
|
|
122
|
-
const accountId =
|
|
123
|
-
params.accountId && normalizeAccountId(params.accountId)
|
|
124
|
-
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
|
|
125
|
-
: resolveDefaultNextcloudTalkAccountId(params.cfg);
|
|
126
|
-
return promptNextcloudTalkAllowFrom({
|
|
127
|
-
cfg: params.cfg,
|
|
128
|
-
prompter: params.prompter,
|
|
129
|
-
accountId,
|
|
130
|
-
});
|
|
131
|
-
}
|
|
132
|
-
|
|
133
|
-
const dmPolicy: ChannelOnboardingDmPolicy = {
|
|
134
|
-
label: "Nextcloud Talk",
|
|
135
|
-
channel,
|
|
136
|
-
policyKey: "channels.nextcloud-talk.dmPolicy",
|
|
137
|
-
allowFromKey: "channels.nextcloud-talk.allowFrom",
|
|
138
|
-
getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing",
|
|
139
|
-
setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy),
|
|
140
|
-
promptAllowFrom: promptNextcloudTalkAllowFromForAccount as (params: {
|
|
141
|
-
cfg: OpenClawConfig;
|
|
142
|
-
prompter: WizardPrompter;
|
|
143
|
-
accountId?: string | undefined;
|
|
144
|
-
}) => Promise<OpenClawConfig>,
|
|
145
|
-
};
|
|
146
|
-
|
|
147
|
-
export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
148
|
-
channel,
|
|
149
|
-
getStatus: async ({ cfg }) => {
|
|
150
|
-
const configured = listNextcloudTalkAccountIds(cfg as CoreConfig).some((accountId) => {
|
|
151
|
-
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
|
|
152
|
-
return Boolean(account.secret && account.baseUrl);
|
|
153
|
-
});
|
|
154
|
-
return {
|
|
155
|
-
channel,
|
|
156
|
-
configured,
|
|
157
|
-
statusLines: [`Nextcloud Talk: ${configured ? "configured" : "needs setup"}`],
|
|
158
|
-
selectionHint: configured ? "configured" : "self-hosted chat",
|
|
159
|
-
quickstartScore: configured ? 1 : 5,
|
|
160
|
-
};
|
|
161
|
-
},
|
|
162
|
-
configure: async ({
|
|
163
|
-
cfg,
|
|
164
|
-
prompter,
|
|
165
|
-
accountOverrides,
|
|
166
|
-
shouldPromptAccountIds,
|
|
167
|
-
forceAllowFrom,
|
|
168
|
-
}) => {
|
|
169
|
-
const defaultAccountId = resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig);
|
|
170
|
-
const accountId = await resolveAccountIdForConfigure({
|
|
171
|
-
cfg,
|
|
172
|
-
prompter,
|
|
173
|
-
label: "Nextcloud Talk",
|
|
174
|
-
accountOverride: accountOverrides["nextcloud-talk"],
|
|
175
|
-
shouldPromptAccountIds,
|
|
176
|
-
listAccountIds: listNextcloudTalkAccountIds as (cfg: OpenClawConfig) => string[],
|
|
177
|
-
defaultAccountId,
|
|
178
|
-
});
|
|
179
|
-
|
|
180
|
-
let next = cfg as CoreConfig;
|
|
181
|
-
const resolvedAccount = resolveNextcloudTalkAccount({
|
|
182
|
-
cfg: next,
|
|
183
|
-
accountId,
|
|
184
|
-
});
|
|
185
|
-
const accountConfigured = Boolean(resolvedAccount.secret && resolvedAccount.baseUrl);
|
|
186
|
-
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
|
187
|
-
const hasConfigSecret = Boolean(
|
|
188
|
-
hasConfiguredSecretInput(resolvedAccount.config.botSecret) ||
|
|
189
|
-
resolvedAccount.config.botSecretFile,
|
|
190
|
-
);
|
|
191
|
-
|
|
192
|
-
let baseUrl = resolvedAccount.baseUrl;
|
|
193
|
-
if (!baseUrl) {
|
|
194
|
-
baseUrl = String(
|
|
195
|
-
await prompter.text({
|
|
196
|
-
message: "Enter Nextcloud instance URL (e.g., https://cloud.example.com)",
|
|
197
|
-
validate: (value) => {
|
|
198
|
-
const v = String(value ?? "").trim();
|
|
199
|
-
if (!v) {
|
|
200
|
-
return "Required";
|
|
201
|
-
}
|
|
202
|
-
if (!v.startsWith("http://") && !v.startsWith("https://")) {
|
|
203
|
-
return "URL must start with http:// or https://";
|
|
204
|
-
}
|
|
205
|
-
return undefined;
|
|
206
|
-
},
|
|
207
|
-
}),
|
|
208
|
-
).trim();
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
const secretStep = await runSingleChannelSecretStep({
|
|
212
|
-
cfg: next,
|
|
213
|
-
prompter,
|
|
214
|
-
providerHint: "nextcloud-talk",
|
|
215
|
-
credentialLabel: "bot secret",
|
|
216
|
-
accountConfigured,
|
|
217
|
-
hasConfigToken: hasConfigSecret,
|
|
218
|
-
allowEnv,
|
|
219
|
-
envValue: process.env.NEXTCLOUD_TALK_BOT_SECRET,
|
|
220
|
-
envPrompt: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?",
|
|
221
|
-
keepPrompt: "Nextcloud Talk bot secret already configured. Keep it?",
|
|
222
|
-
inputPrompt: "Enter Nextcloud Talk bot secret",
|
|
223
|
-
preferredEnvVar: "NEXTCLOUD_TALK_BOT_SECRET",
|
|
224
|
-
onMissingConfigured: async () => await noteNextcloudTalkSecretHelp(prompter),
|
|
225
|
-
applyUseEnv: async (cfg) =>
|
|
226
|
-
setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, {
|
|
227
|
-
baseUrl,
|
|
228
|
-
}),
|
|
229
|
-
applySet: async (cfg, value) =>
|
|
230
|
-
setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, {
|
|
231
|
-
baseUrl,
|
|
232
|
-
botSecret: value,
|
|
233
|
-
}),
|
|
234
|
-
});
|
|
235
|
-
next = secretStep.cfg as CoreConfig;
|
|
236
|
-
|
|
237
|
-
if (secretStep.action === "keep" && baseUrl !== resolvedAccount.baseUrl) {
|
|
238
|
-
next = setNextcloudTalkAccountConfig(next, accountId, {
|
|
239
|
-
baseUrl,
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
const existingApiUser = resolvedAccount.config.apiUser?.trim();
|
|
244
|
-
const existingApiPasswordConfigured = Boolean(
|
|
245
|
-
hasConfiguredSecretInput(resolvedAccount.config.apiPassword) ||
|
|
246
|
-
resolvedAccount.config.apiPasswordFile,
|
|
247
|
-
);
|
|
248
|
-
const configureApiCredentials = await prompter.confirm({
|
|
249
|
-
message: "Configure optional Nextcloud Talk API credentials for room lookups?",
|
|
250
|
-
initialValue: Boolean(existingApiUser && existingApiPasswordConfigured),
|
|
251
|
-
});
|
|
252
|
-
if (configureApiCredentials) {
|
|
253
|
-
const apiUser = String(
|
|
254
|
-
await prompter.text({
|
|
255
|
-
message: "Nextcloud Talk API user",
|
|
256
|
-
initialValue: existingApiUser,
|
|
257
|
-
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
258
|
-
}),
|
|
259
|
-
).trim();
|
|
260
|
-
const apiPasswordStep = await runSingleChannelSecretStep({
|
|
261
|
-
cfg: next,
|
|
262
|
-
prompter,
|
|
263
|
-
providerHint: "nextcloud-talk-api",
|
|
264
|
-
credentialLabel: "API password",
|
|
265
|
-
accountConfigured: Boolean(existingApiUser && existingApiPasswordConfigured),
|
|
266
|
-
hasConfigToken: existingApiPasswordConfigured,
|
|
267
|
-
allowEnv: false,
|
|
268
|
-
envPrompt: "",
|
|
269
|
-
keepPrompt: "Nextcloud Talk API password already configured. Keep it?",
|
|
270
|
-
inputPrompt: "Enter Nextcloud Talk API password",
|
|
271
|
-
preferredEnvVar: "NEXTCLOUD_TALK_API_PASSWORD",
|
|
272
|
-
applySet: async (cfg, value) =>
|
|
273
|
-
setNextcloudTalkAccountConfig(cfg as CoreConfig, accountId, {
|
|
274
|
-
apiUser,
|
|
275
|
-
apiPassword: value,
|
|
276
|
-
}),
|
|
277
|
-
});
|
|
278
|
-
next =
|
|
279
|
-
apiPasswordStep.action === "keep"
|
|
280
|
-
? setNextcloudTalkAccountConfig(next, accountId, { apiUser })
|
|
281
|
-
: (apiPasswordStep.cfg as CoreConfig);
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
if (forceAllowFrom) {
|
|
285
|
-
next = await promptNextcloudTalkAllowFrom({
|
|
286
|
-
cfg: next,
|
|
287
|
-
prompter,
|
|
288
|
-
accountId,
|
|
289
|
-
});
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
return { cfg: next, accountId };
|
|
293
|
-
},
|
|
294
|
-
dmPolicy,
|
|
295
|
-
disable: (cfg) => ({
|
|
296
|
-
...cfg,
|
|
297
|
-
channels: {
|
|
298
|
-
...cfg.channels,
|
|
299
|
-
"nextcloud-talk": { ...cfg.channels?.["nextcloud-talk"], enabled: false },
|
|
300
|
-
},
|
|
301
|
-
}),
|
|
302
|
-
};
|
package/src/policy.test.ts
DELETED
|
@@ -1,138 +0,0 @@
|
|
|
1
|
-
import { describe, expect, it } from "vitest";
|
|
2
|
-
import { resolveNextcloudTalkAllowlistMatch, resolveNextcloudTalkGroupAllow } from "./policy.js";
|
|
3
|
-
|
|
4
|
-
describe("nextcloud-talk policy", () => {
|
|
5
|
-
describe("resolveNextcloudTalkAllowlistMatch", () => {
|
|
6
|
-
it("allows wildcard", () => {
|
|
7
|
-
expect(
|
|
8
|
-
resolveNextcloudTalkAllowlistMatch({
|
|
9
|
-
allowFrom: ["*"],
|
|
10
|
-
senderId: "user-id",
|
|
11
|
-
}).allowed,
|
|
12
|
-
).toBe(true);
|
|
13
|
-
});
|
|
14
|
-
|
|
15
|
-
it("allows sender id match with normalization", () => {
|
|
16
|
-
expect(
|
|
17
|
-
resolveNextcloudTalkAllowlistMatch({
|
|
18
|
-
allowFrom: ["nc:User-Id"],
|
|
19
|
-
senderId: "user-id",
|
|
20
|
-
}),
|
|
21
|
-
).toEqual({ allowed: true, matchKey: "user-id", matchSource: "id" });
|
|
22
|
-
});
|
|
23
|
-
|
|
24
|
-
it("blocks when sender id does not match", () => {
|
|
25
|
-
expect(
|
|
26
|
-
resolveNextcloudTalkAllowlistMatch({
|
|
27
|
-
allowFrom: ["allowed"],
|
|
28
|
-
senderId: "other",
|
|
29
|
-
}).allowed,
|
|
30
|
-
).toBe(false);
|
|
31
|
-
});
|
|
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
|
-
});
|
|
138
|
-
});
|
package/src/replay-guard.test.ts
DELETED
|
@@ -1,70 +0,0 @@
|
|
|
1
|
-
import { mkdtemp, rm } from "node:fs/promises";
|
|
2
|
-
import os from "node:os";
|
|
3
|
-
import path from "node:path";
|
|
4
|
-
import { afterEach, describe, expect, it } from "vitest";
|
|
5
|
-
import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
|
|
6
|
-
|
|
7
|
-
const tempDirs: string[] = [];
|
|
8
|
-
|
|
9
|
-
afterEach(async () => {
|
|
10
|
-
while (tempDirs.length > 0) {
|
|
11
|
-
const dir = tempDirs.pop();
|
|
12
|
-
if (dir) {
|
|
13
|
-
await rm(dir, { recursive: true, force: true });
|
|
14
|
-
}
|
|
15
|
-
}
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
async function makeTempDir(): Promise<string> {
|
|
19
|
-
const dir = await mkdtemp(path.join(os.tmpdir(), "nextcloud-talk-replay-"));
|
|
20
|
-
tempDirs.push(dir);
|
|
21
|
-
return dir;
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
describe("createNextcloudTalkReplayGuard", () => {
|
|
25
|
-
it("persists replay decisions across guard instances", async () => {
|
|
26
|
-
const stateDir = await makeTempDir();
|
|
27
|
-
|
|
28
|
-
const firstGuard = createNextcloudTalkReplayGuard({ stateDir });
|
|
29
|
-
const firstAttempt = await firstGuard.shouldProcessMessage({
|
|
30
|
-
accountId: "account-a",
|
|
31
|
-
roomToken: "room-1",
|
|
32
|
-
messageId: "msg-1",
|
|
33
|
-
});
|
|
34
|
-
const replayAttempt = await firstGuard.shouldProcessMessage({
|
|
35
|
-
accountId: "account-a",
|
|
36
|
-
roomToken: "room-1",
|
|
37
|
-
messageId: "msg-1",
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
const secondGuard = createNextcloudTalkReplayGuard({ stateDir });
|
|
41
|
-
const restartReplayAttempt = await secondGuard.shouldProcessMessage({
|
|
42
|
-
accountId: "account-a",
|
|
43
|
-
roomToken: "room-1",
|
|
44
|
-
messageId: "msg-1",
|
|
45
|
-
});
|
|
46
|
-
|
|
47
|
-
expect(firstAttempt).toBe(true);
|
|
48
|
-
expect(replayAttempt).toBe(false);
|
|
49
|
-
expect(restartReplayAttempt).toBe(false);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
it("scopes replay state by account namespace", async () => {
|
|
53
|
-
const stateDir = await makeTempDir();
|
|
54
|
-
const guard = createNextcloudTalkReplayGuard({ stateDir });
|
|
55
|
-
|
|
56
|
-
const accountAFirst = await guard.shouldProcessMessage({
|
|
57
|
-
accountId: "account-a",
|
|
58
|
-
roomToken: "room-1",
|
|
59
|
-
messageId: "msg-9",
|
|
60
|
-
});
|
|
61
|
-
const accountBFirst = await guard.shouldProcessMessage({
|
|
62
|
-
accountId: "account-b",
|
|
63
|
-
roomToken: "room-1",
|
|
64
|
-
messageId: "msg-9",
|
|
65
|
-
});
|
|
66
|
-
|
|
67
|
-
expect(accountAFirst).toBe(true);
|
|
68
|
-
expect(accountBFirst).toBe(true);
|
|
69
|
-
});
|
|
70
|
-
});
|
package/src/send.test.ts
DELETED
|
@@ -1,104 +0,0 @@
|
|
|
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
|
-
});
|