@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,21 +1,57 @@
1
1
  import { EventEmitter } from "node:events";
2
- import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import type { ClientRequest, IncomingMessage, RequestOptions } from "node:http";
3
+ import { describe, it, expect, vi, beforeAll, beforeEach, afterEach } from "vitest";
4
+
5
+ const ssrfMocks = {
6
+ resolvePinnedHostnameWithPolicy: vi.fn(),
7
+ };
3
8
 
4
9
  // Mock http and https modules before importing the client
5
10
  vi.mock("node:https", () => {
6
- const mockRequest = vi.fn();
7
- return { default: { request: mockRequest }, request: mockRequest };
11
+ const httpsRequest = vi.fn();
12
+ const httpsGet = vi.fn();
13
+ const httpsModule = { request: httpsRequest, get: httpsGet };
14
+ return { default: httpsModule, request: httpsRequest, get: httpsGet };
8
15
  });
9
16
 
10
17
  vi.mock("node:http", () => {
11
- const mockRequest = vi.fn();
12
- return { default: { request: mockRequest }, request: mockRequest };
18
+ const httpRequest = vi.fn();
19
+ const httpGet = vi.fn();
20
+ return { default: { request: httpRequest, get: httpGet }, request: httpRequest, get: httpGet };
13
21
  });
14
22
 
15
- // Import after mocks are set up
16
- const { sendMessage, sendFileUrl } = await import("./client.js");
23
+ vi.mock("openclaw/plugin-sdk/ssrf-runtime", () => ({
24
+ formatErrorMessage: (err: unknown) => (err instanceof Error ? err.message : String(err)),
25
+ resolvePinnedHostnameWithPolicy: ssrfMocks.resolvePinnedHostnameWithPolicy,
26
+ }));
27
+
17
28
  const https = await import("node:https");
18
29
  let fakeNowMs = 1_700_000_000_000;
30
+ let sendMessage: typeof import("./client.js").sendMessage;
31
+ let sendFileUrl: typeof import("./client.js").sendFileUrl;
32
+ let fetchChatUsers: typeof import("./client.js").fetchChatUsers;
33
+ let resolveLegacyWebhookNameToChatUserId: typeof import("./client.js").resolveLegacyWebhookNameToChatUserId;
34
+
35
+ type RequestCallback = (res: IncomingMessage) => void;
36
+ type MockRequestHandler = (
37
+ url: string | URL,
38
+ options: RequestOptions,
39
+ callback?: RequestCallback,
40
+ ) => ClientRequest;
41
+
42
+ function createMockResponseEmitter(statusCode: number): IncomingMessage {
43
+ const res = new EventEmitter() as Partial<IncomingMessage>;
44
+ res.statusCode = statusCode;
45
+ return res as unknown as IncomingMessage;
46
+ }
47
+
48
+ function createMockRequestEmitter(): ClientRequest {
49
+ const req = new EventEmitter() as Partial<ClientRequest>;
50
+ req.write = vi.fn() as ClientRequest["write"];
51
+ req.end = vi.fn() as ClientRequest["end"];
52
+ req.destroy = vi.fn() as ClientRequest["destroy"];
53
+ return req as unknown as ClientRequest;
54
+ }
19
55
 
