From 4a9b60f1d7f8f43140921c34f0b2b7868c30d4e7 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 18 Mar 2026 09:40:51 +0000 Subject: [PATCH] feat: add LINE channel support to Channels page - Add 'line' to getPrimaryChannels() so it appears in the Supported Channels grid - Add LINE SVG logo icon (src/assets/channels/line.svg) - Add LINE logo case to ChannelLogo in both Channels page and ChannelConfigModal - Add LINE to ensurePluginAllowlist (plugins.allow + plugins.enabled) - Add validateLineCredentials via LINE Messaging API /v2/bot/info - Add LINE-specific config validation in validateChannelConfig - Add ensureLinePluginInstalled and register in ALL_BUNDLED_PLUGINS - Add LINE plugin install check in POST /api/channels/config route (soft warning) Co-authored-by: Haze --- electron/api/routes/channels.ts | 10 +++- electron/utils/channel-config.ts | 60 +++++++++++++++++++ electron/utils/plugin-install.ts | 5 ++ src/assets/channels/line.svg | 3 + .../channels/ChannelConfigModal.tsx | 3 + src/pages/Channels/index.tsx | 3 + src/types/channel.ts | 2 +- 7 files changed, 84 insertions(+), 2 deletions(-) create mode 100644 src/assets/channels/line.svg diff --git a/electron/api/routes/channels.ts b/electron/api/routes/channels.ts index ff856db1a..5d09c6635 100644 --- a/electron/api/routes/channels.ts +++ b/electron/api/routes/channels.ts @@ -21,6 +21,7 @@ import { import { ensureDingTalkPluginInstalled, ensureFeishuPluginInstalled, + ensureLinePluginInstalled, ensureQQBotPluginInstalled, ensureWeComPluginInstalled, } from '../../utils/plugin-install'; @@ -363,6 +364,13 @@ export async function handleChannelRoutes( return true; } } + let linePluginWarning: string | undefined; + if (body.channelType === 'line') { + const installResult = await ensureLinePluginInstalled(); + if (!installResult.installed) { + linePluginWarning = installResult.warning || 'LINE plugin not found locally. Install the plugin via OpenClaw CLI if needed.'; + } + } const existingValues = await getChannelFormValues(body.channelType, body.accountId); if (isSameConfigValues(existingValues, body.config)) { await ensureScopedChannelBinding(body.channelType, body.accountId); @@ -372,7 +380,7 @@ export async function handleChannelRoutes( await saveChannelConfig(body.channelType, body.config, body.accountId); await ensureScopedChannelBinding(body.channelType, body.accountId); scheduleGatewayChannelSaveRefresh(ctx, body.channelType, `channel:saveConfig:${body.channelType}`); - sendJson(res, 200, { success: true }); + sendJson(res, 200, { success: true, ...(linePluginWarning ? { warning: linePluginWarning } : {}) }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } diff --git a/electron/utils/channel-config.ts b/electron/utils/channel-config.ts index ba3ea9d62..c9f658a5b 100644 --- a/electron/utils/channel-config.ts +++ b/electron/utils/channel-config.ts @@ -239,6 +239,19 @@ async function ensurePluginAllowlist(currentConfig: OpenClawConfig, channelType: currentConfig.plugins.allow = [...allow, 'qqbot']; } } + + if (channelType === 'line') { + if (!currentConfig.plugins) { + currentConfig.plugins = {}; + } + currentConfig.plugins.enabled = true; + const allow = Array.isArray(currentConfig.plugins.allow) + ? currentConfig.plugins.allow as string[] + : []; + if (!allow.includes('line')) { + currentConfig.plugins.allow = [...allow, 'line']; + } + } } function transformChannelConfig( @@ -959,6 +972,8 @@ export async function validateChannelCredentials( return validateDiscordCredentials(config); case 'telegram': return validateTelegramCredentials(config); + case 'line': + return validateLineCredentials(config); default: return { valid: true, errors: [], warnings: ['No online validation available for this channel type.'] }; } @@ -1070,6 +1085,42 @@ async function validateTelegramCredentials( } } +async function validateLineCredentials( + config: Record +): Promise { + const channelAccessToken = config.channelAccessToken?.trim(); + const channelSecret = config.channelSecret?.trim(); + + if (!channelAccessToken) return { valid: false, errors: ['Channel Access Token is required'], warnings: [] }; + if (!channelSecret) return { valid: false, errors: ['Channel Secret is required'], warnings: [] }; + + try { + const response = await proxyAwareFetch('https://api.line.me/v2/bot/info', { + headers: { Authorization: `Bearer ${channelAccessToken}` }, + }); + if (!response.ok) { + if (response.status === 401) { + return { valid: false, errors: ['Invalid Channel Access Token. Please check and try again.'], warnings: [] }; + } + const errorData = await response.json().catch(() => ({})); + const msg = (errorData as { message?: string }).message || `LINE API error: ${response.status}`; + return { valid: false, errors: [msg], warnings: [] }; + } + const data = (await response.json()) as { displayName?: string; userId?: string; basicId?: string }; + return { + valid: true, + errors: [], + warnings: [], + details: { + botUsername: data.displayName || 'Unknown', + ...(data.basicId ? { basicId: data.basicId } : {}), + }, + }; + } catch (error) { + return { valid: false, errors: [`Connection error: ${error instanceof Error ? error.message : String(error)}`], warnings: [] }; + } +} + export async function validateChannelConfig(channelType: string): Promise { const { exec } = await import('child_process'); @@ -1143,6 +1194,15 @@ export async function validateChannelConfig(channelType: string): Promise + + \ No newline at end of file diff --git a/src/components/channels/ChannelConfigModal.tsx b/src/components/channels/ChannelConfigModal.tsx index b8217e9a7..fab2bfce4 100644 --- a/src/components/channels/ChannelConfigModal.tsx +++ b/src/components/channels/ChannelConfigModal.tsx @@ -41,6 +41,7 @@ import dingtalkIcon from '@/assets/channels/dingtalk.svg'; import feishuIcon from '@/assets/channels/feishu.svg'; import wecomIcon from '@/assets/channels/wecom.svg'; import qqIcon from '@/assets/channels/qq.svg'; +import lineIcon from '@/assets/channels/line.svg'; interface ChannelConfigModalProps { initialSelectedType?: ChannelType | null; @@ -744,6 +745,8 @@ function ChannelLogo({ type }: { type: ChannelType }) { return WeCom; case 'qqbot': return QQ; + case 'line': + return LINE; default: return {CHANNEL_ICONS[type] || '💬'}; } diff --git a/src/pages/Channels/index.tsx b/src/pages/Channels/index.tsx index a87f7f580..7f474181b 100644 --- a/src/pages/Channels/index.tsx +++ b/src/pages/Channels/index.tsx @@ -26,6 +26,7 @@ import dingtalkIcon from '@/assets/channels/dingtalk.svg'; import feishuIcon from '@/assets/channels/feishu.svg'; import wecomIcon from '@/assets/channels/wecom.svg'; import qqIcon from '@/assets/channels/qq.svg'; +import lineIcon from '@/assets/channels/line.svg'; interface ChannelAccountItem { accountId: string; @@ -527,6 +528,8 @@ function ChannelLogo({ type }: { type: ChannelType }) { return WeCom; case 'qqbot': return QQ; + case 'line': + return LINE; default: return {CHANNEL_ICONS[type] || '💬'}; } diff --git a/src/types/channel.ts b/src/types/channel.ts index f84edd749..0420d966f 100644 --- a/src/types/channel.ts +++ b/src/types/channel.ts @@ -561,7 +561,7 @@ export const CHANNEL_META: Record = { * Get primary supported channels (non-plugin, commonly used) */ export function getPrimaryChannels(): ChannelType[] { - return ['telegram', 'discord', 'whatsapp', 'dingtalk', 'feishu', 'wecom', 'qqbot']; + return ['telegram', 'discord', 'whatsapp', 'dingtalk', 'feishu', 'wecom', 'qqbot', 'line']; } /**