mirror of
https://github.com/openclaw/openclaw.git
synced 2026-05-10 23:04:31 +00:00
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:
323
extensions/synology-chat/src/channel.ts
Normal file
323
extensions/synology-chat/src/channel.ts
Normal 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",
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user