@kodelyth/nextcloud-talk 2026.5.42 → 2026.6.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/klaw.plugin.json +799 -2
- package/package.json +16 -4
- package/api.ts +0 -1
- package/channel-plugin-api.ts +0 -1
- package/contract-api.ts +0 -4
- package/doctor-contract-api.ts +0 -1
- package/index.ts +0 -20
- package/runtime-api.ts +0 -29
- package/secret-contract-api.ts +0 -5
- package/setup-entry.ts +0 -13
- package/src/accounts.test.ts +0 -31
- package/src/accounts.ts +0 -149
- package/src/api-credentials.ts +0 -31
- package/src/approval-auth.test.ts +0 -17
- package/src/approval-auth.ts +0 -27
- package/src/bot-preflight.test.ts +0 -135
- package/src/bot-preflight.ts +0 -183
- package/src/channel-api.ts +0 -5
- package/src/channel.adapters.ts +0 -52
- package/src/channel.core.test.ts +0 -75
- package/src/channel.lifecycle.test.ts +0 -91
- package/src/channel.status.test.ts +0 -28
- package/src/channel.ts +0 -225
- package/src/config-schema.ts +0 -79
- package/src/core.test.ts +0 -325
- package/src/doctor-contract.ts +0 -9
- package/src/doctor.test.ts +0 -87
- package/src/doctor.ts +0 -40
- package/src/gateway.ts +0 -109
- package/src/inbound.authz.test.ts +0 -146
- package/src/inbound.behavior.test.ts +0 -309
- package/src/inbound.ts +0 -392
- package/src/message-actions.test.ts +0 -270
- package/src/message-actions.ts +0 -82
- package/src/message-adapter.ts +0 -28
- package/src/monitor-runtime.ts +0 -138
- package/src/monitor.replay.test.ts +0 -276
- package/src/monitor.test-fixtures.ts +0 -30
- package/src/monitor.test-harness.ts +0 -59
- package/src/monitor.ts +0 -385
- package/src/normalize.ts +0 -44
- package/src/policy.ts +0 -111
- package/src/replay-guard.ts +0 -128
- package/src/room-info.test.ts +0 -160
- package/src/room-info.ts +0 -130
- package/src/runtime.ts +0 -9
- package/src/secret-contract.ts +0 -103
- package/src/secret-input.ts +0 -4
- package/src/send.cfg-threading.test.ts +0 -359
- package/src/send.runtime.ts +0 -8
- package/src/send.ts +0 -269
- package/src/session-route.ts +0 -40
- package/src/setup-core.ts +0 -250
- package/src/setup-surface.ts +0 -195
- package/src/setup.test.ts +0 -445
- package/src/signature.ts +0 -82
- package/src/types.ts +0 -195
- package/tsconfig.json +0 -16
package/src/monitor.ts
DELETED
|
@@ -1,385 +0,0 @@
|
|
|
1
|
-
import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
|
|
2
|
-
import { safeParseJsonWithSchema } from "klaw/plugin-sdk/extension-shared";
|
|
3
|
-
import {
|
|
4
|
-
WEBHOOK_RATE_LIMIT_DEFAULTS,
|
|
5
|
-
createAuthRateLimiter,
|
|
6
|
-
isRequestBodyLimitError,
|
|
7
|
-
readRequestBodyWithLimit,
|
|
8
|
-
requestBodyErrorToText,
|
|
9
|
-
} from "klaw/plugin-sdk/webhook-ingress";
|
|
10
|
-
import { z } from "zod";
|
|
11
|
-
import type { NextcloudTalkReplayGuard } from "./replay-guard.js";
|
|
12
|
-
import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
|
|
13
|
-
import type {
|
|
14
|
-
NextcloudTalkInboundMessage,
|
|
15
|
-
NextcloudTalkWebhookHeaders,
|
|
16
|
-
NextcloudTalkWebhookPayload,
|
|
17
|
-
NextcloudTalkWebhookServerOptions,
|
|
18
|
-
} from "./types.js";
|
|
19
|
-
|
|
20
|
-
const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
|
|
21
|
-
const PREAUTH_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
|
|
22
|
-
const PREAUTH_WEBHOOK_BODY_TIMEOUT_MS = 5_000;
|
|
23
|
-
const HEALTH_PATH = "/healthz";
|
|
24
|
-
const WEBHOOK_AUTH_RATE_LIMIT_SCOPE = "nextcloud-talk-webhook-auth";
|
|
25
|
-
const NextcloudTalkWebhookPayloadSchema: z.ZodType<NextcloudTalkWebhookPayload> = z.object({
|
|
26
|
-
type: z.enum(["Create", "Update", "Delete"]),
|
|
27
|
-
actor: z.object({
|
|
28
|
-
type: z.literal("Person"),
|
|
29
|
-
id: z.string().min(1),
|
|
30
|
-
name: z.string(),
|
|
31
|
-
}),
|
|
32
|
-
object: z.object({
|
|
33
|
-
type: z.literal("Note"),
|
|
34
|
-
id: z.string().min(1),
|
|
35
|
-
name: z.string(),
|
|
36
|
-
content: z.string(),
|
|
37
|
-
mediaType: z.string(),
|
|
38
|
-
}),
|
|
39
|
-
target: z.object({
|
|
40
|
-
type: z.literal("Collection"),
|
|
41
|
-
id: z.string().min(1),
|
|
42
|
-
name: z.string(),
|
|
43
|
-
}),
|
|
44
|
-
});
|
|
45
|
-
const WEBHOOK_ERRORS = {
|
|
46
|
-
missingSignatureHeaders: "Missing signature headers",
|
|
47
|
-
invalidBackend: "Invalid backend",
|
|
48
|
-
invalidSignature: "Invalid signature",
|
|
49
|
-
invalidPayloadFormat: "Invalid payload format",
|
|
50
|
-
payloadTooLarge: "Payload too large",
|
|
51
|
-
internalServerError: "Internal server error",
|
|
52
|
-
} as const;
|
|
53
|
-
|
|
54
|
-
export class NextcloudTalkRetryableWebhookError extends Error {
|
|
55
|
-
constructor(message: string, options?: ErrorOptions) {
|
|
56
|
-
super(message, options);
|
|
57
|
-
this.name = "NextcloudTalkRetryableWebhookError";
|
|
58
|
-
}
|
|
59
|
-
}
|
|
60
|
-
|
|
61
|
-
export async function processNextcloudTalkReplayGuardedMessage(params: {
|
|
62
|
-
replayGuard: NextcloudTalkReplayGuard;
|
|
63
|
-
accountId: string;
|
|
64
|
-
message: NextcloudTalkInboundMessage;
|
|
65
|
-
handleMessage: () => Promise<void>;
|
|
66
|
-
}): Promise<"processed" | "duplicate"> {
|
|
67
|
-
const claim = await params.replayGuard.claimMessage({
|
|
68
|
-
accountId: params.accountId,
|
|
69
|
-
roomToken: params.message.roomToken,
|
|
70
|
-
messageId: params.message.messageId,
|
|
71
|
-
});
|
|
72
|
-
if (claim !== "claimed") {
|
|
73
|
-
return "duplicate";
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
try {
|
|
77
|
-
await params.handleMessage();
|
|
78
|
-
await params.replayGuard.commitMessage({
|
|
79
|
-
accountId: params.accountId,
|
|
80
|
-
roomToken: params.message.roomToken,
|
|
81
|
-
messageId: params.message.messageId,
|
|
82
|
-
});
|
|
83
|
-
return "processed";
|
|
84
|
-
} catch (error) {
|
|
85
|
-
if (error instanceof NextcloudTalkRetryableWebhookError) {
|
|
86
|
-
params.replayGuard.releaseMessage({
|
|
87
|
-
accountId: params.accountId,
|
|
88
|
-
roomToken: params.message.roomToken,
|
|
89
|
-
messageId: params.message.messageId,
|
|
90
|
-
error,
|
|
91
|
-
});
|
|
92
|
-
} else {
|
|
93
|
-
// Generic failures are treated as non-retryable because the handler may already
|
|
94
|
-
// have produced a visible side effect, and replaying the webhook would duplicate it.
|
|
95
|
-
await params.replayGuard.commitMessage({
|
|
96
|
-
accountId: params.accountId,
|
|
97
|
-
roomToken: params.message.roomToken,
|
|
98
|
-
messageId: params.message.messageId,
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
throw error;
|
|
102
|
-
}
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function formatError(err: unknown): string {
|
|
106
|
-
if (err instanceof Error) {
|
|
107
|
-
return err.message;
|
|
108
|
-
}
|
|
109
|
-
return typeof err === "string" ? err : JSON.stringify(err);
|
|
110
|
-
}
|
|
111
|
-
|
|
112
|
-
function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
|
|
113
|
-
return safeParseJsonWithSchema(NextcloudTalkWebhookPayloadSchema, body);
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
function writeJsonResponse(
|
|
117
|
-
res: ServerResponse,
|
|
118
|
-
status: number,
|
|
119
|
-
body?: Record<string, unknown>,
|
|
120
|
-
): void {
|
|
121
|
-
if (body) {
|
|
122
|
-
res.writeHead(status, { "Content-Type": "application/json" });
|
|
123
|
-
res.end(JSON.stringify(body));
|
|
124
|
-
return;
|
|
125
|
-
}
|
|
126
|
-
res.writeHead(status);
|
|
127
|
-
res.end();
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function writeWebhookError(res: ServerResponse, status: number, error: string): void {
|
|
131
|
-
if (res.headersSent) {
|
|
132
|
-
return;
|
|
133
|
-
}
|
|
134
|
-
writeJsonResponse(res, status, { error });
|
|
135
|
-
}
|
|
136
|
-
|
|
137
|
-
function validateWebhookHeaders(params: {
|
|
138
|
-
req: IncomingMessage;
|
|
139
|
-
res: ServerResponse;
|
|
140
|
-
isBackendAllowed?: (backend: string) => boolean;
|
|
141
|
-
}): NextcloudTalkWebhookHeaders | null {
|
|
142
|
-
const headers = extractNextcloudTalkHeaders(
|
|
143
|
-
params.req.headers as Record<string, string | string[] | undefined>,
|
|
144
|
-
);
|
|
145
|
-
if (!headers) {
|
|
146
|
-
writeWebhookError(params.res, 400, WEBHOOK_ERRORS.missingSignatureHeaders);
|
|
147
|
-
return null;
|
|
148
|
-
}
|
|
149
|
-
if (params.isBackendAllowed && !params.isBackendAllowed(headers.backend)) {
|
|
150
|
-
writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidBackend);
|
|
151
|
-
return null;
|
|
152
|
-
}
|
|
153
|
-
return headers;
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
function verifyWebhookSignature(params: {
|
|
157
|
-
headers: NextcloudTalkWebhookHeaders;
|
|
158
|
-
body: string;
|
|
159
|
-
secret: string;
|
|
160
|
-
res: ServerResponse;
|
|
161
|
-
clientIp: string;
|
|
162
|
-
authRateLimiter: ReturnType<typeof createAuthRateLimiter>;
|
|
163
|
-
}): boolean {
|
|
164
|
-
const isValid = verifyNextcloudTalkSignature({
|
|
165
|
-
signature: params.headers.signature,
|
|
166
|
-
random: params.headers.random,
|
|
167
|
-
body: params.body,
|
|
168
|
-
secret: params.secret,
|
|
169
|
-
});
|
|
170
|
-
if (!isValid) {
|
|
171
|
-
params.authRateLimiter.recordFailure(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
|
|
172
|
-
writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidSignature);
|
|
173
|
-
return false;
|
|
174
|
-
}
|
|
175
|
-
params.authRateLimiter.reset(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
|
|
176
|
-
return true;
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function decodeWebhookCreateMessage(params: {
|
|
180
|
-
body: string;
|
|
181
|
-
res: ServerResponse;
|
|
182
|
-
}):
|
|
183
|
-
| { kind: "message"; message: NextcloudTalkInboundMessage }
|
|
184
|
-
| { kind: "ignore" }
|
|
185
|
-
| { kind: "invalid" } {
|
|
186
|
-
const payload = parseWebhookPayload(params.body);
|
|
187
|
-
if (!payload) {
|
|
188
|
-
writeWebhookError(params.res, 400, WEBHOOK_ERRORS.invalidPayloadFormat);
|
|
189
|
-
return { kind: "invalid" };
|
|
190
|
-
}
|
|
191
|
-
if (payload.type !== "Create") {
|
|
192
|
-
return { kind: "ignore" };
|
|
193
|
-
}
|
|
194
|
-
return { kind: "message", message: payloadToInboundMessage(payload) };
|
|
195
|
-
}
|
|
196
|
-
|
|
197
|
-
function payloadToInboundMessage(
|
|
198
|
-
payload: NextcloudTalkWebhookPayload,
|
|
199
|
-
): NextcloudTalkInboundMessage {
|
|
200
|
-
// Payload doesn't indicate DM vs room; mark as group and let inbound handler refine.
|
|
201
|
-
const isGroupChat = true;
|
|
202
|
-
|
|
203
|
-
return {
|
|
204
|
-
messageId: payload.object.id,
|
|
205
|
-
roomToken: payload.target.id,
|
|
206
|
-
roomName: payload.target.name,
|
|
207
|
-
senderId: payload.actor.id,
|
|
208
|
-
senderName: payload.actor.name ?? "",
|
|
209
|
-
text: payload.object.content || payload.object.name || "",
|
|
210
|
-
mediaType: payload.object.mediaType || "text/plain",
|
|
211
|
-
timestamp: Date.now(),
|
|
212
|
-
isGroupChat,
|
|
213
|
-
};
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
export function readNextcloudTalkWebhookBody(
|
|
217
|
-
req: IncomingMessage,
|
|
218
|
-
maxBodyBytes: number,
|
|
219
|
-
): Promise<string> {
|
|
220
|
-
return readRequestBodyWithLimit(req, {
|
|
221
|
-
// This read happens before signature verification, so keep the unauthenticated
|
|
222
|
-
// body budget bounded even if the operator-configured post-parse limit is larger.
|
|
223
|
-
maxBytes: Math.min(maxBodyBytes, PREAUTH_WEBHOOK_MAX_BODY_BYTES),
|
|
224
|
-
timeoutMs: PREAUTH_WEBHOOK_BODY_TIMEOUT_MS,
|
|
225
|
-
});
|
|
226
|
-
}
|
|
227
|
-
|
|
228
|
-
export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServerOptions): {
|
|
229
|
-
server: Server;
|
|
230
|
-
start: () => Promise<void>;
|
|
231
|
-
stop: () => void;
|
|
232
|
-
} {
|
|
233
|
-
const { port, host, path, secret, onMessage, onError, abortSignal } = opts;
|
|
234
|
-
const maxBodyBytes =
|
|
235
|
-
typeof opts.maxBodyBytes === "number" &&
|
|
236
|
-
Number.isFinite(opts.maxBodyBytes) &&
|
|
237
|
-
opts.maxBodyBytes > 0
|
|
238
|
-
? Math.floor(opts.maxBodyBytes)
|
|
239
|
-
: DEFAULT_WEBHOOK_MAX_BODY_BYTES;
|
|
240
|
-
const readBody = opts.readBody ?? readNextcloudTalkWebhookBody;
|
|
241
|
-
const isBackendAllowed = opts.isBackendAllowed;
|
|
242
|
-
const shouldProcessMessage = opts.shouldProcessMessage;
|
|
243
|
-
const processMessage = opts.processMessage;
|
|
244
|
-
const authRateLimitMaxRequests =
|
|
245
|
-
typeof opts.authRateLimit?.maxRequests === "number"
|
|
246
|
-
? opts.authRateLimit.maxRequests
|
|
247
|
-
: WEBHOOK_RATE_LIMIT_DEFAULTS.maxRequests;
|
|
248
|
-
const authRateLimitWindowMs =
|
|
249
|
-
typeof opts.authRateLimit?.windowMs === "number"
|
|
250
|
-
? opts.authRateLimit.windowMs
|
|
251
|
-
: WEBHOOK_RATE_LIMIT_DEFAULTS.windowMs;
|
|
252
|
-
const webhookAuthRateLimiter = createAuthRateLimiter({
|
|
253
|
-
maxAttempts: authRateLimitMaxRequests,
|
|
254
|
-
windowMs: authRateLimitWindowMs,
|
|
255
|
-
lockoutMs: authRateLimitWindowMs,
|
|
256
|
-
exemptLoopback: false,
|
|
257
|
-
pruneIntervalMs: authRateLimitWindowMs,
|
|
258
|
-
});
|
|
259
|
-
|
|
260
|
-
const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
|
|
261
|
-
if (req.url === HEALTH_PATH) {
|
|
262
|
-
res.writeHead(200, { "Content-Type": "text/plain" });
|
|
263
|
-
res.end("ok");
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
if (req.url !== path || req.method !== "POST") {
|
|
268
|
-
res.writeHead(404);
|
|
269
|
-
res.end();
|
|
270
|
-
return;
|
|
271
|
-
}
|
|
272
|
-
|
|
273
|
-
const clientIp = req.socket.remoteAddress ?? "unknown";
|
|
274
|
-
if (!webhookAuthRateLimiter.check(clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE).allowed) {
|
|
275
|
-
res.writeHead(429);
|
|
276
|
-
res.end("Too Many Requests");
|
|
277
|
-
return;
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
try {
|
|
281
|
-
const headers = validateWebhookHeaders({
|
|
282
|
-
req,
|
|
283
|
-
res,
|
|
284
|
-
isBackendAllowed,
|
|
285
|
-
});
|
|
286
|
-
if (!headers) {
|
|
287
|
-
return;
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
const body = await readBody(req, maxBodyBytes);
|
|
291
|
-
|
|
292
|
-
const hasValidSignature = verifyWebhookSignature({
|
|
293
|
-
headers,
|
|
294
|
-
body,
|
|
295
|
-
secret,
|
|
296
|
-
res,
|
|
297
|
-
clientIp,
|
|
298
|
-
authRateLimiter: webhookAuthRateLimiter,
|
|
299
|
-
});
|
|
300
|
-
if (!hasValidSignature) {
|
|
301
|
-
return;
|
|
302
|
-
}
|
|
303
|
-
|
|
304
|
-
const decoded = decodeWebhookCreateMessage({
|
|
305
|
-
body,
|
|
306
|
-
res,
|
|
307
|
-
});
|
|
308
|
-
if (decoded.kind === "invalid") {
|
|
309
|
-
return;
|
|
310
|
-
}
|
|
311
|
-
if (decoded.kind === "ignore") {
|
|
312
|
-
writeJsonResponse(res, 200);
|
|
313
|
-
return;
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
const message = decoded.message;
|
|
317
|
-
if (processMessage) {
|
|
318
|
-
writeJsonResponse(res, 200);
|
|
319
|
-
try {
|
|
320
|
-
await processMessage(message);
|
|
321
|
-
} catch (err) {
|
|
322
|
-
onError?.(err instanceof Error ? err : new Error(formatError(err)));
|
|
323
|
-
}
|
|
324
|
-
return;
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
if (shouldProcessMessage) {
|
|
328
|
-
const shouldProcess = await shouldProcessMessage(message);
|
|
329
|
-
if (!shouldProcess) {
|
|
330
|
-
writeJsonResponse(res, 200);
|
|
331
|
-
return;
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
writeJsonResponse(res, 200);
|
|
336
|
-
|
|
337
|
-
try {
|
|
338
|
-
await onMessage(message);
|
|
339
|
-
} catch (err) {
|
|
340
|
-
onError?.(err instanceof Error ? err : new Error(formatError(err)));
|
|
341
|
-
}
|
|
342
|
-
} catch (err) {
|
|
343
|
-
if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
|
|
344
|
-
writeWebhookError(res, 413, WEBHOOK_ERRORS.payloadTooLarge);
|
|
345
|
-
return;
|
|
346
|
-
}
|
|
347
|
-
if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
|
|
348
|
-
writeWebhookError(res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
|
|
349
|
-
return;
|
|
350
|
-
}
|
|
351
|
-
const error = err instanceof Error ? err : new Error(formatError(err));
|
|
352
|
-
onError?.(error);
|
|
353
|
-
writeWebhookError(res, 500, WEBHOOK_ERRORS.internalServerError);
|
|
354
|
-
}
|
|
355
|
-
});
|
|
356
|
-
|
|
357
|
-
const start = (): Promise<void> => {
|
|
358
|
-
return new Promise((resolve) => {
|
|
359
|
-
server.listen(port, host, () => resolve());
|
|
360
|
-
});
|
|
361
|
-
};
|
|
362
|
-
|
|
363
|
-
let stopped = false;
|
|
364
|
-
const stop = () => {
|
|
365
|
-
if (stopped) {
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
stopped = true;
|
|
369
|
-
try {
|
|
370
|
-
server.close();
|
|
371
|
-
} catch {
|
|
372
|
-
// ignore close races while shutting down
|
|
373
|
-
}
|
|
374
|
-
};
|
|
375
|
-
|
|
376
|
-
if (abortSignal) {
|
|
377
|
-
if (abortSignal.aborted) {
|
|
378
|
-
stop();
|
|
379
|
-
} else {
|
|
380
|
-
abortSignal.addEventListener("abort", stop, { once: true });
|
|
381
|
-
}
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
return { server, start, stop };
|
|
385
|
-
}
|
package/src/normalize.ts
DELETED
|
@@ -1,44 +0,0 @@
|
|
|
1
|
-
export function stripNextcloudTalkTargetPrefix(raw: string): string | undefined {
|
|
2
|
-
const trimmed = raw.trim();
|
|
3
|
-
if (!trimmed) {
|
|
4
|
-
return undefined;
|
|
5
|
-
}
|
|
6
|
-
|
|
7
|
-
let normalized = trimmed;
|
|
8
|
-
|
|
9
|
-
if (normalized.startsWith("nextcloud-talk:")) {
|
|
10
|
-
normalized = normalized.slice("nextcloud-talk:".length).trim();
|
|
11
|
-
} else if (normalized.startsWith("nc-talk:")) {
|
|
12
|
-
normalized = normalized.slice("nc-talk:".length).trim();
|
|
13
|
-
} else if (normalized.startsWith("nc:")) {
|
|
14
|
-
normalized = normalized.slice("nc:".length).trim();
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
if (normalized.startsWith("room:")) {
|
|
18
|
-
normalized = normalized.slice("room:".length).trim();
|
|
19
|
-
}
|
|
20
|
-
|
|
21
|
-
if (!normalized) {
|
|
22
|
-
return undefined;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
return normalized;
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined {
|
|
29
|
-
const normalized = stripNextcloudTalkTargetPrefix(raw);
|
|
30
|
-
return normalized ? `nextcloud-talk:${normalized}`.toLowerCase() : undefined;
|
|
31
|
-
}
|
|
32
|
-
|
|
33
|
-
export function looksLikeNextcloudTalkTargetId(raw: string): boolean {
|
|
34
|
-
const trimmed = raw.trim();
|
|
35
|
-
if (!trimmed) {
|
|
36
|
-
return false;
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
if (/^(nextcloud-talk|nc-talk|nc):/i.test(trimmed)) {
|
|
40
|
-
return true;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
return /^[a-z0-9]{8,}$/i.test(trimmed);
|
|
44
|
-
}
|
package/src/policy.ts
DELETED
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
buildChannelKeyCandidates,
|
|
3
|
-
normalizeChannelSlug,
|
|
4
|
-
resolveChannelEntryMatchWithFallback,
|
|
5
|
-
resolveNestedAllowlistDecision,
|
|
6
|
-
} from "klaw/plugin-sdk/channel-targets";
|
|
7
|
-
import type { AllowlistMatch, ChannelGroupContext, GroupToolPolicyConfig } from "../runtime-api.js";
|
|
8
|
-
import type { NextcloudTalkRoomConfig } from "./types.js";
|
|
9
|
-
|
|
10
|
-
export function normalizeNextcloudTalkAllowEntry(raw: string): string {
|
|
11
|
-
return raw
|
|
12
|
-
.trim()
|
|
13
|
-
.replace(/^(nextcloud-talk|nc-talk|nc):/i, "")
|
|
14
|
-
.toLowerCase();
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
export function normalizeNextcloudTalkAllowlist(
|
|
18
|
-
values: Array<string | number> | undefined,
|
|
19
|
-
): string[] {
|
|
20
|
-
return (values ?? [])
|
|
21
|
-
.map((value) => normalizeNextcloudTalkAllowEntry(String(value)))
|
|
22
|
-
.filter(Boolean);
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
export function resolveNextcloudTalkAllowlistMatch(params: {
|
|
26
|
-
allowFrom: Array<string | number> | undefined;
|
|
27
|
-
senderId: string;
|
|
28
|
-
}): AllowlistMatch<"wildcard" | "id"> {
|
|
29
|
-
const allowFrom = normalizeNextcloudTalkAllowlist(params.allowFrom);
|
|
30
|
-
if (allowFrom.length === 0) {
|
|
31
|
-
return { allowed: false };
|
|
32
|
-
}
|
|
33
|
-
if (allowFrom.includes("*")) {
|
|
34
|
-
return { allowed: true, matchKey: "*", matchSource: "wildcard" };
|
|
35
|
-
}
|
|
36
|
-
const senderId = normalizeNextcloudTalkAllowEntry(params.senderId);
|
|
37
|
-
if (allowFrom.includes(senderId)) {
|
|
38
|
-
return { allowed: true, matchKey: senderId, matchSource: "id" };
|
|
39
|
-
}
|
|
40
|
-
return { allowed: false };
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
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
|
-
}): NextcloudTalkRoomMatch {
|
|
56
|
-
const rooms = params.rooms ?? {};
|
|
57
|
-
const allowlistConfigured = Object.keys(rooms).length > 0;
|
|
58
|
-
const roomCandidates = buildChannelKeyCandidates(params.roomToken);
|
|
59
|
-
const match = resolveChannelEntryMatchWithFallback({
|
|
60
|
-
entries: rooms,
|
|
61
|
-
keys: roomCandidates,
|
|
62
|
-
wildcardKey: "*",
|
|
63
|
-
normalizeKey: normalizeChannelSlug,
|
|
64
|
-
});
|
|
65
|
-
const roomConfig = match.entry;
|
|
66
|
-
const allowed = resolveNestedAllowlistDecision({
|
|
67
|
-
outerConfigured: allowlistConfigured,
|
|
68
|
-
outerMatched: Boolean(roomConfig),
|
|
69
|
-
innerConfigured: false,
|
|
70
|
-
innerMatched: false,
|
|
71
|
-
});
|
|
72
|
-
|
|
73
|
-
return {
|
|
74
|
-
roomConfig,
|
|
75
|
-
wildcardConfig: match.wildcardEntry,
|
|
76
|
-
roomKey: match.matchKey ?? match.key,
|
|
77
|
-
matchSource: match.matchSource,
|
|
78
|
-
allowed,
|
|
79
|
-
allowlistConfigured,
|
|
80
|
-
};
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
export function resolveNextcloudTalkGroupToolPolicy(
|
|
84
|
-
params: ChannelGroupContext,
|
|
85
|
-
): GroupToolPolicyConfig | undefined {
|
|
86
|
-
const cfg = params.cfg as {
|
|
87
|
-
channels?: { "nextcloud-talk"?: { rooms?: Record<string, NextcloudTalkRoomConfig> } };
|
|
88
|
-
};
|
|
89
|
-
const roomToken = params.groupId?.trim();
|
|
90
|
-
if (!roomToken) {
|
|
91
|
-
return undefined;
|
|
92
|
-
}
|
|
93
|
-
const match = resolveNextcloudTalkRoomMatch({
|
|
94
|
-
rooms: cfg.channels?.["nextcloud-talk"]?.rooms,
|
|
95
|
-
roomToken,
|
|
96
|
-
});
|
|
97
|
-
return match.roomConfig?.tools ?? match.wildcardConfig?.tools;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
export function resolveNextcloudTalkRequireMention(params: {
|
|
101
|
-
roomConfig?: NextcloudTalkRoomConfig;
|
|
102
|
-
wildcardConfig?: NextcloudTalkRoomConfig;
|
|
103
|
-
}): boolean {
|
|
104
|
-
if (typeof params.roomConfig?.requireMention === "boolean") {
|
|
105
|
-
return params.roomConfig.requireMention;
|
|
106
|
-
}
|
|
107
|
-
if (typeof params.wildcardConfig?.requireMention === "boolean") {
|
|
108
|
-
return params.wildcardConfig.requireMention;
|
|
109
|
-
}
|
|
110
|
-
return true;
|
|
111
|
-
}
|
package/src/replay-guard.ts
DELETED
|
@@ -1,128 +0,0 @@
|
|
|
1
|
-
import path from "node:path";
|
|
2
|
-
import { createClaimableDedupe } from "klaw/plugin-sdk/persistent-dedupe";
|
|
3
|
-
|
|
4
|
-
const DEFAULT_REPLAY_TTL_MS = 24 * 60 * 60 * 1000;
|
|
5
|
-
const DEFAULT_MEMORY_MAX_SIZE = 1_000;
|
|
6
|
-
const DEFAULT_FILE_MAX_ENTRIES = 10_000;
|
|
7
|
-
|
|
8
|
-
function sanitizeSegment(value: string): string {
|
|
9
|
-
const trimmed = value.trim();
|
|
10
|
-
if (!trimmed) {
|
|
11
|
-
return "default";
|
|
12
|
-
}
|
|
13
|
-
return trimmed.replace(/[^a-zA-Z0-9_-]/g, "_");
|
|
14
|
-
}
|
|
15
|
-
|
|
16
|
-
function buildReplayKey(params: { roomToken: string; messageId: string }): string | null {
|
|
17
|
-
const roomToken = params.roomToken.trim();
|
|
18
|
-
const messageId = params.messageId.trim();
|
|
19
|
-
if (!roomToken || !messageId) {
|
|
20
|
-
return null;
|
|
21
|
-
}
|
|
22
|
-
return `${roomToken}:${messageId}`;
|
|
23
|
-
}
|
|
24
|
-
|
|
25
|
-
type NextcloudTalkReplayGuardOptions = {
|
|
26
|
-
stateDir?: string;
|
|
27
|
-
ttlMs?: number;
|
|
28
|
-
memoryMaxSize?: number;
|
|
29
|
-
fileMaxEntries?: number;
|
|
30
|
-
onDiskError?: (error: unknown) => void;
|
|
31
|
-
};
|
|
32
|
-
|
|
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;
|
|
50
|
-
shouldProcessMessage: (params: {
|
|
51
|
-
accountId: string;
|
|
52
|
-
roomToken: string;
|
|
53
|
-
messageId: string;
|
|
54
|
-
}) => Promise<boolean>;
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
export function createNextcloudTalkReplayGuard(
|
|
58
|
-
options: NextcloudTalkReplayGuardOptions,
|
|
59
|
-
): NextcloudTalkReplayGuard {
|
|
60
|
-
const stateDir = options.stateDir?.trim();
|
|
61
|
-
const baseOptions = {
|
|
62
|
-
ttlMs: options.ttlMs ?? DEFAULT_REPLAY_TTL_MS,
|
|
63
|
-
memoryMaxSize: options.memoryMaxSize ?? DEFAULT_MEMORY_MAX_SIZE,
|
|
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
|
-
);
|
|
81
|
-
|
|
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
|
-
},
|
|
112
|
-
shouldProcessMessage: async ({ accountId, roomToken, messageId }) => {
|
|
113
|
-
const replayKey = buildReplayKey({ roomToken, messageId });
|
|
114
|
-
if (!replayKey) {
|
|
115
|
-
return true;
|
|
116
|
-
}
|
|
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, {
|
|
124
|
-
namespace: accountId,
|
|
125
|
-
});
|
|
126
|
-
},
|
|
127
|
-
};
|
|
128
|
-
}
|