import { buildChannelConfigSchema, buildTokenChannelStatusSummary, DEFAULT_ACCOUNT_ID, LineConfigSchema, processLineMessage, resolveAllowlistProviderRuntimeGroupPolicy, resolveDefaultGroupPolicy, type ChannelPlugin, type ChannelStatusIssue, type OpenClawConfig, type LineConfig, type LineChannelData, type ResolvedLineAccount, } from "openclaw/plugin-sdk"; import { getLineRuntime } from "./runtime.js"; // LINE channel metadata const meta = { id: "line", label: "LINE", selectionLabel: "LINE (Messaging API)", detailLabel: "LINE Bot", docsPath: "/channels/line", docsLabel: "line", blurb: "LINE Messaging API bot for Japan/Taiwan/Thailand markets.", systemImage: "message.fill", }; export const linePlugin: ChannelPlugin = { id: "line", meta: { ...meta, quickstartAllowFrom: true, }, pairing: { idLabel: "lineUserId", normalizeAllowEntry: (entry) => { // LINE IDs are case-sensitive; only strip prefix variants (line: / line:user:). return entry.replace(/^line:(?:user:)?/i, ""); }, notifyApproval: async ({ cfg, id }) => { const line = getLineRuntime().channel.line; const account = line.resolveLineAccount({ cfg }); if (!account.channelAccessToken) { throw new Error("LINE channel access token not configured"); } await line.pushMessageLine(id, "OpenClaw: your access has been approved.", { channelAccessToken: account.channelAccessToken, }); }, }, capabilities: { chatTypes: ["direct", "group"], reactions: false, threads: false, media: true, nativeCommands: false, blockStreaming: true, }, reload: { configPrefixes: ["channels.line"] }, configSchema: buildChannelConfigSchema(LineConfigSchema), config: { listAccountIds: (cfg) => getLineRuntime().channel.line.listLineAccountIds(cfg), resolveAccount: (cfg, accountId) => getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId: accountId ?? undefined }), defaultAccountId: (cfg) => getLineRuntime().channel.line.resolveDefaultLineAccountId(cfg), setAccountEnabled: ({ cfg, accountId, enabled }) => { const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; if (accountId === DEFAULT_ACCOUNT_ID) { return { ...cfg, channels: { ...cfg.channels, line: { ...lineConfig, enabled, }, }, }; } return { ...cfg, channels: { ...cfg.channels, line: { ...lineConfig, accounts: { ...lineConfig.accounts, [accountId]: { ...lineConfig.accounts?.[accountId], enabled, }, }, }, }, }; }, deleteAccount: ({ cfg, accountId }) => { const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; if (accountId === DEFAULT_ACCOUNT_ID) { // oxlint-disable-next-line no-unused-vars const { channelSecret, tokenFile, secretFile, ...rest } = lineConfig; return { ...cfg, channels: { ...cfg.channels, line: rest, }, }; } const accounts = { ...lineConfig.accounts }; delete accounts[accountId]; return { ...cfg, channels: { ...cfg.channels, line: { ...lineConfig, accounts: Object.keys(accounts).length > 0 ? accounts : undefined, }, }, }; }, isConfigured: (account) => Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), describeAccount: (account) => ({ accountId: account.accountId, name: account.name, enabled: account.enabled, configured: Boolean(account.channelAccessToken?.trim() && account.channelSecret?.trim()), tokenSource: account.tokenSource ?? undefined, }), resolveAllowFrom: ({ cfg, accountId }) => ( getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId: accountId ?? undefined }) .config.allowFrom ?? [] ).map((entry) => String(entry)), formatAllowFrom: ({ allowFrom }) => allowFrom .map((entry) => String(entry).trim()) .filter(Boolean) .map((entry) => { // LINE sender IDs are case-sensitive; keep original casing. return entry.replace(/^line:(?:user:)?/i, ""); }), }, security: { resolveDmPolicy: ({ cfg, accountId, account }) => { const resolvedAccountId = accountId ?? account.accountId ?? DEFAULT_ACCOUNT_ID; const useAccountPath = Boolean( (cfg.channels?.line as LineConfig | undefined)?.accounts?.[resolvedAccountId], ); const basePath = useAccountPath ? `channels.line.accounts.${resolvedAccountId}.` : "channels.line."; return { policy: account.config.dmPolicy ?? "pairing", allowFrom: account.config.allowFrom ?? [], policyPath: `${basePath}dmPolicy`, allowFromPath: basePath, approveHint: "openclaw pairing approve line ", normalizeEntry: (raw) => raw.replace(/^line:(?:user:)?/i, ""), }; }, collectWarnings: ({ account, cfg }) => { const defaultGroupPolicy = resolveDefaultGroupPolicy(cfg); const { groupPolicy } = resolveAllowlistProviderRuntimeGroupPolicy({ providerConfigPresent: cfg.channels?.line !== undefined, groupPolicy: account.config.groupPolicy, defaultGroupPolicy, }); if (groupPolicy !== "open") { return []; } return [ `- LINE groups: groupPolicy="open" allows any member in groups to trigger. Set channels.line.groupPolicy="allowlist" + channels.line.groupAllowFrom to restrict senders.`, ]; }, }, groups: { resolveRequireMention: ({ cfg, accountId, groupId }) => { const account = getLineRuntime().channel.line.resolveLineAccount({ cfg, accountId: accountId ?? undefined, }); const groups = account.config.groups; if (!groups || !groupId) { return false; } const groupConfig = groups[groupId] ?? groups["*"]; return groupConfig?.requireMention ?? false; }, }, messaging: { normalizeTarget: (target) => { const trimmed = target.trim(); if (!trimmed) { return undefined; } return trimmed.replace(/^line:(group|room|user):/i, "").replace(/^line:/i, ""); }, targetResolver: { looksLikeId: (id) => { const trimmed = id?.trim(); if (!trimmed) { return false; } // LINE user IDs are typically U followed by 32 hex characters // Group IDs are C followed by 32 hex characters // Room IDs are R followed by 32 hex characters return /^[UCR][a-f0-9]{32}$/i.test(trimmed) || /^line:/i.test(trimmed); }, hint: "", }, }, directory: { self: async () => null, listPeers: async () => [], listGroups: async () => [], }, setup: { resolveAccountId: ({ accountId }) => getLineRuntime().channel.line.normalizeAccountId(accountId), applyAccountName: ({ cfg, accountId, name }) => { const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; if (accountId === DEFAULT_ACCOUNT_ID) { return { ...cfg, channels: { ...cfg.channels, line: { ...lineConfig, name, }, }, }; } return { ...cfg, channels: { ...cfg.channels, line: { ...lineConfig, accounts: { ...lineConfig.accounts, [accountId]: { ...lineConfig.accounts?.[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 } : {}), }, }, }, }, }; }, }, outbound: { deliveryMode: "direct", chunker: (text, limit) => getLineRuntime().channel.text.chunkMarkdownText(text, limit), textChunkLimit: 5000, // LINE allows up to 5000 characters per text message sendPayload: async ({ to, payload, accountId, cfg }) => { const runtime = getLineRuntime(); const lineData = (payload.channelData?.line as LineChannelData | undefined) ?? {}; const sendText = runtime.channel.line.pushMessageLine; const sendBatch = runtime.channel.line.pushMessagesLine; const sendFlex = runtime.channel.line.pushFlexMessage; const sendTemplate = runtime.channel.line.pushTemplateMessage; const sendLocation = runtime.channel.line.pushLocationMessage; const sendQuickReplies = runtime.channel.line.pushTextMessageWithQuickReplies; const buildTemplate = runtime.channel.line.buildTemplateMessageFromPayload; const createQuickReplyItems = runtime.channel.line.createQuickReplyItems; let lastResult: { messageId: string; chatId: string } | null = null; const quickReplies = lineData.quickReplies ?? []; const hasQuickReplies = quickReplies.length > 0; const quickReply = hasQuickReplies ? createQuickReplyItems(quickReplies) : undefined; // oxlint-disable-next-line typescript/no-explicit-any const sendMessageBatch = async (messages: Array>) => { if (messages.length === 0) { return; } for (let i = 0; i < messages.length; i += 5) { // LINE SDK expects Message[] but we build dynamically const batch = messages.slice(i, i + 5) as unknown as Parameters[1]; const result = await sendBatch(to, batch, { verbose: false, accountId: accountId ?? undefined, }); lastResult = { messageId: result.messageId, chatId: result.chatId }; } }; const processed = payload.text ? processLineMessage(payload.text) : { text: "", flexMessages: [] }; const chunkLimit = runtime.channel.text.resolveTextChunkLimit?.(cfg, "line", accountId ?? undefined, { fallbackLimit: 5000, }) ?? 5000; const chunks = processed.text ? runtime.channel.text.chunkMarkdownText(processed.text, chunkLimit) : []; const mediaUrls = payload.mediaUrls ?? (payload.mediaUrl ? [payload.mediaUrl] : []); const shouldSendQuickRepliesInline = chunks.length === 0 && hasQuickReplies; if (!shouldSendQuickRepliesInline) { if (lineData.flexMessage) { // LINE SDK expects FlexContainer but we receive contents as unknown const flexContents = lineData.flexMessage.contents as Parameters[2]; lastResult = await sendFlex(to, lineData.flexMessage.altText, flexContents, { verbose: false, accountId: accountId ?? undefined, }); } if (lineData.templateMessage) { const template = buildTemplate(lineData.templateMessage); if (template) { lastResult = await sendTemplate(to, template, { verbose: false, accountId: accountId ?? undefined, }); } } if (lineData.location) { lastResult = await sendLocation(to, lineData.location, { verbose: false, accountId: accountId ?? undefined, }); } for (const flexMsg of processed.flexMessages) { // LINE SDK expects FlexContainer but we receive contents as unknown const flexContents = flexMsg.contents as Parameters[2]; lastResult = await sendFlex(to, flexMsg.altText, flexContents, { verbose: false, accountId: accountId ?? undefined, }); } } const sendMediaAfterText = !(hasQuickReplies && chunks.length > 0); if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && !sendMediaAfterText) { for (const url of mediaUrls) { lastResult = await runtime.channel.line.sendMessageLine(to, "", { verbose: false, mediaUrl: url, accountId: accountId ?? undefined, }); } } if (chunks.length > 0) { for (let i = 0; i < chunks.length; i += 1) { const isLast = i === chunks.length - 1; if (isLast && hasQuickReplies) { lastResult = await sendQuickReplies(to, chunks[i], quickReplies, { verbose: false, accountId: accountId ?? undefined, }); } else { lastResult = await sendText(to, chunks[i], { verbose: false, accountId: accountId ?? undefined, }); } } } else if (shouldSendQuickRepliesInline) { const quickReplyMessages: Array> = []; if (lineData.flexMessage) { quickReplyMessages.push({ type: "flex", altText: lineData.flexMessage.altText.slice(0, 400), contents: lineData.flexMessage.contents, }); } if (lineData.templateMessage) { const template = buildTemplate(lineData.templateMessage); if (template) { quickReplyMessages.push(template); } } if (lineData.location) { quickReplyMessages.push({ type: "location", title: lineData.location.title.slice(0, 100), address: lineData.location.address.slice(0, 100), latitude: lineData.location.latitude, longitude: lineData.location.longitude, }); } for (const flexMsg of processed.flexMessages) { quickReplyMessages.push({ type: "flex", altText: flexMsg.altText.slice(0, 400), contents: flexMsg.contents, }); } for (const url of mediaUrls) { const trimmed = url?.trim(); if (!trimmed) { continue; } quickReplyMessages.push({ type: "image", originalContentUrl: trimmed, previewImageUrl: trimmed, }); } if (quickReplyMessages.length > 0 && quickReply) { const lastIndex = quickReplyMessages.length - 1; quickReplyMessages[lastIndex] = { ...quickReplyMessages[lastIndex], quickReply, }; await sendMessageBatch(quickReplyMessages); } } if (mediaUrls.length > 0 && !shouldSendQuickRepliesInline && sendMediaAfterText) { for (const url of mediaUrls) { lastResult = await runtime.channel.line.sendMessageLine(to, "", { verbose: false, mediaUrl: url, accountId: accountId ?? undefined, }); } } if (lastResult) { return { channel: "line", ...lastResult }; } return { channel: "line", messageId: "empty", chatId: to }; }, sendText: async ({ to, text, accountId }) => { const runtime = getLineRuntime(); const sendText = runtime.channel.line.pushMessageLine; const sendFlex = runtime.channel.line.pushFlexMessage; // Process markdown: extract tables/code blocks, strip formatting const processed = processLineMessage(text); // Send cleaned text first (if non-empty) let result: { messageId: string; chatId: string }; if (processed.text.trim()) { result = await sendText(to, processed.text, { verbose: false, accountId: accountId ?? undefined, }); } else { // If text is empty after processing, still need a result result = { messageId: "processed", chatId: to }; } // Send flex messages for tables/code blocks for (const flexMsg of processed.flexMessages) { // LINE SDK expects FlexContainer but we receive contents as unknown const flexContents = flexMsg.contents as Parameters[2]; await sendFlex(to, flexMsg.altText, flexContents, { verbose: false, accountId: accountId ?? undefined, }); } return { channel: "line", ...result }; }, sendMedia: async ({ to, text, mediaUrl, accountId }) => { const send = getLineRuntime().channel.line.sendMessageLine; const result = await send(to, text, { verbose: false, mediaUrl, accountId: accountId ?? undefined, }); return { channel: "line", ...result }; }, }, status: { defaultRuntime: { accountId: DEFAULT_ACCOUNT_ID, running: false, lastStartAt: null, lastStopAt: null, lastError: null, }, collectStatusIssues: (accounts) => { const issues: ChannelStatusIssue[] = []; for (const account of accounts) { const accountId = account.accountId ?? DEFAULT_ACCOUNT_ID; if (!account.channelAccessToken?.trim()) { issues.push({ channel: "line", accountId, kind: "config", message: "LINE channel access token not configured", }); } if (!account.channelSecret?.trim()) { issues.push({ channel: "line", accountId, kind: "config", message: "LINE channel secret not configured", }); } } return issues; }, buildChannelSummary: ({ snapshot }) => buildTokenChannelStatusSummary(snapshot), probeAccount: async ({ account, timeoutMs }) => getLineRuntime().channel.line.probeLineBot(account.channelAccessToken, timeoutMs), buildAccountSnapshot: ({ account, runtime, probe }) => { const configured = Boolean( account.channelAccessToken?.trim() && account.channelSecret?.trim(), ); return { accountId: account.accountId, name: account.name, enabled: account.enabled, configured, tokenSource: account.tokenSource, running: runtime?.running ?? false, lastStartAt: runtime?.lastStartAt ?? null, lastStopAt: runtime?.lastStopAt ?? null, lastError: runtime?.lastError ?? null, mode: "webhook", probe, lastInboundAt: runtime?.lastInboundAt ?? null, lastOutboundAt: runtime?.lastOutboundAt ?? null, }; }, }, gateway: { startAccount: async (ctx) => { const account = ctx.account; const token = account.channelAccessToken.trim(); const secret = account.channelSecret.trim(); if (!token) { throw new Error( `LINE webhook mode requires a non-empty channel access token for account "${account.accountId}".`, ); } if (!secret) { throw new Error( `LINE webhook mode requires a non-empty channel secret for account "${account.accountId}".`, ); } let lineBotLabel = ""; try { const probe = await getLineRuntime().channel.line.probeLineBot(token, 2500); const displayName = probe.ok ? probe.bot?.displayName?.trim() : null; if (displayName) { lineBotLabel = ` (${displayName})`; } } catch (err) { if (getLineRuntime().logging.shouldLogVerbose()) { ctx.log?.debug?.(`[${account.accountId}] bot probe failed: ${String(err)}`); } } ctx.log?.info(`[${account.accountId}] starting LINE provider${lineBotLabel}`); return getLineRuntime().channel.line.monitorLineProvider({ channelAccessToken: token, channelSecret: secret, accountId: account.accountId, config: ctx.cfg, runtime: ctx.runtime, abortSignal: ctx.abortSignal, webhookPath: account.config.webhookPath, }); }, logoutAccount: async ({ accountId, cfg }) => { const envToken = process.env.LINE_CHANNEL_ACCESS_TOKEN?.trim() ?? ""; const nextCfg = { ...cfg } as OpenClawConfig; const lineConfig = (cfg.channels?.line ?? {}) as LineConfig; const nextLine = { ...lineConfig }; let cleared = false; let changed = false; if (accountId === DEFAULT_ACCOUNT_ID) { if ( nextLine.channelAccessToken || nextLine.channelSecret || nextLine.tokenFile || nextLine.secretFile ) { delete nextLine.channelAccessToken; delete nextLine.channelSecret; delete nextLine.tokenFile; delete nextLine.secretFile; cleared = true; changed = true; } } const accounts = nextLine.accounts ? { ...nextLine.accounts } : undefined; if (accounts && accountId in accounts) { const entry = accounts[accountId]; if (entry && typeof entry === "object") { const nextEntry = { ...entry } as Record; if ( "channelAccessToken" in nextEntry || "channelSecret" in nextEntry || "tokenFile" in nextEntry || "secretFile" in nextEntry ) { cleared = true; delete nextEntry.channelAccessToken; delete nextEntry.channelSecret; delete nextEntry.tokenFile; delete nextEntry.secretFile; changed = true; } if (Object.keys(nextEntry).length === 0) { delete accounts[accountId]; changed = true; } else { accounts[accountId] = nextEntry as typeof entry; } } } if (accounts) { if (Object.keys(accounts).length === 0) { delete nextLine.accounts; changed = true; } else { nextLine.accounts = accounts; } } if (changed) { if (Object.keys(nextLine).length > 0) { nextCfg.channels = { ...nextCfg.channels, line: nextLine }; } else { const nextChannels = { ...nextCfg.channels }; delete (nextChannels as Record).line; if (Object.keys(nextChannels).length > 0) { nextCfg.channels = nextChannels; } else { delete nextCfg.channels; } } await getLineRuntime().config.writeConfigFile(nextCfg); } const resolved = getLineRuntime().channel.line.resolveLineAccount({ cfg: changed ? nextCfg : cfg, accountId, }); const loggedOut = resolved.tokenSource === "none"; return { cleared, envToken: Boolean(envToken), loggedOut }; }, }, agentPrompt: { messageToolHints: () => [ "", "### LINE Rich Messages", "LINE supports rich visual messages. Use these directives in your reply when appropriate:", "", "**Quick Replies** (bottom button suggestions):", " [[quick_replies: Option 1, Option 2, Option 3]]", "", "**Location** (map pin):", " [[location: Place Name | Address | latitude | longitude]]", "", "**Confirm Dialog** (yes/no prompt):", " [[confirm: Question text? | Yes Label | No Label]]", "", "**Button Menu** (title + text + buttons):", " [[buttons: Title | Description | Btn1:action1, Btn2:https://url.com]]", "", "**Media Player Card** (music status):", " [[media_player: Song Title | Artist Name | Source | https://albumart.url | playing]]", " - Status: 'playing' or 'paused' (optional)", "", "**Event Card** (calendar events, meetings):", " [[event: Event Title | Date | Time | Location | Description]]", " - Time, Location, Description are optional", "", "**Agenda Card** (multiple events/schedule):", " [[agenda: Schedule Title | Event1:9:00 AM, Event2:12:00 PM, Event3:3:00 PM]]", "", "**Device Control Card** (smart devices, TVs, etc.):", " [[device: Device Name | Device Type | Status | Control1:data1, Control2:data2]]", "", "**Apple TV Remote** (full D-pad + transport):", " [[appletv_remote: Apple TV | Playing]]", "", "**Auto-converted**: Markdown tables become Flex cards, code blocks become styled cards.", "", "When to use rich messages:", "- Use [[quick_replies:...]] when offering 2-4 clear options", "- Use [[confirm:...]] for yes/no decisions", "- Use [[buttons:...]] for menus with actions/links", "- Use [[location:...]] when sharing a place", "- Use [[media_player:...]] when showing what's playing", "- Use [[event:...]] for calendar event details", "- Use [[agenda:...]] for a day's schedule or event list", "- Use [[device:...]] for smart device status/controls", "- Tables/code in your response auto-convert to visual cards", ], }, };