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