feat(line): add onboarding support for LINE Messaging API

- Created lineOnboardingAdapter with interactive setup flow
- Added Channel Access Token and Channel Secret input with validation
- Included webhook configuration instructions (ngrok/Tailscale)
- Implemented User ID allowFrom management with format validation
- Exported lineOnboardingAdapter from plugin-sdk
- Connected onboarding adapter to LINE channel plugin

This enables seamless LINE bot setup through 'moltbot onboard' wizard,
resolving the 'line does not support onboarding yet' message.

Tested with:
- Local development via ngrok
- Token/Secret validation
- Webhook signature verification
- Full message send/receive flow
- dmPolicy modes (open/allowlist/pairing)

Co-authored-by: Claude <claude@anthropic.com>
This commit is contained in:
edenfunf 2026-01-30 03:39:50 +08:00
parent 5f4715acfc
commit d7cfa542d5
4 changed files with 257 additions and 4 deletions

View File

@ -12,9 +12,11 @@ const plugin = {
configSchema: emptyPluginConfigSchema(), configSchema: emptyPluginConfigSchema(),
register(api: MoltbotPluginApi) { register(api: MoltbotPluginApi) {
setLineRuntime(api.runtime); setLineRuntime(api.runtime);
api.registerChannel({ plugin: linePlugin }); api.registerChannel({
plugin: linePlugin
});
registerLineCardCommand(api); registerLineCardCommand(api);
}, },
}; };
export default plugin; export default plugin;

View File

@ -3,6 +3,7 @@ import {
DEFAULT_ACCOUNT_ID, DEFAULT_ACCOUNT_ID,
LineConfigSchema, LineConfigSchema,
processLineMessage, processLineMessage,
lineOnboardingAdapter,
type ChannelPlugin, type ChannelPlugin,
type MoltbotConfig, type MoltbotConfig,
type LineConfig, type LineConfig,
@ -11,7 +12,6 @@ import {
} from "clawdbot/plugin-sdk"; } from "clawdbot/plugin-sdk";
import { getLineRuntime } from "./runtime.js"; import { getLineRuntime } from "./runtime.js";
// LINE channel metadata // LINE channel metadata
const meta = { const meta = {
id: "line", id: "line",
@ -41,6 +41,7 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
...meta, ...meta,
quickstartAllowFrom: true, quickstartAllowFrom: true,
}, },
onboarding: lineOnboardingAdapter,
pairing: { pairing: {
idLabel: "lineUserId", idLabel: "lineUserId",
normalizeAllowEntry: (entry) => { normalizeAllowEntry: (entry) => {
@ -770,4 +771,5 @@ export const linePlugin: ChannelPlugin<ResolvedLineAccount> = {
"- Tables/code in your response auto-convert to visual cards", "- Tables/code in your response auto-convert to visual cards",
], ],
}, },
}; };

View File

