@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.ts
CHANGED
|
@@ -4,320 +4,369 @@
|
|
|
4
4
|
* Implements the ChannelPlugin interface following the LINE pattern.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
|
+
import { DEFAULT_ACCOUNT_ID } from "openclaw/plugin-sdk/account-id";
|
|
8
|
+
import type { OpenClawConfig } from "openclaw/plugin-sdk/account-resolution";
|
|
7
9
|
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
} from "openclaw/plugin-sdk";
|
|
13
|
-
import {
|
|
10
|
+
createHybridChannelConfigAdapter,
|
|
11
|
+
createScopedDmSecurityResolver,
|
|
12
|
+
} from "openclaw/plugin-sdk/channel-config-helpers";
|
|
13
|
+
import { createChatChannelPlugin, type ChannelPlugin } from "openclaw/plugin-sdk/channel-core";
|
|
14
|
+
import { waitUntilAbort } from "openclaw/plugin-sdk/channel-lifecycle";
|
|
15
|
+
import {
|
|
16
|
+
composeWarningCollectors,
|
|
17
|
+
createConditionalWarningCollector,
|
|
18
|
+
projectAccountConfigWarningCollector,
|
|
19
|
+
projectAccountWarningCollector,
|
|
20
|
+
} from "openclaw/plugin-sdk/channel-policy";
|
|
21
|
+
import { attachChannelToResult } from "openclaw/plugin-sdk/channel-send-result";
|
|
22
|
+
import { createEmptyChannelDirectoryAdapter } from "openclaw/plugin-sdk/directory-runtime";
|
|
14
23
|
import { listAccountIds, resolveAccount } from "./accounts.js";
|
|
24
|
+
import { synologyChatApprovalAuth } from "./approval-auth.js";
|
|
15
25
|
import { sendMessage, sendFileUrl } from "./client.js";
|
|
16
|
-
import {
|
|
26
|
+
import { SynologyChatChannelConfigSchema } from "./config-schema.js";
|
|
27
|
+
import {
|
|
28
|
+
collectSynologyGatewayRoutingWarnings,
|
|
29
|
+
registerSynologyWebhookRoute,
|
|
30
|
+
validateSynologyGatewayAccountStartup,
|
|
31
|
+
} from "./gateway-runtime.js";
|
|
32
|
+
import { collectSynologyChatSecurityAuditFindings } from "./security-audit.js";
|
|
33
|
+
import { synologyChatSetupAdapter, synologyChatSetupWizard } from "./setup-surface.js";
|
|
17
34
|
import type { ResolvedSynologyChatAccount } from "./types.js";
|
|
18
|
-
import { createWebhookHandler } from "./webhook-handler.js";
|
|
19
35
|
|
|
20
36
|
const CHANNEL_ID = "synology-chat";
|
|
21
|
-
const SynologyChatConfigSchema = buildChannelConfigSchema(z.object({}).passthrough());
|
|
22
|
-
|
|
23
|
-
export function createSynologyChatPlugin() {
|
|
24
|
-
return {
|
|
25
|
-
id: CHANNEL_ID,
|
|
26
|
-
|
|
27
|
-
meta: {
|
|
28
|
-
id: CHANNEL_ID,
|
|
29
|
-
label: "Synology Chat",
|
|
30
|
-
selectionLabel: "Synology Chat (Webhook)",
|
|
31
|
-
detailLabel: "Synology Chat (Webhook)",
|
|
32
|
-
docsPath: "/channels/synology-chat",
|
|
33
|
-
blurb: "Connect your Synology NAS Chat to OpenClaw",
|
|
34
|
-
order: 90,
|
|
35
|
-
},
|
|
36
37
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
threads: false,
|
|
41
|
-
reactions: false,
|
|
42
|
-
edit: false,
|
|
43
|
-
unsend: false,
|
|
44
|
-
reply: false,
|
|
45
|
-
effects: false,
|
|
46
|
-
blockStreaming: false,
|
|
47
|
-
},
|
|
48
|
-
|
|
49
|
-
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
50
|
-
|
|
51
|
-
configSchema: SynologyChatConfigSchema,
|
|
52
|
-
|
|
53
|
-
config: {
|
|
54
|
-
listAccountIds: (cfg: any) => listAccountIds(cfg),
|
|
38
|
+
function normalizeLowercaseStringOrEmpty(value: unknown): string {
|
|
39
|
+
return typeof value === "string" ? value.trim().toLowerCase() : "";
|
|
40
|
+
}
|
|
55
41
|
|
|
56
|
-
|
|
42
|
+
const resolveSynologyChatDmPolicy = createScopedDmSecurityResolver<ResolvedSynologyChatAccount>({
|
|
43
|
+
channelKey: CHANNEL_ID,
|
|
44
|
+
resolvePolicy: (account) => account.dmPolicy,
|
|
45
|
+
resolveAllowFrom: (account) => account.allowedUserIds,
|
|
46
|
+
policyPathSuffix: "dmPolicy",
|
|
47
|
+
defaultPolicy: "allowlist",
|
|
48
|
+
approveHint: "openclaw pairing approve synology-chat <code>",
|
|
49
|
+
normalizeEntry: (raw) => normalizeLowercaseStringOrEmpty(raw),
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
type SynologyChannelGatewayContext = {
|
|
53
|
+
cfg: OpenClawConfig;
|
|
54
|
+
accountId: string;
|
|
55
|
+
abortSignal: AbortSignal;
|
|
56
|
+
log?: {
|
|
57
|
+
info: (message: string) => void;
|
|
58
|
+
warn: (message: string) => void;
|
|
59
|
+
error: (message: string) => void;
|
|
60
|
+
};
|
|
61
|
+
};
|
|
62
|
+
type SynologyChannelOutboundContext = {
|
|
63
|
+
cfg: OpenClawConfig;
|
|
64
|
+
to: string;
|
|
65
|
+
text?: string;
|
|
66
|
+
mediaUrl?: string;
|
|
67
|
+
accountId?: string | null;
|
|
68
|
+
};
|
|
69
|
+
type SynologyChannelSendTextContext = SynologyChannelOutboundContext & { text: string };
|
|
70
|
+
type _SynologyChannelSendMediaContext = SynologyChannelOutboundContext & { mediaUrl: string };
|
|
71
|
+
type SynologySecurityWarningContext = {
|
|
72
|
+
cfg: OpenClawConfig;
|
|
73
|
+
account: ResolvedSynologyChatAccount;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const synologyChatConfigAdapter = createHybridChannelConfigAdapter<ResolvedSynologyChatAccount>({
|
|
77
|
+
sectionKey: CHANNEL_ID,
|
|
78
|
+
listAccountIds,
|
|
79
|
+
resolveAccount,
|
|
80
|
+
defaultAccountId: () => DEFAULT_ACCOUNT_ID,
|
|
81
|
+
clearBaseFields: [
|
|
82
|
+
"token",
|
|
83
|
+
"incomingUrl",
|
|
84
|
+
"nasHost",
|
|
85
|
+
"webhookPath",
|
|
86
|
+
"dangerouslyAllowNameMatching",
|
|
87
|
+
"dangerouslyAllowInheritedWebhookPath",
|
|
88
|
+
"dmPolicy",
|
|
89
|
+
"allowedUserIds",
|
|
90
|
+
"rateLimitPerMinute",
|
|
91
|
+
"botName",
|
|
92
|
+
"allowInsecureSsl",
|
|
93
|
+
],
|
|
94
|
+
resolveAllowFrom: (account) => account.allowedUserIds,
|
|
95
|
+
formatAllowFrom: (allowFrom) =>
|
|
96
|
+
allowFrom.map((entry) => normalizeLowercaseStringOrEmpty(String(entry))).filter(Boolean),
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
const collectSynologyChatSecurityWarnings =
|
|
100
|
+
createConditionalWarningCollector<ResolvedSynologyChatAccount>(
|
|
101
|
+
(account) =>
|
|
102
|
+
!account.token &&
|
|
103
|
+
"- Synology Chat: token is not configured. The webhook will reject all requests.",
|
|
104
|
+
(account) =>
|
|
105
|
+
!account.incomingUrl &&
|
|
106
|
+
"- Synology Chat: incomingUrl is not configured. The bot cannot send replies.",
|
|
107
|
+
(account) =>
|
|
108
|
+
account.allowInsecureSsl &&
|
|
109
|
+
"- Synology Chat: SSL verification is disabled (allowInsecureSsl=true). Only use this for local NAS with self-signed certificates.",
|
|
110
|
+
(account) =>
|
|
111
|
+
account.dangerouslyAllowNameMatching &&
|
|
112
|
+
"- Synology Chat: dangerouslyAllowNameMatching=true re-enables mutable username/nickname recipient matching for replies. Prefer stable numeric user IDs.",
|
|
113
|
+
(account) =>
|
|
114
|
+
account.dangerouslyAllowInheritedWebhookPath &&
|
|
115
|
+
account.webhookPathSource === "inherited-base" &&
|
|
116
|
+
"- Synology Chat: dangerouslyAllowInheritedWebhookPath=true opts a named account into a shared inherited webhook path. Prefer an explicit per-account webhookPath.",
|
|
117
|
+
(account) =>
|
|
118
|
+
account.dmPolicy === "open" &&
|
|
119
|
+
account.allowedUserIds.length === 0 &&
|
|
120
|
+
'- Synology Chat: dmPolicy="open" with empty allowedUserIds blocks all senders. Add allowedUserIds=["*"] for public DMs or set explicit user IDs.',
|
|
121
|
+
(account) =>
|
|
122
|
+
account.dmPolicy === "open" &&
|
|
123
|
+
account.allowedUserIds.includes("*") &&
|
|
124
|
+
'- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.',
|
|
125
|
+
(account) =>
|
|
126
|
+
account.dmPolicy === "allowlist" &&
|
|
127
|
+
account.allowedUserIds.length === 0 &&
|
|
128
|
+
'- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open" with allowedUserIds=["*"].',
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
type SynologyChatOutboundResult = {
|
|
132
|
+
channel: typeof CHANNEL_ID;
|
|
133
|
+
messageId: string;
|
|
134
|
+
chatId: string;
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
type SynologyChatPlugin = Omit<
|
|
138
|
+
ChannelPlugin<ResolvedSynologyChatAccount>,
|
|
139
|
+
"pairing" | "security" | "messaging" | "directory" | "outbound" | "gateway" | "agentPrompt"
|
|
140
|
+
> & {
|
|
141
|
+
pairing: {
|
|
142
|
+
idLabel: string;
|
|
143
|
+
normalizeAllowEntry?: (entry: string) => string;
|
|
144
|
+
notifyApproval: (params: { cfg: OpenClawConfig; id: string }) => Promise<void>;
|
|
145
|
+
};
|
|
146
|
+
security: {
|
|
147
|
+
resolveDmPolicy: (params: { cfg: OpenClawConfig; account: ResolvedSynologyChatAccount }) => {
|
|
148
|
+
policy: string | null | undefined;
|
|
149
|
+
allowFrom?: Array<string | number>;
|
|
150
|
+
normalizeEntry?: (raw: string) => string;
|
|
151
|
+
} | null;
|
|
152
|
+
collectWarnings: (params: {
|
|
153
|
+
cfg: OpenClawConfig;
|
|
154
|
+
account: ResolvedSynologyChatAccount;
|
|
155
|
+
}) => string[];
|
|
156
|
+
};
|
|
157
|
+
messaging: {
|
|
158
|
+
targetPrefixes?: readonly string[];
|
|
159
|
+
normalizeTarget: (target: string) => string | undefined;
|
|
160
|
+
targetResolver: {
|
|
161
|
+
looksLikeId: (id: string) => boolean;
|
|
162
|
+
hint: string;
|
|
163
|
+
};
|
|
164
|
+
};
|
|
165
|
+
directory: {
|
|
166
|
+
self?: NonNullable<ChannelPlugin<ResolvedSynologyChatAccount>["directory"]>["self"];
|
|
167
|
+
listPeers?: NonNullable<ChannelPlugin<ResolvedSynologyChatAccount>["directory"]>["listPeers"];
|
|
168
|
+
listGroups?: NonNullable<ChannelPlugin<ResolvedSynologyChatAccount>["directory"]>["listGroups"];
|
|
169
|
+
};
|
|
170
|
+
outbound: {
|
|
171
|
+
deliveryMode: "gateway";
|
|
172
|
+
textChunkLimit: number;
|
|
173
|
+
sendText: (ctx: SynologyChannelSendTextContext) => Promise<SynologyChatOutboundResult>;
|
|
174
|
+
sendMedia: (ctx: SynologyChannelOutboundContext) => Promise<SynologyChatOutboundResult>;
|
|
175
|
+
};
|
|
176
|
+
gateway: {
|
|
177
|
+
startAccount: (ctx: SynologyChannelGatewayContext) => Promise<unknown>;
|
|
178
|
+
stopAccount: (ctx: SynologyChannelGatewayContext) => Promise<void>;
|
|
179
|
+
};
|
|
180
|
+
agentPrompt: {
|
|
181
|
+
messageToolHints: () => string[];
|
|
182
|
+
};
|
|
183
|
+
};
|
|
184
|
+
|
|
185
|
+
const collectSynologyChatRoutingWarnings = projectAccountConfigWarningCollector<
|
|
186
|
+
ResolvedSynologyChatAccount,
|
|
187
|
+
OpenClawConfig,
|
|
188
|
+
SynologySecurityWarningContext
|
|
189
|
+
>(
|
|
190
|
+
(cfg) => cfg,
|
|
191
|
+
({ account, cfg }) => collectSynologyGatewayRoutingWarnings({ account, cfg }),
|
|
192
|
+
);
|
|
193
|
+
|
|
194
|
+
function resolveOutboundAccount(
|
|
195
|
+
cfg: OpenClawConfig,
|
|
196
|
+
accountId?: string | null,
|
|
197
|
+
): ResolvedSynologyChatAccount {
|
|
198
|
+
return resolveAccount(cfg ?? {}, accountId);
|
|
199
|
+
}
|
|
57
200
|
|
|
58
|
-
|
|
201
|
+
function requireIncomingUrl(account: ResolvedSynologyChatAccount): string {
|
|
202
|
+
if (!account.incomingUrl) {
|
|
203
|
+
throw new Error("Synology Chat incoming URL not configured");
|
|
204
|
+
}
|
|
205
|
+
return account.incomingUrl;
|
|
206
|
+
}
|
|
59
207
|
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
cfg,
|
|
73
|
-
sectionKey: `channels.${CHANNEL_ID}`,
|
|
74
|
-
accountId,
|
|
75
|
-
enabled,
|
|
76
|
-
});
|
|
208
|
+
export function createSynologyChatPlugin(): SynologyChatPlugin {
|
|
209
|
+
return createChatChannelPlugin({
|
|
210
|
+
base: {
|
|
211
|
+
id: CHANNEL_ID,
|
|
212
|
+
meta: {
|
|
213
|
+
id: CHANNEL_ID,
|
|
214
|
+
label: "Synology Chat",
|
|
215
|
+
selectionLabel: "Synology Chat (Webhook)",
|
|
216
|
+
detailLabel: "Synology Chat (Webhook)",
|
|
217
|
+
docsPath: "/channels/synology-chat",
|
|
218
|
+
blurb: "Connect your Synology NAS Chat to OpenClaw",
|
|
219
|
+
order: 90,
|
|
77
220
|
},
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
"OpenClaw: your access has been approved.",
|
|
89
|
-
id,
|
|
90
|
-
account.allowInsecureSsl,
|
|
91
|
-
);
|
|
221
|
+
capabilities: {
|
|
222
|
+
chatTypes: ["direct" as const],
|
|
223
|
+
media: true,
|
|
224
|
+
threads: false,
|
|
225
|
+
reactions: false,
|
|
226
|
+
edit: false,
|
|
227
|
+
unsend: false,
|
|
228
|
+
reply: false,
|
|
229
|
+
effects: false,
|
|
230
|
+
blockStreaming: false,
|
|
92
231
|
},
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
account,
|
|
100
|
-
}: {
|
|
101
|
-
cfg: any;
|
|
102
|
-
accountId?: string | null;
|
|
103
|
-
account: ResolvedSynologyChatAccount;
|
|
104
|
-
}) => {
|
|
105
|
-
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
|
|
106
|
-
const channelCfg = (cfg as any).channels?.["synology-chat"];
|
|
107
|
-
const useAccountPath = Boolean(channelCfg?.accounts?.[resolvedAccountId]);
|
|
108
|
-
const basePath = useAccountPath
|
|
109
|
-
? `channels.synology-chat.accounts.${resolvedAccountId}.`
|
|
110
|
-
: "channels.synology-chat.";
|
|
111
|
-
return {
|
|
112
|
-
policy: account.dmPolicy ?? "allowlist",
|
|
113
|
-
allowFrom: account.allowedUserIds ?? [],
|
|
114
|
-
policyPath: `${basePath}dmPolicy`,
|
|
115
|
-
allowFromPath: basePath,
|
|
116
|
-
approveHint: "openclaw pairing approve synology-chat <code>",
|
|
117
|
-
normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
|
|
118
|
-
};
|
|
232
|
+
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
|
|
233
|
+
configSchema: SynologyChatChannelConfigSchema,
|
|
234
|
+
setup: synologyChatSetupAdapter,
|
|
235
|
+
setupWizard: synologyChatSetupWizard,
|
|
236
|
+
config: {
|
|
237
|
+
...synologyChatConfigAdapter,
|
|
119
238
|
},
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
}
|
|
142
|
-
return warnings;
|
|
239
|
+
approvalCapability: synologyChatApprovalAuth,
|
|
240
|
+
messaging: {
|
|
241
|
+
targetPrefixes: ["synology-chat", "synology_chat", "synology"],
|
|
242
|
+
normalizeTarget: (target: string) => {
|
|
243
|
+
const trimmed = target.trim();
|
|
244
|
+
if (!trimmed) {
|
|
245
|
+
return undefined;
|
|
246
|
+
}
|
|
247
|
+
// Strip common prefixes
|
|
248
|
+
return trimmed.replace(/^synology(?:[-_]?chat)?:/i, "").trim();
|
|
249
|
+
},
|
|
250
|
+
targetResolver: {
|
|
251
|
+
looksLikeId: (id: string) => {
|
|
252
|
+
const trimmed = id?.trim();
|
|
253
|
+
if (!trimmed) {
|
|
254
|
+
return false;
|
|
255
|
+
}
|
|
256
|
+
// Synology Chat user IDs are numeric
|
|
257
|
+
return /^\d+$/.test(trimmed) || /^synology(?:[-_]?chat)?:/i.test(trimmed);
|
|
258
|
+
},
|
|
259
|
+
hint: "<userId>",
|
|
260
|
+
},
|
|
143
261
|
},
|
|
144
|
-
|
|
262
|
+
directory: createEmptyChannelDirectoryAdapter(),
|
|
263
|
+
gateway: {
|
|
264
|
+
startAccount: async (ctx: SynologyChannelGatewayContext) => {
|
|
265
|
+
const { cfg, accountId, log, abortSignal } = ctx;
|
|
266
|
+
const account = resolveAccount(cfg, accountId);
|
|
267
|
+
if (!validateSynologyGatewayAccountStartup({ cfg, account, accountId, log }).ok) {
|
|
268
|
+
return waitUntilAbort(abortSignal);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
log?.info?.(
|
|
272
|
+
`Starting Synology Chat channel (account: ${accountId}, path: ${account.webhookPath})`,
|
|
273
|
+
);
|
|
274
|
+
const unregister = registerSynologyWebhookRoute({ account, accountId, log });
|
|
145
275
|
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
276
|
+
log?.info?.(`Registered HTTP route: ${account.webhookPath} for Synology Chat`);
|
|
277
|
+
|
|
278
|
+
// Keep alive until abort signal fires.
|
|
279
|
+
// The gateway expects a Promise that stays pending while the channel is running.
|
|
280
|
+
// Resolving immediately triggers a restart loop.
|
|
281
|
+
return waitUntilAbort(abortSignal, () => {
|
|
282
|
+
log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`);
|
|
283
|
+
unregister();
|
|
284
|
+
});
|
|
285
|
+
},
|
|
286
|
+
|
|
287
|
+
stopAccount: async (ctx: SynologyChannelGatewayContext) => {
|
|
288
|
+
ctx.log?.info?.(`Synology Chat account ${ctx.accountId} stopped`);
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
agentPrompt: {
|
|
292
|
+
messageToolHints: () => [
|
|
293
|
+
"",
|
|
294
|
+
"### Synology Chat Formatting",
|
|
295
|
+
"Synology Chat supports limited formatting. Use these patterns:",
|
|
296
|
+
"",
|
|
297
|
+
"**Links**: Use `<URL|display text>` to create clickable links.",
|
|
298
|
+
" Example: `<https://example.com|Click here>` renders as a clickable link.",
|
|
299
|
+
"",
|
|
300
|
+
"**File sharing**: Include a publicly accessible URL to share files or images.",
|
|
301
|
+
" The NAS will download and attach the file (max 32 MB).",
|
|
302
|
+
"",
|
|
303
|
+
"**Limitations**:",
|
|
304
|
+
"- No markdown, bold, italic, or code blocks",
|
|
305
|
+
"- No buttons, cards, or interactive elements",
|
|
306
|
+
"- No message editing after send",
|
|
307
|
+
"- Keep messages under 2000 characters for best readability",
|
|
308
|
+
"",
|
|
309
|
+
"**Best practices**:",
|
|
310
|
+
"- Use short, clear responses (Synology Chat has a minimal UI)",
|
|
311
|
+
"- Use line breaks to separate sections",
|
|
312
|
+
"- Use numbered or bulleted lists for clarity",
|
|
313
|
+
"- Wrap URLs with `<URL|label>` for user-friendly links",
|
|
314
|
+
],
|
|
152
315
|
},
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
316
|
+
},
|
|
317
|
+
pairing: {
|
|
318
|
+
text: {
|
|
319
|
+
idLabel: "synologyChatUserId",
|
|
320
|
+
message: "OpenClaw: your access has been approved.",
|
|
321
|
+
normalizeAllowEntry: (entry: string) => normalizeLowercaseStringOrEmpty(entry),
|
|
322
|
+
notify: async ({ cfg, id, message }) => {
|
|
323
|
+
const account = resolveAccount(cfg);
|
|
324
|
+
if (!account.incomingUrl) {
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
await sendMessage(account.incomingUrl, message, id, account.allowInsecureSsl);
|
|
159
328
|
},
|
|
160
|
-
hint: "<userId>",
|
|
161
329
|
},
|
|
162
330
|
},
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
331
|
+
security: {
|
|
332
|
+
resolveDmPolicy: resolveSynologyChatDmPolicy,
|
|
333
|
+
collectWarnings: composeWarningCollectors(
|
|
334
|
+
projectAccountWarningCollector<ResolvedSynologyChatAccount, SynologySecurityWarningContext>(
|
|
335
|
+
collectSynologyChatSecurityWarnings,
|
|
336
|
+
),
|
|
337
|
+
collectSynologyChatRoutingWarnings,
|
|
338
|
+
),
|
|
339
|
+
collectAuditFindings: collectSynologyChatSecurityAuditFindings,
|
|
168
340
|
},
|
|
169
|
-
|
|
170
341
|
outbound: {
|
|
171
342
|
deliveryMode: "gateway" as const,
|
|
172
343
|
textChunkLimit: 2000,
|
|
173
344
|
|
|
174
|
-
sendText: async ({ to, text, accountId,
|
|
175
|
-
const account
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
throw new Error("Synology Chat incoming URL not configured");
|
|
179
|
-
}
|
|
180
|
-
|
|
181
|
-
const ok = await sendMessage(account.incomingUrl, text, to, account.allowInsecureSsl);
|
|
345
|
+
sendText: async ({ to, text, accountId, cfg }: SynologyChannelSendTextContext) => {
|
|
346
|
+
const account = resolveOutboundAccount(cfg ?? {}, accountId);
|
|
347
|
+
const incomingUrl = requireIncomingUrl(account);
|
|
348
|
+
const ok = await sendMessage(incomingUrl, text, to, account.allowInsecureSsl);
|
|
182
349
|
if (!ok) {
|
|
183
350
|
throw new Error("Failed to send message to Synology Chat");
|
|
184
351
|
}
|
|
185
|
-
return
|
|
352
|
+
return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to });
|
|
186
353
|
},
|
|
187
354
|
|
|
188
|
-
sendMedia: async ({ to, mediaUrl, accountId,
|
|
189
|
-
const account
|
|
190
|
-
|
|
191
|
-
if (!account.incomingUrl) {
|
|
192
|
-
throw new Error("Synology Chat incoming URL not configured");
|
|
193
|
-
}
|
|
355
|
+
sendMedia: async ({ to, mediaUrl, accountId, cfg }: SynologyChannelOutboundContext) => {
|
|
356
|
+
const account = resolveOutboundAccount(cfg ?? {}, accountId);
|
|
357
|
+
const incomingUrl = requireIncomingUrl(account);
|
|
194
358
|
if (!mediaUrl) {
|
|
195
359
|
throw new Error("No media URL provided");
|
|
196
360
|
}
|
|
197
361
|
|
|
198
|
-
const ok = await sendFileUrl(
|
|
362
|
+
const ok = await sendFileUrl(incomingUrl, mediaUrl, to, account.allowInsecureSsl);
|
|
199
363
|
if (!ok) {
|
|
200
364
|
throw new Error("Failed to send media to Synology Chat");
|
|
201
365
|
}
|
|
202
|
-
return
|
|
366
|
+
return attachChannelToResult(CHANNEL_ID, { messageId: `sc-${Date.now()}`, chatId: to });
|
|
203
367
|
},
|
|
204
368
|
},
|
|
205
|
-
|
|
206
|
-
gateway: {
|
|
207
|
-
startAccount: async (ctx: any) => {
|
|
208
|
-
const { cfg, accountId, log } = ctx;
|
|
209
|
-
const account = resolveAccount(cfg, accountId);
|
|
210
|
-
|
|
211
|
-
if (!account.enabled) {
|
|
212
|
-
log?.info?.(`Synology Chat account ${accountId} is disabled, skipping`);
|
|
213
|
-
return { stop: () => {} };
|
|
214
|
-
}
|
|
215
|
-
|
|
216
|
-
if (!account.token || !account.incomingUrl) {
|
|
217
|
-
log?.warn?.(
|
|
218
|
-
`Synology Chat account ${accountId} not fully configured (missing token or incomingUrl)`,
|
|
219
|
-
);
|
|
220
|
-
return { stop: () => {} };
|
|
221
|
-
}
|
|
222
|
-
|
|
223
|
-
log?.info?.(
|
|
224
|
-
`Starting Synology Chat channel (account: ${accountId}, path: ${account.webhookPath})`,
|
|
225
|
-
);
|
|
226
|
-
|
|
227
|
-
const handler = createWebhookHandler({
|
|
228
|
-
account,
|
|
229
|
-
deliver: async (msg) => {
|
|
230
|
-
const rt = getSynologyRuntime();
|
|
231
|
-
const currentCfg = await rt.config.loadConfig();
|
|
232
|
-
|
|
233
|
-
// Build MsgContext (same format as LINE/Signal/etc.)
|
|
234
|
-
const msgCtx = {
|
|
235
|
-
Body: msg.body,
|
|
236
|
-
From: msg.from,
|
|
237
|
-
To: account.botName,
|
|
238
|
-
SessionKey: msg.sessionKey,
|
|
239
|
-
AccountId: account.accountId,
|
|
240
|
-
OriginatingChannel: CHANNEL_ID as any,
|
|
241
|
-
OriginatingTo: msg.from,
|
|
242
|
-
ChatType: msg.chatType,
|
|
243
|
-
SenderName: msg.senderName,
|
|
244
|
-
};
|
|
245
|
-
|
|
246
|
-
// Dispatch via the SDK's buffered block dispatcher
|
|
247
|
-
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
|
|
248
|
-
ctx: msgCtx,
|
|
249
|
-
cfg: currentCfg,
|
|
250
|
-
dispatcherOptions: {
|
|
251
|
-
deliver: async (payload: { text?: string; body?: string }) => {
|
|
252
|
-
const text = payload?.text ?? payload?.body;
|
|
253
|
-
if (text) {
|
|
254
|
-
await sendMessage(
|
|
255
|
-
account.incomingUrl,
|
|
256
|
-
text,
|
|
257
|
-
msg.from,
|
|
258
|
-
account.allowInsecureSsl,
|
|
259
|
-
);
|
|
260
|
-
}
|
|
261
|
-
},
|
|
262
|
-
onReplyStart: () => {
|
|
263
|
-
log?.info?.(`Agent reply started for ${msg.from}`);
|
|
264
|
-
},
|
|
265
|
-
},
|
|
266
|
-
});
|
|
267
|
-
|
|
268
|
-
return null;
|
|
269
|
-
},
|
|
270
|
-
log,
|
|
271
|
-
});
|
|
272
|
-
|
|
273
|
-
// Register HTTP route via the SDK
|
|
274
|
-
const unregister = registerPluginHttpRoute({
|
|
275
|
-
path: account.webhookPath,
|
|
276
|
-
pluginId: CHANNEL_ID,
|
|
277
|
-
accountId: account.accountId,
|
|
278
|
-
log: (msg: string) => log?.info?.(msg),
|
|
279
|
-
handler,
|
|
280
|
-
});
|
|
281
|
-
|
|
282
|
-
log?.info?.(`Registered HTTP route: ${account.webhookPath} for Synology Chat`);
|
|
283
|
-
|
|
284
|
-
return {
|
|
285
|
-
stop: () => {
|
|
286
|
-
log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`);
|
|
287
|
-
if (typeof unregister === "function") unregister();
|
|
288
|
-
},
|
|
289
|
-
};
|
|
290
|
-
},
|
|
291
|
-
|
|
292
|
-
stopAccount: async (ctx: any) => {
|
|
293
|
-
ctx.log?.info?.(`Synology Chat account ${ctx.accountId} stopped`);
|
|
294
|
-
},
|
|
295
|
-
},
|
|
296
|
-
|
|
297
|
-
agentPrompt: {
|
|
298
|
-
messageToolHints: () => [
|
|
299
|
-
"",
|
|
300
|
-
"### Synology Chat Formatting",
|
|
301
|
-
"Synology Chat supports limited formatting. Use these patterns:",
|
|
302
|
-
"",
|
|
303
|
-
"**Links**: Use `<URL|display text>` to create clickable links.",
|
|
304
|
-
" Example: `<https://example.com|Click here>` renders as a clickable link.",
|
|
305
|
-
"",
|
|
306
|
-
"**File sharing**: Include a publicly accessible URL to share files or images.",
|
|
307
|
-
" The NAS will download and attach the file (max 32 MB).",
|
|
308
|
-
"",
|
|
309
|
-
"**Limitations**:",
|
|
310
|
-
"- No markdown, bold, italic, or code blocks",
|
|
311
|
-
"- No buttons, cards, or interactive elements",
|
|
312
|
-
"- No message editing after send",
|
|
313
|
-
"- Keep messages under 2000 characters for best readability",
|
|
314
|
-
"",
|
|
315
|
-
"**Best practices**:",
|
|
316
|
-
"- Use short, clear responses (Synology Chat has a minimal UI)",
|
|
317
|
-
"- Use line breaks to separate sections",
|
|
318
|
-
"- Use numbered or bulleted lists for clarity",
|
|
319
|
-
"- Wrap URLs with `<URL|label>` for user-friendly links",
|
|
320
|
-
],
|
|
321
|
-
},
|
|
322
|
-
};
|
|
369
|
+
}) as unknown as SynologyChatPlugin;
|
|
323
370
|
}
|
|
371
|
+
|
|
372
|
+
export const synologyChatPlugin = createSynologyChatPlugin();
|