@kodelyth/nextcloud-talk 2026.5.42 → 2026.6.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.
Files changed (58) hide show
  1. package/klaw.plugin.json +799 -2
  2. package/package.json +18 -6
  3. package/api.ts +0 -1
  4. package/channel-plugin-api.ts +0 -1
  5. package/contract-api.ts +0 -4
  6. package/doctor-contract-api.ts +0 -1
  7. package/index.ts +0 -20
  8. package/runtime-api.ts +0 -29
  9. package/secret-contract-api.ts +0 -5
  10. package/setup-entry.ts +0 -13
  11. package/src/accounts.test.ts +0 -31
  12. package/src/accounts.ts +0 -149
  13. package/src/api-credentials.ts +0 -31
  14. package/src/approval-auth.test.ts +0 -17
  15. package/src/approval-auth.ts +0 -27
  16. package/src/bot-preflight.test.ts +0 -135
  17. package/src/bot-preflight.ts +0 -183
  18. package/src/channel-api.ts +0 -5
  19. package/src/channel.adapters.ts +0 -52
  20. package/src/channel.core.test.ts +0 -75
  21. package/src/channel.lifecycle.test.ts +0 -91
  22. package/src/channel.status.test.ts +0 -28
  23. package/src/channel.ts +0 -225
  24. package/src/config-schema.ts +0 -79
  25. package/src/core.test.ts +0 -325
  26. package/src/doctor-contract.ts +0 -9
  27. package/src/doctor.test.ts +0 -87
  28. package/src/doctor.ts +0 -40
  29. package/src/gateway.ts +0 -109
  30. package/src/inbound.authz.test.ts +0 -146
  31. package/src/inbound.behavior.test.ts +0 -309
  32. package/src/inbound.ts +0 -392
  33. package/src/message-actions.test.ts +0 -270
  34. package/src/message-actions.ts +0 -82
  35. package/src/message-adapter.ts +0 -28
  36. package/src/monitor-runtime.ts +0 -138
  37. package/src/monitor.replay.test.ts +0 -276
  38. package/src/monitor.test-fixtures.ts +0 -30
  39. package/src/monitor.test-harness.ts +0 -59
  40. package/src/monitor.ts +0 -385
  41. package/src/normalize.ts +0 -44
  42. package/src/policy.ts +0 -111
  43. package/src/replay-guard.ts +0 -128
  44. package/src/room-info.test.ts +0 -160
  45. package/src/room-info.ts +0 -130
  46. package/src/runtime.ts +0 -9
  47. package/src/secret-contract.ts +0 -103
  48. package/src/secret-input.ts +0 -4
  49. package/src/send.cfg-threading.test.ts +0 -359
  50. package/src/send.runtime.ts +0 -8
  51. package/src/send.ts +0 -269
  52. package/src/session-route.ts +0 -40
  53. package/src/setup-core.ts +0 -250
  54. package/src/setup-surface.ts +0 -195
  55. package/src/setup.test.ts +0 -445
  56. package/src/signature.ts +0 -82
  57. package/src/types.ts +0 -195
  58. 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
- }
@@ -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
- }