@openclaw/nextcloud-talk 2026.1.29
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/index.ts +18 -0
- package/openclaw.plugin.json +11 -0
- package/package.json +30 -0
- package/src/accounts.ts +154 -0
- package/src/channel.ts +404 -0
- package/src/config-schema.ts +78 -0
- package/src/format.ts +79 -0
- package/src/inbound.ts +336 -0
- package/src/monitor.ts +246 -0
- package/src/normalize.ts +31 -0
- package/src/onboarding.ts +341 -0
- package/src/policy.ts +175 -0
- package/src/room-info.ts +111 -0
- package/src/runtime.ts +14 -0
- package/src/send.ts +206 -0
- package/src/signature.ts +67 -0
- package/src/types.ts +179 -0
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
import {
|
|
2
|
+
addWildcardAllowFrom,
|
|
3
|
+
formatDocsLink,
|
|
4
|
+
promptAccountId,
|
|
5
|
+
DEFAULT_ACCOUNT_ID,
|
|
6
|
+
normalizeAccountId,
|
|
7
|
+
type ChannelOnboardingAdapter,
|
|
8
|
+
type ChannelOnboardingDmPolicy,
|
|
9
|
+
type WizardPrompter,
|
|
10
|
+
} from "openclaw/plugin-sdk";
|
|
11
|
+
|
|
12
|
+
import {
|
|
13
|
+
listNextcloudTalkAccountIds,
|
|
14
|
+
resolveDefaultNextcloudTalkAccountId,
|
|
15
|
+
resolveNextcloudTalkAccount,
|
|
16
|
+
} from "./accounts.js";
|
|
17
|
+
import type { CoreConfig, DmPolicy } from "./types.js";
|
|
18
|
+
|
|
19
|
+
const channel = "nextcloud-talk" as const;
|
|
20
|
+
|
|
21
|
+
function setNextcloudTalkDmPolicy(cfg: CoreConfig, dmPolicy: DmPolicy): CoreConfig {
|
|
22
|
+
const existingConfig = cfg.channels?.["nextcloud-talk"];
|
|
23
|
+
const existingAllowFrom: string[] = (existingConfig?.allowFrom ?? []).map((x) => String(x));
|
|
24
|
+
const allowFrom: string[] =
|
|
25
|
+
dmPolicy === "open" ? (addWildcardAllowFrom(existingAllowFrom) as string[]) : existingAllowFrom;
|
|
26
|
+
|
|
27
|
+
const newNextcloudTalkConfig = {
|
|
28
|
+
...existingConfig,
|
|
29
|
+
dmPolicy,
|
|
30
|
+
allowFrom,
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
return {
|
|
34
|
+
...cfg,
|
|
35
|
+
channels: {
|
|
36
|
+
...cfg.channels,
|
|
37
|
+
"nextcloud-talk": newNextcloudTalkConfig,
|
|
38
|
+
},
|
|
39
|
+
} as CoreConfig;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function noteNextcloudTalkSecretHelp(prompter: WizardPrompter): Promise<void> {
|
|
43
|
+
await prompter.note(
|
|
44
|
+
[
|
|
45
|
+
"1) SSH into your Nextcloud server",
|
|
46
|
+
'2) Run: ./occ talk:bot:install "OpenClaw" "<shared-secret>" "<webhook-url>" --feature reaction',
|
|
47
|
+
"3) Copy the shared secret you used in the command",
|
|
48
|
+
"4) Enable the bot in your Nextcloud Talk room settings",
|
|
49
|
+
"Tip: you can also set NEXTCLOUD_TALK_BOT_SECRET in your env.",
|
|
50
|
+
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
|
|
51
|
+
].join("\n"),
|
|
52
|
+
"Nextcloud Talk bot setup",
|
|
53
|
+
);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
async function noteNextcloudTalkUserIdHelp(prompter: WizardPrompter): Promise<void> {
|
|
57
|
+
await prompter.note(
|
|
58
|
+
[
|
|
59
|
+
"1) Check the Nextcloud admin panel for user IDs",
|
|
60
|
+
"2) Or look at the webhook payload logs when someone messages",
|
|
61
|
+
"3) User IDs are typically lowercase usernames in Nextcloud",
|
|
62
|
+
`Docs: ${formatDocsLink("/channels/nextcloud-talk", "channels/nextcloud-talk")}`,
|
|
63
|
+
].join("\n"),
|
|
64
|
+
"Nextcloud Talk user id",
|
|
65
|
+
);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
async function promptNextcloudTalkAllowFrom(params: {
|
|
69
|
+
cfg: CoreConfig;
|
|
70
|
+
prompter: WizardPrompter;
|
|
71
|
+
accountId: string;
|
|
72
|
+
}): Promise<CoreConfig> {
|
|
73
|
+
const { cfg, prompter, accountId } = params;
|
|
74
|
+
const resolved = resolveNextcloudTalkAccount({ cfg, accountId });
|
|
75
|
+
const existingAllowFrom = resolved.config.allowFrom ?? [];
|
|
76
|
+
await noteNextcloudTalkUserIdHelp(prompter);
|
|
77
|
+
|
|
78
|
+
const parseInput = (value: string) =>
|
|
79
|
+
value
|
|
80
|
+
.split(/[\n,;]+/g)
|
|
81
|
+
.map((entry) => entry.trim().toLowerCase())
|
|
82
|
+
.filter(Boolean);
|
|
83
|
+
|
|
84
|
+
let resolvedIds: string[] = [];
|
|
85
|
+
while (resolvedIds.length === 0) {
|
|
86
|
+
const entry = await prompter.text({
|
|
87
|
+
message: "Nextcloud Talk allowFrom (user id)",
|
|
88
|
+
placeholder: "username",
|
|
89
|
+
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
|
|
90
|
+
validate: (value) => (String(value ?? "").trim() ? undefined : "Required"),
|
|
91
|
+
});
|
|
92
|
+
resolvedIds = parseInput(String(entry));
|
|
93
|
+
if (resolvedIds.length === 0) {
|
|
94
|
+
await prompter.note("Please enter at least one valid user ID.", "Nextcloud Talk allowlist");
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const merged = [
|
|
99
|
+
...existingAllowFrom.map((item) => String(item).trim().toLowerCase()).filter(Boolean),
|
|
100
|
+
...resolvedIds,
|
|
101
|
+
];
|
|
102
|
+
const unique = [...new Set(merged)];
|
|
103
|
+
|
|
104
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
105
|
+
return {
|
|
106
|
+
...cfg,
|
|
107
|
+
channels: {
|
|
108
|
+
...cfg.channels,
|
|
109
|
+
"nextcloud-talk": {
|
|
110
|
+
...cfg.channels?.["nextcloud-talk"],
|
|
111
|
+
enabled: true,
|
|
112
|
+
dmPolicy: "allowlist",
|
|
113
|
+
allowFrom: unique,
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
...cfg,
|
|
121
|
+
channels: {
|
|
122
|
+
...cfg.channels,
|
|
123
|
+
"nextcloud-talk": {
|
|
124
|
+
...cfg.channels?.["nextcloud-talk"],
|
|
125
|
+
enabled: true,
|
|
126
|
+
accounts: {
|
|
127
|
+
...cfg.channels?.["nextcloud-talk"]?.accounts,
|
|
128
|
+
[accountId]: {
|
|
129
|
+
...cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId],
|
|
130
|
+
enabled: cfg.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
|
|
131
|
+
dmPolicy: "allowlist",
|
|
132
|
+
allowFrom: unique,
|
|
133
|
+
},
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
async function promptNextcloudTalkAllowFromForAccount(params: {
|
|
141
|
+
cfg: CoreConfig;
|
|
142
|
+
prompter: WizardPrompter;
|
|
143
|
+
accountId?: string;
|
|
144
|
+
}): Promise<CoreConfig> {
|
|
145
|
+
const accountId =
|
|
146
|
+
params.accountId && normalizeAccountId(params.accountId)
|
|
147
|
+
? (normalizeAccountId(params.accountId) ?? DEFAULT_ACCOUNT_ID)
|
|
148
|
+
: resolveDefaultNextcloudTalkAccountId(params.cfg);
|
|
149
|
+
return promptNextcloudTalkAllowFrom({
|
|
150
|
+
cfg: params.cfg,
|
|
151
|
+
prompter: params.prompter,
|
|
152
|
+
accountId,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
const dmPolicy: ChannelOnboardingDmPolicy = {
|
|
157
|
+
label: "Nextcloud Talk",
|
|
158
|
+
channel,
|
|
159
|
+
policyKey: "channels.nextcloud-talk.dmPolicy",
|
|
160
|
+
allowFromKey: "channels.nextcloud-talk.allowFrom",
|
|
161
|
+
getCurrent: (cfg) => cfg.channels?.["nextcloud-talk"]?.dmPolicy ?? "pairing",
|
|
162
|
+
setPolicy: (cfg, policy) => setNextcloudTalkDmPolicy(cfg as CoreConfig, policy as DmPolicy),
|
|
163
|
+
promptAllowFrom: promptNextcloudTalkAllowFromForAccount,
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
export const nextcloudTalkOnboardingAdapter: ChannelOnboardingAdapter = {
|
|
167
|
+
channel,
|
|
168
|
+
getStatus: async ({ cfg }) => {
|
|
169
|
+
const configured = listNextcloudTalkAccountIds(cfg as CoreConfig).some((accountId) => {
|
|
170
|
+
const account = resolveNextcloudTalkAccount({ cfg: cfg as CoreConfig, accountId });
|
|
171
|
+
return Boolean(account.secret && account.baseUrl);
|
|
172
|
+
});
|
|
173
|
+
return {
|
|
174
|
+
channel,
|
|
175
|
+
configured,
|
|
176
|
+
statusLines: [`Nextcloud Talk: ${configured ? "configured" : "needs setup"}`],
|
|
177
|
+
selectionHint: configured ? "configured" : "self-hosted chat",
|
|
178
|
+
quickstartScore: configured ? 1 : 5,
|
|
179
|
+
};
|
|
180
|
+
},
|
|
181
|
+
configure: async ({
|
|
182
|
+
cfg,
|
|
183
|
+
prompter,
|
|
184
|
+
accountOverrides,
|
|
185
|
+
shouldPromptAccountIds,
|
|
186
|
+
forceAllowFrom,
|
|
187
|
+
}) => {
|
|
188
|
+
const nextcloudTalkOverride = accountOverrides["nextcloud-talk"]?.trim();
|
|
189
|
+
const defaultAccountId = resolveDefaultNextcloudTalkAccountId(cfg as CoreConfig);
|
|
190
|
+
let accountId = nextcloudTalkOverride
|
|
191
|
+
? normalizeAccountId(nextcloudTalkOverride)
|
|
192
|
+
: defaultAccountId;
|
|
193
|
+
|
|
194
|
+
if (shouldPromptAccountIds && !nextcloudTalkOverride) {
|
|
195
|
+
accountId = await promptAccountId({
|
|
196
|
+
cfg: cfg as CoreConfig,
|
|
197
|
+
prompter,
|
|
198
|
+
label: "Nextcloud Talk",
|
|
199
|
+
currentId: accountId,
|
|
200
|
+
listAccountIds: listNextcloudTalkAccountIds,
|
|
201
|
+
defaultAccountId,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
let next = cfg as CoreConfig;
|
|
206
|
+
const resolvedAccount = resolveNextcloudTalkAccount({
|
|
207
|
+
cfg: next,
|
|
208
|
+
accountId,
|
|
209
|
+
});
|
|
210
|
+
const accountConfigured = Boolean(resolvedAccount.secret && resolvedAccount.baseUrl);
|
|
211
|
+
const allowEnv = accountId === DEFAULT_ACCOUNT_ID;
|
|
212
|
+
const canUseEnv = allowEnv && Boolean(process.env.NEXTCLOUD_TALK_BOT_SECRET?.trim());
|
|
213
|
+
const hasConfigSecret = Boolean(
|
|
214
|
+
resolvedAccount.config.botSecret || resolvedAccount.config.botSecretFile,
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
let baseUrl = resolvedAccount.baseUrl;
|
|
218
|
+
if (!baseUrl) {
|
|
219
|
+
baseUrl = String(
|
|
220
|
+
await prompter.text({
|
|
221
|
+
message: "Enter Nextcloud instance URL (e.g., https://cloud.example.com)",
|
|
222
|
+
validate: (value) => {
|
|
223
|
+
const v = String(value ?? "").trim();
|
|
224
|
+
if (!v) return "Required";
|
|
225
|
+
if (!v.startsWith("http://") && !v.startsWith("https://")) {
|
|
226
|
+
return "URL must start with http:// or https://";
|
|
227
|
+
}
|
|
228
|
+
return undefined;
|
|
229
|
+
},
|
|
230
|
+
}),
|
|
231
|
+
).trim();
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
let secret: string | null = null;
|
|
235
|
+
if (!accountConfigured) {
|
|
236
|
+
await noteNextcloudTalkSecretHelp(prompter);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
if (canUseEnv && !resolvedAccount.config.botSecret) {
|
|
240
|
+
const keepEnv = await prompter.confirm({
|
|
241
|
+
message: "NEXTCLOUD_TALK_BOT_SECRET detected. Use env var?",
|
|
242
|
+
initialValue: true,
|
|
243
|
+
});
|
|
244
|
+
if (keepEnv) {
|
|
245
|
+
next = {
|
|
246
|
+
...next,
|
|
247
|
+
channels: {
|
|
248
|
+
...next.channels,
|
|
249
|
+
"nextcloud-talk": {
|
|
250
|
+
...next.channels?.["nextcloud-talk"],
|
|
251
|
+
enabled: true,
|
|
252
|
+
baseUrl,
|
|
253
|
+
},
|
|
254
|
+
},
|
|
255
|
+
};
|
|
256
|
+
} else {
|
|
257
|
+
secret = String(
|
|
258
|
+
await prompter.text({
|
|
259
|
+
message: "Enter Nextcloud Talk bot secret",
|
|
260
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
261
|
+
}),
|
|
262
|
+
).trim();
|
|
263
|
+
}
|
|
264
|
+
} else if (hasConfigSecret) {
|
|
265
|
+
const keep = await prompter.confirm({
|
|
266
|
+
message: "Nextcloud Talk secret already configured. Keep it?",
|
|
267
|
+
initialValue: true,
|
|
268
|
+
});
|
|
269
|
+
if (!keep) {
|
|
270
|
+
secret = String(
|
|
271
|
+
await prompter.text({
|
|
272
|
+
message: "Enter Nextcloud Talk bot secret",
|
|
273
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
274
|
+
}),
|
|
275
|
+
).trim();
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
secret = String(
|
|
279
|
+
await prompter.text({
|
|
280
|
+
message: "Enter Nextcloud Talk bot secret",
|
|
281
|
+
validate: (value) => (value?.trim() ? undefined : "Required"),
|
|
282
|
+
}),
|
|
283
|
+
).trim();
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (secret || baseUrl !== resolvedAccount.baseUrl) {
|
|
287
|
+
if (accountId === DEFAULT_ACCOUNT_ID) {
|
|
288
|
+
next = {
|
|
289
|
+
...next,
|
|
290
|
+
channels: {
|
|
291
|
+
...next.channels,
|
|
292
|
+
"nextcloud-talk": {
|
|
293
|
+
...next.channels?.["nextcloud-talk"],
|
|
294
|
+
enabled: true,
|
|
295
|
+
baseUrl,
|
|
296
|
+
...(secret ? { botSecret: secret } : {}),
|
|
297
|
+
},
|
|
298
|
+
},
|
|
299
|
+
};
|
|
300
|
+
} else {
|
|
301
|
+
next = {
|
|
302
|
+
...next,
|
|
303
|
+
channels: {
|
|
304
|
+
...next.channels,
|
|
305
|
+
"nextcloud-talk": {
|
|
306
|
+
...next.channels?.["nextcloud-talk"],
|
|
307
|
+
enabled: true,
|
|
308
|
+
accounts: {
|
|
309
|
+
...next.channels?.["nextcloud-talk"]?.accounts,
|
|
310
|
+
[accountId]: {
|
|
311
|
+
...next.channels?.["nextcloud-talk"]?.accounts?.[accountId],
|
|
312
|
+
enabled: next.channels?.["nextcloud-talk"]?.accounts?.[accountId]?.enabled ?? true,
|
|
313
|
+
baseUrl,
|
|
314
|
+
...(secret ? { botSecret: secret } : {}),
|
|
315
|
+
},
|
|
316
|
+
},
|
|
317
|
+
},
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
if (forceAllowFrom) {
|
|
324
|
+
next = await promptNextcloudTalkAllowFrom({
|
|
325
|
+
cfg: next,
|
|
326
|
+
prompter,
|
|
327
|
+
accountId,
|
|
328
|
+
});
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { cfg: next, accountId };
|
|
332
|
+
},
|
|
333
|
+
dmPolicy,
|
|
334
|
+
disable: (cfg) => ({
|
|
335
|
+
...cfg,
|
|
336
|
+
channels: {
|
|
337
|
+
...cfg.channels,
|
|
338
|
+
"nextcloud-talk": { ...cfg.channels?.["nextcloud-talk"], enabled: false },
|
|
339
|
+
},
|
|
340
|
+
}),
|
|
341
|
+
};
|
package/src/policy.ts
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
import type { AllowlistMatch, ChannelGroupContext, GroupPolicy, GroupToolPolicyConfig } from "openclaw/plugin-sdk";
|
|
2
|
+
import {
|
|
3
|
+
buildChannelKeyCandidates,
|
|
4
|
+
normalizeChannelSlug,
|
|
5
|
+
resolveChannelEntryMatchWithFallback,
|
|
6
|
+
resolveMentionGatingWithBypass,
|
|
7
|
+
resolveNestedAllowlistDecision,
|
|
8
|
+
} from "openclaw/plugin-sdk";
|
|
9
|
+
|
|
10
|
+
import type { NextcloudTalkRoomConfig } from "./types.js";
|
|
11
|
+
|
|
12
|
+
function normalizeAllowEntry(raw: string): string {
|
|
13
|
+
return raw.trim().toLowerCase().replace(/^(nextcloud-talk|nc-talk|nc):/i, "");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function normalizeNextcloudTalkAllowlist(
|
|
17
|
+
values: Array<string | number> | undefined,
|
|
18
|
+
): string[] {
|
|
19
|
+
return (values ?? []).map((value) => normalizeAllowEntry(String(value))).filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function resolveNextcloudTalkAllowlistMatch(params: {
|
|
23
|
+
allowFrom: Array<string | number> | undefined;
|
|
24
|
+
senderId: string;
|
|
25
|
+
senderName?: string | null;
|
|
26
|
+
}): AllowlistMatch<"wildcard" | "id" | "name"> {
|
|
27
|
+
const allowFrom = normalizeNextcloudTalkAllowlist(params.allowFrom);
|
|
28
|
+
if (allowFrom.length === 0) return { allowed: false };
|
|
29
|
+
if (allowFrom.includes("*")) {
|
|
30
|
+
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
|
31
|
+
}
|
|
32
|
+
const senderId = normalizeAllowEntry(params.senderId);
|
|
33
|
+
if (allowFrom.includes(senderId)) {
|
|
34
|
+
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
|
35
|
+
}
|
|
36
|
+
const senderName = params.senderName ? normalizeAllowEntry(params.senderName) : "";
|
|
37
|
+
if (senderName && allowFrom.includes(senderName)) {
|
|
38
|
+
return { allowed: true, matchKey: senderName, matchSource: "name" };
|
|
39
|
+
}
|
|
40
|
+
return { allowed: false };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export type NextcloudTalkRoomMatch = {
|
|
44
|
+
roomConfig?: NextcloudTalkRoomConfig;
|
|
45
|
+
wildcardConfig?: NextcloudTalkRoomConfig;
|
|
46
|
+
roomKey?: string;
|
|
47
|
+
matchSource?: "direct" | "parent" | "wildcard";
|
|
48
|
+
allowed: boolean;
|
|
49
|
+
allowlistConfigured: boolean;
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function resolveNextcloudTalkRoomMatch(params: {
|
|
53
|
+
rooms?: Record<string, NextcloudTalkRoomConfig>;
|
|
54
|
+
roomToken: string;
|
|
55
|
+
roomName?: string | null;
|
|
56
|
+
}): NextcloudTalkRoomMatch {
|
|
57
|
+
const rooms = params.rooms ?? {};
|
|
58
|
+
const allowlistConfigured = Object.keys(rooms).length > 0;
|
|
59
|
+
const roomName = params.roomName?.trim() || undefined;
|
|
60
|
+
const roomCandidates = buildChannelKeyCandidates(
|
|
61
|
+
params.roomToken,
|
|
62
|
+
roomName,
|
|
63
|
+
roomName ? normalizeChannelSlug(roomName) : undefined,
|
|
64
|
+
);
|
|
65
|
+
const match = resolveChannelEntryMatchWithFallback({
|
|
66
|
+
entries: rooms,
|
|
67
|
+
keys: roomCandidates,
|
|
68
|
+
wildcardKey: "*",
|
|
69
|
+
normalizeKey: normalizeChannelSlug,
|
|
70
|
+
});
|
|
71
|
+
const roomConfig = match.entry;
|
|
72
|
+
const allowed = resolveNestedAllowlistDecision({
|
|
73
|
+
outerConfigured: allowlistConfigured,
|
|
74
|
+
outerMatched: Boolean(roomConfig),
|
|
75
|
+
innerConfigured: false,
|
|
76
|
+
innerMatched: false,
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
roomConfig,
|
|
81
|
+
wildcardConfig: match.wildcardEntry,
|
|
82
|
+
roomKey: match.matchKey ?? match.key,
|
|
83
|
+
matchSource: match.matchSource,
|
|
84
|
+
allowed,
|
|
85
|
+
allowlistConfigured,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function resolveNextcloudTalkGroupToolPolicy(
|
|
90
|
+
params: ChannelGroupContext,
|
|
91
|
+
): GroupToolPolicyConfig | undefined {
|
|
92
|
+
const cfg = params.cfg as { channels?: { "nextcloud-talk"?: { rooms?: Record<string, NextcloudTalkRoomConfig> } } };
|
|
93
|
+
const roomToken = params.groupId?.trim();
|
|
94
|
+
if (!roomToken) return undefined;
|
|
95
|
+
const roomName = params.groupChannel?.trim() || undefined;
|
|
96
|
+
const match = resolveNextcloudTalkRoomMatch({
|
|
97
|
+
rooms: cfg.channels?.["nextcloud-talk"]?.rooms,
|
|
98
|
+
roomToken,
|
|
99
|
+
roomName,
|
|
100
|
+
});
|
|
101
|
+
return match.roomConfig?.tools ?? match.wildcardConfig?.tools;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function resolveNextcloudTalkRequireMention(params: {
|
|
105
|
+
roomConfig?: NextcloudTalkRoomConfig;
|
|
106
|
+
wildcardConfig?: NextcloudTalkRoomConfig;
|
|
107
|
+
}): boolean {
|
|
108
|
+
if (typeof params.roomConfig?.requireMention === "boolean") {
|
|
109
|
+
return params.roomConfig.requireMention;
|
|
110
|
+
}
|
|
111
|
+
if (typeof params.wildcardConfig?.requireMention === "boolean") {
|
|
112
|
+
return params.wildcardConfig.requireMention;
|
|
113
|
+
}
|
|
114
|
+
return true;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function resolveNextcloudTalkGroupAllow(params: {
|
|
118
|
+
groupPolicy: GroupPolicy;
|
|
119
|
+
outerAllowFrom: Array<string | number> | undefined;
|
|
120
|
+
innerAllowFrom: Array<string | number> | undefined;
|
|
121
|
+
senderId: string;
|
|
122
|
+
senderName?: string | null;
|
|
123
|
+
}): { allowed: boolean; outerMatch: AllowlistMatch; innerMatch: AllowlistMatch } {
|
|
124
|
+
if (params.groupPolicy === "disabled") {
|
|
125
|
+
return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
|
|
126
|
+
}
|
|
127
|
+
if (params.groupPolicy === "open") {
|
|
128
|
+
return { allowed: true, outerMatch: { allowed: true }, innerMatch: { allowed: true } };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const outerAllow = normalizeNextcloudTalkAllowlist(params.outerAllowFrom);
|
|
132
|
+
const innerAllow = normalizeNextcloudTalkAllowlist(params.innerAllowFrom);
|
|
133
|
+
if (outerAllow.length === 0 && innerAllow.length === 0) {
|
|
134
|
+
return { allowed: false, outerMatch: { allowed: false }, innerMatch: { allowed: false } };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const outerMatch = resolveNextcloudTalkAllowlistMatch({
|
|
138
|
+
allowFrom: params.outerAllowFrom,
|
|
139
|
+
senderId: params.senderId,
|
|
140
|
+
senderName: params.senderName,
|
|
141
|
+
});
|
|
142
|
+
const innerMatch = resolveNextcloudTalkAllowlistMatch({
|
|
143
|
+
allowFrom: params.innerAllowFrom,
|
|
144
|
+
senderId: params.senderId,
|
|
145
|
+
senderName: params.senderName,
|
|
146
|
+
});
|
|
147
|
+
const allowed = resolveNestedAllowlistDecision({
|
|
148
|
+
outerConfigured: outerAllow.length > 0 || innerAllow.length > 0,
|
|
149
|
+
outerMatched: outerAllow.length > 0 ? outerMatch.allowed : true,
|
|
150
|
+
innerConfigured: innerAllow.length > 0,
|
|
151
|
+
innerMatched: innerMatch.allowed,
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
return { allowed, outerMatch, innerMatch };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export function resolveNextcloudTalkMentionGate(params: {
|
|
158
|
+
isGroup: boolean;
|
|
159
|
+
requireMention: boolean;
|
|
160
|
+
wasMentioned: boolean;
|
|
161
|
+
allowTextCommands: boolean;
|
|
162
|
+
hasControlCommand: boolean;
|
|
163
|
+
commandAuthorized: boolean;
|
|
164
|
+
}): { shouldSkip: boolean; shouldBypassMention: boolean } {
|
|
165
|
+
const result = resolveMentionGatingWithBypass({
|
|
166
|
+
isGroup: params.isGroup,
|
|
167
|
+
requireMention: params.requireMention,
|
|
168
|
+
canDetectMention: true,
|
|
169
|
+
wasMentioned: params.wasMentioned,
|
|
170
|
+
allowTextCommands: params.allowTextCommands,
|
|
171
|
+
hasControlCommand: params.hasControlCommand,
|
|
172
|
+
commandAuthorized: params.commandAuthorized,
|
|
173
|
+
});
|
|
174
|
+
return { shouldSkip: result.shouldSkip, shouldBypassMention: result.shouldBypassMention };
|
|
175
|
+
}
|
package/src/room-info.ts
ADDED
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
import type { RuntimeEnv } from "openclaw/plugin-sdk";
|
|
4
|
+
|
|
5
|
+
import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
|
|
6
|
+
|
|
7
|
+
const ROOM_CACHE_TTL_MS = 5 * 60 * 1000;
|
|
8
|
+
const ROOM_CACHE_ERROR_TTL_MS = 30 * 1000;
|
|
9
|
+
|
|
10
|
+
const roomCache = new Map<
|
|
11
|
+
string,
|
|
12
|
+
{ kind?: "direct" | "group"; fetchedAt: number; error?: string }
|
|
13
|
+
>();
|
|
14
|
+
|
|
15
|
+
function resolveRoomCacheKey(params: { accountId: string; roomToken: string }) {
|
|
16
|
+
return `${params.accountId}:${params.roomToken}`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function readApiPassword(params: {
|
|
20
|
+
apiPassword?: string;
|
|
21
|
+
apiPasswordFile?: string;
|
|
22
|
+
}): string | undefined {
|
|
23
|
+
if (params.apiPassword?.trim()) return params.apiPassword.trim();
|
|
24
|
+
if (!params.apiPasswordFile) return undefined;
|
|
25
|
+
try {
|
|
26
|
+
const value = readFileSync(params.apiPasswordFile, "utf-8").trim();
|
|
27
|
+
return value || undefined;
|
|
28
|
+
} catch {
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function coerceRoomType(value: unknown): number | undefined {
|
|
34
|
+
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
35
|
+
if (typeof value === "string" && value.trim()) {
|
|
36
|
+
const parsed = Number.parseInt(value, 10);
|
|
37
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
38
|
+
}
|
|
39
|
+
return undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function resolveRoomKindFromType(type: number | undefined): "direct" | "group" | undefined {
|
|
43
|
+
if (!type) return undefined;
|
|
44
|
+
if (type === 1 || type === 5 || type === 6) return "direct";
|
|
45
|
+
return "group";
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function resolveNextcloudTalkRoomKind(params: {
|
|
49
|
+
account: ResolvedNextcloudTalkAccount;
|
|
50
|
+
roomToken: string;
|
|
51
|
+
runtime?: RuntimeEnv;
|
|
52
|
+
}): Promise<"direct" | "group" | undefined> {
|
|
53
|
+
const { account, roomToken, runtime } = params;
|
|
54
|
+
const key = resolveRoomCacheKey({ accountId: account.accountId, roomToken });
|
|
55
|
+
const cached = roomCache.get(key);
|
|
56
|
+
if (cached) {
|
|
57
|
+
const age = Date.now() - cached.fetchedAt;
|
|
58
|
+
if (cached.kind && age < ROOM_CACHE_TTL_MS) return cached.kind;
|
|
59
|
+
if (cached.error && age < ROOM_CACHE_ERROR_TTL_MS) return undefined;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const apiUser = account.config.apiUser?.trim();
|
|
63
|
+
const apiPassword = readApiPassword({
|
|
64
|
+
apiPassword: account.config.apiPassword,
|
|
65
|
+
apiPasswordFile: account.config.apiPasswordFile,
|
|
66
|
+
});
|
|
67
|
+
if (!apiUser || !apiPassword) return undefined;
|
|
68
|
+
|
|
69
|
+
const baseUrl = account.baseUrl?.trim();
|
|
70
|
+
if (!baseUrl) return undefined;
|
|
71
|
+
|
|
72
|
+
const url = `${baseUrl}/ocs/v2.php/apps/spreed/api/v4/room/${roomToken}`;
|
|
73
|
+
const auth = Buffer.from(`${apiUser}:${apiPassword}`, "utf-8").toString("base64");
|
|
74
|
+
|
|
75
|
+
try {
|
|
76
|
+
const response = await fetch(url, {
|
|
77
|
+
method: "GET",
|
|
78
|
+
headers: {
|
|
79
|
+
Authorization: `Basic ${auth}`,
|
|
80
|
+
"OCS-APIRequest": "true",
|
|
81
|
+
Accept: "application/json",
|
|
82
|
+
},
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
roomCache.set(key, {
|
|
87
|
+
fetchedAt: Date.now(),
|
|
88
|
+
error: `status:${response.status}`,
|
|
89
|
+
});
|
|
90
|
+
runtime?.log?.(
|
|
91
|
+
`nextcloud-talk: room lookup failed (${response.status}) token=${roomToken}`,
|
|
92
|
+
);
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const payload = (await response.json()) as {
|
|
97
|
+
ocs?: { data?: { type?: number | string } };
|
|
98
|
+
};
|
|
99
|
+
const type = coerceRoomType(payload.ocs?.data?.type);
|
|
100
|
+
const kind = resolveRoomKindFromType(type);
|
|
101
|
+
roomCache.set(key, { fetchedAt: Date.now(), kind });
|
|
102
|
+
return kind;
|
|
103
|
+
} catch (err) {
|
|
104
|
+
roomCache.set(key, {
|
|
105
|
+
fetchedAt: Date.now(),
|
|
106
|
+
error: err instanceof Error ? err.message : String(err),
|
|
107
|
+
});
|
|
108
|
+
runtime?.error?.(`nextcloud-talk: room lookup error: ${String(err)}`);
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
}
|
package/src/runtime.ts
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { PluginRuntime } from "openclaw/plugin-sdk";
|
|
2
|
+
|
|
3
|
+
let runtime: PluginRuntime | null = null;
|
|
4
|
+
|
|
5
|
+
export function setNextcloudTalkRuntime(next: PluginRuntime) {
|
|
6
|
+
runtime = next;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function getNextcloudTalkRuntime(): PluginRuntime {
|
|
10
|
+
if (!runtime) {
|
|
11
|
+
throw new Error("Nextcloud Talk runtime not initialized");
|
|
12
|
+
}
|
|
13
|
+
return runtime;
|
|
14
|
+
}
|