@openclaw/nextcloud-talk 2026.3.12 → 2026.5.1-beta.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/api.ts +1 -0
  2. package/channel-plugin-api.ts +1 -0
  3. package/contract-api.ts +4 -0
  4. package/doctor-contract-api.ts +1 -0
  5. package/index.ts +15 -12
  6. package/openclaw.plugin.json +804 -1
  7. package/package.json +31 -4
  8. package/runtime-api.ts +33 -0
  9. package/secret-contract-api.ts +5 -0
  10. package/setup-entry.ts +13 -0
  11. package/src/accounts.ts +25 -42
  12. package/src/approval-auth.test.ts +17 -0
  13. package/src/approval-auth.ts +27 -0
  14. package/src/channel-api.ts +5 -0
  15. package/src/channel.adapters.ts +52 -0
  16. package/src/channel.core.test.ts +75 -0
  17. package/src/channel.lifecycle.test.ts +81 -0
  18. package/src/channel.ts +157 -388
  19. package/src/config-schema.ts +27 -22
  20. package/src/core.test.ts +397 -0
  21. package/src/doctor-contract.ts +9 -0
  22. package/src/doctor.test.ts +40 -0
  23. package/src/doctor.ts +10 -0
  24. package/src/gateway.ts +109 -0
  25. package/src/inbound.authz.test.ts +87 -22
  26. package/src/inbound.behavior.test.ts +202 -0
  27. package/src/inbound.ts +28 -26
  28. package/src/monitor-runtime.ts +138 -0
  29. package/src/monitor.replay.test.ts +238 -0
  30. package/src/monitor.test-harness.ts +2 -2
  31. package/src/monitor.ts +125 -155
  32. package/src/normalize.ts +7 -2
  33. package/src/policy.ts +23 -31
  34. package/src/replay-guard.ts +74 -11
  35. package/src/room-info.test.ts +116 -0
  36. package/src/room-info.ts +11 -3
  37. package/src/runtime.ts +6 -3
  38. package/src/secret-contract.ts +103 -0
  39. package/src/secret-input.ts +1 -10
  40. package/src/send.cfg-threading.test.ts +153 -0
  41. package/src/send.runtime.ts +8 -0
  42. package/src/send.ts +126 -106
  43. package/src/session-route.ts +40 -0
  44. package/src/setup-core.ts +248 -0
  45. package/src/setup-surface.ts +190 -0
  46. package/src/setup.test.ts +422 -0
  47. package/src/signature.ts +20 -10
  48. package/src/types.ts +19 -16
  49. package/tsconfig.json +16 -0
  50. package/src/accounts.test.ts +0 -30
  51. package/src/channel.startup.test.ts +0 -83
  52. package/src/config-schema.test.ts +0 -36
  53. package/src/format.ts +0 -79
  54. package/src/monitor.auth-order.test.ts +0 -28
  55. package/src/monitor.backend.test.ts +0 -27
  56. package/src/monitor.read-body.test.ts +0 -16
  57. package/src/onboarding.ts +0 -302
  58. package/src/policy.test.ts +0 -138
  59. package/src/replay-guard.test.ts +0 -70
  60. package/src/send.test.ts +0 -104
@@ -1,9 +1,112 @@
1
+ import { createMockIncomingRequest } from "openclaw/plugin-sdk/test-env";
1
2
  import { describe, expect, it, vi } from "vitest";
3
+ import {
4
+ NextcloudTalkRetryableWebhookError,
5
+ processNextcloudTalkReplayGuardedMessage,
6
+ readNextcloudTalkWebhookBody,
7
+ } from "./monitor.js";
2
8
  import { createSignedCreateMessageRequest } from "./monitor.test-fixtures.js";
3
9
  import { startWebhookServer } from "./monitor.test-harness.js";
10
+ import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
11
+ import { generateNextcloudTalkSignature } from "./signature.js";
4
12
  import type { NextcloudTalkInboundMessage } from "./types.js";
5
13
 
