refactor: move line to setup wizard

This commit is contained in:
Peter Steinberger
2026-03-15 19:14:11 -07:00
parent 9785b44307
commit ec93398d7b
5 changed files with 434 additions and 131 deletions

View File

@@ -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<ResolvedLineAccount>(
normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""),
});
function patchLineAccountConfig(
cfg: OpenClawConfig,
lineConfig: LineConfig,
accountId: string,
patch: Record<string, unknown>,
): 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<ResolvedLineAccount> = {
id: "line",
meta: {
@@ -131,6 +96,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
},
reload: { configPrefixes: ["channels.line"] },
configSchema: buildChannelConfigSchema(LineConfigSchema),
setupWizard: lineSetupWizard,
config: {
...lineConfigBase,
isConfigured: (account) =>
@@ -200,101 +166,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
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),

View File

@@ -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> = {}): 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<typeof buildChannelOnboardingAdapterFromSetupWizard>[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");
});
});

View File

@@ -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://<gateway-host>/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<string, unknown>;
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<string, unknown>;
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<string, unknown>;
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://<gateway-host>/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),
};

View File

@@ -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 {

View File

@@ -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", () => {