@kodelyth/synology-chat 2026.5.39 → 2026.5.42

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.
Files changed (48) hide show
  1. package/api.ts +3 -0
  2. package/channel-plugin-api.ts +1 -0
  3. package/contract-api.ts +1 -0
  4. package/dist/api.js +3 -0
  5. package/dist/channel-DL2_2tLQ.js +1233 -0
  6. package/dist/channel-plugin-api.js +2 -0
  7. package/dist/contract-api.js +2 -0
  8. package/dist/index.js +18 -0
  9. package/dist/security-audit-Zu_nkF2x.js +14 -0
  10. package/dist/setup-api.js +2 -0
  11. package/dist/setup-entry.js +11 -0
  12. package/dist/setup-surface-BHDzBWdx.js +334 -0
  13. package/index.ts +16 -0
  14. package/klaw.plugin.json +1 -22
  15. package/package.json +3 -3
  16. package/setup-api.ts +1 -0
  17. package/setup-entry.ts +9 -0
  18. package/src/accounts.ts +151 -0
  19. package/src/approval-auth.test.ts +17 -0
  20. package/src/approval-auth.ts +22 -0
  21. package/src/channel.integration.test.ts +204 -0
  22. package/src/channel.test-mocks.ts +176 -0
  23. package/src/channel.test.ts +693 -0
  24. package/src/channel.ts +435 -0
  25. package/src/client.test.ts +399 -0
  26. package/src/client.ts +326 -0
  27. package/src/config-schema.ts +11 -0
  28. package/src/core.test.ts +427 -0
  29. package/src/gateway-runtime.ts +212 -0
  30. package/src/inbound-context.ts +10 -0
  31. package/src/inbound-event.ts +175 -0
  32. package/src/runtime.ts +8 -0
  33. package/src/security-audit.test.ts +72 -0
  34. package/src/security-audit.ts +28 -0
  35. package/src/security.ts +107 -0
  36. package/src/session-key.ts +21 -0
  37. package/src/setup-surface.ts +334 -0
  38. package/src/test-http-utils.ts +75 -0
  39. package/src/types.ts +59 -0
  40. package/src/webhook-handler.test.ts +644 -0
  41. package/src/webhook-handler.ts +652 -0
  42. package/tsconfig.json +16 -0
  43. package/api.js +0 -7
  44. package/channel-plugin-api.js +0 -7
  45. package/contract-api.js +0 -7
  46. package/index.js +0 -7
  47. package/setup-api.js +0 -7
  48. package/setup-entry.js +0 -7
