diff --git a/extensions/synology-chat/index.ts b/extensions/synology-chat/index.ts index 69dbfb9edbf..9078b9f86c7 100644 --- a/extensions/synology-chat/index.ts +++ b/extensions/synology-chat/index.ts @@ -1,6 +1,6 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk/synology-chat"; import { emptyPluginConfigSchema } from "openclaw/plugin-sdk/synology-chat"; -import { createSynologyChatPlugin } from "./src/channel.js"; +import { synologyChatPlugin } from "./src/channel.js"; import { setSynologyRuntime } from "./src/runtime.js"; const plugin = { @@ -10,7 +10,7 @@ const plugin = { configSchema: emptyPluginConfigSchema(), register(api: OpenClawPluginApi) { setSynologyRuntime(api.runtime); - api.registerChannel({ plugin: createSynologyChatPlugin() }); + api.registerChannel({ plugin: synologyChatPlugin }); }, }; diff --git a/extensions/synology-chat/package.json b/extensions/synology-chat/package.json index c6148c856a3..d8ff22d6361 100644 --- a/extensions/synology-chat/package.json +++ b/extensions/synology-chat/package.json @@ -10,6 +10,7 @@ "extensions": [ "./index.ts" ], + "setupEntry": "./setup-entry.ts", "channel": { "id": "synology-chat", "label": "Synology Chat", diff --git a/extensions/synology-chat/setup-entry.ts b/extensions/synology-chat/setup-entry.ts new file mode 100644 index 00000000000..45cc966e082 --- /dev/null +++ b/extensions/synology-chat/setup-entry.ts @@ -0,0 +1,5 @@ +import { synologyChatPlugin } from "./src/channel.js"; + +export default { + plugin: synologyChatPlugin, +}; diff --git a/extensions/synology-chat/src/channel.test.ts b/extensions/synology-chat/src/channel.test.ts index bdce5f37d79..b45f8c355e4 100644 --- a/extensions/synology-chat/src/channel.test.ts +++ b/extensions/synology-chat/src/channel.test.ts @@ -22,6 +22,8 @@ describe("createSynologyChatPlugin", () => { expect(plugin.meta).toBeDefined(); expect(plugin.capabilities).toBeDefined(); expect(plugin.config).toBeDefined(); + expect(plugin.setup).toBeDefined(); + expect(plugin.setupWizard).toBeDefined(); expect(plugin.security).toBeDefined(); expect(plugin.outbound).toBeDefined(); expect(plugin.gateway).toBeDefined(); diff --git a/extensions/synology-chat/src/channel.ts b/extensions/synology-chat/src/channel.ts index d84516dbda5..0bc771a7d26 100644 --- a/extensions/synology-chat/src/channel.ts +++ b/extensions/synology-chat/src/channel.ts @@ -14,6 +14,7 @@ import { z } from "zod"; import { listAccountIds, resolveAccount } from "./accounts.js"; import { sendMessage, sendFileUrl } from "./client.js"; import { getSynologyRuntime } from "./runtime.js"; +import { synologyChatSetupAdapter, synologyChatSetupWizard } from "./setup-surface.js"; import type { ResolvedSynologyChatAccount } from "./types.js"; import { createWebhookHandler } from "./webhook-handler.js"; @@ -68,6 +69,8 @@ export function createSynologyChatPlugin() { reload: { configPrefixes: [`channels.${CHANNEL_ID}`] }, configSchema: SynologyChatConfigSchema, + setup: synologyChatSetupAdapter, + setupWizard: synologyChatSetupWizard, config: { listAccountIds: (cfg: any) => listAccountIds(cfg), @@ -377,3 +380,5 @@ export function createSynologyChatPlugin() { }, }; } + +export const synologyChatPlugin = createSynologyChatPlugin(); diff --git a/extensions/synology-chat/src/setup-surface.test.ts b/extensions/synology-chat/src/setup-surface.test.ts new file mode 100644 index 00000000000..d7a2a1056a0 --- /dev/null +++ b/extensions/synology-chat/src/setup-surface.test.ts @@ -0,0 +1,101 @@ +import { describe, expect, it, vi } from "vitest"; +import { buildChannelSetupFlowAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { synologyChatPlugin } from "./channel.js"; +import { synologyChatSetupWizard } from "./setup-surface.js"; + +function createPrompter(overrides: Partial = {}): WizardPrompter { + return { + intro: vi.fn(async () => {}), + outro: vi.fn(async () => {}), + note: vi.fn(async () => {}), + select: vi.fn(async ({ options }: { options: Array<{ value: string }> }) => { + const first = options[0]; + if (!first) { + throw new Error("no options"); + } + return first.value; + }) as WizardPrompter["select"], + multiselect: vi.fn(async () => []), + text: vi.fn(async () => "") as WizardPrompter["text"], + confirm: vi.fn(async () => false), + progress: vi.fn(() => ({ update: vi.fn(), stop: vi.fn() })), + ...overrides, + }; +} + +const synologyChatConfigureAdapter = buildChannelSetupFlowAdapterFromSetupWizard({ + plugin: synologyChatPlugin, + wizard: synologyChatSetupWizard, +}); + +describe("synology-chat setup wizard", () => { + it("configures token and incoming webhook for the default account", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enter Synology Chat outgoing webhook token") { + return "synology-token"; + } + if (message === "Incoming webhook URL") { + return "https://nas.example.com/webapi/entry.cgi?token=incoming"; + } + if (message === "Outgoing webhook path (optional)") { + return ""; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await synologyChatConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.["synology-chat"]?.enabled).toBe(true); + expect(result.cfg.channels?.["synology-chat"]?.token).toBe("synology-token"); + expect(result.cfg.channels?.["synology-chat"]?.incomingUrl).toBe( + "https://nas.example.com/webapi/entry.cgi?token=incoming", + ); + }); + + it("records allowed user ids when setup forces allowFrom", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enter Synology Chat outgoing webhook token") { + return "synology-token"; + } + if (message === "Incoming webhook URL") { + return "https://nas.example.com/webapi/entry.cgi?token=incoming"; + } + if (message === "Outgoing webhook path (optional)") { + return ""; + } + if (message === "Allowed Synology Chat user ids") { + return "123456, synology-chat:789012"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await synologyChatConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: true, + }); + + expect(result.cfg.channels?.["synology-chat"]?.dmPolicy).toBe("allowlist"); + expect(result.cfg.channels?.["synology-chat"]?.allowedUserIds).toEqual(["123456", "789012"]); + }); +}); diff --git a/extensions/synology-chat/src/setup-surface.ts b/extensions/synology-chat/src/setup-surface.ts new file mode 100644 index 00000000000..77ad0ded2c2 --- /dev/null +++ b/extensions/synology-chat/src/setup-surface.ts @@ -0,0 +1,324 @@ +import { + mergeAllowFromEntries, + setSetupChannelEnabled, + splitSetupEntries, +} from "../../../src/channels/plugins/setup-flow-helpers.js"; +import type { ChannelSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import type { ChannelSetupAdapter } from "../../../src/channels/plugins/types.adapters.js"; +import type { OpenClawConfig } from "../../../src/config/config.js"; +import { DEFAULT_ACCOUNT_ID, normalizeAccountId } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; +import { listAccountIds, resolveAccount } from "./accounts.js"; +import type { SynologyChatAccountRaw, SynologyChatChannelConfig } from "./types.js"; + +const channel = "synology-chat" as const; +const DEFAULT_WEBHOOK_PATH = "/webhook/synology"; + +const SYNOLOGY_SETUP_HELP_LINES = [ + "1) Create an incoming webhook in Synology Chat and copy its URL", + "2) Create an outgoing webhook and copy its secret token", + `3) Point the outgoing webhook to https://${DEFAULT_WEBHOOK_PATH}`, + "4) Keep allowed user IDs handy for DM allowlisting", + `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`, +]; + +const SYNOLOGY_ALLOW_FROM_HELP_LINES = [ + "Allowlist Synology Chat DMs by numeric user id.", + "Examples:", + "- 123456", + "- synology-chat:123456", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`, +]; + +function getChannelConfig(cfg: OpenClawConfig): SynologyChatChannelConfig { + return (cfg.channels?.[channel] as SynologyChatChannelConfig | undefined) ?? {}; +} + +function getRawAccountConfig(cfg: OpenClawConfig, accountId: string): SynologyChatAccountRaw { + const channelConfig = getChannelConfig(cfg); + if (accountId === DEFAULT_ACCOUNT_ID) { + return channelConfig; + } + return channelConfig.accounts?.[accountId] ?? {}; +} + +function patchSynologyChatAccountConfig(params: { + cfg: OpenClawConfig; + accountId: string; + patch: Record; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const channelConfig = getChannelConfig(params.cfg); + if (params.accountId === DEFAULT_ACCOUNT_ID) { + const nextChannelConfig = { ...channelConfig } as Record; + for (const field of params.clearFields ?? []) { + delete nextChannelConfig[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [channel]: { + ...nextChannelConfig, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }; + } + + const nextAccounts = { ...(channelConfig.accounts ?? {}) } as Record< + string, + Record + >; + const nextAccountConfig = { ...(nextAccounts[params.accountId] ?? {}) }; + for (const field of params.clearFields ?? []) { + delete nextAccountConfig[field]; + } + nextAccounts[params.accountId] = { + ...nextAccountConfig, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }; + + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + [channel]: { + ...channelConfig, + ...(params.enabled ? { enabled: true } : {}), + accounts: nextAccounts, + }, + }, + }; +} + +function isSynologyChatConfigured(cfg: OpenClawConfig, accountId: string): boolean { + const account = resolveAccount(cfg, accountId); + return Boolean(account.token.trim() && account.incomingUrl.trim()); +} + +function validateWebhookUrl(value: string): string | undefined { + try { + const parsed = new URL(value); + if (parsed.protocol !== "http:" && parsed.protocol !== "https:") { + return "Incoming webhook must use http:// or https://."; + } + } catch { + return "Incoming webhook must be a valid URL."; + } + return undefined; +} + +function validateWebhookPath(value: string): string | undefined { + const trimmed = value.trim(); + if (!trimmed) { + return undefined; + } + return trimmed.startsWith("/") ? undefined : "Webhook path must start with /."; +} + +function parseSynologyUserId(value: string): string | null { + const cleaned = value.replace(/^synology-chat:/i, "").trim(); + return /^\d+$/.test(cleaned) ? cleaned : null; +} + +function resolveExistingAllowedUserIds(cfg: OpenClawConfig, accountId: string): string[] { + const raw = getRawAccountConfig(cfg, accountId).allowedUserIds; + if (Array.isArray(raw)) { + return raw.map((value) => String(value).trim()).filter(Boolean); + } + return String(raw ?? "") + .split(",") + .map((value) => value.trim()) + .filter(Boolean); +} + +export const synologyChatSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId) ?? DEFAULT_ACCOUNT_ID, + validateInput: ({ accountId, input }) => { + if (input.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "Synology Chat env credentials only support the default account."; + } + if (!input.useEnv && !input.token?.trim()) { + return "Synology Chat requires --token or --use-env."; + } + if (!input.url?.trim()) { + return "Synology Chat requires --url for the incoming webhook."; + } + const urlError = validateWebhookUrl(input.url.trim()); + if (urlError) { + return urlError; + } + if (input.webhookPath?.trim()) { + return validateWebhookPath(input.webhookPath.trim()) ?? null; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: input.useEnv ? ["token"] : undefined, + patch: { + ...(input.useEnv ? {} : { token: input.token?.trim() }), + incomingUrl: input.url?.trim(), + ...(input.webhookPath?.trim() ? { webhookPath: input.webhookPath.trim() } : {}), + }, + }), +}; + +export const synologyChatSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token + incoming webhook", + configuredHint: "configured", + unconfiguredHint: "needs token + incoming webhook", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listAccountIds(cfg).some((accountId) => isSynologyChatConfigured(cfg, accountId)), + resolveStatusLines: ({ cfg, configured }) => [ + `Synology Chat: ${configured ? "configured" : "needs token + incoming webhook"}`, + `Accounts: ${listAccountIds(cfg).length || 0}`, + ], + }, + introNote: { + title: "Synology Chat webhook setup", + lines: SYNOLOGY_SETUP_HELP_LINES, + }, + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "outgoing webhook token", + preferredEnvVar: "SYNOLOGY_CHAT_TOKEN", + helpTitle: "Synology Chat webhook token", + helpLines: SYNOLOGY_SETUP_HELP_LINES, + envPrompt: "SYNOLOGY_CHAT_TOKEN detected. Use env var?", + keepPrompt: "Synology Chat webhook token already configured. Keep it?", + inputPrompt: "Enter Synology Chat outgoing webhook token", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const account = resolveAccount(cfg, accountId); + const raw = getRawAccountConfig(cfg, accountId); + return { + accountConfigured: isSynologyChatConfigured(cfg, accountId), + hasConfiguredValue: Boolean(raw.token?.trim()), + resolvedValue: account.token.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.SYNOLOGY_CHAT_TOKEN?.trim() || undefined + : undefined, + }; + }, + applyUseEnv: async ({ cfg, accountId }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["token"], + patch: {}, + }), + applySet: async ({ cfg, accountId, resolvedValue }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { token: resolvedValue }, + }), + }, + ], + textInputs: [ + { + inputKey: "url", + message: "Incoming webhook URL", + placeholder: + "https://nas.example.com/webapi/entry.cgi?api=SYNO.Chat.External&method=incoming...", + helpTitle: "Synology Chat incoming webhook", + helpLines: [ + "Use the incoming webhook URL from Synology Chat integrations.", + "This is the URL OpenClaw uses to send replies back to Chat.", + ], + currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).incomingUrl?.trim(), + keepPrompt: (value) => `Incoming webhook URL set (${value}). Keep it?`, + validate: ({ value }) => validateWebhookUrl(value), + applySet: async ({ cfg, accountId, value }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { incomingUrl: value.trim() }, + }), + }, + { + inputKey: "webhookPath", + message: "Outgoing webhook path (optional)", + placeholder: DEFAULT_WEBHOOK_PATH, + required: false, + applyEmptyValue: true, + helpTitle: "Synology Chat outgoing webhook path", + helpLines: [ + `Default path: ${DEFAULT_WEBHOOK_PATH}`, + "Change this only if you need multiple Synology Chat webhook routes.", + ], + currentValue: ({ cfg, accountId }) => getRawAccountConfig(cfg, accountId).webhookPath?.trim(), + keepPrompt: (value) => `Outgoing webhook path set (${value}). Keep it?`, + validate: ({ value }) => validateWebhookPath(value), + applySet: async ({ cfg, accountId, value }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: value.trim() ? undefined : ["webhookPath"], + patch: value.trim() ? { webhookPath: value.trim() } : {}, + }), + }, + ], + allowFrom: { + helpTitle: "Synology Chat allowlist", + helpLines: SYNOLOGY_ALLOW_FROM_HELP_LINES, + message: "Allowed Synology Chat user ids", + placeholder: "123456, 987654", + invalidWithoutCredentialNote: "Synology Chat user ids must be numeric.", + parseInputs: splitSetupEntries, + parseId: parseSynologyUserId, + resolveEntries: async ({ entries }) => + entries.map((entry) => { + const id = parseSynologyUserId(entry); + return { + input: entry, + resolved: Boolean(id), + id, + }; + }), + apply: async ({ cfg, accountId, allowFrom }) => + patchSynologyChatAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { + dmPolicy: "allowlist", + allowedUserIds: mergeAllowFromEntries( + resolveExistingAllowedUserIds(cfg, accountId), + allowFrom, + ), + }, + }), + }, + completionNote: { + title: "Synology Chat access control", + lines: [ + `Default outgoing webhook path: ${DEFAULT_WEBHOOK_PATH}`, + 'Set allowed user IDs, or manually switch `channels.synology-chat.dmPolicy` to `"open"` for public DMs.', + 'With `dmPolicy="allowlist"`, an empty allowedUserIds list blocks the route from starting.', + `Docs: ${formatDocsLink("/channels/synology-chat", "channels/synology-chat")}`, + ], + }, + disable: (cfg) => setSetupChannelEnabled(cfg, channel, false), +}; diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index a483e5aaf30..8a57148f430 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -111,6 +111,12 @@ describe("plugin-sdk subpath exports", () => { expect(typeof zaloSdk.zaloSetupAdapter).toBe("object"); }); + it("exports Synology Chat helpers", async () => { + const synologyChatSdk = await import("openclaw/plugin-sdk/synology-chat"); + expect(typeof synologyChatSdk.synologyChatSetupWizard).toBe("object"); + expect(typeof synologyChatSdk.synologyChatSetupAdapter).toBe("object"); + }); + it("exports Zalouser helpers", async () => { const zalouserSdk = await import("openclaw/plugin-sdk/zalouser"); expect(typeof zalouserSdk.zalouserSetupWizard).toBe("object"); diff --git a/src/plugin-sdk/synology-chat.ts b/src/plugin-sdk/synology-chat.ts index dcce2ea760b..f5fae73fbb2 100644 --- a/src/plugin-sdk/synology-chat.ts +++ b/src/plugin-sdk/synology-chat.ts @@ -3,6 +3,7 @@ export { setAccountEnabledInConfigSection } from "../channels/plugins/config-helpers.js"; export { buildChannelConfigSchema } from "../channels/plugins/config-schema.js"; +export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export { isRequestBodyLimitError, readRequestBodyWithLimit, @@ -10,8 +11,13 @@ export { } from "../infra/http-body.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; export { registerPluginHttpRoute } from "../plugins/http-registry.js"; +export type { OpenClawConfig } from "../config/config.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; export { DEFAULT_ACCOUNT_ID } from "../routing/session-key.js"; export type { FixedWindowRateLimiter } from "./webhook-memory-guards.js"; export { createFixedWindowRateLimiter } from "./webhook-memory-guards.js"; +export { + synologyChatSetupAdapter, + synologyChatSetupWizard, +} from "../../extensions/synology-chat/src/setup-surface.js";