@openclaw/nextcloud-talk 2026.2.23 → 2026.2.25

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/package.json CHANGED
@@ -1,11 +1,8 @@
1
1
  {
2
2
  "name": "@openclaw/nextcloud-talk",
3
- "version": "2026.2.23",
3
+ "version": "2026.2.25",
4
4
  "description": "OpenClaw Nextcloud Talk channel plugin",
5
5
  "type": "module",
6
- "devDependencies": {
7
- "openclaw": "workspace:*"
8
- },
9
6
  "openclaw": {
10
7
  "extensions": [
11
8
  "./index.ts"
@@ -0,0 +1,81 @@
1
+ import type { PluginRuntime, RuntimeEnv } from "openclaw/plugin-sdk";
2
+ import { describe, expect, it, vi } from "vitest";
3
+ import type { ResolvedNextcloudTalkAccount } from "./accounts.js";
4
+ import { handleNextcloudTalkInbound } from "./inbound.js";
5
+ import { setNextcloudTalkRuntime } from "./runtime.js";
6
+ import type { CoreConfig, NextcloudTalkInboundMessage } from "./types.js";
7
+
8
+ describe("nextcloud-talk inbound authz", () => {
9
+ it("does not treat DM pairing-store entries as group allowlist entries", async () => {
10
+ const readAllowFromStore = vi.fn(async () => ["attacker"]);
11
+ const buildMentionRegexes = vi.fn(() => [/@openclaw/i]);
12
+
13
+ setNextcloudTalkRuntime({
14
+ channel: {
15
+ pairing: {
16
+ readAllowFromStore,
17
+ },
18
+ commands: {
19
+ shouldHandleTextCommands: () => false,
20
+ },
21
+ text: {
22
+ hasControlCommand: () => false,
23
+ },
24
+ mentions: {
25
+ buildMentionRegexes,
26
+ matchesMentionPatterns: () => false,
27
+ },
28
+ },
29
+ } as unknown as PluginRuntime);
30
+
31
+ const message: NextcloudTalkInboundMessage = {
32
+ messageId: "m-1",
33
+ roomToken: "room-1",
34
+ roomName: "Room 1",
35
+ senderId: "attacker",
36
+ senderName: "Attacker",
37
+ text: "hello",
38
+ mediaType: "text/plain",
39
+ timestamp: Date.now(),
40
+ isGroupChat: true,
41
+ };
42
+
43
+ const account: ResolvedNextcloudTalkAccount = {
44
+ accountId: "default",
45
+ enabled: true,
46
+ baseUrl: "",
47
+ secret: "",
48
+ secretSource: "none",
49
+ config: {
50
+ dmPolicy: "pairing",
51
+ allowFrom: [],
52
+ groupPolicy: "allowlist",
53
+ groupAllowFrom: [],
54
+ },
55
+ };
56
+
57
+ const config: CoreConfig = {
58
+ channels: {
59
+ "nextcloud-talk": {
60
+ dmPolicy: "pairing",
61
+ allowFrom: [],
62
+ groupPolicy: "allowlist",
63
+ groupAllowFrom: [],
64
+ },
65
+ },
66
+ };
67
+
68
+ await handleNextcloudTalkInbound({
69
+ message,
70
+ account,
71
+ config,
72
+ runtime: {
73
+ log: vi.fn(),
74
+ error: vi.fn(),
75
+ } as unknown as RuntimeEnv,
76
+ });
77
+
78
+ expect(readAllowFromStore).toHaveBeenCalledWith("nextcloud-talk");
79
+ expect(buildMentionRegexes).not.toHaveBeenCalled();
80
+ });
81
+ });
package/src/inbound.ts CHANGED
@@ -122,7 +122,7 @@ export async function handleNextcloudTalkInbound(params: {
122
122
  configGroupAllowFrom.length > 0 ? configGroupAllowFrom : configAllowFrom;
123
123
 
124
124
  const effectiveAllowFrom = [...configAllowFrom, ...storeAllowList].filter(Boolean);
125
- const effectiveGroupAllowFrom = [...baseGroupAllowFrom, ...storeAllowList].filter(Boolean);
125
+ const effectiveGroupAllowFrom = [...baseGroupAllowFrom].filter(Boolean);
126
126
 
127
127
  const allowTextCommands = core.channel.commands.shouldHandleTextCommands({
128
128
  cfg: config as OpenClawConfig,
@@ -0,0 +1,28 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { startWebhookServer } from "./monitor.test-harness.js";
3
+
4
+ describe("createNextcloudTalkWebhookServer auth order", () => {
5
+ it("rejects missing signature headers before reading request body", async () => {
6
+ const readBody = vi.fn(async () => {
7
+ throw new Error("should not be called for missing signature headers");
8
+ });
9
+ const harness = await startWebhookServer({
10
+ path: "/nextcloud-auth-order",
11
+ maxBodyBytes: 128,
12
+ readBody,
13
+ onMessage: vi.fn(),
14
+ });
15
+
16
+ const response = await fetch(harness.webhookUrl, {
17
+ method: "POST",
18
+ headers: {
19
+ "content-type": "application/json",
20
+ },
21
+ body: "{}",
22
+ });
23
+
24
+ expect(response.status).toBe(400);
25
+ expect(await response.json()).toEqual({ error: "Missing signature headers" });
26
+ expect(readBody).not.toHaveBeenCalled();
27
+ });
28
+ });
@@ -0,0 +1,46 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { startWebhookServer } from "./monitor.test-harness.js";
3
+ import { generateNextcloudTalkSignature } from "./signature.js";
4
+
5
+ describe("createNextcloudTalkWebhookServer backend allowlist", () => {
6
+ it("rejects requests from unexpected backend origins", async () => {
7
+ const onMessage = vi.fn(async () => {});
8
+ const harness = await startWebhookServer({
9
+ path: "/nextcloud-backend-check",
10
+ isBackendAllowed: (backend) => backend === "https://nextcloud.expected",
11
+ onMessage,
12
+ });
13
+
14
+ const payload = {
15
+ type: "Create",
16
+ actor: { type: "Person", id: "alice", name: "Alice" },
17
+ object: {
18
+ type: "Note",
19
+ id: "msg-1",
20
+ name: "hello",
21
+ content: "hello",
22
+ mediaType: "text/plain",
23
+ },
24
+ target: { type: "Collection", id: "room-1", name: "Room 1" },
25
+ };
26
+ const body = JSON.stringify(payload);
27
+ const { random, signature } = generateNextcloudTalkSignature({
28
+ body,
29
+ secret: "nextcloud-secret",
30
+ });
31
+ const response = await fetch(harness.webhookUrl, {
32
+ method: "POST",
33
+ headers: {
34
+ "content-type": "application/json",
35
+ "x-nextcloud-talk-random": random,
36
+ "x-nextcloud-talk-signature": signature,
37
+ "x-nextcloud-talk-backend": "https://nextcloud.unexpected",
38
+ },
39
+ body,
40
+ });
41
+
42
+ expect(response.status).toBe(401);
43
+ expect(await response.json()).toEqual({ error: "Invalid backend" });
44
+ expect(onMessage).not.toHaveBeenCalled();
45
+ });
46
+ });
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it, vi } from "vitest";
2
+ import { startWebhookServer } from "./monitor.test-harness.js";
3
+ import { generateNextcloudTalkSignature } from "./signature.js";
4
+ import type { NextcloudTalkInboundMessage } from "./types.js";
5
+
6
+ function createSignedRequest(body: string): { random: string; signature: string } {
7
+ return generateNextcloudTalkSignature({
8
+ body,
9
+ secret: "nextcloud-secret",
10
+ });
11
+ }
12
+
13
+ describe("createNextcloudTalkWebhookServer replay handling", () => {
14
+ it("acknowledges replayed requests and skips onMessage side effects", async () => {
15
+ const seen = new Set<string>();
16
+ const onMessage = vi.fn(async () => {});
17
+ const shouldProcessMessage = vi.fn(async (message: NextcloudTalkInboundMessage) => {
18
+ if (seen.has(message.messageId)) {
19
+ return false;
20
+ }
21
+ seen.add(message.messageId);
22
+ return true;
23
+ });
24
+ const harness = await startWebhookServer({
25
+ path: "/nextcloud-replay",
26
+ shouldProcessMessage,
27
+ onMessage,
28
+ });
29
+
30
+ const payload = {
31
+ type: "Create",
32
+ actor: { type: "Person", id: "alice", name: "Alice" },
33
+ object: {
34
+ type: "Note",
35
+ id: "msg-1",
36
+ name: "hello",
37
+ content: "hello",
38
+ mediaType: "text/plain",
39
+ },
40
+ target: { type: "Collection", id: "room-1", name: "Room 1" },
41
+ };
42
+ const body = JSON.stringify(payload);
43
+ const { random, signature } = createSignedRequest(body);
44
+ const headers = {
45
+ "content-type": "application/json",
46
+ "x-nextcloud-talk-random": random,
47
+ "x-nextcloud-talk-signature": signature,
48
+ "x-nextcloud-talk-backend": "https://nextcloud.example",
49
+ };
50
+
51
+ const first = await fetch(harness.webhookUrl, {
52
+ method: "POST",
53
+ headers,
54
+ body,
55
+ });
56
+ const second = await fetch(harness.webhookUrl, {
57
+ method: "POST",
58
+ headers,
59
+ body,
60
+ });
61
+
62
+ expect(first.status).toBe(200);
63
+ expect(second.status).toBe(200);
64
+ expect(shouldProcessMessage).toHaveBeenCalledTimes(2);
65
+ expect(onMessage).toHaveBeenCalledTimes(1);
66
+ });
67
+ });
@@ -0,0 +1,59 @@
1
+ import { type AddressInfo } from "node:net";
2
+ import { afterEach } from "vitest";
3
+ import { createNextcloudTalkWebhookServer } from "./monitor.js";
4
+ import type { NextcloudTalkWebhookServerOptions } from "./types.js";
5
+
6
+ export type WebhookHarness = {
7
+ webhookUrl: string;
8
+ stop: () => Promise<void>;
9
+ };
10
+
11
+ const cleanupFns: Array<() => Promise<void>> = [];
12
+
13
+ afterEach(async () => {
14
+ while (cleanupFns.length > 0) {
15
+ const cleanup = cleanupFns.pop();
16
+ if (cleanup) {
17
+ await cleanup();
18
+ }
19
+ }
20
+ });
21
+
22
+ export type StartWebhookServerParams = Omit<
23
+ NextcloudTalkWebhookServerOptions,
24
+ "port" | "host" | "path" | "secret"
25
+ > & {
26
+ path: string;
27
+ secret?: string;
28
+ host?: string;
29
+ port?: number;
30
+ };
31
+
32
+ export async function startWebhookServer(
33
+ params: StartWebhookServerParams,
34
+ ): Promise<WebhookHarness> {
35
+ const host = params.host ?? "127.0.0.1";
36
+ const port = params.port ?? 0;
37
+ const secret = params.secret ?? "nextcloud-secret";
38
+ const { server, start } = createNextcloudTalkWebhookServer({
39
+ ...params,
40
+ port,
41
+ host,
42
+ secret,
43
+ });
44
+ await start();
45
+ const address = server.address() as AddressInfo | null;
46
+ if (!address) {
47
+ throw new Error("missing server address");
48
+ }
49
+
50
+ const harness: WebhookHarness = {
51
+ webhookUrl: `http://${host}:${address.port}${params.path}`,
52
+ stop: () =>
53
+ new Promise<void>((resolve) => {
54
+ server.close(() => resolve());
55
+ }),
56
+ };
57
+ cleanupFns.push(harness.stop);
58
+ return harness;
59
+ }
package/src/monitor.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
+ import os from "node:os";
2
3
  import {
3
4
  createLoggerBackedRuntime,
4
5
  type RuntimeEnv,
@@ -8,11 +9,13 @@ import {
8
9
  } from "openclaw/plugin-sdk";
9
10
  import { resolveNextcloudTalkAccount } from "./accounts.js";
10
11
  import { handleNextcloudTalkInbound } from "./inbound.js";
12
+ import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
11
13
  import { getNextcloudTalkRuntime } from "./runtime.js";
12
14
  import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
13
15
  import type {
14
16
  CoreConfig,
15
17
  NextcloudTalkInboundMessage,
18
+ NextcloudTalkWebhookHeaders,
16
19
  NextcloudTalkWebhookPayload,
17
20
  NextcloudTalkWebhookServerOptions,
18
21
  } from "./types.js";
@@ -23,6 +26,14 @@ const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
23
26
  const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
24
27
  const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
25
28
  const HEALTH_PATH = "/healthz";
29
+ const WEBHOOK_ERRORS = {
30
+ missingSignatureHeaders: "Missing signature headers",
31
+ invalidBackend: "Invalid backend",
32
+ invalidSignature: "Invalid signature",
33
+ invalidPayloadFormat: "Invalid payload format",
34
+ payloadTooLarge: "Payload too large",
35
+ internalServerError: "Internal server error",
36
+ } as const;
26
37
 
27
38
  function formatError(err: unknown): string {
28
39
  if (err instanceof Error) {
@@ -31,6 +42,14 @@ function formatError(err: unknown): string {
31
42
  return typeof err === "string" ? err : JSON.stringify(err);
32
43
  }
33
44
 
45
+ function normalizeOrigin(value: string): string | null {
46
+ try {
47
+ return new URL(value).origin.toLowerCase();
48
+ } catch {
49
+ return null;
50
+ }
51
+ }
52
+
34
53
  function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
35
54
  try {
36
55
  const data = JSON.parse(body);
@@ -51,6 +70,83 @@ function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
51
70
  }
52
71
  }
53
72
 
73
+ function writeJsonResponse(
74
+ res: ServerResponse,
75
+ status: number,
76
+ body?: Record<string, unknown>,
77
+ ): void {
78
+ if (body) {
79
+ res.writeHead(status, { "Content-Type": "application/json" });
80
+ res.end(JSON.stringify(body));
81
+ return;
82
+ }
83
+ res.writeHead(status);
84
+ res.end();
85
+ }
86
+
87
+ function writeWebhookError(res: ServerResponse, status: number, error: string): void {
88
+ if (res.headersSent) {
89
+ return;
90
+ }
91
+ writeJsonResponse(res, status, { error });
92
+ }
93
+
94
+ function validateWebhookHeaders(params: {
95
+ req: IncomingMessage;
96
+ res: ServerResponse;
97
+ isBackendAllowed?: (backend: string) => boolean;
98
+ }): NextcloudTalkWebhookHeaders | null {
99
+ const headers = extractNextcloudTalkHeaders(
100
+ params.req.headers as Record<string, string | string[] | undefined>,
101
+ );
102
+ if (!headers) {
103
+ writeWebhookError(params.res, 400, WEBHOOK_ERRORS.missingSignatureHeaders);
104
+ return null;
105
+ }
106
+ if (params.isBackendAllowed && !params.isBackendAllowed(headers.backend)) {
107
+ writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidBackend);
108
+ return null;
109
+ }
110
+ return headers;
111
+ }
112
+
113
+ function verifyWebhookSignature(params: {
114
+ headers: NextcloudTalkWebhookHeaders;
115
+ body: string;
116
+ secret: string;
117
+ res: ServerResponse;
118
+ }): boolean {
119
+ const isValid = verifyNextcloudTalkSignature({
120
+ signature: params.headers.signature,
121
+ random: params.headers.random,
122
+ body: params.body,
123
+ secret: params.secret,
124
+ });
125
+ if (!isValid) {
126
+ writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidSignature);
127
+ return false;
128
+ }
129
+ return true;
130
+ }
131
+
132
+ function decodeWebhookCreateMessage(params: {
133
+ body: string;
134
+ res: ServerResponse;
135
+ }):
136
+ | { kind: "message"; message: NextcloudTalkInboundMessage }
137
+ | { kind: "ignore" }
138
+ | { kind: "invalid" } {
139
+ const payload = parseWebhookPayload(params.body);
140
+ if (!payload) {
141
+ writeWebhookError(params.res, 400, WEBHOOK_ERRORS.invalidPayloadFormat);
142
+ return { kind: "invalid" };
143
+ }
144
+ if (payload.type !== "Create") {
145
+ return { kind: "ignore" };
146
+ }
147
+ return { kind: "message", message: payloadToInboundMessage(payload) };
148
+ }
149
+
54
150
  function payloadToInboundMessage(
55
151
  payload: NextcloudTalkWebhookPayload,
56
152
  ): NextcloudTalkInboundMessage {
@@ -92,6 +188,9 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
92
188
  opts.maxBodyBytes > 0
93
189
  ? Math.floor(opts.maxBodyBytes)
94
190
  : DEFAULT_WEBHOOK_MAX_BODY_BYTES;
191
+ const readBody = opts.readBody ?? readNextcloudTalkWebhookBody;
192
+ const isBackendAllowed = opts.isBackendAllowed;
193
+ const shouldProcessMessage = opts.shouldProcessMessage;
95
194
 
96
195
  const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
97
196
  if (req.url === HEALTH_PATH) {
@@ -107,47 +206,49 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
107
206
  }
108
207
 
109
208
  try {
110
- const body = await readNextcloudTalkWebhookBody(req, maxBodyBytes);
111
-
112
- const headers = extractNextcloudTalkHeaders(
113
- req.headers as Record<string, string | string[] | undefined>,
114
- );
209
+ const headers = validateWebhookHeaders({
210
+ req,
211
+ res,
212
+ isBackendAllowed,
213
+ });
115
214
  if (!headers) {
116
- res.writeHead(400, { "Content-Type": "application/json" });
117
- res.end(JSON.stringify({ error: "Missing signature headers" }));
118
215
  return;
119
216
  }
120
217
 
121
- const isValid = verifyNextcloudTalkSignature({
122
- signature: headers.signature,
123
- random: headers.random,
218
+ const body = await readBody(req, maxBodyBytes);
219
+
220
+ const hasValidSignature = verifyWebhookSignature({
221
+ headers,
124
222
  body,
125
223
  secret,
224
+ res,
126
225
  });
127
-
128
- if (!isValid) {
129
- res.writeHead(401, { "Content-Type": "application/json" });
130
- res.end(JSON.stringify({ error: "Invalid signature" }));
226
+ if (!hasValidSignature) {
131
227
  return;
132
228
  }
133
229
 
134
- const payload = parseWebhookPayload(body);
135
- if (!payload) {
136
- res.writeHead(400, { "Content-Type": "application/json" });
137
- res.end(JSON.stringify({ error: "Invalid payload format" }));
230
+ const decoded = decodeWebhookCreateMessage({
231
+ body,
232
+ res,
233
+ });
234
+ if (decoded.kind === "invalid") {
138
235
  return;
139
236
  }
140
-
141
- if (payload.type !== "Create") {
142
- res.writeHead(200);
143
- res.end();
237
+ if (decoded.kind === "ignore") {
238
+ writeJsonResponse(res, 200);
144
239
  return;
145
240
  }
146
241
 
147
- const message = payloadToInboundMessage(payload);
242
+ const message = decoded.message;
243
+ if (shouldProcessMessage) {
244
+ const shouldProcess = await shouldProcessMessage(message);
245
+ if (!shouldProcess) {
246
+ writeJsonResponse(res, 200);
247
+ return;
248
+ }
249
+ }
148
250
 
149
- res.writeHead(200);
150
- res.end();
251
+ writeJsonResponse(res, 200);
151
252
 
152
253
  try {
153
254
  await onMessage(message);
@@ -156,25 +257,16 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
156
257
  }
157
258
  } catch (err) {
158
259
  if (isRequestBodyLimitError(err, "PAYLOAD_TOO_LARGE")) {
159
- if (!res.headersSent) {
160
- res.writeHead(413, { "Content-Type": "application/json" });
161
- res.end(JSON.stringify({ error: "Payload too large" }));
162
- }
260
+ writeWebhookError(res, 413, WEBHOOK_ERRORS.payloadTooLarge);
163
261
  return;
164
262
  }
165
263
  if (isRequestBodyLimitError(err, "REQUEST_BODY_TIMEOUT")) {
166
- if (!res.headersSent) {
167
- res.writeHead(408, { "Content-Type": "application/json" });
168
- res.end(JSON.stringify({ error: requestBodyErrorToText("REQUEST_BODY_TIMEOUT") }));
169
- }
264
+ writeWebhookError(res, 408, requestBodyErrorToText("REQUEST_BODY_TIMEOUT"));
170
265
  return;
171
266
  }
172
267
  const error = err instanceof Error ? err : new Error(formatError(err));
173
268
  onError?.(error);
174
- if (!res.headersSent) {
175
- res.writeHead(500, { "Content-Type": "application/json" });
176
- res.end(JSON.stringify({ error: "Internal server error" }));
177
- }
269
+ writeWebhookError(res, 500, WEBHOOK_ERRORS.internalServerError);
178
270
  }
179
271
  });
180
272
 
@@ -232,12 +324,41 @@ export async function monitorNextcloudTalkProvider(
232
324
  channel: "nextcloud-talk",
233
325
  accountId: account.accountId,
234
326
  });
327
+ const expectedBackendOrigin = normalizeOrigin(account.baseUrl);
328
+ const replayGuard = createNextcloudTalkReplayGuard({
329
+ stateDir: core.state.resolveStateDir(process.env, os.homedir),
330
+ onDiskError: (error) => {
331
+ logger.warn(
332
+ `[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`,
333
+ );
334
+ },
335
+ });
235
336
 
236
337
  const { start, stop } = createNextcloudTalkWebhookServer({
237
338
  port,
238
339
  host,
239
340
  path,
240
341
  secret: account.secret,
342
+ isBackendAllowed: (backend) => {
343
+ if (!expectedBackendOrigin) {
344
+ return true;
345
+ }
346
+ const backendOrigin = normalizeOrigin(backend);
347
+ return backendOrigin === expectedBackendOrigin;
348
+ },
349
+ shouldProcessMessage: async (message) => {
350
+ const shouldProcess = await replayGuard.shouldProcessMessage({
351
+ accountId: account.accountId,
352
+ roomToken: message.roomToken,
353
+ messageId: message.messageId,
354
+ });
355
+ if (!shouldProcess) {
356
+ logger.warn(
357
+ `[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`,
358
+ );
359
+ }
360
+ return shouldProcess;
361
+ },
241
362
  onMessage: async (message) => {
242
363
  core.channel.activity.record({
243
364
  channel: "nextcloud-talk",
@@ -0,0 +1,70 @@
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
+ });
@@ -0,0 +1,65 @@
1
+ import path from "node:path";
2
+ import { createPersistentDedupe } from "openclaw/plugin-sdk";
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
+ export 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
+ shouldProcessMessage: (params: {
35
+ accountId: string;
36
+ roomToken: string;
37
+ messageId: string;
38
+ }) => Promise<boolean>;
39
+ };
40
+
41
+ export function createNextcloudTalkReplayGuard(
42
+ options: NextcloudTalkReplayGuardOptions,
43
+ ): NextcloudTalkReplayGuard {
44
+ const stateDir = options.stateDir.trim();
45
+ const persistentDedupe = createPersistentDedupe({
46
+ ttlMs: options.ttlMs ?? DEFAULT_REPLAY_TTL_MS,
47
+ memoryMaxSize: options.memoryMaxSize ?? DEFAULT_MEMORY_MAX_SIZE,
48
+ fileMaxEntries: options.fileMaxEntries ?? DEFAULT_FILE_MAX_ENTRIES,
49
+ resolveFilePath: (namespace) =>
50
+ path.join(stateDir, "nextcloud-talk", "replay-dedupe", `${sanitizeSegment(namespace)}.json`),
51
+ });
52
+
53
+ return {
54
+ shouldProcessMessage: async ({ accountId, roomToken, messageId }) => {
55
+ const replayKey = buildReplayKey({ roomToken, messageId });
56
+ if (!replayKey) {
57
+ return true;
58
+ }
59
+ return await persistentDedupe.checkAndRecord(replayKey, {
60
+ namespace: accountId,
61
+ onDiskError: options.onDiskError,
62
+ });
63
+ },
64
+ };
65
+ }
package/src/types.ts CHANGED
@@ -169,6 +169,9 @@ export type NextcloudTalkWebhookServerOptions = {
169
169
  path: string;
170
170
  secret: string;
171
171
  maxBodyBytes?: number;
172
+ readBody?: (req: import("node:http").IncomingMessage, maxBodyBytes: number) => Promise<string>;
173
+ isBackendAllowed?: (backend: string) => boolean;
174
+ shouldProcessMessage?: (message: NextcloudTalkInboundMessage) => boolean | Promise<boolean>;
172
175
  onMessage: (message: NextcloudTalkInboundMessage) => void | Promise<void>;
173
176
  onError?: (error: Error) => void;
174
177
  abortSignal?: AbortSignal;