@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,334 @@
1
+ import {
2
+ createAllowFromSection,
3
+ createSetupTranslator,
4
+ createStandardChannelSetupStatus,
5
+ DEFAULT_ACCOUNT_ID,
6
+ formatDocsLink,
7
+ mergeAllowFromEntries,
8
+ normalizeAccountId,
9
+ setSetupChannelEnabled,
10
+ splitSetupEntries,
11
+ type ChannelSetupAdapter,
12
+ type ChannelSetupWizard,
13
+ type KlawConfig,
14
+ } from "klaw/plugin-sdk/setup";
15
+ import { normalizeOptionalString } from "klaw/plugin-sdk/string-coerce-runtime";
16
+ import { listAccountIds, resolveAccount } from "./accounts.js";
17
+ import type { SynologyChatAccountRaw, SynologyChatChannelConfig } from "./types.js";
18
+
19
+ const t = createSetupTranslator();
20
+
21
+ const channel = "synology-chat" as const;
22
+ const DEFAULT_WEBHOOK_PATH = "/webhook/synology";
23
+
24
+ const SYNOLOGY_SETUP_HELP_LINES = [
25
+ t("wizard.synologyChat.helpIncomingWebhook"),
26
+ t("wizard.synologyChat.helpOutgoingWebhook"),
27
+ t("wizard.synologyChat.helpPointWebhook", { path: DEFAULT_WEBHOOK_PATH }),
28
+ t("wizard.synologyChat.helpAllowedUsers"),
29
+ `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`,
30
+ ];
31
+
32
+ const SYNOLOGY_ALLOW_FROM_HELP_LINES = [
33
+ t("wizard.synologyChat.allowlistIntro"),
34
+ t("wizard.synologyChat.examples"),
35
+ "- 123456",
36
+ "- synology-chat:123456",
37
+ t("wizard.synologyChat.multipleEntries"),
38
+ `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`,
39
+ ];
40
+
41
+ function getChannelConfig(cfg: KlawConfig): SynologyChatChannelConfig {
42
+ return (cfg.channels?.[channel] as SynologyChatChannelConfig | undefined) ?? {};
43
+ }
44
+
45
+ function getRawAccountConfig(cfg: KlawConfig, accountId: string): SynologyChatAccountRaw {
46
+ const channelConfig = getChannelConfig(cfg);
47
+ if (accountId === DEFAULT_ACCOUNT_ID) {
48
+ return channelConfig;
49
+ }
50
+ return channelConfig.accounts?.[accountId] ?? {};
51
+ }
52
+
53
+ function patchSynologyChatAccountConfig(params: {
54
+ cfg: KlawConfig;
55
+ accountId: string;
56
+ patch: Record<string, unknown>;
57
+ clearFields?: string[];
58
+ enabled?: boolean;
59
+ }): KlawConfig {
60
+ const channelConfig = getChannelConfig(params.cfg);
61
+ if (params.accountId === DEFAULT_ACCOUNT_ID) {
62
+ const nextChannelConfig = { ...channelConfig } as Record<string, unknown>;
63
+ for (const field of params.clearFields ?? []) {
64
+ delete nextChannelConfig[field];
65
+ }
66
+ return {
67
+ ...params.cfg,
68
+ channels: {
69
+ ...params.cfg.channels,
70
+ [channel]: {
71
+ ...nextChannelConfig,
72
+ ...(params.enabled ? { enabled: true } : {}),
73
+ ...params.patch,
74
+ },
75
+ },
76
+ };
77
+ }
78
+
79
+ const nextAccounts = { ...channelConfig.accounts } as Record<string, Record<string, unknown>>;
80
+ const nextAccountConfig = { ...nextAccounts[params.accountId] };
81
+ for (const field of params.clearFields ?? []) {
82
+ delete nextAccountConfig[field];
83
+ }
84
+ nextAccounts[params.accountId] = {
85
+ ...nextAccountConfig,
86
+ ...(params.enabled ? { enabled: true } : {}),
87
+ ...params.patch,
88
+ };
89
+
90
+ return {
91
+ ...params.cfg,
92
+ channels: {
93
+ ...params.cfg.channels,
94
+ [channel]: {
95
+ ...channelConfig,
96
+ ...(params.enabled ? { enabled: true } : {}),
97
+ accounts: nextAccounts,
98
+ },
99
+ },
100
+ };
101
+ }
102
+
103
+ function isSynologyChatConfigured(cfg: KlawConfig, accountId: string): boolean {
104
+ const account = resolveAccount(cfg, accountId);
105
+ return Boolean(account.token.trim() && account.incomingUrl.trim());
106
+ }
107
+
108
+ function validateWebhookUrl(value: string): string | undefined {
109
+ try {
110
+ const parsed = new URL(value);
111
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
112
+ return "Incoming webhook must use http:// or https://.";
113
+ }
114
+ } catch {
115
+ return "Incoming webhook must be a valid URL.";
116
+ }
117
+ return undefined;
118
+ }
119
+
120
+ function validateWebhookPath(value: string): string | undefined {
121
+ const trimmed = value.trim();
122
+ if (!trimmed) {
123
+ return undefined;
124
+ }
125
+ return trimmed.startsWith("/") ? undefined : "Webhook path must start with /.";
126
+ }
127
+
128
+ function parseSynologyUserId(value: string): string | null {
129
+ const cleaned = value.replace(/^synology(?:[-_]?chat)?:/i, "").trim();
130
+ return /^\d+$/.test(cleaned) ? cleaned : null;
131
+ }
132
+
133
+ function normalizeSynologyAllowedUserId(value: unknown): string {
134
+ if (
135
+ typeof value === "string" ||
136
+ typeof value === "number" ||
137
+ typeof value === "boolean" ||
138
+ typeof value === "bigint"
139
+ ) {
140
+ return `${value}`.trim();
141
+ }
142
+ return "";
143
+ }
144
+
145
+ function resolveExistingAllowedUserIds(cfg: KlawConfig, accountId: string): string[] {
146
+ const raw = getRawAccountConfig(cfg, accountId).allowedUserIds;
147
+ if (Array.isArray(raw)) {
148
+ return raw.map(normalizeSynologyAllowedUserId).filter(Boolean);
149
+ }
150
+ return normalizeSynologyAllowedUserId(raw)
151
+ .split(",")
152
+ .map((value) => value.trim())
153
+ .filter(Boolean);
154
+ }
155
+
156
+ export const synologyChatSetupAdapter: ChannelSetupAdapter = {
157
+ resolveAccountId: ({ accountId }) => normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID,
158
+ validateInput: ({ accountId, input }) => {
159
+ if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) {
160
+ return "Synology Chat env credentials only support the default account.";
161
+ }
162
+ if (!input.useEnv && !input.token?.trim()) {
163
+ return "Synology Chat requires --token or --use-env.";
164
+ }
165
+ if (!input.url?.trim()) {
166
+ return "Synology Chat requires --url for the incoming webhook.";
167
+ }
168
+ const urlError = validateWebhookUrl(input.url.trim());
169
+ if (urlError) {
170
+ return urlError;
171
+ }
172
+ if (input.webhookPath?.trim()) {
173
+ return validateWebhookPath(input.webhookPath.trim()) ?? null;
174
+ }
175
+ return null;
176
+ },
177
+ applyAccountConfig: ({ cfg, accountId, input }) =>
178
+ patchSynologyChatAccountConfig({
179
+ cfg,
180
+ accountId,
181
+ enabled: true,
182
+ clearFields: input.useEnv ? ["token"] : undefined,
183
+ patch: {
184
+ ...(input.useEnv ? {} : { token: input.token?.trim() }),
185
+ incomingUrl: input.url?.trim(),
186
+ ...(input.webhookPath?.trim() ? { webhookPath: input.webhookPath.trim() } : {}),
187
+ },
188
+ }),
189
+ };
190
+
191
+ export const synologyChatSetupWizard: ChannelSetupWizard = {
192
+ channel,
193
+ status: createStandardChannelSetupStatus({
194
+ channelLabel: "Synology Chat",
195
+ configuredLabel: t("wizard.channels.statusConfigured"),
196
+ unconfiguredLabel: t("wizard.channels.statusNeedsTokenIncomingWebhook"),
197
+ configuredHint: t("wizard.channels.statusConfigured"),
198
+ unconfiguredHint: t("wizard.channels.statusNeedsTokenIncomingWebhook"),
199
+ configuredScore: 1,
200
+ unconfiguredScore: 0,
201
+ includeStatusLine: true,
202
+ resolveConfigured: ({ cfg, accountId }) =>
203
+ accountId
204
+ ? isSynologyChatConfigured(cfg, accountId)
205
+ : listAccountIds(cfg).some((candidateAccountId) =>
206
+ isSynologyChatConfigured(cfg, candidateAccountId),
207
+ ),
208
+ resolveExtraStatusLines: ({ cfg }) => [`Accounts: ${listAccountIds(cfg).length || 0}`],
209
+ }),
210
+ introNote: {
211
+ title: t("wizard.synologyChat.setupTitle"),
212
+ lines: SYNOLOGY_SETUP_HELP_LINES,
213
+ },
214
+ credentials: [
215
+ {
216
+ inputKey: "token",
217
+ providerHint: channel,
218
+ credentialLabel: "outgoing webhook token",
219
+ preferredEnvVar: "SYNOLOGY_CHAT_TOKEN",
220
+ helpTitle: t("wizard.synologyChat.webhookTokenTitle"),
221
+ helpLines: SYNOLOGY_SETUP_HELP_LINES,
222
+ envPrompt: t("wizard.synologyChat.tokenEnvPrompt"),
223
+ keepPrompt: t("wizard.synologyChat.tokenKeep"),
224
+ inputPrompt: t("wizard.synologyChat.tokenInput"),
225
+ allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID,
226
+ inspect: ({ cfg, accountId }) => {
227
+ const account = resolveAccount(cfg, accountId);
228
+ const raw = getRawAccountConfig(cfg, accountId);
229
+ return {
230
+ accountConfigured: isSynologyChatConfigured(cfg, accountId),
231
+ hasConfiguredValue: Boolean(normalizeOptionalString(raw.token)),
232
+ resolvedValue: normalizeOptionalString(account.token),
233
+ envValue:
234
+ accountId === DEFAULT_ACCOUNT_ID
235
+ ? normalizeOptionalString(process.env.SYNOLOGY_CHAT_TOKEN)
236
+ : undefined,
237
+ };
238
+ },
239
+ applyUseEnv: async ({ cfg, accountId }) =>
240
+ patchSynologyChatAccountConfig({
241
+ cfg,
242
+ accountId,
243
+ enabled: true,
244
+ clearFields: ["token"],
245
+ patch: {},
246
+ }),
247
+ applySet: async ({ cfg, accountId, resolvedValue }) =>
248
+ patchSynologyChatAccountConfig({
249
+ cfg,
250
+ accountId,
251
+ enabled: true,
252
+ patch: { token: resolvedValue },
253
+ }),
254
+ },
255
+ ],
256
+ textInputs: [
257
+ {
258
+ inputKey: "url",
259
+ message: t("wizard.synologyChat.incomingWebhookUrlPrompt"),
260
+ placeholder:
261
+ "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming...",
262
+ helpTitle: t("wizard.synologyChat.incomingWebhookTitle"),
263
+ helpLines: [
264
+ t("wizard.synologyChat.incomingWebhookHelpUseUrl"),
265
+ t("wizard.synologyChat.incomingWebhookHelpReplies"),
266
+ ],
267
+ currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).incomingUrl?.trim(),
268
+ keepPrompt: (value) => t("wizard.synologyChat.incomingWebhookKeep", { value }),
269
+ validate: ({ value }) => validateWebhookUrl(value),
270
+ applySet: async ({ cfg, accountId, value }) =>
271
+ patchSynologyChatAccountConfig({
272
+ cfg,
273
+ accountId,
274
+ enabled: true,
275
+ patch: { incomingUrl: value.trim() },
276
+ }),
277
+ },
278
+ {
279
+ inputKey: "webhookPath",
280
+ message: t("wizard.synologyChat.outgoingWebhookPathPrompt"),
281
+ placeholder: DEFAULT_WEBHOOK_PATH,
282
+ required: false,
283
+ applyEmptyValue: true,
284
+ helpTitle: t("wizard.synologyChat.outgoingWebhookPathTitle"),
285
+ helpLines: [
286
+ t("wizard.synologyChat.defaultPath", { path: DEFAULT_WEBHOOK_PATH }),
287
+ t("wizard.synologyChat.outgoingWebhookPathHelp"),
288
+ ],
289
+ currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).webhookPath?.trim(),
290
+ keepPrompt: (value) => t("wizard.synologyChat.outgoingWebhookPathKeep", { value }),
291
+ validate: ({ value }) => validateWebhookPath(value),
292
+ applySet: async ({ cfg, accountId, value }) =>
293
+ patchSynologyChatAccountConfig({
294
+ cfg,
295
+ accountId,
296
+ enabled: true,
297
+ clearFields: value.trim() ? undefined : ["webhookPath"],
298
+ patch: value.trim() ? { webhookPath: value.trim() } : {},
299
+ }),
300
+ },
301
+ ],
302
+ allowFrom: createAllowFromSection({
303
+ helpTitle: t("wizard.synologyChat.allowlistTitle"),
304
+ helpLines: SYNOLOGY_ALLOW_FROM_HELP_LINES,
305
+ message: t("wizard.synologyChat.allowedUserIdsPrompt"),
306
+ placeholder: "123456, 987654",
307
+ invalidWithoutCredentialNote: t("wizard.synologyChat.allowedUserIdsInvalid"),
308
+ parseInputs: splitSetupEntries,
309
+ parseId: parseSynologyUserId,
310
+ apply: async ({ cfg, accountId, allowFrom }) =>
311
+ patchSynologyChatAccountConfig({
312
+ cfg,
313
+ accountId,
314
+ enabled: true,
315
+ patch: {
316
+ dmPolicy: "allowlist",
317
+ allowedUserIds: mergeAllowFromEntries(
318
+ resolveExistingAllowedUserIds(cfg, accountId),
319
+ allowFrom,
320
+ ),
321
+ },
322
+ }),
323
+ }),
324
+ completionNote: {
325
+ title: t("wizard.synologyChat.accessControlTitle"),
326
+ lines: [
327
+ `Default outgoing webhook path: ${DEFAULT_WEBHOOK_PATH}`,
328
+ 'Set allowed user IDs, or manually switch `channels.synology-chat.dmPolicy` to `"open"` with `allowedUserIds: ["*"]` for public DMs.',
329
+ 'With `dmPolicy="allowlist"`, an empty allowedUserIds list blocks the route from starting.',
330
+ `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`,
331
+ ],
332
+ },
333
+ disable: (cfg) => setSetupChannelEnabled(cfg, channel, false),
334
+ };
@@ -0,0 +1,75 @@
1
+ import { EventEmitter } from "node:events";
2
+ import type { IncomingMessage, ServerResponse } from "node:http";
3
+
4
+ function makeBaseReq(
5
+ method: string,
6
+ opts: { headers?: Record<string, string>; url?: string } = {},
7
+ ): IncomingMessage & { destroyed: boolean } {
8
+ const req = new EventEmitter() as IncomingMessage & { destroyed: boolean };
9
+ req.method = method;
10
+ req.headers = opts.headers ?? {};
11
+ req.url = opts.url ?? "/webhook/synology";
12
+ req.socket = { remoteAddress: "127.0.0.1" } as unknown as IncomingMessage["socket"];
13
+ req.destroyed = false;
14
+ req.destroy = ((_: Error | undefined) => {
15
+ if (req.destroyed) {
16
+ return req;
17
+ }
18
+ req.destroyed = true;
19
+ return req;
20
+ }) as IncomingMessage["destroy"];
21
+ return req;
22
+ }
23
+
24
+ export function makeReq(
25
+ method: string,
26
+ body: string,
27
+ opts: { headers?: Record<string, string>; url?: string } = {},
28
+ ): IncomingMessage {
29
+ const req = makeBaseReq(method, opts);
30
+ process.nextTick(() => {
31
+ if (req.destroyed) {
32
+ return;
33
+ }
34
+ req.emit("data", Buffer.from(body));
35
+ req.emit("end");
36
+ });
37
+ return req;
38
+ }
39
+
40
+ export function makeStalledReq(
41
+ method: string,
42
+ opts: { headers?: Record<string, string>; url?: string } = {},
43
+ ): IncomingMessage {
44
+ return makeBaseReq(method, opts);
45
+ }
46
+
47
+ export function makeRes(): ServerResponse & { status: number; body: string } {
48
+ const res = {
49
+ status: 0,
50
+ body: "",
51
+ writeHead(statusCode: number, _headers: Record<string, string>) {
52
+ res.status = statusCode;
53
+ },
54
+ end(body?: string) {
55
+ res.body = body ?? "";
56
+ },
57
+ } as unknown as ServerResponse & { status: number; body: string };
58
+ Object.defineProperty(res, "statusCode", {
59
+ configurable: true,
60
+ enumerable: true,
61
+ get() {
62
+ return res.status;
63
+ },
64
+ set(value: number) {
65
+ res.status = value;
66
+ },
67
+ });
68
+ return res;
69
+ }
70
+
71
+ export function makeFormBody(fields: Record<string, string>): string {
72
+ return Object.entries(fields)
73
+ .map(([k, v]) => `${encodeURIComponent(k)}=${encodeURIComponent(v)}`)
74
+ .join("&");
75
+ }
package/src/types.ts ADDED
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Type definitions for the Synology Chat channel plugin.
3
+ */
4
+
5
+ type SynologyChatConfigFields = {
6
+ enabled?: boolean;
7
+ token?: string;
8
+ incomingUrl?: string;
9
+ nasHost?: string;
10
+ webhookPath?: string;
11
+ dangerouslyAllowNameMatching?: boolean;
12
+ dangerouslyAllowInheritedWebhookPath?: boolean;
13
+ dmPolicy?: "open" | "allowlist" | "disabled";
14
+ allowedUserIds?: string | string[];
15
+ rateLimitPerMinute?: number;
16
+ botName?: string;
17
+ allowInsecureSsl?: boolean;
18
+ };
19
+
20
+ export type SynologyWebhookPathSource = "default" | "inherited-base" | "explicit";
21
+
22
+ /** Raw channel config from klaw.json channels.synology-chat */
23
+ export interface SynologyChatChannelConfig extends SynologyChatConfigFields {
24
+ accounts?: Record<string, SynologyChatAccountRaw>;
25
+ }
26
+
27
+ /** Raw per-account config (overrides base config) */
28
+ export interface SynologyChatAccountRaw extends SynologyChatConfigFields {}
29
+
30
+ /** Fully resolved account config with defaults applied */
31
+ export interface ResolvedSynologyChatAccount {
32
+ accountId: string;
33
+ enabled: boolean;
34
+ token: string;
35
+ incomingUrl: string;
36
+ nasHost: string;
37
+ webhookPath: string;
38
+ webhookPathSource: SynologyWebhookPathSource;
39
+ dangerouslyAllowNameMatching: boolean;
40
+ dangerouslyAllowInheritedWebhookPath: boolean;
41
+ dmPolicy: "open" | "allowlist" | "disabled";
42
+ allowedUserIds: string[];
43
+ rateLimitPerMinute: number;
44
+ botName: string;
45
+ allowInsecureSsl: boolean;
46
+ }
47
+
48
+ /** Payload received from Synology Chat outgoing webhook (form-urlencoded) */
49
+ export interface SynologyWebhookPayload {
50
+ token: string;
51
+ channel_id?: string;
52
+ channel_name?: string;
53
+ user_id: string;
54
+ username: string;
55
+ post_id?: string;
56
+ timestamp?: string;
57
+ text: string;
58
+ trigger_word?: string;
59
+ }