@openclaw/synology-chat 2026.2.22 → 2026.5.2-beta.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.
@@ -1,217 +1,644 @@
1
1
  /**
2
2
  * Inbound webhook handler for Synology Chat outgoing webhooks.
3
- * Parses form-urlencoded body, validates security, delivers to agent.
3
+ * Parses form-urlencoded/JSON body, validates security, delivers to agent.
4
4
  */
5
5
 
6
6
  import type { IncomingMessage, ServerResponse } from "node:http";
7
7
  import * as querystring from "node:querystring";
8
- import { sendMessage } from "./client.js";
9
- import { validateToken, checkUserAllowed, sanitizeInput, RateLimiter } from "./security.js";
8
+ import {
9
+ beginWebhookRequestPipelineOrReject,
10
+ createWebhookInFlightLimiter,
11
+ isRequestBodyLimitError,
12
+ readRequestBodyWithLimit,
13
+ requestBodyErrorToText,
14
+ } from "openclaw/plugin-sdk/webhook-ingress";
15
+ import * as synologyClient from "./client.js";
16
+ import { validateToken, authorizeUserForDm, sanitizeInput, RateLimiter } from "./security.js";
10
17
  import type { SynologyWebhookPayload, ResolvedSynologyChatAccount } from "./types.js";
11
18
 
19
+ function normalizeLowercaseStringOrEmpty(value: unknown): string {
20
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
21
+ }
22
+
12
23
  // One rate limiter per account, created lazily
13
24
  const rateLimiters = new Map<string, RateLimiter>();
25
+ const invalidTokenRateLimiters = new Map<string, InvalidTokenRateLimiter>();
26
+ const webhookInFlightLimiter = createWebhookInFlightLimiter();
27
+ const PREAUTH_MAX_BODY_BYTES = 64 * 1024;
28
+ const PREAUTH_BODY_TIMEOUT_MS = 5_000;
29
+ const PREAUTH_MAX_REQUESTS_PER_MINUTE = 10;
30
+ const INVALID_TOKEN_WINDOW_MS = 60_000;
31
+ const INVALID_TOKEN_MAX_TRACKED_KEYS = 5_000;
32
+
33
+ type InvalidTokenRateLimitState = {
34
+ count: number;
35
+ windowStartMs: number;
36
+ };
37
+
38
+ class InvalidTokenRateLimiter {
39
+ private readonly limit: number;
40
+ private readonly state = new Map<string, InvalidTokenRateLimitState>();
41
+
42
+ constructor(limit: number) {
43
+ this.limit = limit;
44
+ }
45
+
46
+ private normalizeState(key: string, nowMs: number): InvalidTokenRateLimitState | undefined {
47
+ const existing = this.state.get(key);
48
+ if (!existing) {
49
+ return undefined;
50
+ }
51
+ if (nowMs - existing.windowStartMs >= INVALID_TOKEN_WINDOW_MS) {
52
+ this.state.delete(key);
53
+ return undefined;
54
+ }
55
+ return existing;
56
+ }
57
+
58
+ private touch(key: string, value: InvalidTokenRateLimitState): void {
59
+ this.state.delete(key);
60
+ this.state.set(key, value);
61
+ while (this.state.size > INVALID_TOKEN_MAX_TRACKED_KEYS) {
62
+ const oldestKey = this.state.keys().next().value;
63
+ if (!oldestKey) {
64
+ break;
65
+ }
66
+ this.state.delete(oldestKey);
67
+ }
68
+ }
69
+
70
+ isLocked(key: string, nowMs = Date.now()): boolean {
71
+ if (!key) {
72
+ return false;
73
+ }
74
+ const existing = this.normalizeState(key, nowMs);
75
+ return (existing?.count ?? 0) > this.limit;
76
+ }
77
+
78
+ recordFailure(key: string, nowMs = Date.now()): boolean {
79
+ if (!key) {
80
+ return false;
81
+ }
82
+ const existing = this.normalizeState(key, nowMs);
83
+ const nextCount = (existing?.count ?? 0) + 1;
84
+ const windowStartMs = existing?.windowStartMs ?? nowMs;
85
+ this.touch(key, { count: nextCount, windowStartMs });
86
+ return nextCount > this.limit;
87
+ }
88
+
89
+ clear(): void {
90
+ this.state.clear();
91
+ }
92
+
93
+ maxRequests(): number {
94
+ return this.limit;
95
+ }
96
+ }
14
97
 