20
56
  async function settleTimers<T>(promise: Promise<T>): Promise<T> {
21
57
  await Promise.resolve();
@@ -23,53 +59,52 @@ async function settleTimers<T>(promise: Promise<T>): Promise<T> {
23
59
  return promise;
24
60
  }
25
61
 
26
- function mockSuccessResponse() {
62
+ function mockResponse(statusCode: number, body: string) {
27
63
  const httpsRequest = vi.mocked(https.request);
28
- httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => {
29
- const res = new EventEmitter() as any;
30
- res.statusCode = 200;
64
+ httpsRequest.mockImplementation(((...args) => {
65
+ const callback = args[2];
66
+ const res = createMockResponseEmitter(statusCode);
31
67
  process.nextTick(() => {
32
- callback(res);
33
- res.emit("data", Buffer.from('{"success":true}'));
68
+ callback?.(res);
69
+ res.emit("data", Buffer.from(body));
34
70
  res.emit("end");
35
71
  });
36
- const req = new EventEmitter() as any;
37
- req.write = vi.fn();
38
- req.end = vi.fn();
39
- req.destroy = vi.fn();
40
- return req;
41
- });
72
+ return createMockRequestEmitter();
73
+ }) as MockRequestHandler);
74
+ }
75
+
76
+ function mockSuccessResponse() {
77
+ mockResponse(200, '{"success":true}');
42
78
  }
43
79
 
44
80
  function mockFailureResponse(statusCode = 500) {
45
- const httpsRequest = vi.mocked(https.request);
46
- httpsRequest.mockImplementation((_url: any, _opts: any, callback: any) => {
47
- const res = new EventEmitter() as any;
48
- res.statusCode = statusCode;
49
- process.nextTick(() => {
50
- callback(res);
51
- res.emit("data", Buffer.from("error"));
52
- res.emit("end");
53
- });
54
- const req = new EventEmitter() as any;
55
- req.write = vi.fn();
56
- req.end = vi.fn();
57
- req.destroy = vi.fn();
58
- return req;
59
- });
81
+ mockResponse(statusCode, "error");
60
82
  }
61
83
 
62
- describe("sendMessage", () => {
84
+ function installFakeTimerHarness() {
85
+ beforeAll(async () => {
86
+ ({ sendMessage, sendFileUrl, fetchChatUsers, resolveLegacyWebhookNameToChatUserId } =
87
+ await import("./client.js"));
88
+ });
89
+
63
90
  beforeEach(() => {
64
91
  vi.clearAllMocks();
65
92
  vi.useFakeTimers();
66
93
  fakeNowMs += 10_000;
67
94
  vi.setSystemTime(fakeNowMs);
95
+ ssrfMocks.resolvePinnedHostnameWithPolicy.mockResolvedValue({
96
+ hostname: "example.com",
97
+ addresses: ["93.184.216.34"],
98
+ });
68
99
  });
69
100
 
70
101
  afterEach(() => {
71
102
  vi.useRealTimers();
72
103
  });
104
+ }
105
+
106
+ describe("sendMessage", () => {
107
+ installFakeTimerHarness();
73
108
 
74
109
  it("returns true on successful send", async () => {
75
110
  mockSuccessResponse();
@@ -91,19 +126,24 @@ describe("sendMessage", () => {
91
126
  const callArgs = httpsRequest.mock.calls[0];
92
127
  expect(callArgs[0]).toBe("https://nas.example.com/incoming");
93
128
  });
94
- });
95
129
 
96
- describe("sendFileUrl", () => {
97
- beforeEach(() => {
98
- vi.clearAllMocks();
99
- vi.useFakeTimers();
100
- fakeNowMs += 10_000;
101
- vi.setSystemTime(fakeNowMs);
130
+ it("verifies TLS by default", async () => {
131
+ mockSuccessResponse();
132
+ await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello"));
133
+ const httpsRequest = vi.mocked(https.request);
134
+ expect(httpsRequest.mock.calls[0]?.[1]).toMatchObject({ rejectUnauthorized: true });
102
135
  });
103
136
 
104
- afterEach(() => {
105
- vi.useRealTimers();
137
+ it("only disables TLS verification when explicitly requested", async () => {
138
+ mockSuccessResponse();
139
+ await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello", undefined, true));
140
+ const httpsRequest = vi.mocked(https.request);
141
+ expect(httpsRequest.mock.calls[0]?.[1]).toMatchObject({ rejectUnauthorized: false });
106
142
  });
143
+ });
144
+
145
+ describe("sendFileUrl", () => {
146
+ installFakeTimerHarness();
107
147
 
108
148
  it("returns true on success", async () => {
109
149
  mockSuccessResponse();
@@ -120,4 +160,216 @@ describe("sendFileUrl", () => {
120
160
  );
121
161
  expect(result).toBe(false);
122
162
  });
163
+
164
+ it("verifies TLS by default", async () => {
165
+ mockSuccessResponse();
166
+ await settleTimers(
167
+ sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png"),
168
+ );
169
+ const httpsRequest = vi.mocked(https.request);
170
+ expect(httpsRequest.mock.calls[0]?.[1]).toMatchObject({ rejectUnauthorized: true });
171
+ });
172
+
173
+ it("respects the shared send interval before posting a file URL", async () => {
174
+ mockSuccessResponse();
175
+ await settleTimers(sendMessage("https://nas.example.com/incoming", "hello"));
176
+ vi.mocked(https.request).mockClear();
177
+
178
+ const promise = sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png");
179
+ await Promise.resolve();
180
+ expect(vi.mocked(https.request)).not.toHaveBeenCalled();
181
+
182
+ await vi.advanceTimersByTimeAsync(499);
183
+ expect(vi.mocked(https.request)).not.toHaveBeenCalled();
184
+
185
+ await vi.advanceTimersByTimeAsync(1);
186
+ await promise;
187
+ expect(vi.mocked(https.request)).toHaveBeenCalledTimes(1);
188
+ });
189
+
190
+ it("rejects malformed file URLs before making a request", async () => {
191
+ const result = await settleTimers(sendFileUrl("https://nas.example.com/incoming", "not-a-url"));
192
+ expect(result).toBe(false);
193
+ expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled();
194
+ expect(vi.mocked(https.request)).not.toHaveBeenCalled();
195
+ });
196
+
197
+ it("rejects non-http file URLs before making a request", async () => {
198
+ const result = await settleTimers(
199
+ sendFileUrl("https://nas.example.com/incoming", "file:///tmp/secret.txt"),
200
+ );
201
+ expect(result).toBe(false);
202
+ expect(ssrfMocks.resolvePinnedHostnameWithPolicy).not.toHaveBeenCalled();
203
+ expect(vi.mocked(https.request)).not.toHaveBeenCalled();
204
+ });
205
+
206
+ it("rejects SSRF-blocked hosts before making a request", async () => {
207
+ ssrfMocks.resolvePinnedHostnameWithPolicy.mockRejectedValueOnce(
208
+ new Error("Blocked private network target"),
209
+ );
210
+ const result = await settleTimers(
211
+ sendFileUrl("https://nas.example.com/incoming", "http://169.254.169.254/latest/meta-data"),
212
+ );
213
+ expect(result).toBe(false);
214
+ expect(ssrfMocks.resolvePinnedHostnameWithPolicy).toHaveBeenCalledWith("169.254.169.254");
215
+ expect(vi.mocked(https.request)).not.toHaveBeenCalled();
216
+ });
217
+ });
218
+
219
+ // Helper to mock the user_list API response for fetchChatUsers / resolveLegacyWebhookNameToChatUserId
220
+ function mockUserListResponse(users: Array<Record<string, unknown>>) {
221
+ mockUserListResponseImpl(users, false);
222
+ }
223
+
224
+ function mockUserListResponseOnce(users: Array<Record<string, unknown>>) {
225
+ mockUserListResponseImpl(users, true);
226
+ }
227
+
228
+ function mockUserListResponseImpl(users: Array<Record<string, unknown>>, once: boolean) {
229
+ const httpsGet = vi.mocked(https.get);
230
+ const impl: MockRequestHandler = (_url, _opts, callback) => {
231
+ const res = createMockResponseEmitter(200);
232
+ process.nextTick(() => {
233
+ callback?.(res);
234
+ res.emit("data", Buffer.from(JSON.stringify({ success: true, data: { users } })));
235
+ res.emit("end");
236
+ });
237
+ return createMockRequestEmitter();
238
+ };
239
+ if (once) {
240
+ httpsGet.mockImplementationOnce(impl);
241
+ return;
242
+ }
243
+ httpsGet.mockImplementation(impl);
244
+ }
245
+
246
+ describe("resolveLegacyWebhookNameToChatUserId", () => {
247
+ const baseUrl =
248
+ "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22test%22";
249
+ const baseUrl2 =
250
+ "https://nas2.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22test-2%22";
251
+
252
+ beforeEach(() => {
253
+ vi.clearAllMocks();
254
+ vi.useFakeTimers();
255
+ // Advance time to invalidate any cached user list from previous tests
256
+ fakeNowMs += 10 * 60 * 1000;
257
+ vi.setSystemTime(fakeNowMs);
258
+ });
259
+
260
+ afterEach(() => {
261
+ vi.useRealTimers();
262
+ });
263
+
264
+ it("resolves user by nickname (webhook username = Chat nickname)", async () => {
265
+ mockUserListResponse([
266
+ { user_id: 4, username: "jmn67", nickname: "jmn" },
267
+ { user_id: 7, username: "she67", nickname: "sarah" },
268
+ ]);
269
+ const result = await resolveLegacyWebhookNameToChatUserId({
270
+ incomingUrl: baseUrl,
271
+ mutableWebhookUsername: "jmn",
272
+ });
273
+ expect(result).toBe(4);
274
+ });
275
+
276
+ it("resolves user by username when nickname does not match", async () => {
277
+ mockUserListResponse([
278
+ { user_id: 4, username: "jmn67", nickname: "" },
279
+ { user_id: 7, username: "she67", nickname: "sarah" },
280
+ ]);
281
+ // Advance time to invalidate cache
282
+ fakeNowMs += 10 * 60 * 1000;
283
+ vi.setSystemTime(fakeNowMs);
284
+ const result = await resolveLegacyWebhookNameToChatUserId({
285
+ incomingUrl: baseUrl,
286
+ mutableWebhookUsername: "jmn67",
287
+ });
288
+ expect(result).toBe(4);
289
+ });
290
+
291
+ it("is case-insensitive", async () => {
292
+ mockUserListResponse([{ user_id: 4, username: "JMN67", nickname: "JMN" }]);
293
+ fakeNowMs += 10 * 60 * 1000;
294
+ vi.setSystemTime(fakeNowMs);
295
+ const result = await resolveLegacyWebhookNameToChatUserId({
296
+ incomingUrl: baseUrl,
297
+ mutableWebhookUsername: "jmn",
298
+ });
299
+ expect(result).toBe(4);
300
+ });
301
+
302
+ it("returns undefined when user is not found", async () => {
303
+ mockUserListResponse([{ user_id: 4, username: "jmn67", nickname: "jmn" }]);
304
+ fakeNowMs += 10 * 60 * 1000;
305
+ vi.setSystemTime(fakeNowMs);
306
+ const result = await resolveLegacyWebhookNameToChatUserId({
307
+ incomingUrl: baseUrl,
308
+ mutableWebhookUsername: "unknown_user",
309
+ });
310
+ expect(result).toBeUndefined();
311
+ });
312
+
313
+ it("uses method=user_list instead of method=chatbot in the API URL", async () => {
314
+ mockUserListResponse([]);
315
+ fakeNowMs += 10 * 60 * 1000;
316
+ vi.setSystemTime(fakeNowMs);
317
+ await resolveLegacyWebhookNameToChatUserId({
318
+ incomingUrl: baseUrl,
319
+ mutableWebhookUsername: "anyone",
320
+ });
321
+ const httpsGet = vi.mocked(https.get);
322
+ expect(httpsGet).toHaveBeenCalledWith(
323
+ expect.stringContaining("method=user_list"),
324
+ expect.any(Object),
325
+ expect.any(Function),
326
+ );
327
+ });
328
+
329
+ it("keeps user cache scoped per incoming URL", async () => {
330
+ mockUserListResponseOnce([{ user_id: 4, username: "jmn67", nickname: "jmn" }]);
331
+ mockUserListResponseOnce([{ user_id: 9, username: "jmn67", nickname: "jmn" }]);
332
+
333
+ const result1 = await resolveLegacyWebhookNameToChatUserId({
334
+ incomingUrl: baseUrl,
335
+ mutableWebhookUsername: "jmn",
336
+ });
337
+ const result2 = await resolveLegacyWebhookNameToChatUserId({
338
+ incomingUrl: baseUrl2,
339
+ mutableWebhookUsername: "jmn",
340
+ });
341
+
342
+ expect(result1).toBe(4);
343
+ expect(result2).toBe(9);
344
+ const httpsGet = vi.mocked(https.get);
345
+ expect(httpsGet).toHaveBeenCalledTimes(2);
346
+ });
347
+ });
348
+
349
+ describe("fetchChatUsers", () => {
350
+ installFakeTimerHarness();
351
+
352
+ it("filters malformed user entries while keeping valid ones", async () => {
353
+ mockUserListResponse([
354
+ { user_id: 4, username: "jmn67", nickname: "jmn" },
355
+ { user_id: "bad", username: "broken" },
356
+ ]);
357
+
358
+ const users = await fetchChatUsers(
359
+ "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22test%22",
360
+ );
361
+
362
+ expect(users).toEqual([{ user_id: 4, username: "jmn67", nickname: "jmn" }]);
363
+ });
364
+
365
+ it("verifies TLS by default for user_list lookups", async () => {
366
+ mockUserListResponse([{ user_id: 4, username: "jmn67", nickname: "jmn" }]);
367
+ const freshUrl =
368
+ "https://fresh-nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=chatbot&version=2&token=%22fresh%22";
369
+
370
+ await fetchChatUsers(freshUrl);
371
+
372
+ const httpsGet = vi.mocked(https.get);
373
+ expect(httpsGet.mock.calls[0]?.[1]).toMatchObject({ rejectUnauthorized: true });
374
+ });
123
375
  });
package/src/client.ts CHANGED
@@ -5,10 +5,79 @@
5
5
 
6
6
  import * as http from "node:http";
7
7
  import * as https from "node:https";
8
+ import { safeParseJsonWithSchema, safeParseWithSchema } from "openclaw/plugin-sdk/extension-shared";
9
+ import {
10
+ formatErrorMessage,
11
+ resolvePinnedHostnameWithPolicy,
12
+ } from "openclaw/plugin-sdk/ssrf-runtime";
13
+ import { z } from "zod";
8
14
 
9
15
  const MIN_SEND_INTERVAL_MS = 500;
10
16
  let lastSendTime = 0;
11
17
 
18
+ function normalizeLowercaseStringOrEmpty(value: unknown): string {
19
+ return typeof value === "string" ? value.trim().toLowerCase() : "";
20
+ }
21
+
22
+ // --- Chat user_id resolution ---
23
+ // Synology Chat uses two different user_id spaces:
24
+ // - Outgoing webhook user_id: per-integration sequential ID (e.g. 1)
25
+ // - Chat API user_id: global internal ID (e.g. 4)
26
+ // The chatbot API (method=chatbot) requires the Chat API user_id in the
27
+ // user_ids array. We resolve via the user_list API and cache the result.
28
+
29
+ interface ChatUser {
30
+ user_id: number;
31
+ username: string;
32
+ nickname: string;
33
+ }
34
+
35
+ type ChatUserCacheEntry = {
36
+ users: ChatUser[];
37
+ cachedAt: number;
38
+ };
39
+
40
+ type ChatWebhookPayload = {
41
+ text?: string;
42
+ file_url?: string;
43
+ user_ids?: number[];
44
+ };
45
+
46
+ const ChatUserSchema = z
47
+ .object({
48
+ user_id: z.number(),
49
+ username: z.string().optional(),
50
+ nickname: z.string().optional(),
51
+ })
52
+ .transform(
53
+ (user): ChatUser => ({
54
+ user_id: user.user_id,
55
+ username: user.username ?? "",
56
+ nickname: user.nickname ?? "",
57
+ }),
58
+ );
59
+
60
+ const ChatUserListResponseSchema = z.object({
61
+ success: z.boolean(),
62
+ data: z
63
+ .object({
64
+ users: z
65
+ .array(z.unknown())
66
+ .optional()
67
+ .transform((users) =>
68
+ (users ?? []).flatMap((user) => {
69
+ const parsed = safeParseWithSchema(ChatUserSchema, user);
70
+ return parsed ? [parsed] : [];
71
+ }),
72
+ ),
73
+ })
74
+ .optional(),
75
+ });
76
+
77
+ // Cache user lists per bot endpoint to avoid cross-account bleed.
78
+ const chatUserCache = new Map<string, ChatUserCacheEntry>();
79
+ const CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
80
+
12
81
  /**
13
82
  * Send a text message to Synology Chat via the incoming webhook.
14
83
  *
@@ -21,20 +90,11 @@ export async function sendMessage(
21
90
  incomingUrl: string,
22
91
  text: string,
23
92
  userId?: string | number,
24
- allowInsecureSsl = true,
93
+ allowInsecureSsl = false,
25
94
  ): Promise<boolean> {
26
95
  // Synology Chat API requires user_ids (numeric) to specify the recipient
27
96
  // The @mention is optional but user_ids is mandatory
28
- const payloadObj: Record<string, any> = { text };
29
- if (userId) {
30
- // userId can be numeric ID or username - if numeric, add to user_ids
31
- const numericId = typeof userId === "number" ? userId : parseInt(userId, 10);
32
- if (!isNaN(numericId)) {
33
- payloadObj.user_ids = [numericId];
34
- }
35
- }
36
- const payload = JSON.stringify(payloadObj);
37
- const body = `payload=${encodeURIComponent(payload)}`;
97
+ const body = buildWebhookBody({ text }, userId);
38
98
 
39
99
  // Internal rate limit: min 500ms between sends
40
100
  const now = Date.now();
@@ -51,13 +111,15 @@ export async function sendMessage(
51
111
  try {
52
112
  const ok = await doPost(incomingUrl, body, allowInsecureSsl);
53
113
  lastSendTime = Date.now();
54
- if (ok) return true;
114
+ if (ok) {
115
+ return true;
116
+ }
55
117
  } catch {
56
118
  // will retry
57
119
  }
58
120
 
59
121
  if (attempt < maxRetries - 1) {
60
- await sleep(baseDelay * Math.pow(2, attempt));
122
+ await sleep(baseDelay * 2 ** attempt);
61
123
  }
62
124
  }
63
125
 
@@ -71,19 +133,18 @@ export async function sendFileUrl(
71
133
  incomingUrl: string,
72
134
  fileUrl: string,
73
135
  userId?: string | number,
74
- allowInsecureSsl = true,
136
+ allowInsecureSsl = false,
75
137
  ): Promise<boolean> {
76
- const payloadObj: Record<string, any> = { file_url: fileUrl };
77
- if (userId) {
78
- const numericId = typeof userId === "number" ? userId : parseInt(userId, 10);
79
- if (!isNaN(numericId)) {
80
- payloadObj.user_ids = [numericId];
138
+ try {
139
+ const safeFileUrl = await assertSafeWebhookFileUrl(fileUrl);
140
+ const body = buildWebhookBody({ file_url: safeFileUrl }, userId);
141
+
142
+ const now = Date.now();
143
+ const elapsed = now - lastSendTime;
144
+ if (elapsed < MIN_SEND_INTERVAL_MS) {
145
+ await sleep(MIN_SEND_INTERVAL_MS - elapsed);
81
146
  }
82
- }
83
- const payload = JSON.stringify(payloadObj);
84
- const body = `payload=${encodeURIComponent(payload)}`;
85
147
 
86
- try {
87
148
  const ok = await doPost(incomingUrl, body, allowInsecureSsl);
88
149
  lastSendTime = Date.now();
89
150
  return ok;
@@ -92,7 +153,139 @@ export async function sendFileUrl(
92
153
  }
93
154
  }
94
155
 
95
- function doPost(url: string, body: string, allowInsecureSsl = true): Promise<boolean> {
156
+ /**
157
+ * Fetch the list of Chat users visible to this bot via the user_list API.
158
+ * Results are cached for CACHE_TTL_MS to avoid excessive API calls.
159
+ *
160
+ * The user_list endpoint uses the same base URL as the chatbot API but
161
+ * with method=user_list instead of method=chatbot.
162
+ */
163
+ export async function fetchChatUsers(
164
+ incomingUrl: string,
165
+ allowInsecureSsl = false,
166
+ log?: { warn: (...args: unknown[]) => void },
167
+ ): Promise<ChatUser[]> {
168
+ const now = Date.now();
169
+ const listUrl = incomingUrl.replace(/method=\w+/, "method=user_list");
170
+ const cached = chatUserCache.get(listUrl);
171
+ if (cached && now - cached.cachedAt < CACHE_TTL_MS) {
172
+ return cached.users;
173
+ }
174
+
175
+ return new Promise((resolve) => {
176
+ let parsedUrl: URL;
177
+ try {
178
+ parsedUrl = new URL(listUrl);
179
+ } catch {
180
+ log?.warn("fetchChatUsers: invalid user_list URL, using cached data");
181
+ resolve(cached?.users ?? []);
182
+ return;
183
+ }
184
+ const transport = parsedUrl.protocol === "https:" ? https : http;
185
+ const requestOptions: http.RequestOptions | https.RequestOptions =
186
+ parsedUrl.protocol === "https:" ? { rejectUnauthorized: !allowInsecureSsl } : {};
187
+
188
+ transport
189
+ .get(listUrl, requestOptions, (res) => {
190
+ let data = "";
191
+ res.on("data", (c: Buffer) => {
192
+ data += c.toString();
193
+ });
194
+ res.on("end", () => {
195
+ const result = safeParseJsonWithSchema(ChatUserListResponseSchema, data);
196
+ if (!result) {
197
+ log?.warn("fetchChatUsers: failed to parse user_list response");
198
+ resolve(cached?.users ?? []);
199
+ return;
200
+ }
201
+
202
+ if (result.success) {
203
+ const users = result.data?.users ?? [];
204
+ chatUserCache.set(listUrl, {
205
+ users,
206
+ cachedAt: now,
207
+ });
208
+ resolve(users);
209
+ return;
210
+ }
211
+
212
+ log?.warn(`fetchChatUsers: API returned success=${result.success}, using cached data`);
213
+ resolve(cached?.users ?? []);
214
+ });
215
+ })
216
+ .on("error", (err) => {
217
+ log?.warn(`fetchChatUsers: HTTP error — ${err instanceof Error ? err.message : err}`);
218
+ resolve(cached?.users ?? []);
219
+ });
220
+ });
221
+ }
222
+
223
+ async function assertSafeWebhookFileUrl(fileUrl: string): Promise<string> {
224
+ let parsed: URL;
225
+ try {
226
+ parsed = new URL(fileUrl);
227
+ } catch (err) {
228
+ throw new Error(`Invalid Synology Chat file URL: ${formatErrorMessage(err)}`, { cause: err });
229
+ }
230
+
231
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
232
+ throw new Error("Synology Chat file URL must use HTTP or HTTPS");
233
+ }
234
+
235
+ await resolvePinnedHostnameWithPolicy(parsed.hostname);
236
+ return parsed.toString();
237
+ }
238
+
239
+ /**
240
+ * Resolve a mutable webhook username/nickname to the correct Chat API user_id.
241
+ *
242
+ * Synology Chat outgoing webhooks send a user_id that may NOT match the
243
+ * Chat-internal user_id needed by the chatbot API (method=chatbot).
244
+ * The webhook's "username" field corresponds to the Chat user's "nickname".
245
+ *
246
+ * @returns The correct Chat user_id, or undefined if not found
247
+ */
248
+ export async function resolveLegacyWebhookNameToChatUserId(params: {
249
+ incomingUrl: string;
250
+ mutableWebhookUsername: string;
251
+ allowInsecureSsl?: boolean;
252
+ log?: { warn: (...args: unknown[]) => void };
253
+ }): Promise<number | undefined> {
254
+ const users = await fetchChatUsers(params.incomingUrl, params.allowInsecureSsl, params.log);
255
+ const lower = normalizeLowercaseStringOrEmpty(params.mutableWebhookUsername);
256
+
257
+ // Match by nickname first (webhook "username" field = Chat "nickname")
258
+ const byNickname = users.find((u) => normalizeLowercaseStringOrEmpty(u.nickname) === lower);
259
+ if (byNickname) {
260
+ return byNickname.user_id;
261
+ }
262
+
263
+ // Then by username
264
+ const byUsername = users.find((u) => normalizeLowercaseStringOrEmpty(u.username) === lower);
265
+ if (byUsername) {
266
+ return byUsername.user_id;
267
+ }
268
+
269
+ return undefined;
270
+ }
271
+
272
+ function buildWebhookBody(payload: ChatWebhookPayload, userId?: string | number): string {
273
+ const numericId = parseNumericUserId(userId);
274
+ if (numericId !== undefined) {
275
+ payload.user_ids = [numericId];
276
+ }
277
+ return `payload=${encodeURIComponent(JSON.stringify(payload))}`;
278
+ }
279
+
280
+ function parseNumericUserId(userId?: string | number): number | undefined {
281
+ if (userId === undefined) {
282
+ return undefined;
283
+ }
284
+ const numericId = typeof userId === "number" ? userId : Number.parseInt(userId, 10);
285
+ return Number.isNaN(numericId) ? undefined : numericId;
286
+ }
287
+
288
+ function doPost(url: string, body: string, allowInsecureSsl = false): Promise<boolean> {
96
289
  return new Promise((resolve, reject) => {
97
290
  let parsedUrl: URL;
98
291
  try {
@@ -0,0 +1,11 @@
1
+ import { buildChannelConfigSchema } from "openclaw/plugin-sdk/channel-config-schema";
2
+ import { z } from "openclaw/plugin-sdk/zod";
3
+
4
+ export const SynologyChatChannelConfigSchema = buildChannelConfigSchema(
5
+ z
6
+ .object({
7
+ dangerouslyAllowNameMatching: z.boolean().optional(),
8
+ dangerouslyAllowInheritedWebhookPath: z.boolean().optional(),
9
+ })
10
+ .passthrough(),
11
+ );