@@ -0,0 +1,2 @@
1
+ import { t as synologyChatPlugin } from "./channel-DL2_2tLQ.js";
2
+ export { synologyChatPlugin };
@@ -0,0 +1,2 @@
1
+ import { t as collectSynologyChatSecurityAuditFindings } from "./security-audit-Zu_nkF2x.js";
2
+ export { collectSynologyChatSecurityAuditFindings };
package/dist/index.js ADDED
@@ -0,0 +1,18 @@
1
+ import { defineBundledChannelEntry } from "klaw/plugin-sdk/channel-entry-contract";
2
+ //#region extensions/synology-chat/index.ts
3
+ var synology_chat_default = defineBundledChannelEntry({
4
+ id: "synology-chat",
5
+ name: "Synology Chat",
6
+ description: "Native Synology Chat channel plugin for Klaw",
7
+ importMetaUrl: import.meta.url,
8
+ plugin: {
9
+ specifier: "./channel-plugin-api.js",
10
+ exportName: "synologyChatPlugin"
11
+ },
12
+ runtime: {
13
+ specifier: "./api.js",
14
+ exportName: "setSynologyRuntime"
15
+ }
16
+ });
17
+ //#endregion
18
+ export { synology_chat_default as default };
@@ -0,0 +1,14 @@
1
+ //#region extensions/synology-chat/src/security-audit.ts
2
+ function collectSynologyChatSecurityAuditFindings(params) {
3
+ if (!params.account.dangerouslyAllowNameMatching) return [];
4
+ const accountId = params.accountId?.trim() || params.account.accountId || "default";
5
+ return [{
6
+ checkId: "channels.synology-chat.reply.dangerous_name_matching_enabled",
7
+ severity: "info",
8
+ title: `Synology Chat dangerous name matching is enabled${params.orderedAccountIds.length > 1 || params.hasExplicitAccountPath ? ` (account: ${accountId})` : ""}`,
9
+ detail: "dangerouslyAllowNameMatching=true re-enables mutable username/nickname matching for reply delivery. This is a break-glass compatibility mode, not a hardened default.",
10
+ remediation: "Prefer stable numeric Synology Chat user IDs for reply delivery, then disable dangerouslyAllowNameMatching."
11
+ }];
12
+ }
13
+ //#endregion
14
+ export { collectSynologyChatSecurityAuditFindings as t };
@@ -0,0 +1,2 @@
1
+ import { n as synologyChatSetupWizard, t as synologyChatSetupAdapter } from "./setup-surface-BHDzBWdx.js";
2
+ export { synologyChatSetupAdapter, synologyChatSetupWizard };
@@ -0,0 +1,11 @@
1
+ import { defineBundledChannelSetupEntry } from "klaw/plugin-sdk/channel-entry-contract";
2
+ //#region extensions/synology-chat/setup-entry.ts
3
+ var setup_entry_default = defineBundledChannelSetupEntry({
4
+ importMetaUrl: import.meta.url,
5
+ plugin: {
6
+ specifier: "./api.js",
7
+ exportName: "synologyChatPlugin"
8
+ }
9
+ });
10
+ //#endregion
11
+ export { setup_entry_default as default };
@@ -0,0 +1,334 @@
1
+ import { normalizeOptionalString } from "klaw/plugin-sdk/string-coerce-runtime";
2
+ import { DEFAULT_ACCOUNT_ID, listCombinedAccountIds, resolveMergedAccountConfig } from "klaw/plugin-sdk/account-resolution";
3
+ import { resolveDangerousNameMatchingEnabled } from "klaw/plugin-sdk/dangerous-name-runtime";
4
+ import { DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID$1, createAllowFromSection, createSetupTranslator, createStandardChannelSetupStatus, formatDocsLink, mergeAllowFromEntries, normalizeAccountId, setSetupChannelEnabled, splitSetupEntries } from "klaw/plugin-sdk/setup";
5
+ //#region extensions/synology-chat/src/accounts.ts
6
+ /**
7
+ * Account resolution: reads config from channels.synology-chat,
8
+ * merges per-account overrides, falls back to environment variables.
9
+ */
10
+ /** Extract the channel config from the full Klaw config object. */
11
+ function getChannelConfig$1(cfg) {
12
+ return cfg?.channels?.["synology-chat"];
13
+ }
14
+ function resolveImplicitAccountId(channelCfg) {
15
+ return channelCfg.token || process.env.SYNOLOGY_CHAT_TOKEN ? DEFAULT_ACCOUNT_ID : void 0;
16
+ }
17
+ function getRawAccountConfig$1(channelCfg, accountId) {
18
+ if (accountId === DEFAULT_ACCOUNT_ID) return channelCfg;
19
+ return channelCfg.accounts?.[accountId] ?? {};
20
+ }
21
+ function hasExplicitWebhookPath(rawAccount) {
22
+ return typeof rawAccount?.webhookPath === "string" && rawAccount.webhookPath.trim().length > 0;
23
+ }
24
+ function resolveWebhookPathSource(params) {
25
+ if (hasExplicitWebhookPath(params.rawAccount)) return "explicit";
26
+ if (params.accountId !== DEFAULT_ACCOUNT_ID && hasExplicitWebhookPath(params.channelCfg)) return "inherited-base";
27
+ return "default";
28
+ }
29
+ /** Parse allowedUserIds from string or array to string[]. */
30
+ function parseAllowedUserIds(raw) {
31
+ if (!raw) return [];
32
+ if (Array.isArray(raw)) return raw.filter(Boolean);
33
+ return raw.split(",").map((s) => s.trim()).filter(Boolean);
34
+ }
35
+ function parseRateLimitPerMinute(raw) {
36
+ if (raw == null) return 30;
37
+ const trimmed = raw.trim();
38
+ if (!/^-?\d+$/.test(trimmed)) return 30;
39
+ return Number.parseInt(trimmed, 10);
40
+ }
41
+ /**
42
+ * List all configured account IDs for this channel.
43
+ * Returns ["default"] if there's a base config, plus any named accounts.
44
+ */
45
+ function listAccountIds(cfg) {
46
+ const channelCfg = getChannelConfig$1(cfg);
47
+ if (!channelCfg) return [];
48
+ return listCombinedAccountIds({
49
+ configuredAccountIds: Object.keys(channelCfg.accounts ?? {}),
50
+ implicitAccountId: resolveImplicitAccountId(channelCfg)
51
+ });
52
+ }
53
+ /**
54
+ * Resolve a specific account by ID with full defaults applied.
55
+ * Falls back to env vars for the "default" account.
56
+ */
57
+ function resolveAccount(cfg, accountId) {
58
+ const channelCfg = getChannelConfig$1(cfg) ?? {};
59
+ const id = accountId || DEFAULT_ACCOUNT_ID;
60
+ const accountOverrides = id === DEFAULT_ACCOUNT_ID ? void 0 : channelCfg.accounts?.[id] ?? void 0;
61
+ const rawAccount = getRawAccountConfig$1(channelCfg, id);
62
+ const merged = resolveMergedAccountConfig({
63
+ channelConfig: channelCfg,
64
+ accounts: channelCfg.accounts,
65
+ accountId: id
66
+ });
67
+ const envToken = process.env.SYNOLOGY_CHAT_TOKEN ?? "";
68
+ const envIncomingUrl = process.env.SYNOLOGY_CHAT_INCOMING_URL ?? "";
69
+ const envNasHost = process.env.SYNOLOGY_NAS_HOST ?? "localhost";
70
+ const envAllowedUserIds = process.env.SYNOLOGY_ALLOWED_USER_IDS ?? "";
71
+ const envRateLimitValue = parseRateLimitPerMinute(process.env.SYNOLOGY_RATE_LIMIT);
72
+ const envBotName = process.env.KLAW_BOT_NAME ?? "Klaw";
73
+ const webhookPathSource = resolveWebhookPathSource({
74
+ accountId: id,
75
+ channelCfg,
76
+ rawAccount
77
+ });
78
+ const dangerouslyAllowInheritedWebhookPath = rawAccount.dangerouslyAllowInheritedWebhookPath ?? channelCfg.dangerouslyAllowInheritedWebhookPath ?? false;
79
+ return {
80
+ accountId: id,
81
+ enabled: merged.enabled ?? true,
82
+ token: merged.token ?? envToken,
83
+ incomingUrl: merged.incomingUrl ?? envIncomingUrl,
84
+ nasHost: merged.nasHost ?? envNasHost,
85
+ webhookPath: merged.webhookPath ?? "/webhook/synology",
86
+ webhookPathSource,
87
+ dangerouslyAllowNameMatching: resolveDangerousNameMatchingEnabled({
88
+ providerConfig: channelCfg,
89
+ accountConfig: accountOverrides
90
+ }),
91
+ dangerouslyAllowInheritedWebhookPath,
92
+ dmPolicy: merged.dmPolicy ?? "allowlist",
93
+ allowedUserIds: parseAllowedUserIds(merged.allowedUserIds ?? envAllowedUserIds),
94
+ rateLimitPerMinute: merged.rateLimitPerMinute ?? envRateLimitValue,
95
+ botName: merged.botName ?? envBotName,
96
+ allowInsecureSsl: merged.allowInsecureSsl ?? false
97
+ };
98
+ }
99
+ //#endregion
100
+ //#region extensions/synology-chat/src/setup-surface.ts
101
+ const t = createSetupTranslator();
102
+ const channel = "synology-chat";
103
+ const DEFAULT_WEBHOOK_PATH = "/webhook/synology";
104
+ const SYNOLOGY_SETUP_HELP_LINES = [
105
+ t("wizard.synologyChat.helpIncomingWebhook"),
106
+ t("wizard.synologyChat.helpOutgoingWebhook"),
107
+ t("wizard.synologyChat.helpPointWebhook", { path: DEFAULT_WEBHOOK_PATH }),
108
+ t("wizard.synologyChat.helpAllowedUsers"),
109
+ `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`
110
+ ];
111
+ const SYNOLOGY_ALLOW_FROM_HELP_LINES = [
112
+ t("wizard.synologyChat.allowlistIntro"),
113
+ t("wizard.synologyChat.examples"),
114
+ "- 123456",
115
+ "- synology-chat:123456",
116
+ t("wizard.synologyChat.multipleEntries"),
117
+ `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`
118
+ ];
119
+ function getChannelConfig(cfg) {
120
+ return cfg.channels?.[channel] ?? {};
121
+ }
122
+ function getRawAccountConfig(cfg, accountId) {
123
+ const channelConfig = getChannelConfig(cfg);
124
+ if (accountId === DEFAULT_ACCOUNT_ID$1) return channelConfig;
125
+ return channelConfig.accounts?.[accountId] ?? {};
126
+ }
127
+ function patchSynologyChatAccountConfig(params) {
128
+ const channelConfig = getChannelConfig(params.cfg);
129
+ if (params.accountId === DEFAULT_ACCOUNT_ID$1) {
130
+ const nextChannelConfig = { ...channelConfig };
131
+ for (const field of params.clearFields ?? []) delete nextChannelConfig[field];
132
+ return {
133
+ ...params.cfg,
134
+ channels: {
135
+ ...params.cfg.channels,
136
+ [channel]: {
137
+ ...nextChannelConfig,
138
+ ...params.enabled ? { enabled: true } : {},
139
+ ...params.patch
140
+ }
141
+ }
142
+ };
143
+ }
144
+ const nextAccounts = { ...channelConfig.accounts };
145
+ const nextAccountConfig = { ...nextAccounts[params.accountId] };
146
+ for (const field of params.clearFields ?? []) delete nextAccountConfig[field];
147
+ nextAccounts[params.accountId] = {
148
+ ...nextAccountConfig,
149
+ ...params.enabled ? { enabled: true } : {},
150
+ ...params.patch
151
+ };
152
+ return {
153
+ ...params.cfg,
154
+ channels: {
155
+ ...params.cfg.channels,
156
+ [channel]: {
157
+ ...channelConfig,
158
+ ...params.enabled ? { enabled: true } : {},
159
+ accounts: nextAccounts
160
+ }
161
+ }
162
+ };
163
+ }
164
+ function isSynologyChatConfigured(cfg, accountId) {
165
+ const account = resolveAccount(cfg, accountId);
166
+ return Boolean(account.token.trim() && account.incomingUrl.trim());
167
+ }
168
+ function validateWebhookUrl(value) {
169
+ try {
170
+ const parsed = new URL(value);
171
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "Incoming webhook must use http:// or https://.";
172
+ } catch {
173
+ return "Incoming webhook must be a valid URL.";
174
+ }
175
+ }
176
+ function validateWebhookPath(value) {
177
+ const trimmed = value.trim();
178
+ if (!trimmed) return;
179
+ return trimmed.startsWith("/") ? void 0 : "Webhook path must start with /.";
180
+ }
181
+ function parseSynologyUserId(value) {
182
+ const cleaned = value.replace(/^synology(?:[-_]?chat)?:/i, "").trim();
183
+ return /^\d+$/.test(cleaned) ? cleaned : null;
184
+ }
185
+ function normalizeSynologyAllowedUserId(value) {
186
+ if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return `${value}`.trim();
187
+ return "";
188
+ }
189
+ function resolveExistingAllowedUserIds(cfg, accountId) {
190
+ const raw = getRawAccountConfig(cfg, accountId).allowedUserIds;
191
+ if (Array.isArray(raw)) return raw.map(normalizeSynologyAllowedUserId).filter(Boolean);
192
+ return normalizeSynologyAllowedUserId(raw).split(",").map((value) => value.trim()).filter(Boolean);
193
+ }
194
+ const synologyChatSetupAdapter = {
195
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID$1,
196
+ validateInput: ({ accountId, input }) => {
197
+ if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID$1) return "Synology Chat env credentials only support the default account.";
198
+ if (!input.useEnv && !input.token?.trim()) return "Synology Chat requires --token or --use-env.";
199
+ if (!input.url?.trim()) return "Synology Chat requires --url for the incoming webhook.";
200
+ const urlError = validateWebhookUrl(input.url.trim());
201
+ if (urlError) return urlError;
202
+ if (input.webhookPath?.trim()) return validateWebhookPath(input.webhookPath.trim()) ?? null;
203
+ return null;
204
+ },
205
+ applyAccountConfig: ({ cfg, accountId, input }) => patchSynologyChatAccountConfig({
206
+ cfg,
207
+ accountId,
208
+ enabled: true,
209
+ clearFields: input.useEnv ? ["token"] : void 0,
210
+ patch: {
211
+ ...input.useEnv ? {} : { token: input.token?.trim() },
212
+ incomingUrl: input.url?.trim(),
213
+ ...input.webhookPath?.trim() ? { webhookPath: input.webhookPath.trim() } : {}
214
+ }
215
+ })
216
+ };
217
+ const synologyChatSetupWizard = {
218
+ channel,
219
+ status: createStandardChannelSetupStatus({
220
+ channelLabel: "Synology Chat",
221
+ configuredLabel: t("wizard.channels.statusConfigured"),
222
+ unconfiguredLabel: t("wizard.channels.statusNeedsTokenIncomingWebhook"),
223
+ configuredHint: t("wizard.channels.statusConfigured"),
224
+ unconfiguredHint: t("wizard.channels.statusNeedsTokenIncomingWebhook"),
225
+ configuredScore: 1,
226
+ unconfiguredScore: 0,
227
+ includeStatusLine: true,
228
+ resolveConfigured: ({ cfg, accountId }) => accountId ? isSynologyChatConfigured(cfg, accountId) : listAccountIds(cfg).some((candidateAccountId) => isSynologyChatConfigured(cfg, candidateAccountId)),
229
+ resolveExtraStatusLines: ({ cfg }) => [`Accounts: ${listAccountIds(cfg).length || 0}`]
230
+ }),
231
+ introNote: {
232
+ title: t("wizard.synologyChat.setupTitle"),
233
+ lines: SYNOLOGY_SETUP_HELP_LINES
234
+ },
235
+ credentials: [{
236
+ inputKey: "token",
237
+ providerHint: channel,
238
+ credentialLabel: "outgoing webhook token",
239
+ preferredEnvVar: "SYNOLOGY_CHAT_TOKEN",
240
+ helpTitle: t("wizard.synologyChat.webhookTokenTitle"),
241
+ helpLines: SYNOLOGY_SETUP_HELP_LINES,
242
+ envPrompt: t("wizard.synologyChat.tokenEnvPrompt"),
243
+ keepPrompt: t("wizard.synologyChat.tokenKeep"),
244
+ inputPrompt: t("wizard.synologyChat.tokenInput"),
245
+ allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID$1,
246
+ inspect: ({ cfg, accountId }) => {
247
+ const account = resolveAccount(cfg, accountId);
248
+ const raw = getRawAccountConfig(cfg, accountId);
249
+ return {
250
+ accountConfigured: isSynologyChatConfigured(cfg, accountId),
251
+ hasConfiguredValue: Boolean(normalizeOptionalString(raw.token)),
252
+ resolvedValue: normalizeOptionalString(account.token),
253
+ envValue: accountId === DEFAULT_ACCOUNT_ID$1 ? normalizeOptionalString(process.env.SYNOLOGY_CHAT_TOKEN) : void 0
254
+ };
255
+ },
256
+ applyUseEnv: async ({ cfg, accountId }) => patchSynologyChatAccountConfig({
257
+ cfg,
258
+ accountId,
259
+ enabled: true,
260
+ clearFields: ["token"],
261
+ patch: {}
262
+ }),
263
+ applySet: async ({ cfg, accountId, resolvedValue }) => patchSynologyChatAccountConfig({
264
+ cfg,
265
+ accountId,
266
+ enabled: true,
267
+ patch: { token: resolvedValue }
268
+ })
269
+ }],
270
+ textInputs: [{
271
+ inputKey: "url",
272
+ message: t("wizard.synologyChat.incomingWebhookUrlPrompt"),
273
+ placeholder: "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming...",
274
+ helpTitle: t("wizard.synologyChat.incomingWebhookTitle"),
275
+ helpLines: [t("wizard.synologyChat.incomingWebhookHelpUseUrl"), t("wizard.synologyChat.incomingWebhookHelpReplies")],
276
+ currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).incomingUrl?.trim(),
277
+ keepPrompt: (value) => t("wizard.synologyChat.incomingWebhookKeep", { value }),
278
+ validate: ({ value }) => validateWebhookUrl(value),
279
+ applySet: async ({ cfg, accountId, value }) => patchSynologyChatAccountConfig({
280
+ cfg,
281
+ accountId,
282
+ enabled: true,
283
+ patch: { incomingUrl: value.trim() }
284
+ })
285
+ }, {
286
+ inputKey: "webhookPath",
287
+ message: t("wizard.synologyChat.outgoingWebhookPathPrompt"),
288
+ placeholder: DEFAULT_WEBHOOK_PATH,
289
+ required: false,
290
+ applyEmptyValue: true,
291
+ helpTitle: t("wizard.synologyChat.outgoingWebhookPathTitle"),
292
+ helpLines: [t("wizard.synologyChat.defaultPath", { path: DEFAULT_WEBHOOK_PATH }), t("wizard.synologyChat.outgoingWebhookPathHelp")],
293
+ currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).webhookPath?.trim(),
294
+ keepPrompt: (value) => t("wizard.synologyChat.outgoingWebhookPathKeep", { value }),
295
+ validate: ({ value }) => validateWebhookPath(value),
296
+ applySet: async ({ cfg, accountId, value }) => patchSynologyChatAccountConfig({
297
+ cfg,
298
+ accountId,
299
+ enabled: true,
300
+ clearFields: value.trim() ? void 0 : ["webhookPath"],
301
+ patch: value.trim() ? { webhookPath: value.trim() } : {}
302
+ })
303
+ }],
304
+ allowFrom: createAllowFromSection({
305
+ helpTitle: t("wizard.synologyChat.allowlistTitle"),
306
+ helpLines: SYNOLOGY_ALLOW_FROM_HELP_LINES,
307
+ message: t("wizard.synologyChat.allowedUserIdsPrompt"),
308
+ placeholder: "123456, 987654",
309
+ invalidWithoutCredentialNote: t("wizard.synologyChat.allowedUserIdsInvalid"),
310
+ parseInputs: splitSetupEntries,
311
+ parseId: parseSynologyUserId,
312
+ apply: async ({ cfg, accountId, allowFrom }) => patchSynologyChatAccountConfig({
313
+ cfg,
314
+ accountId,
315
+ enabled: true,
316
+ patch: {
317
+ dmPolicy: "allowlist",
318
+ allowedUserIds: mergeAllowFromEntries(resolveExistingAllowedUserIds(cfg, accountId), allowFrom)
319
+ }
320
+ })
321
+ }),
322
+ completionNote: {
323
+ title: t("wizard.synologyChat.accessControlTitle"),
324
+ lines: [
325
+ `Default outgoing webhook path: ${DEFAULT_WEBHOOK_PATH}`,
326
+ "Set allowed user IDs, or manually switch `channels.synology-chat.dmPolicy` to `\"open\"` with `allowedUserIds: [\"*\"]` for public DMs.",
327
+ "With `dmPolicy=\"allowlist\"`, an empty allowedUserIds list blocks the route from starting.",
328
+ `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`
329
+ ]
330
+ },
331
+ disable: (cfg) => setSetupChannelEnabled(cfg, channel, false)
332
+ };
333
+ //#endregion
334
+ export { resolveAccount as i, synologyChatSetupWizard as n, listAccountIds as r, synologyChatSetupAdapter as t };
package/index.ts ADDED
@@ -0,0 +1,16 @@
1
+ import { defineBundledChannelEntry } from "klaw/plugin-sdk/channel-entry-contract";
2
+
3
+ export default defineBundledChannelEntry({
4
+ id: "synology-chat",
5
+ name: "Synology Chat",
6
+ description: "Native Synology Chat channel plugin for Klaw",
7
+ importMetaUrl: import.meta.url,
8
+ plugin: {
9
+ specifier: "./channel-plugin-api.js",
10
+ exportName: "synologyChatPlugin",
11
+ },
12
+ runtime: {
13
+ specifier: "./api.js",
14
+ exportName: "setSynologyRuntime",
15
+ },
16
+ });
package/klaw.plugin.json CHANGED
@@ -3,9 +3,7 @@
3
3
  "activation": {
4
4
  "onStartup": false
5
5
  },