@ -0,0 +1,248 @@
import type { MoltbotConfig } from "../../../config/config.js";
import type { DmPolicy } from "../../../config/types.js";
import { DEFAULT_ACCOUNT_ID } from "../../../routing/session-key.js";
import { formatDocsLink } from "../../../terminal/links.js";
import type { WizardPrompter } from "../../../wizard/prompts.js";
import type { ChannelOnboardingAdapter, ChannelOnboardingDmPolicy } from "../onboarding-types.js";
import { addWildcardAllowFrom } from "./helpers.js";
// ✨ LINE config 類型定義
type LineConfig = {
enabled?: boolean;
channelAccessToken?: string;
channelSecret?: string;
tokenFile?: string;
secretFile?: string;
dmPolicy?: DmPolicy;
allowFrom?: Array<string | number>;
};
const channel = "line" as const;
function setLineDmPolicy(cfg: MoltbotConfig, dmPolicy: DmPolicy) {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
const allowFrom = dmPolicy === "open" ? addWildcardAllowFrom(lineConfig.allowFrom) : undefined;
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
dmPolicy,
...(allowFrom ? { allowFrom } : {}),
},
},
};
}
async function noteLineTokenHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) Go to https://developers.line.biz/console/",
"2) Create a Messaging API channel (or select existing)",
"3) Go to 'Messaging API' tab",
"4) Issue a Channel Access Token (long-lived)",
"5) Copy the token",
`Docs: ${formatDocsLink("/channels/line")}`,
"Website: https://molt.bot",
].join("\n"),
"LINE Channel Access Token",
);
}
async function noteLineSecretHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"1) In the same Messaging API tab",
"2) Find 'Channel Secret' section",
"3) Copy the secret",
`Docs: ${formatDocsLink("/channels/line")}`,
].join("\n"),
"LINE Channel Secret",
);
}
async function noteLineWebhookHelp(prompter: WizardPrompter): Promise<void> {
await prompter.note(
[
"📌 Webhook Setup Required:",
"1) In LINE Developers Console, go to 'Messaging API' tab",
"2) Set Webhook URL to: https://YOUR_PUBLIC_URL/webhook/line",
"3) Enable 'Use webhook'",
"4) Verify the webhook (it should show success)",
"",
"For local development:",
"- Use ngrok: ngrok http 18789",
"- Or use Tailscale Funnel (built into Moltbot)",
`Docs: ${formatDocsLink("/channels/line")}`,
].join("\n"),
"LINE Webhook",
);
}
const dmPolicy: ChannelOnboardingDmPolicy = {
label: "LINE",
channel,
policyKey: "channels.line.dmPolicy",
allowFromKey: "channels.line.allowFrom",
getCurrent: (cfg) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
return lineConfig.dmPolicy ?? "pairing";
},
setPolicy: (cfg, policy) => setLineDmPolicy(cfg, policy),
promptAllowFrom: async ({ cfg, prompter }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
const existingAllowFrom = lineConfig.allowFrom ?? [];
await prompter.note(
[
"LINE User IDs are typically U followed by 32 hex characters.",
"You can find user IDs in the Moltbot logs when users message your bot.",
"Example: Ub1234567890abcdef1234567890abcdef",
].join("\n"),
"LINE User ID",
);
const entry = await prompter.text({
message: "LINE allowFrom (user ID)",
placeholder: "",
initialValue: existingAllowFrom[0] ? String(existingAllowFrom[0]) : undefined,
validate: (value) => {
const trimmed = String(value ?? "").trim();
if (!trimmed) return "Required";
if (!/^U[a-f0-9]{32}$/i.test(trimmed)) {
return "Invalid LINE user ID format (should be U + 32 hex characters)";
}
return undefined;
},
});
const userId = String(entry).trim();
const merged = [
...existingAllowFrom.map((item: string | number) => String(item).trim()).filter(Boolean),
userId,
];
const unique = [...new Set(merged)];
return {
...cfg,
channels: {
...cfg.channels,
line: {
...lineConfig,
enabled: true,
dmPolicy: "allowlist" as DmPolicy,
allowFrom: unique,
},
},
};
},
};
export const lineOnboardingAdapter: ChannelOnboardingAdapter = {
channel,
getStatus: async ({ cfg }) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
const configured = Boolean(
lineConfig.channelAccessToken?.trim() || lineConfig.tokenFile?.trim(),
);
return {
channel,
configured,
statusLines: [`LINE: ${configured ? "configured" : "needs credentials"}`],
selectionHint: configured ? "configured" : "popular in Japan/Taiwan/Thailand",
quickstartScore: configured ? 1 : 7,
};
},
configure: async ({ cfg, prompter, forceAllowFrom }) => {
let next = cfg;
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
const hasToken = Boolean(lineConfig.channelAccessToken || lineConfig.tokenFile);
const hasSecret = Boolean(lineConfig.channelSecret || lineConfig.secretFile);
// Prompt for Channel Access Token
let channelAccessToken: string | null = null;
if (!hasToken) {
await noteLineTokenHelp(prompter);
} else {
const keep = await prompter.confirm({
message: "LINE Channel Access Token already configured. Keep it?",
initialValue: true,
});
if (!keep) {
await noteLineTokenHelp(prompter);
channelAccessToken = null;
}
}
if (!hasToken || channelAccessToken === null) {
channelAccessToken = String(
await prompter.text({
message: "Enter LINE Channel Access Token",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
// Prompt for Channel Secret
let channelSecret: string | null = null;
if (!hasSecret) {
await noteLineSecretHelp(prompter);
} else {
const keep = await prompter.confirm({
message: "LINE Channel Secret already configured. Keep it?",
initialValue: true,
});
if (!keep) {
await noteLineSecretHelp(prompter);
channelSecret = null;
}
}
if (!hasSecret || channelSecret === null) {
channelSecret = String(
await prompter.text({
message: "Enter LINE Channel Secret",
validate: (value) => (value?.trim() ? undefined : "Required"),
}),
).trim();
}
// Update config
if (channelAccessToken || channelSecret) {
next = {
...next,
channels: {
...next.channels,
line: {
...lineConfig,
enabled: true,
...(channelAccessToken ? { channelAccessToken } : {}),
...(channelSecret ? { channelSecret } : {}),
},
},
};
}
// Show webhook setup instructions
await noteLineWebhookHelp(prompter);
// Prompt for allowFrom if needed
if (forceAllowFrom && dmPolicy.promptAllowFrom) {
next = await dmPolicy.promptAllowFrom({ cfg: next, prompter });
}
return { cfg: next, accountId: DEFAULT_ACCOUNT_ID };
},
dmPolicy,
disable: (cfg) => {
const lineConfig = (cfg.channels?.line ?? {}) as LineConfig;
return {
...cfg,
channels: {
...cfg.channels,
line: { ...lineConfig, enabled: false },
},
};
},
};

View File

@ -301,6 +301,7 @@ export {
type ResolvedTelegramAccount, type ResolvedTelegramAccount,
} from "../telegram/accounts.js"; } from "../telegram/accounts.js";
export { telegramOnboardingAdapter } from "../channels/plugins/onboarding/telegram.js"; export { telegramOnboardingAdapter } from "../channels/plugins/onboarding/telegram.js";
export { lineOnboardingAdapter } from "../channels/plugins/onboarding/line.js";
export { export {
looksLikeTelegramTargetId, looksLikeTelegramTargetId,
normalizeTelegramMessagingTarget, normalizeTelegramMessagingTarget,