325 lines
12 KiB
TypeScript
325 lines
12 KiB
TypeScript
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<string, unknown>): Record<string, string> {
|
|
const next: Record<string, string> = {};
|
|
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<string, string> | undefined,
|
|
incoming: Record<string, unknown>,
|
|
): 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<void> {
|
|
// 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<boolean> {
|
|
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<string, string> }>(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<string, unknown>; 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;
|
|
}
|