@openclaw/synology-chat 2026.5.2 → 2026.5.3-beta.2
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/dist/index.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { defineBundledChannelEntry } from "openclaw/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 OpenClaw",
|
|
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,11 @@
|
|
|
1
|
+
import { defineBundledChannelSetupEntry } from "openclaw/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,336 @@
|
|
|
1
|
+
import { DEFAULT_ACCOUNT_ID, listCombinedAccountIds, resolveMergedAccountConfig } from "openclaw/plugin-sdk/account-resolution";
|
|
2
|
+
import { resolveDangerousNameMatchingEnabled } from "openclaw/plugin-sdk/dangerous-name-runtime";
|
|
3
|
+
import { DEFAULT_ACCOUNT_ID as DEFAULT_ACCOUNT_ID$1, createAllowFromSection, createStandardChannelSetupStatus, formatDocsLink, mergeAllowFromEntries, normalizeAccountId, setSetupChannelEnabled, splitSetupEntries } from "openclaw/plugin-sdk/setup";
|
|
4
|
+
//#region extensions/synology-chat/src/accounts.ts
|
|
5
|
+
/**
|
|
6
|
+
* Account resolution: reads config from channels.synology-chat,
|
|
7
|
+
* merges per-account overrides, falls back to environment variables.
|
|
8
|
+
*/
|
|
9
|
+
/** Extract the channel config from the full OpenClaw config object. */
|
|
10
|
+
function getChannelConfig$1(cfg) {
|
|
11
|
+
return cfg?.channels?.["synology-chat"];
|
|
12
|
+
}
|
|
13
|
+
function resolveImplicitAccountId(channelCfg) {
|
|
14
|
+
return channelCfg.token || process.env.SYNOLOGY_CHAT_TOKEN ? DEFAULT_ACCOUNT_ID : void 0;
|
|
15
|
+
}
|
|
16
|
+
function getRawAccountConfig$1(channelCfg, accountId) {
|
|
17
|
+
if (accountId === DEFAULT_ACCOUNT_ID) return channelCfg;
|
|
18
|
+
return channelCfg.accounts?.[accountId] ?? {};
|
|
19
|
+
}
|
|
20
|
+
function hasExplicitWebhookPath(rawAccount) {
|
|
21
|
+
return typeof rawAccount?.webhookPath === "string" && rawAccount.webhookPath.trim().length > 0;
|
|
22
|
+
}
|
|
23
|
+
function resolveWebhookPathSource(params) {
|
|
24
|
+
if (hasExplicitWebhookPath(params.rawAccount)) return "explicit";
|
|
25
|
+
if (params.accountId !== DEFAULT_ACCOUNT_ID && hasExplicitWebhookPath(params.channelCfg)) return "inherited-base";
|
|
26
|
+
return "default";
|
|
27
|
+
}
|
|
28
|
+
/** Parse allowedUserIds from string or array to string[]. */
|
|
29
|
+
function parseAllowedUserIds(raw) {
|
|
30
|
+
if (!raw) return [];
|
|
31
|
+
if (Array.isArray(raw)) return raw.filter(Boolean);
|
|
32
|
+
return raw.split(",").map((s) => s.trim()).filter(Boolean);
|
|
33
|
+
}
|
|
34
|
+
function parseRateLimitPerMinute(raw) {
|
|
35
|
+
if (raw == null) return 30;
|
|
36
|
+
const trimmed = raw.trim();
|
|
37
|
+
if (!/^-?\d+$/.test(trimmed)) return 30;
|
|
38
|
+
return Number.parseInt(trimmed, 10);
|
|
39
|
+
}
|
|
40
|
+
/**
|
|
41
|
+
* List all configured account IDs for this channel.
|
|
42
|
+
* Returns ["default"] if there's a base config, plus any named accounts.
|
|
43
|
+
*/
|
|
44
|
+
function listAccountIds(cfg) {
|
|
45
|
+
const channelCfg = getChannelConfig$1(cfg);
|
|
46
|
+
if (!channelCfg) return [];
|
|
47
|
+
return listCombinedAccountIds({
|
|
48
|
+
configuredAccountIds: Object.keys(channelCfg.accounts ?? {}),
|
|
49
|
+
implicitAccountId: resolveImplicitAccountId(channelCfg)
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Resolve a specific account by ID with full defaults applied.
|
|
54
|
+
* Falls back to env vars for the "default" account.
|
|
55
|
+
*/
|
|
56
|
+
function resolveAccount(cfg, accountId) {
|
|
57
|
+
const channelCfg = getChannelConfig$1(cfg) ?? {};
|
|
58
|
+
const id = accountId || DEFAULT_ACCOUNT_ID;
|
|
59
|
+
const accountOverrides = id === DEFAULT_ACCOUNT_ID ? void 0 : channelCfg.accounts?.[id] ?? void 0;
|
|
60
|
+
const rawAccount = getRawAccountConfig$1(channelCfg, id);
|
|
61
|
+
const merged = resolveMergedAccountConfig({
|
|
62
|
+
channelConfig: channelCfg,
|
|
63
|
+
accounts: channelCfg.accounts,
|
|
64
|
+
accountId: id
|
|
65
|
+
});
|
|
66
|
+
const envToken = process.env.SYNOLOGY_CHAT_TOKEN ?? "";
|
|
67
|
+
const envIncomingUrl = process.env.SYNOLOGY_CHAT_INCOMING_URL ?? "";
|
|
68
|
+
const envNasHost = process.env.SYNOLOGY_NAS_HOST ?? "localhost";
|
|
69
|
+
const envAllowedUserIds = process.env.SYNOLOGY_ALLOWED_USER_IDS ?? "";
|
|
70
|
+
const envRateLimitValue = parseRateLimitPerMinute(process.env.SYNOLOGY_RATE_LIMIT);
|
|
71
|
+
const envBotName = process.env.OPENCLAW_BOT_NAME ?? "OpenClaw";
|
|
72
|
+
const webhookPathSource = resolveWebhookPathSource({
|
|
73
|
+
accountId: id,
|
|
74
|
+
channelCfg,
|
|
75
|
+
rawAccount
|
|
76
|
+
});
|
|
77
|
+
const dangerouslyAllowInheritedWebhookPath = rawAccount.dangerouslyAllowInheritedWebhookPath ?? channelCfg.dangerouslyAllowInheritedWebhookPath ?? false;
|
|
78
|
+
return {
|
|
79
|
+
accountId: id,
|
|
80
|
+
enabled: merged.enabled ?? true,
|
|
81
|
+
token: merged.token ?? envToken,
|
|
82
|
+
incomingUrl: merged.incomingUrl ?? envIncomingUrl,
|
|
83
|
+
nasHost: merged.nasHost ?? envNasHost,
|
|
84
|
+
webhookPath: merged.webhookPath ?? "/webhook/synology",
|
|
85
|
+
webhookPathSource,
|
|
86
|
+
dangerouslyAllowNameMatching: resolveDangerousNameMatchingEnabled({
|
|
87
|
+
providerConfig: channelCfg,
|
|
88
|
+
accountConfig: accountOverrides
|
|
89
|
+
}),
|
|
90
|
+
dangerouslyAllowInheritedWebhookPath,
|
|
91
|
+
dmPolicy: merged.dmPolicy ?? "allowlist",
|
|
92
|
+
allowedUserIds: parseAllowedUserIds(merged.allowedUserIds ?? envAllowedUserIds),
|
|
93
|
+
rateLimitPerMinute: merged.rateLimitPerMinute ?? envRateLimitValue,
|
|
94
|
+
botName: merged.botName ?? envBotName,
|
|
95
|
+
allowInsecureSsl: merged.allowInsecureSsl ?? false
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
//#endregion
|
|
99
|
+
//#region extensions/synology-chat/src/setup-surface.ts
|
|
100
|
+
const channel = "synology-chat";
|
|
101
|
+
const DEFAULT_WEBHOOK_PATH = "/webhook/synology";
|
|
102
|
+
const SYNOLOGY_SETUP_HELP_LINES = [
|
|
103
|
+
"1) Create an incoming webhook in Synology Chat and copy its URL",
|
|
104
|
+
"2) Create an outgoing webhook and copy its secret token",
|
|
105
|
+
`3) Point the outgoing webhook to https://<gateway-host>${DEFAULT_WEBHOOK_PATH}`,
|
|
106
|
+
"4) Keep allowed user IDs handy for DM allowlisting",
|
|
107
|
+
`Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`
|
|
108
|
+
];
|
|
109
|
+
const SYNOLOGY_ALLOW_FROM_HELP_LINES = [
|
|
110
|
+
"Allowlist Synology Chat DMs by numeric user id.",
|
|
111
|
+
"Examples:",
|
|
112
|
+
"- 123456",
|
|
113
|
+
"- synology-chat:123456",
|
|
114
|
+
"Multiple entries: comma-separated.",
|
|
115
|
+
`Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`
|
|
116
|
+
];
|
|
117
|
+
function normalizeOptionalString(value) {
|
|
118
|
+
if (typeof value !== "string") return;
|
|
119
|
+
return value.trim() || void 0;
|
|
120
|
+
}
|
|
121
|
+
function getChannelConfig(cfg) {
|
|
122
|
+
return cfg.channels?.[channel] ?? {};
|
|
123
|
+
}
|
|
124
|
+
function getRawAccountConfig(cfg, accountId) {
|
|
125
|
+
const channelConfig = getChannelConfig(cfg);
|
|
126
|
+
if (accountId === DEFAULT_ACCOUNT_ID$1) return channelConfig;
|
|
127
|
+
return channelConfig.accounts?.[accountId] ?? {};
|
|
128
|
+
}
|
|
129
|
+
function patchSynologyChatAccountConfig(params) {
|
|
130
|
+
const channelConfig = getChannelConfig(params.cfg);
|
|
131
|
+
if (params.accountId === DEFAULT_ACCOUNT_ID$1) {
|
|
132
|
+
const nextChannelConfig = { ...channelConfig };
|
|
133
|
+
for (const field of params.clearFields ?? []) delete nextChannelConfig[field];
|
|
134
|
+
return {
|
|
135
|
+
...params.cfg,
|
|
136
|
+
channels: {
|
|
137
|
+
...params.cfg.channels,
|
|
138
|
+
[channel]: {
|
|
139
|
+
...nextChannelConfig,
|
|
140
|
+
...params.enabled ? { enabled: true } : {},
|
|
141
|
+
...params.patch
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
}
|
|
146
|
+
const nextAccounts = { ...channelConfig.accounts };
|
|
147
|
+
const nextAccountConfig = { ...nextAccounts[params.accountId] };
|
|
148
|
+
for (const field of params.clearFields ?? []) delete nextAccountConfig[field];
|
|
149
|
+
nextAccounts[params.accountId] = {
|
|
150
|
+
...nextAccountConfig,
|
|
151
|
+
...params.enabled ? { enabled: true } : {},
|
|
152
|
+
...params.patch
|
|
153
|
+
};
|
|
154
|
+
return {
|
|
155
|
+
...params.cfg,
|
|
156
|
+
channels: {
|
|
157
|
+
...params.cfg.channels,
|
|
158
|
+
[channel]: {
|
|
159
|
+
...channelConfig,
|
|
160
|
+
...params.enabled ? { enabled: true } : {},
|
|
161
|
+
accounts: nextAccounts
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
function isSynologyChatConfigured(cfg, accountId) {
|
|
167
|
+
const account = resolveAccount(cfg, accountId);
|
|
168
|
+
return Boolean(account.token.trim() && account.incomingUrl.trim());
|
|
169
|
+
}
|
|
170
|
+
function validateWebhookUrl(value) {
|
|
171
|
+
try {
|
|
172
|
+
const parsed = new URL(value);
|
|
173
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return "Incoming webhook must use http:// or https://.";
|
|
174
|
+
} catch {
|
|
175
|
+
return "Incoming webhook must be a valid URL.";
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function validateWebhookPath(value) {
|
|
179
|
+
const trimmed = value.trim();
|
|
180
|
+
if (!trimmed) return;
|
|
181
|
+
return trimmed.startsWith("/") ? void 0 : "Webhook path must start with /.";
|
|
182
|
+
}
|
|
183
|
+
function parseSynologyUserId(value) {
|
|
184
|
+
const cleaned = value.replace(/^synology(?:[-_]?chat)?:/i, "").trim();
|
|
185
|
+
return /^\d+$/.test(cleaned) ? cleaned : null;
|
|
186
|
+
}
|
|
187
|
+
function normalizeSynologyAllowedUserId(value) {
|
|
188
|
+
if (typeof value === "string" || typeof value === "number" || typeof value === "boolean" || typeof value === "bigint") return `${value}`.trim();
|
|
189
|
+
return "";
|
|
190
|
+
}
|
|
191
|
+
function resolveExistingAllowedUserIds(cfg, accountId) {
|
|
192
|
+
const raw = getRawAccountConfig(cfg, accountId).allowedUserIds;
|
|
193
|
+
if (Array.isArray(raw)) return raw.map(normalizeSynologyAllowedUserId).filter(Boolean);
|
|
194
|
+
return normalizeSynologyAllowedUserId(raw).split(",").map((value) => value.trim()).filter(Boolean);
|
|
195
|
+
}
|
|
196
|
+
const synologyChatSetupAdapter = {
|
|
197
|
+
resolveAccountId: ({ accountId }) => normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID$1,
|
|
198
|
+
validateInput: ({ accountId, input }) => {
|
|
199
|
+
if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID$1) return "Synology Chat env credentials only support the default account.";
|
|
200
|
+
if (!input.useEnv && !input.token?.trim()) return "Synology Chat requires --token or --use-env.";
|
|
201
|
+
if (!input.url?.trim()) return "Synology Chat requires --url for the incoming webhook.";
|
|
202
|
+
const urlError = validateWebhookUrl(input.url.trim());
|
|
203
|
+
if (urlError) return urlError;
|
|
204
|
+
if (input.webhookPath?.trim()) return validateWebhookPath(input.webhookPath.trim()) ?? null;
|
|
205
|
+
return null;
|
|
206
|
+
},
|
|
207
|
+
applyAccountConfig: ({ cfg, accountId, input }) => patchSynologyChatAccountConfig({
|
|
208
|
+
cfg,
|
|
209
|
+
accountId,
|
|
210
|
+
enabled: true,
|
|
211
|
+
clearFields: input.useEnv ? ["token"] : void 0,
|
|
212
|
+
patch: {
|
|
213
|
+
...input.useEnv ? {} : { token: input.token?.trim() },
|
|
214
|
+
incomingUrl: input.url?.trim(),
|
|
215
|
+
...input.webhookPath?.trim() ? { webhookPath: input.webhookPath.trim() } : {}
|
|
216
|
+
}
|
|
217
|
+
})
|
|
218
|
+
};
|
|
219
|
+
const synologyChatSetupWizard = {
|
|
220
|
+
channel,
|
|
221
|
+
status: createStandardChannelSetupStatus({
|
|
222
|
+
channelLabel: "Synology Chat",
|
|
223
|
+
configuredLabel: "configured",
|
|
224
|
+
unconfiguredLabel: "needs token + incoming webhook",
|
|
225
|
+
configuredHint: "configured",
|
|
226
|
+
unconfiguredHint: "needs token + incoming webhook",
|
|
227
|
+
configuredScore: 1,
|
|
228
|
+
unconfiguredScore: 0,
|
|
229
|
+
includeStatusLine: true,
|
|
230
|
+
resolveConfigured: ({ cfg, accountId }) => accountId ? isSynologyChatConfigured(cfg, accountId) : listAccountIds(cfg).some((candidateAccountId) => isSynologyChatConfigured(cfg, candidateAccountId)),
|
|
231
|
+
resolveExtraStatusLines: ({ cfg }) => [`Accounts: ${listAccountIds(cfg).length || 0}`]
|
|
232
|
+
}),
|
|
233
|
+
introNote: {
|
|
234
|
+
title: "Synology Chat webhook setup",
|
|
235
|
+
lines: SYNOLOGY_SETUP_HELP_LINES
|
|
236
|
+
},
|
|
237
|
+
credentials: [{
|
|
238
|
+
inputKey: "token",
|
|
239
|
+
providerHint: channel,
|
|
240
|
+
credentialLabel: "outgoing webhook token",
|
|
241
|
+
preferredEnvVar: "SYNOLOGY_CHAT_TOKEN",
|
|
242
|
+
helpTitle: "Synology Chat webhook token",
|
|
243
|
+
helpLines: SYNOLOGY_SETUP_HELP_LINES,
|
|
244
|
+
envPrompt: "SYNOLOGY_CHAT_TOKEN detected. Use env var?",
|
|
245
|
+
keepPrompt: "Synology Chat webhook token already configured. Keep it?",
|
|
246
|
+
inputPrompt: "Enter Synology Chat outgoing webhook token",
|
|
247
|
+
allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID$1,
|
|
248
|
+
inspect: ({ cfg, accountId }) => {
|
|
249
|
+
const account = resolveAccount(cfg, accountId);
|
|
250
|
+
const raw = getRawAccountConfig(cfg, accountId);
|
|
251
|
+
return {
|
|
252
|
+
accountConfigured: isSynologyChatConfigured(cfg, accountId),
|
|
253
|
+
hasConfiguredValue: Boolean(normalizeOptionalString(raw.token)),
|
|
254
|
+
resolvedValue: normalizeOptionalString(account.token),
|
|
255
|
+
envValue: accountId === DEFAULT_ACCOUNT_ID$1 ? normalizeOptionalString(process.env.SYNOLOGY_CHAT_TOKEN) : void 0
|
|
256
|
+
};
|
|
257
|
+
},
|
|
258
|
+
applyUseEnv: async ({ cfg, accountId }) => patchSynologyChatAccountConfig({
|
|
259
|
+
cfg,
|
|
260
|
+
accountId,
|
|
261
|
+
enabled: true,
|
|
262
|
+
clearFields: ["token"],
|
|
263
|
+
patch: {}
|
|
264
|
+
}),
|
|
265
|
+
applySet: async ({ cfg, accountId, resolvedValue }) => patchSynologyChatAccountConfig({
|
|
266
|
+
cfg,
|
|
267
|
+
accountId,
|
|
268
|
+
enabled: true,
|
|
269
|
+
patch: { token: resolvedValue }
|
|
270
|
+
})
|
|
271
|
+
}],
|
|
272
|
+
textInputs: [{
|
|
273
|
+
inputKey: "url",
|
|
274
|
+
message: "Incoming webhook URL",
|
|
275
|
+
placeholder: "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming...",
|
|
276
|
+
helpTitle: "Synology Chat incoming webhook",
|
|
277
|
+
helpLines: ["Use the incoming webhook URL from Synology Chat integrations.", "This is the URL OpenClaw uses to send replies back to Chat."],
|
|
278
|
+
currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).incomingUrl?.trim(),
|
|
279
|
+
keepPrompt: (value) => `Incoming webhook URL set (${value}). Keep it?`,
|
|
280
|
+
validate: ({ value }) => validateWebhookUrl(value),
|
|
281
|
+
applySet: async ({ cfg, accountId, value }) => patchSynologyChatAccountConfig({
|
|
282
|
+
cfg,
|
|
283
|
+
accountId,
|
|
284
|
+
enabled: true,
|
|
285
|
+
patch: { incomingUrl: value.trim() }
|
|
286
|
+
})
|
|
287
|
+
}, {
|
|
288
|
+
inputKey: "webhookPath",
|
|
289
|
+
message: "Outgoing webhook path (optional)",
|
|
290
|
+
placeholder: DEFAULT_WEBHOOK_PATH,
|
|
291
|
+
required: false,
|
|
292
|
+
applyEmptyValue: true,
|
|
293
|
+
helpTitle: "Synology Chat outgoing webhook path",
|
|
294
|
+
helpLines: [`Default path: ${DEFAULT_WEBHOOK_PATH}`, "Change this only if you need multiple Synology Chat webhook routes."],
|
|
295
|
+
currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).webhookPath?.trim(),
|
|
296
|
+
keepPrompt: (value) => `Outgoing webhook path set (${value}). Keep it?`,
|
|
297
|
+
validate: ({ value }) => validateWebhookPath(value),
|
|
298
|
+
applySet: async ({ cfg, accountId, value }) => patchSynologyChatAccountConfig({
|
|
299
|
+
cfg,
|
|
300
|
+
accountId,
|
|
301
|
+
enabled: true,
|
|
302
|
+
clearFields: value.trim() ? void 0 : ["webhookPath"],
|
|
303
|
+
patch: value.trim() ? { webhookPath: value.trim() } : {}
|
|
304
|
+
})
|
|
305
|
+
}],
|
|
306
|
+
allowFrom: createAllowFromSection({
|
|
307
|
+
helpTitle: "Synology Chat allowlist",
|
|
308
|
+
helpLines: SYNOLOGY_ALLOW_FROM_HELP_LINES,
|
|
309
|
+
message: "Allowed Synology Chat user ids",
|
|
310
|
+
placeholder: "123456, 987654",
|
|
311
|
+
invalidWithoutCredentialNote: "Synology Chat user ids must be numeric.",
|
|
312
|
+
parseInputs: splitSetupEntries,
|
|
313
|
+
parseId: parseSynologyUserId,
|
|
314
|
+
apply: async ({ cfg, accountId, allowFrom }) => patchSynologyChatAccountConfig({
|
|
315
|
+
cfg,
|
|
316
|
+
accountId,
|
|
317
|
+
enabled: true,
|
|
318
|
+
patch: {
|
|
319
|
+
dmPolicy: "allowlist",
|
|
320
|
+
allowedUserIds: mergeAllowFromEntries(resolveExistingAllowedUserIds(cfg, accountId), allowFrom)
|
|
321
|
+
}
|
|
322
|
+
})
|
|
323
|
+
}),
|
|
324
|
+
completionNote: {
|
|
325
|
+
title: "Synology Chat access control",
|
|
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
|
+
};
|
|
335
|
+
//#endregion
|
|
336
|
+
export { resolveAccount as i, synologyChatSetupWizard as n, listAccountIds as r, synologyChatSetupAdapter as t };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@openclaw/synology-chat",
|
|
3
|
-
"version": "2026.5.2",
|
|
3
|
+
"version": "2026.5.3-beta.2",
|
|
4
4
|
"description": "Synology Chat channel plugin for OpenClaw",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
@@ -33,14 +33,30 @@
|
|
|
33
33
|
"minHostVersion": ">=2026.4.10"
|
|
34
34
|
},
|
|
35
35
|
"compat": {
|
|
36
|
-
"pluginApi": ">=2026.5.2"
|
|
36
|
+
"pluginApi": ">=2026.5.3-beta.2"
|
|
37
37
|
},
|
|
38
38
|
"build": {
|
|
39
|
-
"openclawVersion": "2026.5.2"
|
|
39
|
+
"openclawVersion": "2026.5.3-beta.2"
|
|
40
40
|
},
|
|
41
41
|
"release": {
|
|
42
42
|
"publishToClawHub": true,
|
|
43
43
|
"publishToNpm": true
|
|
44
|
+
},
|
|
45
|
+
"runtimeExtensions": [
|
|
46
|
+
"./dist/index.js"
|
|
47
|
+
],
|
|
48
|
+
"runtimeSetupEntry": "./dist/setup-entry.js"
|
|
49
|
+
},
|
|
50
|
+
"files": [
|
|
51
|
+
"dist/**",
|
|
52
|
+
"openclaw.plugin.json"
|
|
53
|
+
],
|
|
54
|
+
"peerDependencies": {
|
|
55
|
+
"openclaw": ">=2026.5.3-beta.2"
|
|
56
|
+
},
|
|
57
|
+
"peerDependenciesMeta": {
|
|
58
|
+
"openclaw": {
|
|
59
|
+
"optional": true
|
|
44
60
|
}
|
|
45
61
|
}
|
|
46
62
|
}
|
package/api.ts
DELETED
package/channel-plugin-api.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { synologyChatPlugin } from "./src/channel.js";
|
package/contract-api.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { collectSynologyChatSecurityAuditFindings } from "./src/security-audit.js";
|
package/index.ts
DELETED
|
@@ -1,16 +0,0 @@
|
|
|
1
|
-
import { defineBundledChannelEntry } from "openclaw/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 OpenClaw",
|
|
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/setup-api.ts
DELETED
|
@@ -1 +0,0 @@
|
|
|
1
|
-
export { synologyChatSetupAdapter, synologyChatSetupWizard } from "./src/setup-surface.js";
|
package/setup-entry.ts
DELETED
package/src/accounts.ts
DELETED
|
@@ -1,151 +0,0 @@
|
|
|
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 OpenClawConfig,
|
|
11
|
-
} from "openclaw/plugin-sdk/account-resolution";
|
|
12
|
-
import { resolveDangerousNameMatchingEnabled } from "openclaw/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 OpenClaw config object. */
|
|
20
|
-
function getChannelConfig(cfg: OpenClawConfig): 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: OpenClawConfig): 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: OpenClawConfig,
|
|
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.OPENCLAW_BOT_NAME ?? "OpenClaw";
|
|
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
|
-
}
|
|
@@ -1,17 +0,0 @@
|
|
|
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
|
-
});
|
package/src/approval-auth.ts
DELETED
|
@@ -1,22 +0,0 @@
|
|
|
1
|
-
import {
|
|
2
|
-
createResolvedApproverActionAuthAdapter,
|
|
3
|
-
resolveApprovalApprovers,
|
|
4
|
-
} from "openclaw/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
|
-
});
|