14
+ describe("readNextcloudTalkWebhookBody", () => {
15
+ it("reads valid body within max bytes", async () => {
16
+ const req = createMockIncomingRequest(['{"type":"Create"}']);
17
+ const body = await readNextcloudTalkWebhookBody(req, 1024);
18
+ expect(body).toBe('{"type":"Create"}');
19
+ });
20
+
21
+ it("rejects when payload exceeds max bytes", async () => {
22
+ const req = createMockIncomingRequest(["x".repeat(300)]);
23
+ await expect(readNextcloudTalkWebhookBody(req, 128)).rejects.toThrow("PayloadTooLarge");
24
+ });
25
+ });
26
+
27
+ describe("createNextcloudTalkWebhookServer auth order", () => {
28
+ it("rejects missing signature headers before reading request body", async () => {
29
+ const readBody = vi.fn(async () => {
30
+ throw new Error("should not be called for missing signature headers");
31
+ });
32
+ const harness = await startWebhookServer({
33
+ path: "/nextcloud-auth-order",
34
+ maxBodyBytes: 128,
35
+ readBody,
36
+ onMessage: vi.fn(),
37
+ });
38
+
39
+ const response = await fetch(harness.webhookUrl, {
40
+ method: "POST",
41
+ headers: {
42
+ "content-type": "application/json",
43
+ },
44
+ body: "{}",
45
+ });
46
+
47
+ expect(response.status).toBe(400);
48
+ expect(await response.json()).toEqual({ error: "Missing signature headers" });
49
+ expect(readBody).not.toHaveBeenCalled();
50
+ });
51
+ });
52
+
53
+ describe("createNextcloudTalkWebhookServer backend allowlist", () => {
54
+ it("rejects requests from unexpected backend origins", async () => {
55
+ const onMessage = vi.fn(async () => {});
56
+ const harness = await startWebhookServer({
57
+ path: "/nextcloud-backend-check",
58
+ isBackendAllowed: (backend) => backend === "https://nextcloud.expected",
59
+ onMessage,
60
+ });
61
+
62
+ const { body, headers } = createSignedCreateMessageRequest({
63
+ backend: "https://nextcloud.unexpected",
64
+ });
65
+ const response = await fetch(harness.webhookUrl, {
66
+ method: "POST",
67
+ headers,
68
+ body,
69
+ });
70
+
71
+ expect(response.status).toBe(401);
72
+ expect(await response.json()).toEqual({ error: "Invalid backend" });
73
+ expect(onMessage).not.toHaveBeenCalled();
74
+ });
75
+ });
76
+
6
77
  describe("createNextcloudTalkWebhookServer replay handling", () => {
78
+ function createReplayGuardedProcess(params: {
79
+ stateDir?: string;
80
+ accountId?: string;
81
+ handleMessage: () => Promise<void>;
82
+ }) {
83
+ const replayGuard = createNextcloudTalkReplayGuard(
84
+ params.stateDir ? { stateDir: params.stateDir } : {},
85
+ );
86
+
87
+ return (message: NextcloudTalkInboundMessage) =>
88
+ processNextcloudTalkReplayGuardedMessage({
89
+ replayGuard,
90
+ accountId: params.accountId ?? "acct",
91
+ message,
92
+ handleMessage: params.handleMessage,
93
+ });
94
+ }
95
+
96
+ function buildInboundMessage(): NextcloudTalkInboundMessage {
97
+ return {
98
+ messageId: "msg-1",
99
+ roomToken: "room-token",
100
+ roomName: "Room 1",
101
+ senderId: "alice",
102
+ senderName: "Alice",
103
+ text: "hello",
104
+ mediaType: "text/plain",
105
+ timestamp: 1_700_000_000_000,
106
+ isGroupChat: true,
107
+ };
108
+ }
109
+
7
110
  it("acknowledges replayed requests and skips onMessage side effects", async () => {
8
111
  const seen = new Set<string>();
9
112
  const onMessage = vi.fn(async () => {});
@@ -38,4 +141,139 @@ describe("createNextcloudTalkWebhookServer replay handling", () => {
38
141
  expect(shouldProcessMessage).toHaveBeenCalledTimes(2);
39
142
  expect(onMessage).toHaveBeenCalledTimes(1);
40
143
  });
144
+
145
+ it("allows a retry after replay-guarded processing fails before commit", async () => {
146
+ let attempts = 0;
147
+ const handleMessage = vi.fn(async () => {
148
+ attempts += 1;
149
+ if (attempts === 1) {
150
+ throw new NextcloudTalkRetryableWebhookError("transient nextcloud failure");
151
+ }
152
+ });
153
+ const processMessage = createReplayGuardedProcess({
154
+ handleMessage,
155
+ });
156
+ const message = buildInboundMessage();
157
+
158
+ await expect(processMessage(message)).rejects.toThrow("transient nextcloud failure");
159
+ await expect(processMessage(message)).resolves.toBe("processed");
160
+
161
+ expect(handleMessage).toHaveBeenCalledTimes(2);
162
+ });
163
+
164
+ it("keeps replay committed after a non-retryable replay-guarded processing failure", async () => {
165
+ const visibleSideEffect = vi.fn();
166
+ const handleMessage = vi.fn(async () => {
167
+ visibleSideEffect();
168
+ throw new Error("post-send failure");
169
+ });
170
+ const processMessage = createReplayGuardedProcess({
171
+ handleMessage,
172
+ });
173
+ const message = buildInboundMessage();
174
+
175
+ await expect(processMessage(message)).rejects.toThrow("post-send failure");
176
+ await expect(processMessage(message)).resolves.toBe("duplicate");
177
+
178
+ expect(handleMessage).toHaveBeenCalledTimes(1);
179
+ expect(visibleSideEffect).toHaveBeenCalledTimes(1);
180
+ });
181
+ });
182
+
183
+ describe("createNextcloudTalkWebhookServer payload validation", () => {
184
+ it("rejects malformed webhook payloads after signature verification", async () => {
185
+ const payload = {
186
+ type: "Create",
187
+ actor: { type: "Person", id: "alice", name: "Alice" },
188
+ object: {
189
+ type: "Note",
190
+ id: "msg-1",
191
+ name: "hello",
192
+ content: "hello",
193
+ mediaType: "text/plain",
194
+ },
195
+ target: { type: "Collection", id: "", name: "Room 1" },
196
+ };
197
+ const body = JSON.stringify(payload);
198
+ const { random, signature } = generateNextcloudTalkSignature({
199
+ body,
200
+ secret: "nextcloud-secret", // pragma: allowlist secret
201
+ });
202
+ const harness = await startWebhookServer({
203
+ path: "/nextcloud-invalid-payload",
204
+ onMessage: vi.fn(),
205
+ });
206
+
207
+ const response = await fetch(harness.webhookUrl, {
208
+ method: "POST",
209
+ headers: {
210
+ "content-type": "application/json",
211
+ "x-nextcloud-talk-random": random,
212
+ "x-nextcloud-talk-signature": signature,
213
+ "x-nextcloud-talk-backend": "https://nextcloud.example",
214
+ },
215
+ body,
216
+ });
217
+
218
+ expect(response.status).toBe(400);
219
+ expect(await response.json()).toEqual({ error: "Invalid payload format" });
220
+ });
221
+ });
222
+
223
+ describe("createNextcloudTalkWebhookServer auth rate limiting", () => {
224
+ it("rate limits repeated invalid signature attempts from the same source", async () => {
225
+ const maxRequests = 1;
226
+ const harness = await startWebhookServer({
227
+ path: "/nextcloud-auth-rate-limit",
228
+ authRateLimit: { maxRequests },
229
+ onMessage: vi.fn(),
230
+ });
231
+ const { body, headers } = createSignedCreateMessageRequest();
232
+ const invalidHeaders = {
233
+ ...headers,
234
+ "x-nextcloud-talk-signature": "invalid-signature",
235
+ };
236
+
237
+ let firstResponse: Response | undefined;
238
+ let lastResponse: Response | undefined;
239
+ for (let attempt = 0; attempt <= maxRequests; attempt += 1) {
240
+ const response = await fetch(harness.webhookUrl, {
241
+ method: "POST",
242
+ headers: invalidHeaders,
243
+ body,
244
+ });
245
+ if (attempt === 0) {
246
+ firstResponse = response;
247
+ }
248
+ lastResponse = response;
249
+ }
250
+
251
+ expect(firstResponse).toBeDefined();
252
+ expect(firstResponse?.status).toBe(401);
253
+ expect(lastResponse).toBeDefined();
254
+ expect(lastResponse?.status).toBe(429);
255
+ expect(await lastResponse?.text()).toBe("Too Many Requests");
256
+ });
257
+
258
+ it("does not rate limit valid signed webhook bursts from the same source", async () => {
259
+ const maxRequests = 1;
260
+ const harness = await startWebhookServer({
261
+ path: "/nextcloud-auth-rate-limit-valid",
262
+ authRateLimit: { maxRequests },
263
+ onMessage: vi.fn(),
264
+ });
265
+ const { body, headers } = createSignedCreateMessageRequest();
266
+
267
+ let lastResponse: Response | undefined;
268
+ for (let attempt = 0; attempt <= maxRequests; attempt += 1) {
269
+ lastResponse = await fetch(harness.webhookUrl, {
270
+ method: "POST",
271
+ headers,
272
+ body,
273
+ });
274
+ }
275
+
276
+ expect(lastResponse).toBeDefined();
277
+ expect(lastResponse?.status).toBe(200);
278
+ });
41
279
  });
@@ -3,7 +3,7 @@ import { afterEach } from "vitest";
3
3
  import { createNextcloudTalkWebhookServer } from "./monitor.js";
4
4
  import type { NextcloudTalkWebhookServerOptions } from "./types.js";
5
5
 
6
- export type WebhookHarness = {
6
+ type WebhookHarness = {
7
7
  webhookUrl: string;
8
8
  stop: () => Promise<void>;
9
9
  };
@@ -19,7 +19,7 @@ afterEach(async () => {
19
19
  }
20
20
  });
21
21
 
22
- export type StartWebhookServerParams = Omit<
22
+ type StartWebhookServerParams = Omit<
23
23
  NextcloudTalkWebhookServerOptions,
24
24
  "port" | "host" | "path" | "secret"
25
25
  > & {
package/src/monitor.ts CHANGED
@@ -1,31 +1,47 @@
1
1
  import { createServer, type IncomingMessage, type Server, type ServerResponse } from "node:http";
2
- import os from "node:os";
2
+ import { safeParseJsonWithSchema } from "openclaw/plugin-sdk/extension-shared";
3
3
  import {
4
- createLoggerBackedRuntime,
5
- type RuntimeEnv,
4
+ WEBHOOK_RATE_LIMIT_DEFAULTS,
5
+ createAuthRateLimiter,
6
6
  isRequestBodyLimitError,
7
7
  readRequestBodyWithLimit,
8
8
  requestBodyErrorToText,
9
- } from "openclaw/plugin-sdk/nextcloud-talk";
10
- import { resolveNextcloudTalkAccount } from "./accounts.js";
11
- import { handleNextcloudTalkInbound } from "./inbound.js";
12
- import { createNextcloudTalkReplayGuard } from "./replay-guard.js";
13
- import { getNextcloudTalkRuntime } from "./runtime.js";
9
+ } from "openclaw/plugin-sdk/webhook-ingress";
10
+ import { z } from "zod";
11
+ import type { NextcloudTalkReplayGuard } from "./replay-guard.js";
14
12
  import { extractNextcloudTalkHeaders, verifyNextcloudTalkSignature } from "./signature.js";
15
13
  import type {
16
- CoreConfig,
17
14
  NextcloudTalkInboundMessage,
18
15
  NextcloudTalkWebhookHeaders,
19
16
  NextcloudTalkWebhookPayload,
20
17
  NextcloudTalkWebhookServerOptions,
21
18
  } from "./types.js";
22
19
 
23
- const DEFAULT_WEBHOOK_PORT = 8788;
24
- const DEFAULT_WEBHOOK_HOST = "0.0.0.0";
25
- const DEFAULT_WEBHOOK_PATH = "/nextcloud-talk-webhook";
26
20
  const DEFAULT_WEBHOOK_MAX_BODY_BYTES = 1024 * 1024;
27
- const DEFAULT_WEBHOOK_BODY_TIMEOUT_MS = 30_000;
21
+ const PREAUTH_WEBHOOK_MAX_BODY_BYTES = 64 * 1024;
22
+ const PREAUTH_WEBHOOK_BODY_TIMEOUT_MS = 5_000;
28
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
+ });
29
45
  const WEBHOOK_ERRORS = {
30
46
  missingSignatureHeaders: "Missing signature headers",
31
47
  invalidBackend: "Invalid backend",
@@ -35,41 +51,68 @@ const WEBHOOK_ERRORS = {
35
51
  internalServerError: "Internal server error",
36
52
  } as const;
37
53
 
38
- function formatError(err: unknown): string {
39
- if (err instanceof Error) {
40
- return err.message;
54
+ export class NextcloudTalkRetryableWebhookError extends Error {
55
+ constructor(message: string, options?: ErrorOptions) {
56
+ super(message, options);
57
+ this.name = "NextcloudTalkRetryableWebhookError";
41
58
  }
42
- return typeof err === "string" ? err : JSON.stringify(err);
43
59
  }
44
60
 
45
- function normalizeOrigin(value: string): string | null {
46
- try {
47
- return new URL(value).origin.toLowerCase();
48
- } catch {
49
- return null;
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";
50
74
  }
51
- }
52
75
 
53
- function parseWebhookPayload(body: string): NextcloudTalkWebhookPayload | null {
54
76
  try {
55
- const data = JSON.parse(body);
56
- if (
57
- !data.type ||
58
- !data.actor?.type ||
59
- !data.actor?.id ||
60
- !data.object?.type ||
61
- !data.object?.id ||
62
- !data.target?.type ||
63
- !data.target?.id
64
- ) {
65
- return null;
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
+ });
66
100
  }
67
- return data as NextcloudTalkWebhookPayload;
68
- } catch {
69
- return null;
101
+ throw error;
70
102
  }
71
103
  }
72
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
+
73
116
  function writeJsonResponse(
74
117
  res: ServerResponse,
75
118
  status: number,
@@ -115,6 +158,8 @@ function verifyWebhookSignature(params: {
115
158
  body: string;
116
159
  secret: string;
117
160
  res: ServerResponse;
161
+ clientIp: string;
162
+ authRateLimiter: ReturnType<typeof createAuthRateLimiter>;
118
163
  }): boolean {
119
164
  const isValid = verifyNextcloudTalkSignature({
120
165
  signature: params.headers.signature,
@@ -123,9 +168,11 @@ function verifyWebhookSignature(params: {
123
168
  secret: params.secret,
124
169
  });
125
170
  if (!isValid) {
171
+ params.authRateLimiter.recordFailure(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
126
172
  writeWebhookError(params.res, 401, WEBHOOK_ERRORS.invalidSignature);
127
173
  return false;
128
174
  }
175
+ params.authRateLimiter.reset(params.clientIp, WEBHOOK_AUTH_RATE_LIMIT_SCOPE);
129
176
  return true;
130
177
  }
131
178
 
@@ -154,7 +201,7 @@ function payloadToInboundMessage(
154
201
  const isGroupChat = true;
155
202
 
156
203
  return {
157
- messageId: String(payload.object.id),
204
+ messageId: payload.object.id,
158
205
  roomToken: payload.target.id,
159
206
  roomName: payload.target.name,
160
207
  senderId: payload.actor.id,
@@ -171,8 +218,10 @@ export function readNextcloudTalkWebhookBody(
171
218
  maxBodyBytes: number,
172
219
  ): Promise<string> {
173
220
  return readRequestBodyWithLimit(req, {
174
- maxBytes: maxBodyBytes,
175
- timeoutMs: DEFAULT_WEBHOOK_BODY_TIMEOUT_MS,
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,
176
225
  });
177
226
  }
178
227
 
@@ -191,6 +240,22 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
191
240
  const readBody = opts.readBody ?? readNextcloudTalkWebhookBody;
192
241
  const isBackendAllowed = opts.isBackendAllowed;
193
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
+ });
194
259
 
195
260
  const server = createServer(async (req: IncomingMessage, res: ServerResponse) => {
196
261
  if (req.url === HEALTH_PATH) {
@@ -205,6 +270,13 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
205
270
  return;
206
271
  }
207
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
+
208
280
  try {
209
281
  const headers = validateWebhookHeaders({
210
282
  req,
@@ -222,6 +294,8 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
222
294
  body,
223
295
  secret,
224
296
  res,
297
+ clientIp,
298
+ authRateLimiter: webhookAuthRateLimiter,
225
299
  });
226
300
  if (!hasValidSignature) {
227
301
  return;
@@ -240,6 +314,16 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
240
314
  }
241
315
 
242
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
+
243
327
  if (shouldProcessMessage) {
244
328
  const shouldProcess = await shouldProcessMessage(message);
245
329
  if (!shouldProcess) {
@@ -299,117 +383,3 @@ export function createNextcloudTalkWebhookServer(opts: NextcloudTalkWebhookServe
299
383
 
300
384
  return { server, start, stop };
301
385
  }
302
-
303
- export type NextcloudTalkMonitorOptions = {
304
- accountId?: string;
305
- config?: CoreConfig;
306
- runtime?: RuntimeEnv;
307
- abortSignal?: AbortSignal;
308
- onMessage?: (message: NextcloudTalkInboundMessage) => void | Promise<void>;
309
- statusSink?: (patch: { lastInboundAt?: number; lastOutboundAt?: number }) => void;
310
- };
311
-
312
- export async function monitorNextcloudTalkProvider(
313
- opts: NextcloudTalkMonitorOptions,
314
- ): Promise<{ stop: () => void }> {
315
- const core = getNextcloudTalkRuntime();
316
- const cfg = opts.config ?? (core.config.loadConfig() as CoreConfig);
317
- const account = resolveNextcloudTalkAccount({
318
- cfg,
319
- accountId: opts.accountId,
320
- });
321
- const runtime: RuntimeEnv =
322
- opts.runtime ??
323
- createLoggerBackedRuntime({
324
- logger: core.logging.getChildLogger(),
325
- exitError: () => new Error("Runtime exit not available"),
326
- });
327
-
328
- if (!account.secret) {
329
- throw new Error(`Nextcloud Talk bot secret not configured for account "${account.accountId}"`);
330
- }
331
-
332
- const port = account.config.webhookPort ?? DEFAULT_WEBHOOK_PORT;
333
- const host = account.config.webhookHost ?? DEFAULT_WEBHOOK_HOST;
334
- const path = account.config.webhookPath ?? DEFAULT_WEBHOOK_PATH;
335
-
336
- const logger = core.logging.getChildLogger({
337
- channel: "nextcloud-talk",
338
- accountId: account.accountId,
339
- });
340
- const expectedBackendOrigin = normalizeOrigin(account.baseUrl);
341
- const replayGuard = createNextcloudTalkReplayGuard({
342
- stateDir: core.state.resolveStateDir(process.env, os.homedir),
343
- onDiskError: (error) => {
344
- logger.warn(
345
- `[nextcloud-talk:${account.accountId}] replay guard disk error: ${String(error)}`,
346
- );
347
- },
348
- });
349
-
350
- const { start, stop } = createNextcloudTalkWebhookServer({
351
- port,
352
- host,
353
- path,
354
- secret: account.secret,
355
- isBackendAllowed: (backend) => {
356
- if (!expectedBackendOrigin) {
357
- return true;
358
- }
359
- const backendOrigin = normalizeOrigin(backend);
360
- return backendOrigin === expectedBackendOrigin;
361
- },
362
- shouldProcessMessage: async (message) => {
363
- const shouldProcess = await replayGuard.shouldProcessMessage({
364
- accountId: account.accountId,
365
- roomToken: message.roomToken,
366
- messageId: message.messageId,
367
- });
368
- if (!shouldProcess) {
369
- logger.warn(
370
- `[nextcloud-talk:${account.accountId}] replayed webhook ignored room=${message.roomToken} messageId=${message.messageId}`,
371
- );
372
- }
373
- return shouldProcess;
374
- },
375
- onMessage: async (message) => {
376
- core.channel.activity.record({
377
- channel: "nextcloud-talk",
378
- accountId: account.accountId,
379
- direction: "inbound",
380
- at: message.timestamp,
381
- });
382
- if (opts.onMessage) {
383
- await opts.onMessage(message);
384
- return;
385
- }
386
- await handleNextcloudTalkInbound({
387
- message,
388
- account,
389
- config: cfg,
390
- runtime,
391
- statusSink: opts.statusSink,
392
- });
393
- },
394
- onError: (error) => {
395
- logger.error(`[nextcloud-talk:${account.accountId}] webhook error: ${error.message}`);
396
- },
397
- abortSignal: opts.abortSignal,
398
- });
399
-
400
- if (opts.abortSignal?.aborted) {
401
- return { stop };
402
- }
403
- await start();
404
- if (opts.abortSignal?.aborted) {
405
- stop();
406
- return { stop };
407
- }
408
-
409
- const publicUrl =
410
- account.config.webhookPublicUrl ??
411
- `http://${host === "0.0.0.0" ? "localhost" : host}:${port}${path}`;
412
- logger.info(`[nextcloud-talk:${account.accountId}] webhook listening on ${publicUrl}`);
413
-
414
- return { stop };
415
- }
package/src/normalize.ts CHANGED
@@ -1,4 +1,4 @@
1
- export function normalizeNextcloudTalkMessagingTarget(raw: string): string | undefined {
1
+ export function stripNextcloudTalkTargetPrefix(raw: string): string | undefined {
2
2
  const trimmed = raw.trim();
3
3
  if (!trimmed) {
4
4
  return undefined;
@@ -22,7 +22,12 @@ export function normalizeNextcloudTalkMessagingTarget(raw: string): string | und
22
22
  return undefined;
23
23
  }
24
24
 
25
- return `nextcloud-talk:${normalized}`.toLowerCase();
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;
26
31
  }
27
32
 
28
33
  export function looksLikeNextcloudTalkTargetId(raw: string): boolean {