15
98
  function getRateLimiter(account: ResolvedSynologyChatAccount): RateLimiter {
16
99
  let rl = rateLimiters.get(account.accountId);
17
- if (!rl) {
100
+ if (!rl || rl.maxRequests() !== account.rateLimitPerMinute) {
101
+ rl?.clear();
18
102
  rl = new RateLimiter(account.rateLimitPerMinute);
19
103
  rateLimiters.set(account.accountId, rl);
20
104
  }
21
105
  return rl;
22
106
  }
23
107
 
108
+ function getInvalidTokenRateLimiter(account: ResolvedSynologyChatAccount): InvalidTokenRateLimiter {
109
+ const limit = Math.min(account.rateLimitPerMinute, PREAUTH_MAX_REQUESTS_PER_MINUTE);
110
+ let rl = invalidTokenRateLimiters.get(account.accountId);
111
+ if (!rl || rl.maxRequests() !== limit) {
112
+ rl?.clear();
113
+ rl = new InvalidTokenRateLimiter(limit);
114
+ invalidTokenRateLimiters.set(account.accountId, rl);
115
+ }
116
+ return rl;
117
+ }
118
+
119
+ export function clearSynologyWebhookRateLimiterStateForTest(): void {
120
+ for (const limiter of rateLimiters.values()) {
121
+ limiter.clear();
122
+ }
123
+ rateLimiters.clear();
124
+ for (const limiter of invalidTokenRateLimiters.values()) {
125
+ limiter.clear();
126
+ }
127
+ invalidTokenRateLimiters.clear();
128
+ webhookInFlightLimiter.clear();
129
+ }
130
+
131
+ function getSynologyWebhookInvalidTokenRateLimitKey(req: IncomingMessage): string {
132
+ return req.socket?.remoteAddress ?? "unknown";
133
+ }
134
+
135
+ function getSynologyWebhookInFlightKey(account: ResolvedSynologyChatAccount): string {
136
+ // Synology webhook ingress is typically a single upstream per account, and this
137
+ // handler does not have a trusted-proxy-aware client IP config. Keep the shared
138
+ // pre-auth concurrency budget scoped per account instead of keying on a fragile
139
+ // remoteAddress value that can collapse behind proxies or to "unknown".
140
+ return account.accountId;
141
+ }
142
+
24
143
  /** Read the full request body as a string. */
25
- function readBody(req: IncomingMessage): Promise<string> {
26
- return new Promise((resolve, reject) => {
27
- const chunks: Buffer[] = [];
28
- let size = 0;
29
- const maxSize = 1_048_576; // 1MB
30
-
31
- req.on("data", (chunk: Buffer) => {
32
- size += chunk.length;
33
- if (size > maxSize) {
34
- req.destroy();
35
- reject(new Error("Request body too large"));
36
- return;
37
- }
38
- chunks.push(chunk);
144
+ async function readBody(
145
+ req: IncomingMessage,
146
+ timeoutMs = PREAUTH_BODY_TIMEOUT_MS,
147
+ ): Promise<
148
+ | { ok: true; body: string }
149
+ | {
150
+ ok: false;
151
+ statusCode: number;
152
+ error: string;
153
+ }
154
+ > {
155
+ try {
156
+ const body = await readRequestBodyWithLimit(req, {
157
+ maxBytes: PREAUTH_MAX_BODY_BYTES,
158
+ timeoutMs,
39
159
  });
40
- req.on("end", () => resolve(Buffer.concat(chunks).toString("utf-8")));
41
- req.on("error", reject);
42
- });
160
+ return { ok: true, body };
161
+ } catch (err) {
162
+ if (isRequestBodyLimitError(err)) {
163
+ return {
164
+ ok: false,
165
+ statusCode: err.statusCode,
166
+ error: requestBodyErrorToText(err.code),
167
+ };
168
+ }
169
+ return {
170
+ ok: false,
171
+ statusCode: 400,
172
+ error: "Invalid request body",
173
+ };
174
+ }
175
+ }
176
+
177
+ function firstNonEmptyString(value: unknown): string | undefined {
178
+ if (Array.isArray(value)) {
179
+ for (const item of value) {
180
+ const normalized = firstNonEmptyString(item);
181
+ if (normalized) {
182
+ return normalized;
183
+ }
184
+ }
185
+ return undefined;
186
+ }
187
+ if (value === null || value === undefined) {
188
+ return undefined;
189
+ }
190
+ const str = typeof value === "string" ? value.trim() : "";
191
+ return str.length > 0 ? str : undefined;
192
+ }
193
+
194
+ function pickAlias(record: Record<string, unknown>, aliases: string[]): string | undefined {
195
+ for (const alias of aliases) {
196
+ const normalized = firstNonEmptyString(record[alias]);
197
+ if (normalized) {
198
+ return normalized;
199
+ }
200
+ }
201
+ return undefined;
202
+ }
203
+
204
+ function parseQueryParams(req: IncomingMessage): Record<string, unknown> {
205
+ try {
206
+ const url = new URL(req.url ?? "", "http://localhost");
207
+ const out: Record<string, unknown> = {};
208
+ for (const [key, value] of url.searchParams.entries()) {
209
+ out[key] = value;
210
+ }
211
+ return out;
212
+ } catch {
213
+ return {};
214
+ }
215
+ }
216
+
217
+ function parseFormBody(body: string): Record<string, unknown> {
218
+ return querystring.parse(body) as Record<string, unknown>;
219
+ }
220
+
221
+ function parseJsonBody(body: string): Record<string, unknown> {
222
+ if (!body.trim()) {
223
+ return {};
224
+ }
225
+ const parsed = JSON.parse(body);
226
+ if (!parsed || Array.isArray(parsed) || typeof parsed !== "object") {
227
+ throw new Error("Invalid JSON body");
228
+ }
229
+ return parsed as Record<string, unknown>;
230
+ }
231
+
232
+ function headerValue(header: string | string[] | undefined): string | undefined {
233
+ return firstNonEmptyString(header);
234
+ }
235
+
236
+ function extractTokenFromHeaders(req: IncomingMessage): string | undefined {
237
+ const explicit =
238
+ headerValue(req.headers["x-synology-token"]) ??
239
+ headerValue(req.headers["x-webhook-token"]) ??
240
+ headerValue(req.headers["x-openclaw-token"]);
241
+ if (explicit) {
242
+ return explicit;
243
+ }
244
+
245
+ const auth = headerValue(req.headers.authorization);
246
+ if (!auth) {
247
+ return undefined;
248
+ }
249
+
250
+ const bearerMatch = auth.match(/^Bearer\s+(.+)$/i);
251
+ if (bearerMatch?.[1]) {
252
+ return bearerMatch[1].trim();
253
+ }
254
+ return auth.trim();
43
255
  }
44
256
 
45
- /** Parse form-urlencoded body into SynologyWebhookPayload. */
46
- function parsePayload(body: string): SynologyWebhookPayload | null {
47
- const parsed = querystring.parse(body);
257
+ /**
258
+ * Parse/normalize incoming webhook payload.
259
+ *
260
+ * Supports:
261
+ * - application/x-www-form-urlencoded
262
+ * - application/json
263
+ *
264
+ * Token resolution order: body.token -> query.token -> headers
265
+ * Field aliases:
266
+ * - user_id <- user_id | userId | user
267
+ * - text <- text | message | content
268
+ */
269
+ function parsePayload(req: IncomingMessage, body: string): SynologyWebhookPayload | null {
270
+ const contentType = normalizeLowercaseStringOrEmpty(req.headers["content-type"]);
271
+
272
+ let bodyFields: Record<string, unknown> = {};
273
+ if (contentType.includes("application/json")) {
274
+ bodyFields = parseJsonBody(body);
275
+ } else if (contentType.includes("application/x-www-form-urlencoded")) {
276
+ bodyFields = parseFormBody(body);
277
+ } else {
278
+ // Fallback for clients with missing/incorrect content-type.
279
+ // Try JSON first, then form-urlencoded.
280
+ try {
281
+ bodyFields = parseJsonBody(body);
282
+ } catch {
283
+ bodyFields = parseFormBody(body);
284
+ }
285
+ }
48
286
 
49
- const token = String(parsed.token ?? "");
50
- const userId = String(parsed.user_id ?? "");
51
- const username = String(parsed.username ?? "unknown");
52
- const text = String(parsed.text ?? "");
287
+ const queryFields = parseQueryParams(req);
288
+ const headerToken = extractTokenFromHeaders(req);
53
289
 
54
- if (!token || !userId || !text) return null;
290
+ const token =
291
+ pickAlias(bodyFields, ["token"]) ?? pickAlias(queryFields, ["token"]) ?? headerToken;
292
+ const userId =
293
+ pickAlias(bodyFields, ["user_id", "userId", "user"]) ??
294
+ pickAlias(queryFields, ["user_id", "userId", "user"]);
295
+ const text =
296
+ pickAlias(bodyFields, ["text", "message", "content"]) ??
297
+ pickAlias(queryFields, ["text", "message", "content"]);
298
+
299
+ if (!token || !userId || !text) {
300
+ return null;
301
+ }
55
302
 
56
303
  return {
57
304
  token,
58
- channel_id: parsed.channel_id ? String(parsed.channel_id) : undefined,
59
- channel_name: parsed.channel_name ? String(parsed.channel_name) : undefined,
305
+ channel_id:
306
+ pickAlias(bodyFields, ["channel_id"]) ?? pickAlias(queryFields, ["channel_id"]) ?? undefined,
307
+ channel_name:
308
+ pickAlias(bodyFields, ["channel_name"]) ??
309
+ pickAlias(queryFields, ["channel_name"]) ??
310
+ undefined,
60
311
  user_id: userId,
61
- username,
62
- post_id: parsed.post_id ? String(parsed.post_id) : undefined,
63
- timestamp: parsed.timestamp ? String(parsed.timestamp) : undefined,
312
+ username:
313
+ pickAlias(bodyFields, ["username", "user_name", "name"]) ??
314
+ pickAlias(queryFields, ["username", "user_name", "name"]) ??
315
+ "unknown",
316
+ post_id: pickAlias(bodyFields, ["post_id"]) ?? pickAlias(queryFields, ["post_id"]) ?? undefined,
317
+ timestamp:
318
+ pickAlias(bodyFields, ["timestamp"]) ?? pickAlias(queryFields, ["timestamp"]) ?? undefined,
64
319
  text,
65
- trigger_word: parsed.trigger_word ? String(parsed.trigger_word) : undefined,
320
+ trigger_word:
321
+ pickAlias(bodyFields, ["trigger_word", "triggerWord"]) ??
322
+ pickAlias(queryFields, ["trigger_word", "triggerWord"]) ??
323
+ undefined,
66
324
  };
67
325
  }
68
326
 
69
327
  /** Send a JSON response. */
70
- function respond(res: ServerResponse, statusCode: number, body: Record<string, unknown>) {
328
+ function respondJson(res: ServerResponse, statusCode: number, body: Record<string, unknown>) {
71
329
  res.writeHead(statusCode, { "Content-Type": "application/json" });
72
330
  res.end(JSON.stringify(body));
73
331
  }
74
332
 
333
+ /** Send a no-content ACK. */
334
+ function respondNoContent(res: ServerResponse) {
335
+ res.writeHead(204);
336
+ res.end();
337
+ }
338
+
75
339
  export interface WebhookHandlerDeps {
76
340
  account: ResolvedSynologyChatAccount;
77
- deliver: (msg: {
78
- body: string;
79
- from: string;
80
- senderName: string;
81
- provider: string;
82
- chatType: string;
83
- sessionKey: string;
84
- accountId: string;
85
- }) => Promise<string | null>;
341
+ deliver: (msg: import("./inbound-context.js").SynologyInboundMessage) => Promise<string | null>;
86
342
  log?: {
87
343
  info: (...args: unknown[]) => void;
88
344
  warn: (...args: unknown[]) => void;
89
345
  error: (...args: unknown[]) => void;
90
346
  };
347
+ bodyTimeoutMs?: number;
91
348
  }
92
349
 
93
350
  /**
94
351
  * Create an HTTP request handler for Synology Chat outgoing webhooks.
95
352
  *
96
353
  * This handler:
97
- * 1. Parses form-urlencoded body
354
+ * 1. Parses form-urlencoded/JSON payload
98
355
  * 2. Validates token (constant-time)
99
356
  * 3. Checks user allowlist
100
357
  * 4. Checks rate limit
101
358
  * 5. Sanitizes input
102
- * 6. Delivers to the agent via deliver()
103
- * 7. Sends the agent response back to Synology Chat
359
+ * 6. Immediately ACKs request (204)
360
+ * 7. Delivers to the agent asynchronously and sends final reply via incomingUrl
104
361
  */
105
- export function createWebhookHandler(deps: WebhookHandlerDeps) {
106
- const { account, deliver, log } = deps;
107
- const rateLimiter = getRateLimiter(account);
362
+ type SynologyWebhookAuthorization =
363
+ | { ok: false; statusCode: number; error: string }
364
+ | { ok: true; commandAuthorized: boolean };
108
365
 
109
- return async (req: IncomingMessage, res: ServerResponse) => {
110
- // Only accept POST
111
- if (req.method !== "POST") {
112
- respond(res, 405, { error: "Method not allowed" });
113
- return;
114
- }
366
+ type AuthorizedSynologyWebhook = {
367
+ payload: SynologyWebhookPayload;
368
+ body: string;
369
+ commandAuthorized: boolean;
370
+ preview: string;
371
+ };
115
372
 
116
- // Parse body
117
- let body: string;
118
- try {
119
- body = await readBody(req);
120
- } catch (err) {
121
- log?.error("Failed to read request body", err);
122
- respond(res, 400, { error: "Invalid request body" });
123
- return;
124
- }
373
+ async function parseWebhookPayloadRequest(params: {
374
+ req: IncomingMessage;
375
+ res: ServerResponse;
376
+ log?: WebhookHandlerDeps["log"];
377
+ bodyTimeoutMs?: number;
378
+ }): Promise<{ ok: false } | { ok: true; payload: SynologyWebhookPayload }> {
379
+ const bodyResult = await readBody(params.req, params.bodyTimeoutMs);
380
+ if (!bodyResult.ok) {
381
+ params.log?.error("Failed to read request body", bodyResult.error);
382
+ respondJson(params.res, bodyResult.statusCode, { error: bodyResult.error });
383
+ return { ok: false };
384
+ }
125
385
 
126
- // Parse payload
127
- const payload = parsePayload(body);
128
- if (!payload) {
129
- respond(res, 400, { error: "Missing required fields (token, user_id, text)" });
130
- return;
131
- }
386
+ let payload: SynologyWebhookPayload | null = null;
387
+ try {
388
+ payload = parsePayload(params.req, bodyResult.body);
389
+ } catch (err) {
390
+ params.log?.warn("Failed to parse webhook payload", err);
391
+ respondJson(params.res, 400, { error: "Invalid request body" });
392
+ return { ok: false };
393
+ }
394
+ if (!payload) {
395
+ respondJson(params.res, 400, { error: "Missing required fields (token, user_id, text)" });
396
+ return { ok: false };
397
+ }
398
+ return { ok: true, payload };
399
+ }
132
400
 
133
- // Token validation
134
- if (!validateToken(payload.token, account.token)) {
135
- log?.warn(`Invalid token from ${req.socket?.remoteAddress}`);
136
- respond(res, 401, { error: "Invalid token" });
137
- return;
138
- }
401
+ function authorizeSynologyWebhook(params: {
402
+ req: IncomingMessage;
403
+ account: ResolvedSynologyChatAccount;
404
+ payload: SynologyWebhookPayload;
405
+ invalidTokenRateLimiter: InvalidTokenRateLimiter;
406
+ rateLimiter: RateLimiter;
407
+ log?: WebhookHandlerDeps["log"];
408
+ }): SynologyWebhookAuthorization {
409
+ const invalidTokenRateLimitKey = getSynologyWebhookInvalidTokenRateLimitKey(params.req);
410
+ // Once a source has exhausted its invalid-token budget, reject all requests in the window.
411
+ if (params.invalidTokenRateLimiter.isLocked(invalidTokenRateLimitKey)) {
412
+ params.log?.warn(`Rate limit exceeded for remote IP: ${invalidTokenRateLimitKey}`);
413
+ return { ok: false, statusCode: 429, error: "Rate limit exceeded" };
414
+ }
139
415
 
140
- // User allowlist check
141
- if (
142
- account.dmPolicy === "allowlist" &&
143
- !checkUserAllowed(payload.user_id, account.allowedUserIds)
144
- ) {
145
- log?.warn(`Unauthorized user: ${payload.user_id}`);
146
- respond(res, 403, { error: "User not authorized" });
147
- return;
416
+ if (!validateToken(params.payload.token, params.account.token)) {
417
+ if (params.invalidTokenRateLimiter.recordFailure(invalidTokenRateLimitKey)) {
418
+ params.log?.warn(`Rate limit exceeded for remote IP: ${invalidTokenRateLimitKey}`);
419
+ return { ok: false, statusCode: 429, error: "Rate limit exceeded" };
148
420
  }
421
+ params.log?.warn(`Invalid token from ${params.req.socket?.remoteAddress}`);
422
+ return { ok: false, statusCode: 401, error: "Invalid token" };
423
+ }
149
424
 
150
- if (account.dmPolicy === "disabled") {
151
- respond(res, 403, { error: "DMs are disabled" });
152
- return;
425
+ const auth = authorizeUserForDm(
426
+ params.payload.user_id,
427
+ params.account.dmPolicy,
428
+ params.account.allowedUserIds,
429
+ );
430
+ if (!auth.allowed) {
431
+ if (auth.reason === "disabled") {
432
+ return { ok: false, statusCode: 403, error: "DMs are disabled" };
153
433
  }
154
-
155
- // Rate limit
156
- if (!rateLimiter.check(payload.user_id)) {
157
- log?.warn(`Rate limit exceeded for user: ${payload.user_id}`);
158
- respond(res, 429, { error: "Rate limit exceeded" });
159
- return;
434
+ if (auth.reason === "allowlist-empty") {
435
+ params.log?.warn(
436
+ "Synology Chat allowlist is empty while dmPolicy=allowlist; rejecting message",
437
+ );
438
+ return {
439
+ ok: false,
440
+ statusCode: 403,
441
+ error:
442
+ 'Allowlist is empty. Configure allowedUserIds or use dmPolicy=open with allowedUserIds=["*"].',
443
+ };
160
444
  }
445
+ params.log?.warn(`Unauthorized user: ${params.payload.user_id}`);
446
+ return { ok: false, statusCode: 403, error: "User not authorized" };
447
+ }
448
+
449
+ if (!params.rateLimiter.check(params.payload.user_id)) {
450
+ // Keep a separate post-auth budget so authenticated users are still throttled per sender.
451
+ params.log?.warn(`Rate limit exceeded for user: ${params.payload.user_id}`);
452
+ return { ok: false, statusCode: 429, error: "Rate limit exceeded" };
453
+ }
161
454
 
162
- // Sanitize input
163
- let cleanText = sanitizeInput(payload.text);
455
+ return { ok: true, commandAuthorized: auth.allowed };
456
+ }
164
457
 
165
- // Strip trigger word
166
- if (payload.trigger_word && cleanText.startsWith(payload.trigger_word)) {
167
- cleanText = cleanText.slice(payload.trigger_word.length).trim();
168
- }
458
+ function sanitizeSynologyWebhookText(payload: SynologyWebhookPayload): string {
459
+ let cleanText = sanitizeInput(payload.text);
460
+ if (payload.trigger_word && cleanText.startsWith(payload.trigger_word)) {
461
+ cleanText = cleanText.slice(payload.trigger_word.length).trim();
462
+ }
463
+ return cleanText;
464
+ }
169
465
 
170
- if (!cleanText) {
171
- respond(res, 200, { text: "" });
466
+ async function parseAndAuthorizeSynologyWebhook(params: {
467
+ req: IncomingMessage;
468
+ res: ServerResponse;
469
+ account: ResolvedSynologyChatAccount;
470
+ invalidTokenRateLimiter: InvalidTokenRateLimiter;
471
+ rateLimiter: RateLimiter;
472
+ log?: WebhookHandlerDeps["log"];
473
+ bodyTimeoutMs?: number;
474
+ }): Promise<{ ok: false } | { ok: true; message: AuthorizedSynologyWebhook }> {
475
+ const parsed = await parseWebhookPayloadRequest(params);
476
+ if (!parsed.ok) {
477
+ return { ok: false };
478
+ }
479
+
480
+ const authorized = authorizeSynologyWebhook({
481
+ req: params.req,
482
+ account: params.account,
483
+ payload: parsed.payload,
484
+ invalidTokenRateLimiter: params.invalidTokenRateLimiter,
485
+ rateLimiter: params.rateLimiter,
486
+ log: params.log,
487
+ });
488
+ if (!authorized.ok) {
489
+ respondJson(params.res, authorized.statusCode, { error: authorized.error });
490
+ return { ok: false };
491
+ }
492
+
493
+ const cleanText = sanitizeSynologyWebhookText(parsed.payload);
494
+ if (!cleanText) {
495
+ respondNoContent(params.res);
496
+ return { ok: false };
497
+ }
498
+ const preview = cleanText.length > 100 ? `${cleanText.slice(0, 100)}...` : cleanText;
499
+ return {
500
+ ok: true,
501
+ message: {
502
+ payload: parsed.payload,
503
+ body: cleanText,
504
+ commandAuthorized: authorized.commandAuthorized,
505
+ preview,
506
+ },
507
+ };
508
+ }
509
+
510
+ async function resolveSynologyReplyDeliveryUserId(params: {
511
+ account: ResolvedSynologyChatAccount;
512
+ payload: SynologyWebhookPayload;
513
+ log?: WebhookHandlerDeps["log"];
514
+ }): Promise<string> {
515
+ if (!params.account.dangerouslyAllowNameMatching) {
516
+ return params.payload.user_id;
517
+ }
518
+
519
+ const resolvedChatApiUserId = await synologyClient.resolveLegacyWebhookNameToChatUserId({
520
+ incomingUrl: params.account.incomingUrl,
521
+ mutableWebhookUsername: params.payload.username,
522
+ allowInsecureSsl: params.account.allowInsecureSsl,
523
+ log: params.log,
524
+ });
525
+ if (resolvedChatApiUserId !== undefined) {
526
+ return String(resolvedChatApiUserId);
527
+ }
528
+ params.log?.warn(
529
+ `Could not resolve Chat API user_id for "${params.payload.username}" — falling back to webhook user_id ${params.payload.user_id}. Reply delivery may fail.`,
530
+ );
531
+ return params.payload.user_id;
532
+ }
533
+
534
+ async function processAuthorizedSynologyWebhook(params: {
535
+ account: ResolvedSynologyChatAccount;
536
+ deliver: WebhookHandlerDeps["deliver"];
537
+ log?: WebhookHandlerDeps["log"];
538
+ message: AuthorizedSynologyWebhook;
539
+ }): Promise<void> {
540
+ const authorizedWebhookUserId = params.message.payload.user_id;
541
+ let deliveryUserId = authorizedWebhookUserId;
542
+ try {
543
+ deliveryUserId = await resolveSynologyReplyDeliveryUserId({
544
+ account: params.account,
545
+ payload: params.message.payload,
546
+ log: params.log,
547
+ });
548
+
549
+ const deliverPromise = params.deliver({
550
+ body: params.message.body,
551
+ from: authorizedWebhookUserId,
552
+ senderName: params.message.payload.username,
553
+ provider: "synology-chat",
554
+ chatType: "direct",
555
+ accountId: params.account.accountId,
556
+ commandAuthorized: params.message.commandAuthorized,
557
+ chatUserId: deliveryUserId,
558
+ });
559
+ const timeoutPromise = new Promise<null>((_, reject) =>
560
+ setTimeout(() => reject(new Error("Agent response timeout (120s)")), 120_000),
561
+ );
562
+ const reply = await Promise.race([deliverPromise, timeoutPromise]);
563
+ if (!reply) {
172
564
  return;
173
565
  }
174
566
 
175
- const preview = cleanText.length > 100 ? `${cleanText.slice(0, 100)}...` : cleanText;
176
- log?.info(`Message from ${payload.username} (${payload.user_id}): ${preview}`);
567
+ await synologyClient.sendMessage(
568
+ params.account.incomingUrl,
569
+ reply,
570
+ deliveryUserId,
571
+ params.account.allowInsecureSsl,
572
+ );
573
+ const replyPreview = reply.length > 100 ? `${reply.slice(0, 100)}...` : reply;
574
+ params.log?.info?.(
575
+ `Reply sent to ${params.message.payload.username} (${deliveryUserId}): ${replyPreview}`,
576
+ );
577
+ } catch (err) {
578
+ const errMsg = err instanceof Error ? `${err.message}\n${err.stack}` : String(err);
579
+ params.log?.error?.(
580
+ `Failed to process message from ${params.message.payload.username}: ${errMsg}`,
581
+ );
582
+ await synologyClient.sendMessage(
583
+ params.account.incomingUrl,
584
+ "Sorry, an error occurred while processing your message.",
585
+ deliveryUserId,
586
+ params.account.allowInsecureSsl,
587
+ );
588
+ }
589
+ }
590
+
591
+ export function createWebhookHandler(deps: WebhookHandlerDeps) {
592
+ const { account, deliver, log } = deps;
593
+ const rateLimiter = getRateLimiter(account);
594
+ const invalidTokenRateLimiter = getInvalidTokenRateLimiter(account);
177
595
 
178
- // Respond 200 immediately to avoid Synology Chat timeout
179
- respond(res, 200, { text: "Processing..." });
596
+ return async (req: IncomingMessage, res: ServerResponse) => {
597
+ // Only accept POST
598
+ if (req.method !== "POST") {
599
+ respondJson(res, 405, { error: "Method not allowed" });
600
+ return;
601
+ }
602
+ const requestLifecycle = beginWebhookRequestPipelineOrReject({
603
+ req,
604
+ res,
605
+ inFlightLimiter: webhookInFlightLimiter,
606
+ inFlightKey: getSynologyWebhookInFlightKey(account),
607
+ });
608
+ if (!requestLifecycle.ok) {
609
+ return;
610
+ }
180
611
 
181
- // Deliver to agent asynchronously (with 120s timeout to match nginx proxy_read_timeout)
612
+ let authorized: Awaited<ReturnType<typeof parseAndAuthorizeSynologyWebhook>>;
182
613
  try {
183
- const sessionKey = `synology-chat-${payload.user_id}`;
184
- const deliverPromise = deliver({
185
- body: cleanText,
186
- from: payload.user_id,
187
- senderName: payload.username,
188
- provider: "synology-chat",
189
- chatType: "direct",
190
- sessionKey,
191
- accountId: account.accountId,
614
+ authorized = await parseAndAuthorizeSynologyWebhook({
615
+ req,
616
+ res,
617
+ account,
618
+ invalidTokenRateLimiter,
619
+ rateLimiter,
620
+ log,
621
+ bodyTimeoutMs: deps.bodyTimeoutMs,
192
622
  });
623
+ } finally {
624
+ // Only bound the pre-auth request pipeline; async reply delivery is outside webhook ingress.
625
+ requestLifecycle.release();
626
+ }
627
+ if (!authorized.ok) {
628
+ return;
629
+ }
193
630
 
194
- const timeoutPromise = new Promise<null>((_, reject) =>
195
- setTimeout(() => reject(new Error("Agent response timeout (120s)")), 120_000),
196
- );
197
-
198
- const reply = await Promise.race([deliverPromise, timeoutPromise]);
631
+ log?.info(
632
+ `Message from ${authorized.message.payload.username} (${authorized.message.payload.user_id}): ${authorized.message.preview}`,
633
+ );
199
634
 
200
- // Send reply back to Synology Chat
201
- if (reply) {
202
- await sendMessage(account.incomingUrl, reply, payload.user_id, account.allowInsecureSsl);
203
- const replyPreview = reply.length > 100 ? `${reply.slice(0, 100)}...` : reply;
204
- log?.info(`Reply sent to ${payload.username} (${payload.user_id}): ${replyPreview}`);
205
- }
206
- } catch (err) {
207
- const errMsg = err instanceof Error ? `${err.message}\n${err.stack}` : String(err);
208
- log?.error(`Failed to process message from ${payload.username}: ${errMsg}`);
209
- await sendMessage(
210
- account.incomingUrl,
211
- "Sorry, an error occurred while processing your message.",
212
- payload.user_id,
213
- account.allowInsecureSsl,
214
- );
215
- }
635
+ // ACK immediately so Synology Chat won't remain in "Processing..."
636
+ respondNoContent(res);
637
+ await processAuthorizedSynologyWebhook({
638
+ account,
639
+ deliver,
640
+ log,
641
+ message: authorized.message,
642
+ });
216
643
  };
217
644
  }