@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.
- package/api.ts +3 -0
- package/channel-plugin-api.ts +1 -0
- package/contract-api.ts +1 -0
- package/index.ts +11 -12
- package/openclaw.plugin.json +35 -1
- package/package.json +20 -5
- package/setup-api.ts +1 -0
- package/setup-entry.ts +9 -0
- package/src/accounts.ts +107 -43
- package/src/approval-auth.test.ts +17 -0
- package/src/approval-auth.ts +22 -0
- package/src/channel.integration.test.ts +191 -0
- package/src/channel.test-mocks.ts +176 -0
- package/src/channel.test.ts +471 -174
- package/src/channel.ts +327 -278
- package/src/client.test.ts +296 -44
- package/src/client.ts +217 -24
- package/src/config-schema.ts +11 -0
- package/src/core.test.ts +373 -0
- package/src/gateway-runtime.ts +212 -0
- package/src/inbound-context.ts +10 -0
- package/src/inbound-turn.ts +171 -0
- package/src/runtime.ts +8 -20
- package/src/security-audit.test.ts +66 -0
- package/src/security-audit.ts +28 -0
- package/src/security.ts +67 -53
- package/src/session-key.ts +21 -0
- package/src/setup-surface.ts +338 -0
- package/src/test-http-utils.ts +75 -0
- package/src/types.ts +13 -14
- package/src/webhook-handler.test.ts +418 -75
- package/src/webhook-handler.ts +565 -138
- package/tsconfig.json +16 -0
- package/src/accounts.test.ts +0 -133
- package/src/security.test.ts +0 -98
package/src/channel.test.ts
CHANGED
|
@@ -1,55 +1,56 @@
|
|
|
1
|
-
import {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
vi.mock("openclaw/plugin-sdk", () => ({
|
|
5
|
-
DEFAULT_ACCOUNT_ID: "default",
|
|
6
|
-
setAccountEnabledInConfigSection: vi.fn((_opts: any) => ({})),
|
|
7
|
-
registerPluginHttpRoute: vi.fn(() => vi.fn()),
|
|
8
|
-
buildChannelConfigSchema: vi.fn((schema: any) => ({ schema })),
|
|
9
|
-
}));
|
|
1
|
+
import { createPluginSetupWizardStatus } from "openclaw/plugin-sdk/plugin-test-runtime";
|
|
2
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
3
|
+
import type { ResolvedSynologyChatAccount } from "./types.js";
|
|
10
4
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
5
|
+
const securityAccountDefaults: ResolvedSynologyChatAccount = {
|
|
6
|
+
accountId: "default",
|
|
7
|
+
enabled: true,
|
|
8
|
+
token: "t",
|
|
9
|
+
incomingUrl: "https://nas/incoming",
|
|
10
|
+
nasHost: "h",
|
|
11
|
+
webhookPath: "/w",
|
|
12
|
+
webhookPathSource: "default" as const,
|
|
13
|
+
dangerouslyAllowNameMatching: false,
|
|
14
|
+
dangerouslyAllowInheritedWebhookPath: false,
|
|
15
|
+
dmPolicy: "allowlist" as const,
|
|
16
|
+
allowedUserIds: [],
|
|
17
|
+
rateLimitPerMinute: 30,
|
|
18
|
+
botName: "Bot",
|
|
19
|
+
allowInsecureSsl: false,
|
|
20
|
+
};
|
|
15
21
|
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
22
|
+
function makeSecurityAccount(
|
|
23
|
+
overrides: Partial<ResolvedSynologyChatAccount> = {},
|
|
24
|
+
): ResolvedSynologyChatAccount {
|
|
25
|
+
return { ...securityAccountDefaults, ...overrides };
|
|
26
|
+
}
|
|
19
27
|
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
counts: {},
|
|
27
|
-
}),
|
|
28
|
-
},
|
|
29
|
-
},
|
|
30
|
-
})),
|
|
31
|
-
}));
|
|
28
|
+
const clientModule = await import("./client.js");
|
|
29
|
+
const gatewayRuntimeModule = await import("./gateway-runtime.js");
|
|
30
|
+
const mockSendMessage = vi.spyOn(clientModule, "sendMessage").mockResolvedValue(true);
|
|
31
|
+
const registerSynologyWebhookRouteMock = vi
|
|
32
|
+
.spyOn(gatewayRuntimeModule, "registerSynologyWebhookRoute")
|
|
33
|
+
.mockImplementation(() => vi.fn());
|
|
32
34
|
|
|
33
|
-
vi.mock("
|
|
34
|
-
|
|
35
|
-
object: vi.fn(() => ({
|
|
36
|
-
passthrough: vi.fn(() => ({ _type: "zod-schema" })),
|
|
37
|
-
})),
|
|
38
|
-
},
|
|
35
|
+
vi.mock("./webhook-handler.js", () => ({
|
|
36
|
+
createWebhookHandler: vi.fn(() => vi.fn()),
|
|
39
37
|
}));
|
|
40
38
|
|
|
41
|
-
const { createSynologyChatPlugin } = await import("./channel.js");
|
|
39
|
+
const { createSynologyChatPlugin, synologyChatPlugin } = await import("./channel.js");
|
|
40
|
+
const getSynologyChatSetupStatus = createPluginSetupWizardStatus(synologyChatPlugin);
|
|
42
41
|
|
|
43
42
|
describe("createSynologyChatPlugin", () => {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
vi.stubEnv("SYNOLOGY_CHAT_TOKEN", "");
|
|
45
|
+
vi.stubEnv("SYNOLOGY_CHAT_INCOMING_URL", "");
|
|
46
|
+
mockSendMessage.mockClear();
|
|
47
|
+
registerSynologyWebhookRouteMock.mockClear();
|
|
48
|
+
mockSendMessage.mockResolvedValue(true);
|
|
49
|
+
registerSynologyWebhookRouteMock.mockImplementation(() => vi.fn());
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
afterEach(() => {
|
|
53
|
+
vi.unstubAllEnvs();
|
|
53
54
|
});
|
|
54
55
|
|
|
55
56
|
describe("meta", () => {
|
|
@@ -71,22 +72,96 @@ describe("createSynologyChatPlugin", () => {
|
|
|
71
72
|
});
|
|
72
73
|
|
|
73
74
|
describe("config", () => {
|
|
74
|
-
it("listAccountIds
|
|
75
|
+
it("listAccountIds includes default and named accounts when configured", () => {
|
|
75
76
|
const plugin = createSynologyChatPlugin();
|
|
76
|
-
const result = plugin.config.listAccountIds({
|
|
77
|
-
|
|
77
|
+
const result = plugin.config.listAccountIds({
|
|
78
|
+
channels: {
|
|
79
|
+
"synology-chat": {
|
|
80
|
+
token: "base-token",
|
|
81
|
+
accounts: {
|
|
82
|
+
office: { token: "office-token" },
|
|
83
|
+
},
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
expect(result).toEqual(["default", "office"]);
|
|
78
88
|
});
|
|
79
89
|
|
|
80
|
-
it("resolveAccount
|
|
81
|
-
const cfg = {
|
|
90
|
+
it("resolveAccount merges account overrides with base config defaults", () => {
|
|
91
|
+
const cfg = {
|
|
92
|
+
channels: {
|
|
93
|
+
"synology-chat": {
|
|
94
|
+
token: "base-token",
|
|
95
|
+
incomingUrl: "https://nas/base",
|
|
96
|
+
nasHost: "nas-base",
|
|
97
|
+
allowedUserIds: ["base-user"],
|
|
98
|
+
rateLimitPerMinute: 45,
|
|
99
|
+
botName: "Base Bot",
|
|
100
|
+
accounts: {
|
|
101
|
+
office: {
|
|
102
|
+
token: "office-token",
|
|
103
|
+
allowInsecureSsl: true,
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
},
|
|
108
|
+
};
|
|
82
109
|
const plugin = createSynologyChatPlugin();
|
|
83
|
-
const account = plugin.config.resolveAccount(cfg, "
|
|
84
|
-
expect(account
|
|
110
|
+
const account = plugin.config.resolveAccount(cfg, "office");
|
|
111
|
+
expect(account).toMatchObject({
|
|
112
|
+
accountId: "office",
|
|
113
|
+
token: "office-token",
|
|
114
|
+
incomingUrl: "https://nas/base",
|
|
115
|
+
nasHost: "nas-base",
|
|
116
|
+
allowedUserIds: ["base-user"],
|
|
117
|
+
rateLimitPerMinute: 45,
|
|
118
|
+
botName: "Base Bot",
|
|
119
|
+
allowInsecureSsl: true,
|
|
120
|
+
});
|
|
85
121
|
});
|
|
86
122
|
|
|
87
123
|
it("defaultAccountId returns 'default'", () => {
|
|
88
124
|
const plugin = createSynologyChatPlugin();
|
|
89
|
-
expect(plugin.config.defaultAccountId({})).toBe("default");
|
|
125
|
+
expect(plugin.config.defaultAccountId?.({})).toBe("default");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("setup status honors the selected named account", async () => {
|
|
129
|
+
const status = await getSynologyChatSetupStatus({
|
|
130
|
+
cfg: {
|
|
131
|
+
channels: {
|
|
132
|
+
"synology-chat": {
|
|
133
|
+
accounts: {
|
|
134
|
+
ops: {
|
|
135
|
+
token: "ops-token",
|
|
136
|
+
incomingUrl: "https://nas/ops",
|
|
137
|
+
},
|
|
138
|
+
work: {
|
|
139
|
+
token: "work-token",
|
|
140
|
+
},
|
|
141
|
+
},
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
},
|
|
145
|
+
accountOverrides: {
|
|
146
|
+
"synology-chat": "work",
|
|
147
|
+
},
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
expect(status.configured).toBe(false);
|
|
151
|
+
expect(status.statusLines).toEqual([
|
|
152
|
+
"Synology Chat: needs token + incoming webhook",
|
|
153
|
+
"Accounts: 2",
|
|
154
|
+
]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("formats allowFrom entries through the shared adapter", () => {
|
|
158
|
+
const plugin = createSynologyChatPlugin();
|
|
159
|
+
expect(
|
|
160
|
+
plugin.config.formatAllowFrom?.({
|
|
161
|
+
cfg: {},
|
|
162
|
+
allowFrom: [" USER1 ", 42],
|
|
163
|
+
}),
|
|
164
|
+
).toEqual(["user1", "42"]);
|
|
90
165
|
});
|
|
91
166
|
});
|
|
92
167
|
|
|
@@ -100,6 +175,9 @@ describe("createSynologyChatPlugin", () => {
|
|
|
100
175
|
incomingUrl: "u",
|
|
101
176
|
nasHost: "h",
|
|
102
177
|
webhookPath: "/w",
|
|
178
|
+
webhookPathSource: "default" as const,
|
|
179
|
+
dangerouslyAllowNameMatching: false,
|
|
180
|
+
dangerouslyAllowInheritedWebhookPath: false,
|
|
103
181
|
dmPolicy: "allowlist" as const,
|
|
104
182
|
allowedUserIds: ["user1"],
|
|
105
183
|
rateLimitPerMinute: 30,
|
|
@@ -107,97 +185,158 @@ describe("createSynologyChatPlugin", () => {
|
|
|
107
185
|
allowInsecureSsl: true,
|
|
108
186
|
};
|
|
109
187
|
const result = plugin.security.resolveDmPolicy({ cfg: {}, account });
|
|
188
|
+
if (!result) {
|
|
189
|
+
throw new Error("resolveDmPolicy returned null");
|
|
190
|
+
}
|
|
110
191
|
expect(result.policy).toBe("allowlist");
|
|
111
192
|
expect(result.allowFrom).toEqual(["user1"]);
|
|
112
|
-
expect(
|
|
113
|
-
expect(result.normalizeEntry(" USER1 ")).toBe("user1");
|
|
193
|
+
expect(result.normalizeEntry?.(" USER1 ")).toBe("user1");
|
|
114
194
|
});
|
|
115
195
|
});
|
|
116
196
|
|
|
117
197
|
describe("pairing", () => {
|
|
118
|
-
it("
|
|
198
|
+
it("normalizes entries and notifies approved users", async () => {
|
|
119
199
|
const plugin = createSynologyChatPlugin();
|
|
120
200
|
expect(plugin.pairing.idLabel).toBe("synologyChatUserId");
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
201
|
+
const normalize = plugin.pairing.normalizeAllowEntry;
|
|
202
|
+
const notifyApproval = plugin.pairing.notifyApproval;
|
|
203
|
+
if (!normalize || !notifyApproval) {
|
|
204
|
+
throw new Error("synology-chat pairing helpers unavailable");
|
|
205
|
+
}
|
|
206
|
+
expect(normalize(" USER1 ")).toBe("user1");
|
|
207
|
+
|
|
208
|
+
await notifyApproval({
|
|
209
|
+
cfg: {
|
|
210
|
+
channels: {
|
|
211
|
+
"synology-chat": {
|
|
212
|
+
token: "t",
|
|
213
|
+
incomingUrl: "https://nas/incoming",
|
|
214
|
+
allowInsecureSsl: true,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
},
|
|
218
|
+
id: "USER1",
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
expect(mockSendMessage).toHaveBeenCalledWith(
|
|
222
|
+
"https://nas/incoming",
|
|
223
|
+
"OpenClaw: your access has been approved.",
|
|
224
|
+
"USER1",
|
|
225
|
+
true,
|
|
226
|
+
);
|
|
124
227
|
});
|
|
125
228
|
});
|
|
126
229
|
|
|
127
230
|
describe("security.collectWarnings", () => {
|
|
231
|
+
function makeSharedWebhookConfig(alertsOverrides: Record<string, unknown> = {}) {
|
|
232
|
+
return {
|
|
233
|
+
channels: {
|
|
234
|
+
"synology-chat": {
|
|
235
|
+
token: "base-token",
|
|
236
|
+
webhookPath: "/webhook/shared",
|
|
237
|
+
accounts: {
|
|
238
|
+
alerts: {
|
|
239
|
+
token: "alerts-token",
|
|
240
|
+
incomingUrl: "https://nas/alerts",
|
|
241
|
+
dmPolicy: "allowlist",
|
|
242
|
+
allowedUserIds: ["123"],
|
|
243
|
+
...alertsOverrides,
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
},
|
|
247
|
+
},
|
|
248
|
+
};
|
|
249
|
+
}
|
|
250
|
+
|
|
128
251
|
it("warns when token is missing", () => {
|
|
129
252
|
const plugin = createSynologyChatPlugin();
|
|
130
|
-
const account = {
|
|
131
|
-
|
|
132
|
-
enabled: true,
|
|
133
|
-
token: "",
|
|
134
|
-
incomingUrl: "https://nas/incoming",
|
|
135
|
-
nasHost: "h",
|
|
136
|
-
webhookPath: "/w",
|
|
137
|
-
dmPolicy: "allowlist" as const,
|
|
138
|
-
allowedUserIds: [],
|
|
139
|
-
rateLimitPerMinute: 30,
|
|
140
|
-
botName: "Bot",
|
|
141
|
-
allowInsecureSsl: false,
|
|
142
|
-
};
|
|
143
|
-
const warnings = plugin.security.collectWarnings({ account });
|
|
253
|
+
const account = makeSecurityAccount({ token: "" });
|
|
254
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
144
255
|
expect(warnings.some((w: string) => w.includes("token"))).toBe(true);
|
|
145
256
|
});
|
|
146
257
|
|
|
147
258
|
it("warns when allowInsecureSsl is true", () => {
|
|
148
259
|
const plugin = createSynologyChatPlugin();
|
|
149
|
-
const account = {
|
|
150
|
-
|
|
151
|
-
enabled: true,
|
|
152
|
-
token: "t",
|
|
153
|
-
incomingUrl: "https://nas/incoming",
|
|
154
|
-
nasHost: "h",
|
|
155
|
-
webhookPath: "/w",
|
|
156
|
-
dmPolicy: "allowlist" as const,
|
|
157
|
-
allowedUserIds: [],
|
|
158
|
-
rateLimitPerMinute: 30,
|
|
159
|
-
botName: "Bot",
|
|
160
|
-
allowInsecureSsl: true,
|
|
161
|
-
};
|
|
162
|
-
const warnings = plugin.security.collectWarnings({ account });
|
|
260
|
+
const account = makeSecurityAccount({ allowInsecureSsl: true });
|
|
261
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
163
262
|
expect(warnings.some((w: string) => w.includes("SSL"))).toBe(true);
|
|
164
263
|
});
|
|
165
264
|
|
|
265
|
+
it("warns when dangerous name matching is enabled", () => {
|
|
266
|
+
const plugin = createSynologyChatPlugin();
|
|
267
|
+
const account = makeSecurityAccount({ dangerouslyAllowNameMatching: true });
|
|
268
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
269
|
+
expect(warnings.some((w: string) => w.includes("dangerouslyAllowNameMatching"))).toBe(true);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("warns when inherited shared webhookPath is dangerously re-enabled", () => {
|
|
273
|
+
const plugin = createSynologyChatPlugin();
|
|
274
|
+
const account = makeSecurityAccount({
|
|
275
|
+
accountId: "alerts",
|
|
276
|
+
webhookPathSource: "inherited-base",
|
|
277
|
+
dangerouslyAllowInheritedWebhookPath: true,
|
|
278
|
+
});
|
|
279
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
280
|
+
expect(
|
|
281
|
+
warnings.some((w: string) => w.includes("dangerouslyAllowInheritedWebhookPath=true")),
|
|
282
|
+
).toBe(true);
|
|
283
|
+
});
|
|
284
|
+
|
|
166
285
|
it("warns when dmPolicy is open", () => {
|
|
167
286
|
const plugin = createSynologyChatPlugin();
|
|
168
|
-
const account = {
|
|
169
|
-
|
|
170
|
-
enabled: true,
|
|
171
|
-
token: "t",
|
|
172
|
-
incomingUrl: "https://nas/incoming",
|
|
173
|
-
nasHost: "h",
|
|
174
|
-
webhookPath: "/w",
|
|
175
|
-
dmPolicy: "open" as const,
|
|
176
|
-
allowedUserIds: [],
|
|
177
|
-
rateLimitPerMinute: 30,
|
|
178
|
-
botName: "Bot",
|
|
179
|
-
allowInsecureSsl: false,
|
|
180
|
-
};
|
|
181
|
-
const warnings = plugin.security.collectWarnings({ account });
|
|
287
|
+
const account = makeSecurityAccount({ dmPolicy: "open", allowedUserIds: ["*"] });
|
|
288
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
182
289
|
expect(warnings.some((w: string) => w.includes("open"))).toBe(true);
|
|
183
290
|
});
|
|
184
291
|
|
|
185
|
-
it("
|
|
292
|
+
it("warns when dmPolicy is open and allowedUserIds is empty", () => {
|
|
186
293
|
const plugin = createSynologyChatPlugin();
|
|
187
|
-
const account = {
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
294
|
+
const account = makeSecurityAccount({ dmPolicy: "open", allowedUserIds: [] });
|
|
295
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
296
|
+
expect(warnings.some((w: string) => w.includes("empty allowedUserIds"))).toBe(true);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
it("warns when dmPolicy is allowlist and allowedUserIds is empty", () => {
|
|
300
|
+
const plugin = createSynologyChatPlugin();
|
|
301
|
+
const account = makeSecurityAccount();
|
|
302
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
303
|
+
expect(warnings.some((w: string) => w.includes("empty allowedUserIds"))).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("warns when named multi-account routes inherit a shared webhookPath", () => {
|
|
307
|
+
const plugin = createSynologyChatPlugin();
|
|
308
|
+
const cfg = makeSharedWebhookConfig();
|
|
309
|
+
const account = plugin.config.resolveAccount(cfg, "alerts");
|
|
310
|
+
const warnings = plugin.security.collectWarnings({ cfg, account });
|
|
311
|
+
expect(warnings.some((w: string) => w.includes("must set an explicit webhookPath"))).toBe(
|
|
312
|
+
true,
|
|
313
|
+
);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it("warns when enabled accounts share the same exact webhookPath", () => {
|
|
317
|
+
const plugin = createSynologyChatPlugin();
|
|
318
|
+
const base = makeSharedWebhookConfig({ webhookPath: "/webhook/shared" }).channels[
|
|
319
|
+
"synology-chat"
|
|
320
|
+
];
|
|
321
|
+
const cfg = {
|
|
322
|
+
channels: {
|
|
323
|
+
"synology-chat": {
|
|
324
|
+
...base,
|
|
325
|
+
incomingUrl: "https://nas/default",
|
|
326
|
+
dmPolicy: "allowlist",
|
|
327
|
+
allowedUserIds: ["123"],
|
|
328
|
+
},
|
|
329
|
+
},
|
|
199
330
|
};
|
|
200
|
-
const
|
|
331
|
+
const account = plugin.config.resolveAccount(cfg, "alerts");
|
|
332
|
+
const warnings = plugin.security.collectWarnings({ cfg, account });
|
|
333
|
+
expect(warnings.some((w: string) => w.includes("conflicts on webhookPath"))).toBe(true);
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
it("returns no warnings for fully configured account", () => {
|
|
337
|
+
const plugin = createSynologyChatPlugin();
|
|
338
|
+
const account = makeSecurityAccount({ allowedUserIds: ["user1"] });
|
|
339
|
+
const warnings = plugin.security.collectWarnings({ cfg: {}, account });
|
|
201
340
|
expect(warnings).toHaveLength(0);
|
|
202
341
|
});
|
|
203
342
|
});
|
|
@@ -206,6 +345,8 @@ describe("createSynologyChatPlugin", () => {
|
|
|
206
345
|
it("normalizeTarget strips prefix and trims", () => {
|
|
207
346
|
const plugin = createSynologyChatPlugin();
|
|
208
347
|
expect(plugin.messaging.normalizeTarget("synology-chat:123")).toBe("123");
|
|
348
|
+
expect(plugin.messaging.normalizeTarget("synology_chat:123")).toBe("123");
|
|
349
|
+
expect(plugin.messaging.normalizeTarget("synology:123")).toBe("123");
|
|
209
350
|
expect(plugin.messaging.normalizeTarget(" 456 ")).toBe("456");
|
|
210
351
|
expect(plugin.messaging.normalizeTarget("")).toBeUndefined();
|
|
211
352
|
});
|
|
@@ -214,6 +355,8 @@ describe("createSynologyChatPlugin", () => {
|
|
|
214
355
|
const plugin = createSynologyChatPlugin();
|
|
215
356
|
expect(plugin.messaging.targetResolver.looksLikeId("12345")).toBe(true);
|
|
216
357
|
expect(plugin.messaging.targetResolver.looksLikeId("synology-chat:99")).toBe(true);
|
|
358
|
+
expect(plugin.messaging.targetResolver.looksLikeId("synology_chat:99")).toBe(true);
|
|
359
|
+
expect(plugin.messaging.targetResolver.looksLikeId("synology:99")).toBe(true);
|
|
217
360
|
expect(plugin.messaging.targetResolver.looksLikeId("notanumber")).toBe(false);
|
|
218
361
|
expect(plugin.messaging.targetResolver.looksLikeId("")).toBe(false);
|
|
219
362
|
});
|
|
@@ -222,9 +365,10 @@ describe("createSynologyChatPlugin", () => {
|
|
|
222
365
|
describe("directory", () => {
|
|
223
366
|
it("returns empty stubs", async () => {
|
|
224
367
|
const plugin = createSynologyChatPlugin();
|
|
225
|
-
|
|
226
|
-
expect(await plugin.directory.
|
|
227
|
-
expect(await plugin.directory.
|
|
368
|
+
const params = { cfg: {}, runtime: {} as never };
|
|
369
|
+
expect(await plugin.directory.self?.(params)).toBeNull();
|
|
370
|
+
expect(await plugin.directory.listPeers?.(params)).toEqual([]);
|
|
371
|
+
expect(await plugin.directory.listGroups?.(params)).toEqual([]);
|
|
228
372
|
});
|
|
229
373
|
});
|
|
230
374
|
|
|
@@ -232,9 +376,9 @@ describe("createSynologyChatPlugin", () => {
|
|
|
232
376
|
it("returns formatting hints", () => {
|
|
233
377
|
const plugin = createSynologyChatPlugin();
|
|
234
378
|
const hints = plugin.agentPrompt.messageToolHints();
|
|
235
|
-
expect(
|
|
236
|
-
expect(hints
|
|
237
|
-
expect(hints.
|
|
379
|
+
expect(hints).toContain("### Synology Chat Formatting");
|
|
380
|
+
expect(hints).toContain("**Links**: Use `<URL|display text>` to create clickable links.");
|
|
381
|
+
expect(hints).toContain("- No buttons, cards, or interactive elements");
|
|
238
382
|
});
|
|
239
383
|
});
|
|
240
384
|
|
|
@@ -243,18 +387,10 @@ describe("createSynologyChatPlugin", () => {
|
|
|
243
387
|
const plugin = createSynologyChatPlugin();
|
|
244
388
|
await expect(
|
|
245
389
|
plugin.outbound.sendText({
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
incomingUrl: "",
|
|
251
|
-
nasHost: "h",
|
|
252
|
-
webhookPath: "/w",
|
|
253
|
-
dmPolicy: "open",
|
|
254
|
-
allowedUserIds: [],
|
|
255
|
-
rateLimitPerMinute: 30,
|
|
256
|
-
botName: "Bot",
|
|
257
|
-
allowInsecureSsl: true,
|
|
390
|
+
cfg: {
|
|
391
|
+
channels: {
|
|
392
|
+
"synology-chat": { enabled: true, token: "t", incomingUrl: "" },
|
|
393
|
+
},
|
|
258
394
|
},
|
|
259
395
|
text: "hello",
|
|
260
396
|
to: "user1",
|
|
@@ -265,43 +401,34 @@ describe("createSynologyChatPlugin", () => {
|
|
|
265
401
|
it("sendText returns OutboundDeliveryResult on success", async () => {
|
|
266
402
|
const plugin = createSynologyChatPlugin();
|
|
267
403
|
const result = await plugin.outbound.sendText({
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
rateLimitPerMinute: 30,
|
|
278
|
-
botName: "Bot",
|
|
279
|
-
allowInsecureSsl: true,
|
|
404
|
+
cfg: {
|
|
405
|
+
channels: {
|
|
406
|
+
"synology-chat": {
|
|
407
|
+
enabled: true,
|
|
408
|
+
token: "t",
|
|
409
|
+
incomingUrl: "https://nas/incoming",
|
|
410
|
+
allowInsecureSsl: true,
|
|
411
|
+
},
|
|
412
|
+
},
|
|
280
413
|
},
|
|
281
414
|
text: "hello",
|
|
282
415
|
to: "user1",
|
|
283
416
|
});
|
|
284
|
-
expect(result
|
|
285
|
-
|
|
286
|
-
|
|
417
|
+
expect(result).toMatchObject({
|
|
418
|
+
channel: "synology-chat",
|
|
419
|
+
chatId: "user1",
|
|
420
|
+
});
|
|
421
|
+
expect(result.messageId).toMatch(/^sc-\d+$/);
|
|
287
422
|
});
|
|
288
423
|
|
|
289
424
|
it("sendMedia throws when missing incomingUrl", async () => {
|
|
290
425
|
const plugin = createSynologyChatPlugin();
|
|
291
426
|
await expect(
|
|
292
427
|
plugin.outbound.sendMedia({
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
incomingUrl: "",
|
|
298
|
-
nasHost: "h",
|
|
299
|
-
webhookPath: "/w",
|
|
300
|
-
dmPolicy: "open",
|
|
301
|
-
allowedUserIds: [],
|
|
302
|
-
rateLimitPerMinute: 30,
|
|
303
|
-
botName: "Bot",
|
|
304
|
-
allowInsecureSsl: true,
|
|
428
|
+
cfg: {
|
|
429
|
+
channels: {
|
|
430
|
+
"synology-chat": { enabled: true, token: "t", incomingUrl: "" },
|
|
431
|
+
},
|
|
305
432
|
},
|
|
306
433
|
mediaUrl: "https://example.com/img.png",
|
|
307
434
|
to: "user1",
|
|
@@ -311,30 +438,200 @@ describe("createSynologyChatPlugin", () => {
|
|
|
311
438
|
});
|
|
312
439
|
|
|
313
440
|
describe("gateway", () => {
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
441
|
+
function makeStartAccountCtx(
|
|
442
|
+
accountConfig: Record<string, unknown>,
|
|
443
|
+
abortController = new AbortController(),
|
|
444
|
+
) {
|
|
445
|
+
return {
|
|
446
|
+
abortController,
|
|
447
|
+
ctx: {
|
|
448
|
+
cfg: {
|
|
449
|
+
channels: { "synology-chat": accountConfig },
|
|
450
|
+
},
|
|
451
|
+
accountId: "default",
|
|
452
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
453
|
+
abortSignal: abortController.signal,
|
|
454
|
+
},
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function makeNamedStartAccountCtx(
|
|
459
|
+
accountOverrides: Record<string, unknown>,
|
|
460
|
+
abortController = new AbortController(),
|
|
461
|
+
) {
|
|
462
|
+
return {
|
|
463
|
+
abortController,
|
|
464
|
+
ctx: {
|
|
465
|
+
cfg: {
|
|
466
|
+
channels: {
|
|
467
|
+
"synology-chat": {
|
|
468
|
+
enabled: true,
|
|
469
|
+
token: "default-token",
|
|
470
|
+
incomingUrl: "https://nas/default",
|
|
471
|
+
webhookPath: "/webhook/synology-shared",
|
|
472
|
+
dmPolicy: "allowlist",
|
|
473
|
+
allowedUserIds: ["123"],
|
|
474
|
+
accounts: {
|
|
475
|
+
alerts: {
|
|
476
|
+
enabled: true,
|
|
477
|
+
token: "alerts-token",
|
|
478
|
+
incomingUrl: "https://nas/alerts",
|
|
479
|
+
...accountOverrides,
|
|
480
|
+
},
|
|
481
|
+
},
|
|
482
|
+
},
|
|
483
|
+
},
|
|
484
|
+
},
|
|
485
|
+
accountId: "alerts",
|
|
486
|
+
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
487
|
+
abortSignal: abortController.signal,
|
|
319
488
|
},
|
|
320
|
-
accountId: "default",
|
|
321
|
-
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
322
489
|
};
|
|
323
|
-
|
|
324
|
-
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async function expectPendingStartAccountPromise(
|
|
493
|
+
result: Promise<unknown>,
|
|
494
|
+
abortController: AbortController,
|
|
495
|
+
) {
|
|
496
|
+
expect(result).toBeInstanceOf(Promise);
|
|
497
|
+
let settled = false;
|
|
498
|
+
void result.then(
|
|
499
|
+
() => {
|
|
500
|
+
settled = true;
|
|
501
|
+
},
|
|
502
|
+
() => {
|
|
503
|
+
settled = true;
|
|
504
|
+
},
|
|
505
|
+
);
|
|
506
|
+
await Promise.resolve();
|
|
507
|
+
expect(settled).toBe(false);
|
|
508
|
+
abortController.abort();
|
|
509
|
+
await result;
|
|
510
|
+
}
|
|
511
|
+
|
|
512
|
+
async function expectPendingStartAccount(accountConfig: Record<string, unknown>) {
|
|
513
|
+
const plugin = createSynologyChatPlugin();
|
|
514
|
+
const { ctx, abortController } = makeStartAccountCtx(accountConfig);
|
|
515
|
+
const result = plugin.gateway.startAccount(ctx);
|
|
516
|
+
await expectPendingStartAccountPromise(result, abortController);
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
it("startAccount returns pending promise for disabled account", async () => {
|
|
520
|
+
await expectPendingStartAccount({ enabled: false });
|
|
521
|
+
});
|
|
522
|
+
|
|
523
|
+
it("startAccount returns pending promise for account without token", async () => {
|
|
524
|
+
await expectPendingStartAccount({ enabled: true });
|
|
325
525
|
});
|
|
326
526
|
|
|
327
|
-
it("startAccount
|
|
527
|
+
it("startAccount refuses allowlist accounts with empty allowedUserIds", async () => {
|
|
528
|
+
const registerMock = registerSynologyWebhookRouteMock;
|
|
529
|
+
registerMock.mockClear();
|
|
328
530
|
const plugin = createSynologyChatPlugin();
|
|
329
|
-
const ctx = {
|
|
531
|
+
const { ctx, abortController } = makeStartAccountCtx({
|
|
532
|
+
enabled: true,
|
|
533
|
+
token: "t",
|
|
534
|
+
incomingUrl: "https://nas/incoming",
|
|
535
|
+
dmPolicy: "allowlist",
|
|
536
|
+
allowedUserIds: [],
|
|
537
|
+
});
|
|
538
|
+
|
|
539
|
+
const result = plugin.gateway.startAccount(ctx);
|
|
540
|
+
await expectPendingStartAccountPromise(result, abortController);
|
|
541
|
+
expect(ctx.log.warn).toHaveBeenCalledWith(expect.stringContaining("empty allowedUserIds"));
|
|
542
|
+
expect(registerMock).not.toHaveBeenCalled();
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
it("startAccount refuses open accounts with empty allowedUserIds", async () => {
|
|
546
|
+
const registerMock = registerSynologyWebhookRouteMock;
|
|
547
|
+
registerMock.mockClear();
|
|
548
|
+
const plugin = createSynologyChatPlugin();
|
|
549
|
+
const { ctx, abortController } = makeStartAccountCtx({
|
|
550
|
+
enabled: true,
|
|
551
|
+
token: "t",
|
|
552
|
+
incomingUrl: "https://nas/incoming",
|
|
553
|
+
dmPolicy: "open",
|
|
554
|
+
allowedUserIds: [],
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
const result = plugin.gateway.startAccount(ctx);
|
|
558
|
+
await expectPendingStartAccountPromise(result, abortController);
|
|
559
|
+
expect(ctx.log.warn).toHaveBeenCalledWith(
|
|
560
|
+
expect.stringContaining("dmPolicy=open but empty allowedUserIds"),
|
|
561
|
+
);
|
|
562
|
+
expect(registerMock).not.toHaveBeenCalled();
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
it("startAccount refuses named accounts without explicit webhookPath in multi-account setups", async () => {
|
|
566
|
+
const registerMock = registerSynologyWebhookRouteMock;
|
|
567
|
+
const plugin = createSynologyChatPlugin();
|
|
568
|
+
const { ctx, abortController } = makeNamedStartAccountCtx({
|
|
569
|
+
dmPolicy: "allowlist",
|
|
570
|
+
allowedUserIds: ["123"],
|
|
571
|
+
});
|
|
572
|
+
|
|
573
|
+
const result = plugin.gateway.startAccount(ctx);
|
|
574
|
+
await expectPendingStartAccountPromise(result, abortController);
|
|
575
|
+
expect(ctx.log.warn).toHaveBeenCalledWith(
|
|
576
|
+
expect.stringContaining("must set an explicit webhookPath"),
|
|
577
|
+
);
|
|
578
|
+
expect(registerMock).not.toHaveBeenCalled();
|
|
579
|
+
});
|
|
580
|
+
|
|
581
|
+
it("startAccount refuses duplicate exact webhook paths across accounts", async () => {
|
|
582
|
+
const registerMock = registerSynologyWebhookRouteMock;
|
|
583
|
+
const plugin = createSynologyChatPlugin();
|
|
584
|
+
const { ctx, abortController } = makeNamedStartAccountCtx({
|
|
585
|
+
webhookPath: "/webhook/synology-shared",
|
|
586
|
+
dmPolicy: "open",
|
|
587
|
+
allowedUserIds: ["*"],
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
const result = plugin.gateway.startAccount(ctx);
|
|
591
|
+
await expectPendingStartAccountPromise(result, abortController);
|
|
592
|
+
expect(ctx.log.warn).toHaveBeenCalledWith(
|
|
593
|
+
expect.stringContaining("conflicts on webhookPath"),
|
|
594
|
+
);
|
|
595
|
+
expect(registerMock).not.toHaveBeenCalled();
|
|
596
|
+
});
|
|
597
|
+
|
|
598
|
+
it("re-registers same account/path through the route registrar", async () => {
|
|
599
|
+
const unregisterFirst = vi.fn();
|
|
600
|
+
const unregisterSecond = vi.fn();
|
|
601
|
+
const registerMock = registerSynologyWebhookRouteMock;
|
|
602
|
+
registerMock.mockReturnValueOnce(unregisterFirst).mockReturnValueOnce(unregisterSecond);
|
|
603
|
+
|
|
604
|
+
const plugin = createSynologyChatPlugin();
|
|
605
|
+
const abortFirst = new AbortController();
|
|
606
|
+
const abortSecond = new AbortController();
|
|
607
|
+
const makeCtx = (abortCtrl: AbortController) => ({
|
|
330
608
|
cfg: {
|
|
331
|
-
channels: {
|
|
609
|
+
channels: {
|
|
610
|
+
"synology-chat": {
|
|
611
|
+
enabled: true,
|
|
612
|
+
token: "t",
|
|
613
|
+
incomingUrl: "https://nas/incoming",
|
|
614
|
+
webhookPath: "/webhook/synology",
|
|
615
|
+
dmPolicy: "allowlist",
|
|
616
|
+
allowedUserIds: ["123"],
|
|
617
|
+
},
|
|
618
|
+
},
|
|
332
619
|
},
|
|
333
620
|
accountId: "default",
|
|
334
621
|
log: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
622
|
+
abortSignal: abortCtrl.signal,
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
const firstPromise = plugin.gateway.startAccount(makeCtx(abortFirst));
|
|
626
|
+
const secondPromise = plugin.gateway.startAccount(makeCtx(abortSecond));
|
|
627
|
+
|
|
628
|
+
expect(registerMock).toHaveBeenCalledTimes(2);
|
|
629
|
+
expect(unregisterFirst).not.toHaveBeenCalled();
|
|
630
|
+
expect(unregisterSecond).not.toHaveBeenCalled();
|
|
631
|
+
|
|
632
|
+
abortFirst.abort();
|
|
633
|
+
abortSecond.abort();
|
|
634
|
+
await Promise.allSettled([firstPromise, secondPromise]);
|
|
338
635
|
});
|
|
339
636
|
});
|
|
340
637
|
});
|