@openclaw/synology-chat 2026.5.2 → 2026.5.3-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.
- package/dist/api.js +3 -0
- package/dist/channel-BYl2GyR_.js +1206 -0
- package/dist/channel-plugin-api.js +2 -0
- package/dist/contract-api.js +2 -0
- package/dist/index.js +18 -0
- package/dist/security-audit-Zu_nkF2x.js +14 -0
- package/dist/setup-api.js +2 -0
- package/dist/setup-entry.js +11 -0
- package/dist/setup-surface-BTp4n9pb.js +336 -0
- package/package.json +19 -3
- package/api.ts +0 -3
- package/channel-plugin-api.ts +0 -1
- package/contract-api.ts +0 -1
- package/index.ts +0 -16
- package/setup-api.ts +0 -1
- package/setup-entry.ts +0 -9
- package/src/accounts.ts +0 -151
- package/src/approval-auth.test.ts +0 -17
- package/src/approval-auth.ts +0 -22
- package/src/channel.integration.test.ts +0 -191
- package/src/channel.test-mocks.ts +0 -176
- package/src/channel.test.ts +0 -637
- package/src/channel.ts +0 -372
- package/src/client.test.ts +0 -375
- package/src/client.ts +0 -335
- package/src/config-schema.ts +0 -11
- package/src/core.test.ts +0 -373
- package/src/gateway-runtime.ts +0 -212
- package/src/inbound-context.ts +0 -10
- package/src/inbound-turn.ts +0 -171
- package/src/runtime.ts +0 -8
- package/src/security-audit.test.ts +0 -66
- package/src/security-audit.ts +0 -28
- package/src/security.ts +0 -126
- package/src/session-key.ts +0 -21
- package/src/setup-surface.ts +0 -338
- package/src/test-http-utils.ts +0 -75
- package/src/types.ts +0 -59
- package/src/webhook-handler.test.ts +0 -606
- package/src/webhook-handler.ts +0 -644
- package/tsconfig.json +0 -16
package/src/client.test.ts
DELETED
|
@@ -1,375 +0,0 @@
|
|
|
1
|
-
import { EventEmitter } from "node:events";
|
|
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
|
-
};
|
|
8
|
-
|
|
9
|
-
// Mock http and https modules before importing the client
|
|
10
|
-
vi.mock("node:https", () => {
|
|
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 };
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
vi.mock("node:http", () => {
|
|
18
|
-
const httpRequest = vi.fn();
|
|
19
|
-
const httpGet = vi.fn();
|
|
20
|
-
return { default: { request: httpRequest, get: httpGet }, request: httpRequest, get: httpGet };
|
|
21
|
-
});
|
|
22
|
-
|
|
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
|
-
|
|
28
|
-
const https = await import("node:https");
|
|
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
|
-
}
|
|
55
|
-
|
|
56
|
-
async function settleTimers<T>(promise: Promise<T>): Promise<T> {
|
|
57
|
-
await Promise.resolve();
|
|
58
|
-
await vi.runAllTimersAsync();
|
|
59
|
-
return promise;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
function mockResponse(statusCode: number, body: string) {
|
|
63
|
-
const httpsRequest = vi.mocked(https.request);
|
|
64
|
-
httpsRequest.mockImplementation(((...args) => {
|
|
65
|
-
const callback = args[2];
|
|
66
|
-
const res = createMockResponseEmitter(statusCode);
|
|
67
|
-
process.nextTick(() => {
|
|
68
|
-
callback?.(res);
|
|
69
|
-
res.emit("data", Buffer.from(body));
|
|
70
|
-
res.emit("end");
|
|
71
|
-
});
|
|
72
|
-
return createMockRequestEmitter();
|
|
73
|
-
}) as MockRequestHandler);
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
function mockSuccessResponse() {
|
|
77
|
-
mockResponse(200, '{"success":true}');
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
function mockFailureResponse(statusCode = 500) {
|
|
81
|
-
mockResponse(statusCode, "error");
|
|
82
|
-
}
|
|
83
|
-
|
|
84
|
-
function installFakeTimerHarness() {
|
|
85
|
-
beforeAll(async () => {
|
|
86
|
-
({ sendMessage, sendFileUrl, fetchChatUsers, resolveLegacyWebhookNameToChatUserId } =
|
|
87
|
-
await import("./client.js"));
|
|
88
|
-
});
|
|
89
|
-
|
|
90
|
-
beforeEach(() => {
|
|
91
|
-
vi.clearAllMocks();
|
|
92
|
-
vi.useFakeTimers();
|
|
93
|
-
fakeNowMs += 10_000;
|
|
94
|
-
vi.setSystemTime(fakeNowMs);
|
|
95
|
-
ssrfMocks.resolvePinnedHostnameWithPolicy.mockResolvedValue({
|
|
96
|
-
hostname: "example.com",
|
|
97
|
-
addresses: ["93.184.216.34"],
|
|
98
|
-
});
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
afterEach(() => {
|
|
102
|
-
vi.useRealTimers();
|
|
103
|
-
});
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
describe("sendMessage", () => {
|
|
107
|
-
installFakeTimerHarness();
|
|
108
|
-
|
|
109
|
-
it("returns true on successful send", async () => {
|
|
110
|
-
mockSuccessResponse();
|
|
111
|
-
const result = await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello"));
|
|
112
|
-
expect(result).toBe(true);
|
|
113
|
-
});
|
|
114
|
-
|
|
115
|
-
it("returns false on server error after retries", async () => {
|
|
116
|
-
mockFailureResponse(500);
|
|
117
|
-
const result = await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello"));
|
|
118
|
-
expect(result).toBe(false);
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
it("includes user_ids when userId is numeric", async () => {
|
|
122
|
-
mockSuccessResponse();
|
|
123
|
-
await settleTimers(sendMessage("https://nas.example.com/incoming", "Hello", 42));
|
|
124
|
-
const httpsRequest = vi.mocked(https.request);
|
|
125
|
-
expect(httpsRequest).toHaveBeenCalled();
|
|
126
|
-
const callArgs = httpsRequest.mock.calls[0];
|
|
127
|
-
expect(callArgs[0]).toBe("https://nas.example.com/incoming");
|
|
128
|
-
});
|
|
129
|
-
|
|
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 });
|
|
135
|
-
});
|
|
136
|
-
|
|
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 });
|
|
142
|
-
});
|
|
143
|
-
});
|
|
144
|
-
|
|
145
|
-
describe("sendFileUrl", () => {
|
|
146
|
-
installFakeTimerHarness();
|
|
147
|
-
|
|
148
|
-
it("returns true on success", async () => {
|
|
149
|
-
mockSuccessResponse();
|
|
150
|
-
const result = await settleTimers(
|
|
151
|
-
sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png"),
|
|
152
|
-
);
|
|
153
|
-
expect(result).toBe(true);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
it("returns false on failure", async () => {
|
|
157
|
-
mockFailureResponse(500);
|
|
158
|
-
const result = await settleTimers(
|
|
159
|
-
sendFileUrl("https://nas.example.com/incoming", "https://example.com/file.png"),
|
|
160
|
-
);
|
|
161
|
-
expect(result).toBe(false);
|
|
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
|
-
});
|
|
375
|
-
});
|
package/src/client.ts
DELETED
|
@@ -1,335 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Synology Chat HTTP client.
|
|
3
|
-
* Sends messages TO Synology Chat via the incoming webhook URL.
|
|
4
|
-
*/
|
|
5
|
-
|
|
6
|
-
import * as http from "node:http";
|
|
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";
|
|
14
|
-
|
|
15
|
-
const MIN_SEND_INTERVAL_MS = 500;
|
|
16
|
-
let lastSendTime = 0;
|
|
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
|
-
|
|
81
|
-
/**
|
|
82
|
-
* Send a text message to Synology Chat via the incoming webhook.
|
|
83
|
-
*
|
|
84
|
-
* @param incomingUrl - Synology Chat incoming webhook URL
|
|
85
|
-
* @param text - Message text to send
|
|
86
|
-
* @param userId - Optional user ID to mention with @
|
|
87
|
-
* @returns true if sent successfully
|
|
88
|
-
*/
|
|
89
|
-
export async function sendMessage(
|
|
90
|
-
incomingUrl: string,
|
|
91
|
-
text: string,
|
|
92
|
-
userId?: string | number,
|
|
93
|
-
allowInsecureSsl = false,
|
|
94
|
-
): Promise<boolean> {
|
|
95
|
-
// Synology Chat API requires user_ids (numeric) to specify the recipient
|
|
96
|
-
// The @mention is optional but user_ids is mandatory
|
|
97
|
-
const body = buildWebhookBody({ text }, userId);
|
|
98
|
-
|
|
99
|
-
// Internal rate limit: min 500ms between sends
|
|
100
|
-
const now = Date.now();
|
|
101
|
-
const elapsed = now - lastSendTime;
|
|
102
|
-
if (elapsed < MIN_SEND_INTERVAL_MS) {
|
|
103
|
-
await sleep(MIN_SEND_INTERVAL_MS - elapsed);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
// Retry with exponential backoff (3 attempts, 300ms base)
|
|
107
|
-
const maxRetries = 3;
|
|
108
|
-
const baseDelay = 300;
|
|
109
|
-
|
|
110
|
-
for (let attempt = 0; attempt < maxRetries; attempt++) {
|
|
111
|
-
try {
|
|
112
|
-
const ok = await doPost(incomingUrl, body, allowInsecureSsl);
|
|
113
|
-
lastSendTime = Date.now();
|
|
114
|
-
if (ok) {
|
|
115
|
-
return true;
|
|
116
|
-
}
|
|
117
|
-
} catch {
|
|
118
|
-
// will retry
|
|
119
|
-
}
|
|
120
|
-
|
|
121
|
-
if (attempt < maxRetries - 1) {
|
|
122
|
-
await sleep(baseDelay * 2 ** attempt);
|
|
123
|
-
}
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
return false;
|
|
127
|
-
}
|
|
128
|
-
|
|
129
|
-
/**
|
|
130
|
-
* Send a file URL to Synology Chat.
|
|
131
|
-
*/
|
|
132
|
-
export async function sendFileUrl(
|
|
133
|
-
incomingUrl: string,
|
|
134
|
-
fileUrl: string,
|
|
135
|
-
userId?: string | number,
|
|
136
|
-
allowInsecureSsl = false,
|
|
137
|
-
): Promise<boolean> {
|
|
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);
|
|
146
|
-
}
|
|
147
|
-
|
|
148
|
-
const ok = await doPost(incomingUrl, body, allowInsecureSsl);
|
|
149
|
-
lastSendTime = Date.now();
|
|
150
|
-
return ok;
|
|
151
|
-
} catch {
|
|
152
|
-
return false;
|
|
153
|
-
}
|
|
154
|
-
}
|
|
155
|
-
|
|
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> {
|
|
289
|
-
return new Promise((resolve, reject) => {
|
|
290
|
-
let parsedUrl: URL;
|
|
291
|
-
try {
|
|
292
|
-
parsedUrl = new URL(url);
|
|
293
|
-
} catch {
|
|
294
|
-
reject(new Error(`Invalid URL: ${url}`));
|
|
295
|
-
return;
|
|
296
|
-
}
|
|
297
|
-
const transport = parsedUrl.protocol === "https:" ? https : http;
|
|
298
|
-
|
|
299
|
-
const req = transport.request(
|
|
300
|
-
url,
|
|
301
|
-
{
|
|
302
|
-
method: "POST",
|
|
303
|
-
headers: {
|
|
304
|
-
"Content-Type": "application/x-www-form-urlencoded",
|
|
305
|
-
"Content-Length": Buffer.byteLength(body),
|
|
306
|
-
},
|
|
307
|
-
timeout: 30_000,
|
|
308
|
-
// Synology NAS may use self-signed certs on local network.
|
|
309
|
-
// Set allowInsecureSsl: true in channel config to skip verification.
|
|
310
|
-
rejectUnauthorized: !allowInsecureSsl,
|
|
311
|
-
},
|
|
312
|
-
(res) => {
|
|
313
|
-
let data = "";
|
|
314
|
-
res.on("data", (chunk: Buffer) => {
|
|
315
|
-
data += chunk.toString();
|
|
316
|
-
});
|
|
317
|
-
res.on("end", () => {
|
|
318
|
-
resolve(res.statusCode === 200);
|
|
319
|
-
});
|
|
320
|
-
},
|
|
321
|
-
);
|
|
322
|
-
|
|
323
|
-
req.on("error", reject);
|
|
324
|
-
req.on("timeout", () => {
|
|
325
|
-
req.destroy();
|
|
326
|
-
reject(new Error("Request timeout"));
|
|
327
|
-
});
|
|
328
|
-
req.write(body);
|
|
329
|
-
req.end();
|
|
330
|
-
});
|
|
331
|
-
}
|
|
332
|
-
|
|
333
|
-
function sleep(ms: number): Promise<void> {
|
|
334
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
335
|
-
}
|