diff --git a/extensions/line/src/channel.ts b/extensions/line/src/channel.ts index 982d7670082..4c2b51cd6d0 100644 --- a/extensions/line/src/channel.ts +++ b/extensions/line/src/channel.ts @@ -20,6 +20,7 @@ import { type ResolvedLineAccount, } from "openclaw/plugin-sdk/line"; import { getLineRuntime } from "./runtime.js"; +import { lineSetupAdapter, lineSetupWizard } from "./setup-surface.js"; // LINE channel metadata const meta = { @@ -62,42 +63,6 @@ const resolveLineDmPolicy = createScopedDmSecurityResolver( normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""), }); -function patchLineAccountConfig( - cfg: OpenClawConfig, - lineConfig: LineConfig, - accountId: string, - patch: Record, -): OpenClawConfig { - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - ...patch, - }, - }, - }; - } - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - accounts: { - ...lineConfig.accounts, - [accountId]: { - ...lineConfig.accounts?.[accountId], - ...patch, - }, - }, - }, - }, - }; -} - export const linePlugin: ChannelPlugin = { id: "line", meta: { @@ -131,6 +96,7 @@ export const linePlugin: ChannelPlugin = { }, reload: { configPrefixes: ["channels.line"] }, configSchema: buildChannelConfigSchema(LineConfigSchema), + setupWizard: lineSetupWizard, config: { ...lineConfigBase, isConfigured: (account) => @@ -200,101 +166,7 @@ export const linePlugin: ChannelPlugin = { listPeers: async () => [], listGroups: async () => [], }, - setup: { - resolveAccountId: ({ accountId }) => - getLineRuntime().channel.line.normalizeAccountId(accountId), - applyAccountName: ({ cfg, accountId, name }) => { - const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; - return patchLineAccountConfig(cfg, lineConfig, accountId, { name }); - }, - validateInput: ({ accountId, input }) => { - const typedInput = input as { - useEnv?: boolean; - channelAccessToken?: string; - channelSecret?: string; - tokenFile?: string; - secretFile?: string; - }; - if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { - return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account."; - } - if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) { - return "LINE requires channelAccessToken or --token-file (or --use-env)."; - } - if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) { - return "LINE requires channelSecret or --secret-file (or --use-env)."; - } - return null; - }, - applyAccountConfig: ({ cfg, accountId, input }) => { - const typedInput = input as { - name?: string; - useEnv?: boolean; - channelAccessToken?: string; - channelSecret?: string; - tokenFile?: string; - secretFile?: string; - }; - const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; - - if (accountId === DEFAULT_ACCOUNT_ID) { - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - enabled: true, - ...(typedInput.name ? { name: typedInput.name } : {}), - ...(typedInput.useEnv - ? {} - : typedInput.tokenFile - ? { tokenFile: typedInput.tokenFile } - : typedInput.channelAccessToken - ? { channelAccessToken: typedInput.channelAccessToken } - : {}), - ...(typedInput.useEnv - ? {} - : typedInput.secretFile - ? { secretFile: typedInput.secretFile } - : typedInput.channelSecret - ? { channelSecret: typedInput.channelSecret } - : {}), - }, - }, - }; - } - - return { - ...cfg, - channels: { - ...cfg.channels, - line: { - ...lineConfig, - enabled: true, - accounts: { - ...lineConfig.accounts, - [accountId]: { - ...lineConfig.accounts?.[accountId], - enabled: true, - ...(typedInput.name ? { name: typedInput.name } : {}), - ...(typedInput.tokenFile - ? { tokenFile: typedInput.tokenFile } - : typedInput.channelAccessToken - ? { channelAccessToken: typedInput.channelAccessToken } - : {}), - ...(typedInput.secretFile - ? { secretFile: typedInput.secretFile } - : typedInput.channelSecret - ? { channelSecret: typedInput.channelSecret } - : {}), - }, - }, - }, - }, - }; - }, - }, + setup: lineSetupAdapter, outbound: { deliveryMode: "direct", chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit), diff --git a/extensions/line/src/setup-surface.test.ts b/extensions/line/src/setup-surface.test.ts new file mode 100644 index 00000000000..9fbddc19675 --- /dev/null +++ b/extensions/line/src/setup-surface.test.ts @@ -0,0 +1,77 @@ +import type { OpenClawConfig } from "openclaw/plugin-sdk/line"; +import { describe, expect, it, vi } from "vitest"; +import { buildChannelOnboardingAdapterFromSetupWizard } from "../../../src/channels/plugins/setup-wizard.js"; +import { + listLineAccountIds, + resolveDefaultLineAccountId, + resolveLineAccount, +} from "../../../src/line/accounts.js"; +import type { WizardPrompter } from "../../../src/wizard/prompts.js"; +import { createRuntimeEnv } from "../../test-utils/runtime-env.js"; +import { lineSetupAdapter, lineSetupWizard } 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 lineConfigureAdapter = buildChannelOnboardingAdapterFromSetupWizard({ + plugin: { + id: "line", + meta: { label: "LINE" }, + config: { + listAccountIds: listLineAccountIds, + defaultAccountId: resolveDefaultLineAccountId, + resolveAllowFrom: ({ cfg, accountId }: { cfg: OpenClawConfig; accountId?: string | null }) => + resolveLineAccount({ cfg, accountId: accountId ?? undefined }).config.allowFrom, + }, + setup: lineSetupAdapter, + } as Parameters[0]["plugin"], + wizard: lineSetupWizard, +}); + +describe("line setup wizard", () => { + it("configures token and secret for the default account", async () => { + const prompter = createPrompter({ + text: vi.fn(async ({ message }: { message: string }) => { + if (message === "Enter LINE channel access token") { + return "line-token"; + } + if (message === "Enter LINE channel secret") { + return "line-secret"; + } + throw new Error(`Unexpected prompt: ${message}`); + }) as WizardPrompter["text"], + }); + + const result = await lineConfigureAdapter.configure({ + cfg: {} as OpenClawConfig, + runtime: createRuntimeEnv(), + prompter, + options: {}, + accountOverrides: {}, + shouldPromptAccountIds: false, + forceAllowFrom: false, + }); + + expect(result.accountId).toBe("default"); + expect(result.cfg.channels?.line?.enabled).toBe(true); + expect(result.cfg.channels?.line?.channelAccessToken).toBe("line-token"); + expect(result.cfg.channels?.line?.channelSecret).toBe("line-secret"); + }); +}); diff --git a/extensions/line/src/setup-surface.ts b/extensions/line/src/setup-surface.ts new file mode 100644 index 00000000000..1b7a22dfb11 --- /dev/null +++ b/extensions/line/src/setup-surface.ts @@ -0,0 +1,350 @@ +import type { ChannelOnboardingDmPolicy } from "../../../src/channels/plugins/onboarding-types.js"; +import { + setOnboardingChannelEnabled, + setTopLevelChannelDmPolicyWithAllowFrom, + splitOnboardingEntries, +} from "../../../src/channels/plugins/onboarding/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 { + listLineAccountIds, + normalizeAccountId, + resolveLineAccount, +} from "../../../src/line/accounts.js"; +import type { LineConfig } from "../../../src/line/types.js"; +import { DEFAULT_ACCOUNT_ID } from "../../../src/routing/session-key.js"; +import { formatDocsLink } from "../../../src/terminal/links.js"; + +const channel = "line" as const; + +const LINE_SETUP_HELP_LINES = [ + "1) Open the LINE Developers Console and create or pick a Messaging API channel", + "2) Copy the channel access token and channel secret", + "3) Enable Use webhook in the Messaging API settings", + "4) Point the webhook at https:///line/webhook", + `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, +]; + +const LINE_ALLOW_FROM_HELP_LINES = [ + "Allowlist LINE DMs by user id.", + "LINE ids are case-sensitive.", + "Examples:", + "- U1234567890abcdef1234567890abcdef", + "- line:user:U1234567890abcdef1234567890abcdef", + "Multiple entries: comma-separated.", + `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, +]; + +function patchLineAccountConfig(params: { + cfg: OpenClawConfig; + accountId: string; + patch: Record; + clearFields?: string[]; + enabled?: boolean; +}): OpenClawConfig { + const accountId = normalizeAccountId(params.accountId); + const lineConfig = ((params.cfg.channels?.line ?? {}) as LineConfig) ?? {}; + const clearFields = params.clearFields ?? []; + + if (accountId === DEFAULT_ACCOUNT_ID) { + const nextLine = { ...lineConfig } as Record; + for (const field of clearFields) { + delete nextLine[field]; + } + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + line: { + ...nextLine, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }; + } + + const nextAccount = { + ...(lineConfig.accounts?.[accountId] ?? {}), + } as Record; + for (const field of clearFields) { + delete nextAccount[field]; + } + + return { + ...params.cfg, + channels: { + ...params.cfg.channels, + line: { + ...lineConfig, + ...(params.enabled ? { enabled: true } : {}), + accounts: { + ...lineConfig.accounts, + [accountId]: { + ...nextAccount, + ...(params.enabled ? { enabled: true } : {}), + ...params.patch, + }, + }, + }, + }, + }; +} + +function isLineConfigured(cfg: OpenClawConfig, accountId: string): boolean { + const resolved = resolveLineAccount({ cfg, accountId }); + return Boolean(resolved.channelAccessToken.trim() && resolved.channelSecret.trim()); +} + +function parseLineAllowFromId(raw: string): string | null { + const trimmed = raw.trim().replace(/^line:(?:user:)?/i, ""); + if (!/^U[a-f0-9]{32}$/i.test(trimmed)) { + return null; + } + return trimmed; +} + +const lineDmPolicy: ChannelOnboardingDmPolicy = { + label: "LINE", + channel, + policyKey: "channels.line.dmPolicy", + allowFromKey: "channels.line.allowFrom", + getCurrent: (cfg) => cfg.channels?.line?.dmPolicy ?? "pairing", + setPolicy: (cfg, policy) => + setTopLevelChannelDmPolicyWithAllowFrom({ + cfg, + channel, + dmPolicy: policy, + }), +}; + +export const lineSetupAdapter: ChannelSetupAdapter = { + resolveAccountId: ({ accountId }) => normalizeAccountId(accountId), + applyAccountName: ({ cfg, accountId, name }) => + patchLineAccountConfig({ + cfg, + accountId, + patch: name?.trim() ? { name: name.trim() } : {}, + }), + validateInput: ({ accountId, input }) => { + const typedInput = input as { + useEnv?: boolean; + channelAccessToken?: string; + channelSecret?: string; + tokenFile?: string; + secretFile?: string; + }; + if (typedInput.useEnv && accountId !== DEFAULT_ACCOUNT_ID) { + return "LINE_CHANNEL_ACCESS_TOKEN can only be used for the default account."; + } + if (!typedInput.useEnv && !typedInput.channelAccessToken && !typedInput.tokenFile) { + return "LINE requires channelAccessToken or --token-file (or --use-env)."; + } + if (!typedInput.useEnv && !typedInput.channelSecret && !typedInput.secretFile) { + return "LINE requires channelSecret or --secret-file (or --use-env)."; + } + return null; + }, + applyAccountConfig: ({ cfg, accountId, input }) => { + const typedInput = input as { + useEnv?: boolean; + channelAccessToken?: string; + channelSecret?: string; + tokenFile?: string; + secretFile?: string; + }; + const normalizedAccountId = normalizeAccountId(accountId); + if (normalizedAccountId === DEFAULT_ACCOUNT_ID) { + return patchLineAccountConfig({ + cfg, + accountId: normalizedAccountId, + enabled: true, + clearFields: typedInput.useEnv + ? ["channelAccessToken", "channelSecret", "tokenFile", "secretFile"] + : undefined, + patch: typedInput.useEnv + ? {} + : { + ...(typedInput.tokenFile + ? { tokenFile: typedInput.tokenFile } + : typedInput.channelAccessToken + ? { channelAccessToken: typedInput.channelAccessToken } + : {}), + ...(typedInput.secretFile + ? { secretFile: typedInput.secretFile } + : typedInput.channelSecret + ? { channelSecret: typedInput.channelSecret } + : {}), + }, + }); + } + return patchLineAccountConfig({ + cfg, + accountId: normalizedAccountId, + enabled: true, + patch: { + ...(typedInput.tokenFile + ? { tokenFile: typedInput.tokenFile } + : typedInput.channelAccessToken + ? { channelAccessToken: typedInput.channelAccessToken } + : {}), + ...(typedInput.secretFile + ? { secretFile: typedInput.secretFile } + : typedInput.channelSecret + ? { channelSecret: typedInput.channelSecret } + : {}), + }, + }); + }, +}; + +export const lineSetupWizard: ChannelSetupWizard = { + channel, + status: { + configuredLabel: "configured", + unconfiguredLabel: "needs token + secret", + configuredHint: "configured", + unconfiguredHint: "needs token + secret", + configuredScore: 1, + unconfiguredScore: 0, + resolveConfigured: ({ cfg }) => + listLineAccountIds(cfg).some((accountId) => isLineConfigured(cfg, accountId)), + resolveStatusLines: ({ cfg, configured }) => [ + `LINE: ${configured ? "configured" : "needs token + secret"}`, + `Accounts: ${listLineAccountIds(cfg).length || 0}`, + ], + }, + introNote: { + title: "LINE Messaging API", + lines: LINE_SETUP_HELP_LINES, + shouldShow: ({ cfg, accountId }) => !isLineConfigured(cfg, accountId), + }, + credentials: [ + { + inputKey: "token", + providerHint: channel, + credentialLabel: "channel access token", + preferredEnvVar: "LINE_CHANNEL_ACCESS_TOKEN", + helpTitle: "LINE Messaging API", + helpLines: LINE_SETUP_HELP_LINES, + envPrompt: "LINE_CHANNEL_ACCESS_TOKEN detected. Use env var?", + keepPrompt: "LINE channel access token already configured. Keep it?", + inputPrompt: "Enter LINE channel access token", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolved = resolveLineAccount({ cfg, accountId }); + return { + accountConfigured: Boolean( + resolved.channelAccessToken.trim() && resolved.channelSecret.trim(), + ), + hasConfiguredValue: Boolean( + resolved.config.channelAccessToken?.trim() || resolved.config.tokenFile?.trim(), + ), + resolvedValue: resolved.channelAccessToken.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() || undefined + : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["channelAccessToken", "tokenFile"], + patch: {}, + }), + applySet: ({ cfg, accountId, resolvedValue }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["tokenFile"], + patch: { channelAccessToken: resolvedValue }, + }), + }, + { + inputKey: "password", + providerHint: "line-secret", + credentialLabel: "channel secret", + preferredEnvVar: "LINE_CHANNEL_SECRET", + helpTitle: "LINE Messaging API", + helpLines: LINE_SETUP_HELP_LINES, + envPrompt: "LINE_CHANNEL_SECRET detected. Use env var?", + keepPrompt: "LINE channel secret already configured. Keep it?", + inputPrompt: "Enter LINE channel secret", + allowEnv: ({ accountId }) => accountId === DEFAULT_ACCOUNT_ID, + inspect: ({ cfg, accountId }) => { + const resolved = resolveLineAccount({ cfg, accountId }); + return { + accountConfigured: Boolean( + resolved.channelAccessToken.trim() && resolved.channelSecret.trim(), + ), + hasConfiguredValue: Boolean( + resolved.config.channelSecret?.trim() || resolved.config.secretFile?.trim(), + ), + resolvedValue: resolved.channelSecret.trim() || undefined, + envValue: + accountId === DEFAULT_ACCOUNT_ID + ? process.env.LINE_CHANNEL_SECRET?.trim() || undefined + : undefined, + }; + }, + applyUseEnv: ({ cfg, accountId }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["channelSecret", "secretFile"], + patch: {}, + }), + applySet: ({ cfg, accountId, resolvedValue }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + clearFields: ["secretFile"], + patch: { channelSecret: resolvedValue }, + }), + }, + ], + allowFrom: { + helpTitle: "LINE allowlist", + helpLines: LINE_ALLOW_FROM_HELP_LINES, + message: "LINE allowFrom (user id)", + placeholder: "U1234567890abcdef1234567890abcdef", + invalidWithoutCredentialNote: + "LINE allowFrom requires raw user ids like U1234567890abcdef1234567890abcdef.", + parseInputs: splitOnboardingEntries, + parseId: parseLineAllowFromId, + resolveEntries: async ({ entries }) => + entries.map((entry) => { + const id = parseLineAllowFromId(entry); + return { + input: entry, + resolved: Boolean(id), + id, + }; + }), + apply: ({ cfg, accountId, allowFrom }) => + patchLineAccountConfig({ + cfg, + accountId, + enabled: true, + patch: { dmPolicy: "allowlist", allowFrom }, + }), + }, + dmPolicy: lineDmPolicy, + completionNote: { + title: "LINE webhook", + lines: [ + "Enable Use webhook in the LINE console after saving credentials.", + "Default webhook URL: https:///line/webhook", + "If you set channels.line.webhookPath, update the URL to match.", + `Docs: ${formatDocsLink("/channels/line", "channels/line")}`, + ], + }, + disable: (cfg) => setOnboardingChannelEnabled(cfg, channel, false), +}; diff --git a/src/plugin-sdk/line.ts b/src/plugin-sdk/line.ts index 0318e5ac1e7..d0c6ffcaf86 100644 --- a/src/plugin-sdk/line.ts +++ b/src/plugin-sdk/line.ts @@ -8,6 +8,7 @@ export type { OpenClawConfig } from "../config/config.js"; export type { ReplyPayload } from "../auto-reply/types.js"; export type { PluginRuntime } from "../plugins/runtime/types.js"; export type { OpenClawPluginApi } from "../plugins/types.js"; +export type { ChannelSetupAdapter } from "../channels/plugins/types.adapters.js"; export { emptyPluginConfigSchema } from "../plugins/config-schema.js"; @@ -26,6 +27,7 @@ export { buildTokenChannelStatusSummary, } from "./status-helpers.js"; +export { lineSetupAdapter, lineSetupWizard } from "../../extensions/line/src/setup-surface.js"; export { LineConfigSchema } from "../line/config-schema.js"; export type { LineChannelData, LineConfig, ResolvedLineAccount } from "../line/types.js"; export { diff --git a/src/plugin-sdk/subpaths.test.ts b/src/plugin-sdk/subpaths.test.ts index 6b696be7269..3315cbe5963 100644 --- a/src/plugin-sdk/subpaths.test.ts +++ b/src/plugin-sdk/subpaths.test.ts @@ -82,6 +82,8 @@ describe("plugin-sdk subpath exports", () => { it("exports LINE helpers", () => { expect(typeof lineSdk.processLineMessage).toBe("function"); expect(typeof lineSdk.createInfoCard).toBe("function"); + expect(typeof lineSdk.lineSetupWizard).toBe("object"); + expect(typeof lineSdk.lineSetupAdapter).toBe("object"); }); it("exports Microsoft Teams helpers", () => {