/** * Synology Chat Channel Plugin for HanzoBot. * * Implements the ChannelPlugin interface following the LINE pattern. */ import { DEFAULT_ACCOUNT_ID, setAccountEnabledInConfigSection, registerPluginHttpRoute, buildChannelConfigSchema, } from "@hanzo/bot/plugin-sdk/synology-chat"; 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()); const activeRouteUnregisters = new Map void>(); function waitUntilAbort(signal?: AbortSignal, onAbort?: () => void): Promise { return new Promise((resolve) => { const complete = () => { onAbort?.(); resolve(); }; if (!signal) { return; } if (signal.aborted) { complete(); return; } signal.addEventListener("abort", complete, { once: true }); }); } export function createSynologyChatPlugin() { return { id: CHANNEL_ID, meta: { id: CHANNEL_ID, label: "Synology Chat", selectionLabel: "Synology Chat (Webhook)", detailLabel: "Synology Chat (Webhook)", docsPath: "/channels/synology-chat", blurb: "Connect your Synology NAS Chat to HanzoBot", 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, "HanzoBot: 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: "bot pairing approve synology-chat ", 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.', ); } if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) { warnings.push( '- Synology Chat: dmPolicy="allowlist" with empty allowedUserIds blocks all senders. Add users or set dmPolicy="open".', ); } 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: "", }, }, directory: { self: async () => null, listPeers: async () => [], listGroups: async () => [], }, outbound: { deliveryMode: "gateway" as const, textChunkLimit: 2000, sendText: async ({ to, text, accountId, cfg }: any) => { const account: ResolvedSynologyChatAccount = resolveAccount(cfg ?? {}, 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, cfg }: any) => { const account: ResolvedSynologyChatAccount = resolveAccount(cfg ?? {}, 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 waitUntilAbort(ctx.abortSignal); } if (!account.token || !account.incomingUrl) { log?.warn?.( `Synology Chat account ${accountId} not fully configured (missing token or incomingUrl)`, ); return waitUntilAbort(ctx.abortSignal); } if (account.dmPolicy === "allowlist" && account.allowedUserIds.length === 0) { log?.warn?.( `Synology Chat account ${accountId} has dmPolicy=allowlist but empty allowedUserIds; refusing to start route`, ); return waitUntilAbort(ctx.abortSignal); } 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(); // The Chat API user_id (for sending) may differ from the webhook // user_id (used for sessions/pairing). Use chatUserId for API calls. const sendUserId = msg.chatUserId ?? msg.from; // Build MsgContext using SDK's finalizeInboundContext for proper normalization const msgCtx = rt.channel.reply.finalizeInboundContext({ Body: msg.body, RawBody: msg.body, CommandBody: msg.body, From: `synology-chat:${msg.from}`, To: `synology-chat:${msg.from}`, SessionKey: msg.sessionKey, AccountId: account.accountId, OriginatingChannel: CHANNEL_ID, OriginatingTo: `synology-chat:${msg.from}`, ChatType: msg.chatType, SenderName: msg.senderName, SenderId: msg.from, Provider: CHANNEL_ID, Surface: CHANNEL_ID, ConversationLabel: msg.senderName || msg.from, Timestamp: Date.now(), CommandAuthorized: true, }); // 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, sendUserId, account.allowInsecureSsl, ); } }, onReplyStart: () => { log?.info?.(`Agent reply started for ${msg.from}`); }, }, }); return null; }, log, }); // Deregister any stale route from a previous start (e.g. on auto-restart) // to avoid "already registered" collisions that trigger infinite loops. const routeKey = `${accountId}:${account.webhookPath}`; const prevUnregister = activeRouteUnregisters.get(routeKey); if (prevUnregister) { log?.info?.(`Deregistering stale route before re-registering: ${account.webhookPath}`); prevUnregister(); activeRouteUnregisters.delete(routeKey); } const unregister = registerPluginHttpRoute({ path: account.webhookPath, auth: "plugin", replaceExisting: true, pluginId: CHANNEL_ID, accountId: account.accountId, log: (msg: string) => log?.info?.(msg), handler, }); activeRouteUnregisters.set(routeKey, unregister); log?.info?.(`Registered HTTP route: ${account.webhookPath} for Synology Chat`); // Keep alive until abort signal fires. // The gateway expects a Promise that stays pending while the channel is running. // Resolving immediately triggers a restart loop. return waitUntilAbort(ctx.abortSignal, () => { log?.info?.(`Stopping Synology Chat channel (account: ${accountId})`); if (typeof unregister === "function") unregister(); activeRouteUnregisters.delete(routeKey); }); }, 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 `` to create clickable links.", " Example: `` 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 `` for user-friendly links", ], }, }; }