6
- "channels": [
7
- "synology-chat"
8
- ],
6
+ "channels": ["synology-chat"],
9
7
  "channelEnvVars": {
10
8
  "synology-chat": [
11
9
  "SYNOLOGY_CHAT_TOKEN",
@@ -20,24 +18,5 @@
20
18
  "type": "object",
21
19
  "additionalProperties": false,
22
20
  "properties": {}
23
- },
24
- "channelConfigs": {
25
- "synology-chat": {
26
- "schema": {
27
- "$schema": "http://json-schema.org/draft-07/schema#",
28
- "type": "object",
29
- "properties": {
30
- "dangerouslyAllowNameMatching": {
31
- "type": "boolean"
32
- },
33
- "dangerouslyAllowInheritedWebhookPath": {
34
- "type": "boolean"
35
- }
36
- },
37
- "additionalProperties": {}
38
- },
39
- "label": "Synology Chat",
40
- "description": "Connect your Synology NAS Chat to Klaw with full agent capabilities."
41
- }
42
21
  }
43
22
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kodelyth/synology-chat",
3
- "version": "2026.5.39",
3
+ "version": "2026.5.42",
4
4
  "description": "Synology Chat channel plugin for Klaw",
5
5
  "repository": {
6
6
  "type": "git",
@@ -12,9 +12,9 @@
12
12
  },
13
13
  "klaw": {
14
14
  "extensions": [
15
- "./index.js"
15
+ "./index.ts"
16
16
  ],
17
- "setupEntry": "./setup-entry.js",
17
+ "setupEntry": "./setup-entry.ts",
18
18
  "channel": {
19
19
  "id": "synology-chat",
20
20
  "label": "Synology Chat",
package/setup-api.ts ADDED
@@ -0,0 +1 @@
1
+ export { synologyChatSetupAdapter, synologyChatSetupWizard } from "./src/setup-surface.js";
package/setup-entry.ts ADDED
@@ -0,0 +1,9 @@
1
+ import { defineBundledChannelSetupEntry } from "klaw/plugin-sdk/channel-entry-contract";
2
+
3
+ export default defineBundledChannelSetupEntry({
4
+ importMetaUrl: import.meta.url,
5
+ plugin: {
6
+ specifier: "./api.js",
7
+ exportName: "synologyChatPlugin",
8
+ },
9
+ });
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Account resolution: reads config from channels.synology-chat,
3
+ * merges per-account overrides, falls back to environment variables.
4
+ */
5
+
6
+ import {
7
+ DEFAULT_ACCOUNT_ID,
8
+ listCombinedAccountIds,
9
+ resolveMergedAccountConfig,
10
+ type KlawConfig,
11
+ } from "klaw/plugin-sdk/account-resolution";
12
+ import { resolveDangerousNameMatchingEnabled } from "klaw/plugin-sdk/dangerous-name-runtime";
13
+ import type {
14
+ SynologyChatChannelConfig,
15
+ ResolvedSynologyChatAccount,
16
+ SynologyWebhookPathSource,
17
+ } from "./types.js";
18
+
19
+ /** Extract the channel config from the full Klaw config object. */
20
+ function getChannelConfig(cfg: KlawConfig): SynologyChatChannelConfig | undefined {
21
+ return cfg?.channels?.["synology-chat"] as SynologyChatChannelConfig | undefined;
22
+ }
23
+
24
+ function resolveImplicitAccountId(channelCfg: SynologyChatChannelConfig): string | undefined {
25
+ return channelCfg.token || process.env.SYNOLOGY_CHAT_TOKEN ? DEFAULT_ACCOUNT_ID : undefined;
26
+ }
27
+
28
+ function getRawAccountConfig(
29
+ channelCfg: SynologyChatChannelConfig,
30
+ accountId: string,
31
+ ): SynologyChatChannelConfig {
32
+ if (accountId === DEFAULT_ACCOUNT_ID) {
33
+ return channelCfg;
34
+ }
35
+ return channelCfg.accounts?.[accountId] ?? {};
36
+ }
37
+
38
+ function hasExplicitWebhookPath(rawAccount: SynologyChatChannelConfig | undefined): boolean {
39
+ return typeof rawAccount?.webhookPath === "string" && rawAccount.webhookPath.trim().length > 0;
40
+ }
41
+
42
+ function resolveWebhookPathSource(params: {
43
+ accountId: string;
44
+ channelCfg: SynologyChatChannelConfig;
45
+ rawAccount: SynologyChatChannelConfig;
46
+ }): SynologyWebhookPathSource {
47
+ if (hasExplicitWebhookPath(params.rawAccount)) {
48
+ return "explicit";
49
+ }
50
+ if (params.accountId !== DEFAULT_ACCOUNT_ID && hasExplicitWebhookPath(params.channelCfg)) {
51
+ return "inherited-base";
52
+ }
53
+ return "default";
54
+ }
55
+
56
+ /** Parse allowedUserIds from string or array to string[]. */
57
+ function parseAllowedUserIds(raw: string | string[] | undefined): string[] {
58
+ if (!raw) {
59
+ return [];
60
+ }
61
+ if (Array.isArray(raw)) {
62
+ return raw.filter(Boolean);
63
+ }
64
+ return raw
65
+ .split(",")
66
+ .map((s) => s.trim())
67
+ .filter(Boolean);
68
+ }
69
+
70
+ function parseRateLimitPerMinute(raw: string | undefined): number {
71
+ if (raw == null) {
72
+ return 30;
73
+ }
74
+ const trimmed = raw.trim();
75
+ if (!/^-?\d+$/.test(trimmed)) {
76
+ return 30;
77
+ }
78
+ return Number.parseInt(trimmed, 10);
79
+ }
80
+
81
+ /**
82
+ * List all configured account IDs for this channel.
83
+ * Returns ["default"] if there's a base config, plus any named accounts.
84
+ */
85
+ export function listAccountIds(cfg: KlawConfig): string[] {
86
+ const channelCfg = getChannelConfig(cfg);
87
+ if (!channelCfg) {
88
+ return [];
89
+ }
90
+
91
+ return listCombinedAccountIds({
92
+ configuredAccountIds: Object.keys(channelCfg.accounts ?? {}),
93
+ implicitAccountId: resolveImplicitAccountId(channelCfg),
94
+ });
95
+ }
96
+
97
+ /**
98
+ * Resolve a specific account by ID with full defaults applied.
99
+ * Falls back to env vars for the "default" account.
100
+ */
101
+ export function resolveAccount(
102
+ cfg: KlawConfig,
103
+ accountId?: string | null,
104
+ ): ResolvedSynologyChatAccount {
105
+ const channelCfg = getChannelConfig(cfg) ?? {};
106
+ const id = accountId || DEFAULT_ACCOUNT_ID;
107
+ const accountOverrides =
108
+ id === DEFAULT_ACCOUNT_ID ? undefined : (channelCfg.accounts?.[id] ?? undefined);
109
+ const rawAccount = getRawAccountConfig(channelCfg, id);
110
+ const merged = resolveMergedAccountConfig<Record<string, unknown> & SynologyChatChannelConfig>({
111
+ channelConfig: channelCfg as Record<string, unknown> & SynologyChatChannelConfig,
112
+ accounts: channelCfg.accounts as
113
+ | Record<string, Partial<Record<string, unknown> & SynologyChatChannelConfig>>
114
+ | undefined,
115
+ accountId: id,
116
+ });
117
+
118
+ // Env var fallbacks (primarily for the "default" account)
119
+ const envToken = process.env.SYNOLOGY_CHAT_TOKEN ?? "";
120
+ const envIncomingUrl = process.env.SYNOLOGY_CHAT_INCOMING_URL ?? "";
121
+ const envNasHost = process.env.SYNOLOGY_NAS_HOST ?? "localhost";
122
+ const envAllowedUserIds = process.env.SYNOLOGY_ALLOWED_USER_IDS ?? "";
123
+ const envRateLimitValue = parseRateLimitPerMinute(process.env.SYNOLOGY_RATE_LIMIT);
124
+ const envBotName = process.env.KLAW_BOT_NAME ?? "Klaw";
125
+ const webhookPathSource = resolveWebhookPathSource({ accountId: id, channelCfg, rawAccount });
126
+ const dangerouslyAllowInheritedWebhookPath =
127
+ rawAccount.dangerouslyAllowInheritedWebhookPath ??
128
+ channelCfg.dangerouslyAllowInheritedWebhookPath ??
129
+ false;
130
+
131
+ // Merge: account override > base channel config > env var
132
+ return {
133
+ accountId: id,
134
+ enabled: merged.enabled ?? true,
135
+ token: merged.token ?? envToken,
136
+ incomingUrl: merged.incomingUrl ?? envIncomingUrl,
137
+ nasHost: merged.nasHost ?? envNasHost,
138
+ webhookPath: merged.webhookPath ?? "/webhook/synology",
139
+ webhookPathSource,
140
+ dangerouslyAllowNameMatching: resolveDangerousNameMatchingEnabled({
141
+ providerConfig: channelCfg,
142
+ accountConfig: accountOverrides,
143
+ }),
144
+ dangerouslyAllowInheritedWebhookPath,
145
+ dmPolicy: merged.dmPolicy ?? "allowlist",
146
+ allowedUserIds: parseAllowedUserIds(merged.allowedUserIds ?? envAllowedUserIds),
147
+ rateLimitPerMinute: merged.rateLimitPerMinute ?? envRateLimitValue,
148
+ botName: merged.botName ?? envBotName,
149
+ allowInsecureSsl: merged.allowInsecureSsl ?? false,
150
+ };
151
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { synologyChatApprovalAuth } from "./approval-auth.js";
3
+
4
+ describe("synologyChatApprovalAuth", () => {
5
+ it("authorizes numeric Synology Chat user ids", () => {
6
+ const cfg = { channels: { "synology-chat": { allowedUserIds: ["123"] } } };
7
+
8
+ expect(
9
+ synologyChatApprovalAuth.authorizeActorAction({
10
+ cfg,
11
+ senderId: "123",
12
+ action: "approve",
13
+ approvalKind: "plugin",
14
+ }),
15
+ ).toEqual({ authorized: true });
16
+ });
17
+ });
@@ -0,0 +1,22 @@
1
+ import {
2
+ createResolvedApproverActionAuthAdapter,
3
+ resolveApprovalApprovers,
4
+ } from "klaw/plugin-sdk/approval-auth-runtime";
5
+ import { resolveAccount } from "./accounts.js";
6
+
7
+ function normalizeSynologyChatApproverId(value: string | number): string | undefined {
8
+ const trimmed = String(value).trim();
9
+ return /^\d+$/.test(trimmed) ? trimmed : undefined;
10
+ }
11
+
12
+ export const synologyChatApprovalAuth = createResolvedApproverActionAuthAdapter({
13
+ channelLabel: "Synology Chat",
14
+ resolveApprovers: ({ cfg, accountId }) => {
15
+ const account = resolveAccount(cfg ?? {}, accountId);
16
+ return resolveApprovalApprovers({
17
+ allowFrom: account.allowedUserIds,
18
+ normalizeApprover: normalizeSynologyChatApproverId,
19
+ });
20
+ },
21
+ normalizeSenderId: (value) => normalizeSynologyChatApproverId(value),
22
+ });