feat(channels): add Synology Chat native channel (#23012)

* feat(channels): add Synology Chat native channel

Webhook-based integration with Synology NAS Chat (DSM 7+).
Supports outgoing webhooks, incoming messages, multi-account,
DM policies, rate limiting, and input sanitization.

- HMAC-based constant-time token validation
- Configurable SSL verification (allowInsecureSsl) for self-signed NAS certs
- 54 unit tests across 5 test suites
- Follows the same ChannelPlugin pattern as LINE/Discord/Telegram

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* feat(synology-chat): add pairing, warnings, messaging, agent hints

- Enable media capability (file_url already supported by client)
- Add pairing.notifyApproval to message approved users
- Add security.collectWarnings for missing token/URL, insecure SSL, open DM policy
- Add messaging.normalizeTarget and targetResolver for user ID resolution
- Add directory stubs (self, listPeers, listGroups)
- Add agentPrompt.messageToolHints with Synology Chat formatting guide
- 63 tests (up from 54), all passing

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jean-Marc
2026-02-22 00:09:58 +01:00
committed by GitHub
parent fbf0c99d7c
commit 03586e3d00
16 changed files with 1959 additions and 0 deletions

View File

@@ -0,0 +1,323 @@
/**
* Synology Chat Channel Plugin for OpenClaw.
*
* Implements the ChannelPlugin interface following the LINE pattern.
*/
import {
DEFAULT_ACCOUNT_ID,
setAccountEnabledInConfigSection,
registerPluginHttpRoute,
buildChannelConfigSchema,
} from "openclaw/plugin-sdk";
import { z } from "zod";
import { listAccountIds, resolveAccount } from "./accounts.js";
import { sendMessage, sendFileUrl } from "./client.js";
import { getSynologyRuntime } from "./runtime.js";
import type { ResolvedSynologyChatAccount } from "./types.js";
import { createWebhookHandler } from "./webhook-handler.js";
const CHANNEL_ID = "synology-chat";
const SynologyChatConfigSchema = buildChannelConfigSchema(z.object({}).passthrough());
export function createSynologyChatPlugin() {
return {
id: CHANNEL_ID,
meta: {
id: CHANNEL_ID,
label: "Synology Chat",
selectionLabel: "Synology Chat (Webhook)",
detailLabel: "Synology Chat (Webhook)",
docsPath: "synology-chat",
blurb: "Connect your Synology NAS Chat to OpenClaw",
order: 90,
},
capabilities: {
chatTypes: ["direct" as const],
media: true,
threads: false,
reactions: false,
edit: false,
unsend: false,
reply: false,
effects: false,
blockStreaming: false,
},
reload: { configPrefixes: [`channels.${CHANNEL_ID}`] },
configSchema: SynologyChatConfigSchema,
config: {
listAccountIds: (cfg: any) => listAccountIds(cfg),
resolveAccount: (cfg: any, accountId?: string | null) => resolveAccount(cfg, accountId),
defaultAccountId: (_cfg: any) => DEFAULT_ACCOUNT_ID,
setAccountEnabled: ({ cfg, accountId, enabled }: any) => {
const channelConfig = cfg?.channels?.[CHANNEL_ID] ?? {};
if (accountId === DEFAULT_ACCOUNT_ID) {
return {
...cfg,
channels: {
...cfg.channels,
[CHANNEL_ID]: { ...channelConfig, enabled },
},
};
}
return setAccountEnabledInConfigSection({
cfg,
sectionKey: `channels.${CHANNEL_ID}`,
accountId,
enabled,
});
},
},
pairing: {
idLabel: "synologyChatUserId",
normalizeAllowEntry: (entry: string) => entry.toLowerCase().trim(),
notifyApproval: async ({ cfg, id }: { cfg: any; id: string }) => {
const account = resolveAccount(cfg);
if (!account.incomingUrl) return;
await sendMessage(
account.incomingUrl,
"OpenClaw: your access has been approved.",
id,
account.allowInsecureSsl,
);
},
},
security: {
resolveDmPolicy: ({
cfg,
accountId,
account,
}: {
cfg: any;
accountId?: string | null;
account: ResolvedSynologyChatAccount;
}) => {
const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID;
const channelCfg = (cfg as any).channels?.["synology-chat"];
const useAccountPath = Boolean(channelCfg?.accounts?.[resolvedAccountId]);
const basePath = useAccountPath
? `channels.synology-chat.accounts.${resolvedAccountId}.`
: "channels.synology-chat.";
return {
policy: account.dmPolicy ?? "allowlist",
allowFrom: account.allowedUserIds ?? [],
policyPath: `${basePath}dmPolicy`,
allowFromPath: basePath,
approveHint: "openclaw pairing approve synology-chat <code>",
normalizeEntry: (raw: string) => raw.toLowerCase().trim(),
};
},
collectWarnings: ({ account }: { account: ResolvedSynologyChatAccount }) => {
const warnings: string[] = [];
if (!account.token) {
warnings.push(
"- Synology Chat: token is not configured. The webhook will reject all requests.",
);
}
if (!account.incomingUrl) {
warnings.push(
"- Synology Chat: incomingUrl is not configured. The bot cannot send replies.",
);
}
if (account.allowInsecureSsl) {
warnings.push(
"- Synology Chat: SSL verification is disabled (allowInsecureSsl=true). Only use this for local NAS with self-signed certificates.",
);
}
if (account.dmPolicy === "open") {
warnings.push(
'- Synology Chat: dmPolicy="open" allows any user to message the bot. Consider "allowlist" for production use.',
);
}
return warnings;
},
},
messaging: {
normalizeTarget: (target: string) => {
const trimmed = target.trim();
if (!trimmed) return undefined;
// Strip common prefixes
return trimmed.replace(/^synology[-_]?chat:/i, "").trim();
},
targetResolver: {
looksLikeId: (id: string) => {
const trimmed = id?.trim();
if (!trimmed) return false;
// Synology Chat user IDs are numeric
return /^\d+$/.test(trimmed) || /^synology[-_]?chat:/i.test(trimmed);
},
hint: "<userId>",
},
},
directory: {
self: async () => null,
listPeers: async () => [],
listGroups: async () => [],
},
outbound: {
deliveryMode: "gateway" as const,
textChunkLimit: 2000,
sendText: async ({ to, text, accountId, account: ctxAccount }: any) => {
const account: ResolvedSynologyChatAccount = ctxAccount ?? resolveAccount({}, accountId);
if (!account.incomingUrl) {
throw new Error("Synology Chat incoming URL not configured");
}
const ok = await sendMessage(account.incomingUrl, text, to, account.allowInsecureSsl);
if (!ok) {
throw new Error("Failed to send message to Synology Chat");
}
return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to };
},
sendMedia: async ({ to, mediaUrl, accountId, account: ctxAccount }: any) => {
const account: ResolvedSynologyChatAccount = ctxAccount ?? resolveAccount({}, accountId);
if (!account.incomingUrl) {
throw new Error("Synology Chat incoming URL not configured");
}
if (!mediaUrl) {
throw new Error("No media URL provided");
}
const ok = await sendFileUrl(account.incomingUrl, mediaUrl, to, account.allowInsecureSsl);
if (!ok) {
throw new Error("Failed to send media to Synology Chat");
}
return { channel: CHANNEL_ID, messageId: `sc-${Date.now()}`, chatId: to };
},
},
gateway: {
startAccount: async (ctx: any) => {
const { cfg, accountId, log } = ctx;
const account = resolveAccount(cfg, accountId);
if (!account.enabled) {
log?.info?.(`Synology Chat account ${accountId} is disabled, skipping`);
return { stop: () => {} };
}
if (!account.token || !account.incomingUrl) {
log?.warn?.(
`Synology Chat account ${accountId} not fully configured (missing token or incomingUrl)`,
);
return { stop: () => {} };
}
log?.info?.(
`Starting Synology Chat channel (account: ${accountId}, path: ${account.webhookPath})`,
);
const handler = createWebhookHandler({
account,
deliver: async (msg) => {
const rt = getSynologyRuntime();
const currentCfg = await rt.config.loadConfig();
// Build MsgContext (same format as LINE/Signal/etc.)
const msgCtx = {
Body: msg.body,
From: msg.from,
To: account.botName,
SessionKey: msg.sessionKey,
AccountId: account.accountId,
OriginatingChannel: CHANNEL_ID as any,
OriginatingTo: msg.from,
ChatType: msg.chatType,
SenderName: msg.senderName,
};
// Dispatch via the SDK's buffered block dispatcher
await rt.channel.reply.dispatchReplyWithBufferedBlockDispatcher({
ctx: msgCtx,
cfg: currentCfg,
dispatcherOptions: {
deliver: async (payload: { text?: string; body?: string }) => {
const text = payload?.text ?? payload?.body;
if (text) {
await sendMessage(
account.incomingUrl,
text,
msg.from,
account.allowInsecureSsl,
);
}
},
onReplyStart: () => {
log?.info?.(`Agent reply started for ${msg.from}`);
},
},
});
return null;
},
log,
});
// Register HTTP route via the SDK
const unregister = registerPluginHttpRoute({
path: account.webhookPath,
pluginId: CHANNEL_ID,
accountId: account.accountId,
log: (msg: string) => log?.info?.(msg),
handler,
});
log?.info?.(`Registered HTTP route: ${account.webhookPath} for Synology Chat`);
return {
stop: () => {
log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`);
if (typeof unregister === "function") unregister();
},
};
},
stopAccount: async (ctx: any) => {
ctx.log?.info?.(`Synology Chat account ${ctx.accountId} stopped`);
},
},
agentPrompt: {
messageToolHints: () => [
"",
"### Synology Chat Formatting",
"Synology Chat supports limited formatting. Use these patterns:",
"",
"**Links**: Use `<URL|display text>` to create clickable links.",
" Example: `<https://example.com|Click here>` renders as a clickable link.",
"",
"**File sharing**: Include a publicly accessible URL to share files or images.",
" The NAS will download and attach the file (max 32 MB).",
"",
"**Limitations**:",
"- No markdown, bold, italic, or code blocks",
"- No buttons, cards, or interactive elements",
"- No message editing after send",
"- Keep messages under 2000 characters for best readability",
"",
"**Best practices**:",
"- Use short, clear responses (Synology Chat has a minimal UI)",
"- Use line breaks to separate sections",
"- Use numbered or bulleted lists for clarity",
"- Wrap URLs with `<URL|label>` for user-friendly links",
],
},
};
}