import type { IncomingMessage, ServerResponse } from 'http'; import { app } from 'electron'; import { existsSync, cpSync, mkdirSync, rmSync, readFileSync } from 'node:fs'; import { homedir } from 'node:os'; import { join } from 'node:path'; import { deleteChannelConfig, getChannelFormValues, listConfiguredChannels, saveChannelConfig, setChannelEnabled, validateChannelConfig, validateChannelCredentials, } from '../../utils/channel-config'; import { assignChannelToAgent, clearAllBindingsForChannel } from '../../utils/agent-config'; import { whatsAppLoginManager } from '../../utils/whatsapp-login'; import type { HostApiContext } from '../context'; import { parseJsonBody, sendJson } from '../route-utils'; function scheduleGatewayChannelRestart(ctx: HostApiContext, reason: string): void { if (ctx.gatewayManager.getStatus().state === 'stopped') { return; } ctx.gatewayManager.debouncedRestart(); void reason; } const FORCE_RESTART_CHANNELS = new Set(['dingtalk', 'wecom', 'feishu', 'whatsapp']); function scheduleGatewayChannelSaveRefresh( ctx: HostApiContext, channelType: string, reason: string, ): void { if (ctx.gatewayManager.getStatus().state === 'stopped') { return; } if (FORCE_RESTART_CHANNELS.has(channelType)) { ctx.gatewayManager.debouncedRestart(); void reason; return; } ctx.gatewayManager.debouncedReload(); void reason; } // ── Generic plugin installer with version-aware upgrades ───────── function readPluginVersion(pkgJsonPath: string): string | null { try { const raw = readFileSync(pkgJsonPath, 'utf-8'); const parsed = JSON.parse(raw) as { version?: string }; return parsed.version ?? null; } catch { return null; } } function ensurePluginInstalled( pluginDirName: string, candidateSources: string[], pluginLabel: string, ): { installed: boolean; warning?: string } { const targetDir = join(homedir(), '.openclaw', 'extensions', pluginDirName); const targetManifest = join(targetDir, 'openclaw.plugin.json'); const targetPkgJson = join(targetDir, 'package.json'); const sourceDir = candidateSources.find((dir) => existsSync(join(dir, 'openclaw.plugin.json'))); // If already installed, check whether an upgrade is available if (existsSync(targetManifest)) { if (!sourceDir) return { installed: true }; // no bundled source to compare, keep existing const installedVersion = readPluginVersion(targetPkgJson); const sourceVersion = readPluginVersion(join(sourceDir, 'package.json')); if (!sourceVersion || !installedVersion || sourceVersion === installedVersion) { return { installed: true }; // same version or unable to compare } // Version differs — fall through to overwrite install console.log( `[plugin] Upgrading ${pluginLabel} plugin: ${installedVersion} → ${sourceVersion}`, ); } // Fresh install or upgrade if (!sourceDir) { return { installed: false, warning: `Bundled ${pluginLabel} plugin mirror not found. Checked: ${candidateSources.join(' | ')}`, }; } try { mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true }); rmSync(targetDir, { recursive: true, force: true }); cpSync(sourceDir, targetDir, { recursive: true, dereference: true }); if (!existsSync(join(targetDir, 'openclaw.plugin.json'))) { return { installed: false, warning: `Failed to install ${pluginLabel} plugin mirror (manifest missing).` }; } return { installed: true }; } catch { return { installed: false, warning: `Failed to install bundled ${pluginLabel} plugin mirror` }; } } // ── Per-channel plugin helpers (thin wrappers around ensurePluginInstalled) ── function buildCandidateSources(pluginDirName: string): string[] { return app.isPackaged ? [ join(process.resourcesPath, 'openclaw-plugins', pluginDirName), join(process.resourcesPath, 'app.asar.unpacked', 'build', 'openclaw-plugins', pluginDirName), join(process.resourcesPath, 'app.asar.unpacked', 'openclaw-plugins', pluginDirName), ] : [ join(app.getAppPath(), 'build', 'openclaw-plugins', pluginDirName), join(process.cwd(), 'build', 'openclaw-plugins', pluginDirName), join(__dirname, '../../../build/openclaw-plugins', pluginDirName), ]; } function ensureDingTalkPluginInstalled(): { installed: boolean; warning?: string } { return ensurePluginInstalled('dingtalk', buildCandidateSources('dingtalk'), 'DingTalk'); } function ensureWeComPluginInstalled(): { installed: boolean; warning?: string } { return ensurePluginInstalled('wecom', buildCandidateSources('wecom'), 'WeCom'); } function ensureFeishuPluginInstalled(): { installed: boolean; warning?: string } { return ensurePluginInstalled( 'feishu-openclaw-plugin', buildCandidateSources('feishu-openclaw-plugin'), 'Feishu', ); } function ensureQQBotPluginInstalled(): { installed: boolean; warning?: string } { return ensurePluginInstalled('qqbot', buildCandidateSources('qqbot'), 'QQ Bot'); } function toComparableConfig(input: Record): Record { const next: Record = {}; for (const [key, value] of Object.entries(input)) { if (value === undefined || value === null) continue; if (typeof value === 'string') { next[key] = value.trim(); continue; } if (typeof value === 'number' || typeof value === 'boolean') { next[key] = String(value); } } return next; } function isSameConfigValues( existing: Record | undefined, incoming: Record, ): boolean { if (!existing) return false; const next = toComparableConfig(incoming); const keys = new Set([...Object.keys(existing), ...Object.keys(next)]); if (keys.size === 0) return false; for (const key of keys) { if ((existing[key] ?? '') !== (next[key] ?? '')) { return false; } } return true; } function inferAgentIdFromAccountId(accountId: string): string { if (accountId === 'default') return 'main'; return accountId; } async function ensureScopedChannelBinding(channelType: string, accountId?: string): Promise { // Multi-agent safety: only bind when the caller explicitly scopes the account. // Global channel saves (no accountId) must not override routing to "main". if (!accountId) return; await assignChannelToAgent(inferAgentIdFromAccountId(accountId), channelType).catch(() => undefined); } export async function handleChannelRoutes( req: IncomingMessage, res: ServerResponse, url: URL, ctx: HostApiContext, ): Promise { if (url.pathname === '/api/channels/configured' && req.method === 'GET') { sendJson(res, 200, { success: true, channels: await listConfiguredChannels() }); return true; } if (url.pathname === '/api/channels/config/validate' && req.method === 'POST') { try { const body = await parseJsonBody<{ channelType: string }>(req); sendJson(res, 200, { success: true, ...(await validateChannelConfig(body.channelType)) }); } catch (error) { sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] }); } return true; } if (url.pathname === '/api/channels/credentials/validate' && req.method === 'POST') { try { const body = await parseJsonBody<{ channelType: string; config: Record }>(req); sendJson(res, 200, { success: true, ...(await validateChannelCredentials(body.channelType, body.config)) }); } catch (error) { sendJson(res, 500, { success: false, valid: false, errors: [String(error)], warnings: [] }); } return true; } if (url.pathname === '/api/channels/whatsapp/start' && req.method === 'POST') { try { const body = await parseJsonBody<{ accountId: string }>(req); await whatsAppLoginManager.start(body.accountId); sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } return true; } if (url.pathname === '/api/channels/whatsapp/cancel' && req.method === 'POST') { try { await whatsAppLoginManager.stop(); sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } return true; } if (url.pathname === '/api/channels/config' && req.method === 'POST') { try { const body = await parseJsonBody<{ channelType: string; config: Record; accountId?: string }>(req); if (body.channelType === 'dingtalk') { const installResult = await ensureDingTalkPluginInstalled(); if (!installResult.installed) { sendJson(res, 500, { success: false, error: installResult.warning || 'DingTalk plugin install failed' }); return true; } } if (body.channelType === 'wecom') { const installResult = await ensureWeComPluginInstalled(); if (!installResult.installed) { sendJson(res, 500, { success: false, error: installResult.warning || 'WeCom plugin install failed' }); return true; } } if (body.channelType === 'qqbot') { const installResult = await ensureQQBotPluginInstalled(); if (!installResult.installed) { sendJson(res, 500, { success: false, error: installResult.warning || 'QQ Bot plugin install failed' }); return true; } } if (body.channelType === 'feishu') { const installResult = await ensureFeishuPluginInstalled(); if (!installResult.installed) { sendJson(res, 500, { success: false, error: installResult.warning || 'Feishu plugin install failed' }); return true; } } const existingValues = await getChannelFormValues(body.channelType, body.accountId); if (isSameConfigValues(existingValues, body.config)) { await ensureScopedChannelBinding(body.channelType, body.accountId); sendJson(res, 200, { success: true, noChange: true }); return true; } 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 }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } return true; } if (url.pathname === '/api/channels/config/enabled' && req.method === 'PUT') { try { const body = await parseJsonBody<{ channelType: string; enabled: boolean }>(req); await setChannelEnabled(body.channelType, body.enabled); scheduleGatewayChannelRestart(ctx, `channel:setEnabled:${body.channelType}`); sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } return true; } if (url.pathname.startsWith('/api/channels/config/') && req.method === 'GET') { try { const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length)); const accountId = url.searchParams.get('accountId') || undefined; sendJson(res, 200, { success: true, values: await getChannelFormValues(channelType, accountId), }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } return true; } if (url.pathname.startsWith('/api/channels/config/') && req.method === 'DELETE') { try { const channelType = decodeURIComponent(url.pathname.slice('/api/channels/config/'.length)); await deleteChannelConfig(channelType); await clearAllBindingsForChannel(channelType); scheduleGatewayChannelRestart(ctx, `channel:deleteConfig:${channelType}`); sendJson(res, 200, { success: true }); } catch (error) { sendJson(res, 500, { success: false, error: String(error) }); } return true; } void ctx; return false; }