fix wecom channel (#530)
This commit is contained in:
committed by
GitHub
Unverified
parent
7e54aad9e6
commit
f1e2e9fa01
@@ -6,11 +6,136 @@
|
||||
* stale plugins) and when a user configures a channel.
|
||||
*/
|
||||
import { app } from 'electron';
|
||||
import { existsSync, cpSync, mkdirSync, rmSync, readFileSync } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { existsSync, cpSync, mkdirSync, rmSync, readFileSync, writeFileSync, readdirSync, realpathSync } from 'node:fs';
|
||||
import { homedir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { logger } from './logger';
|
||||
|
||||
// ── Known plugin-ID corrections ─────────────────────────────────────────────
|
||||
// Some npm packages ship with an openclaw.plugin.json whose "id" field
|
||||
// doesn't match the ID the plugin code actually exports. After copying we
|
||||
// patch both the manifest AND the compiled JS so the Gateway accepts them.
|
||||
const MANIFEST_ID_FIXES: Record<string, string> = {
|
||||
'wecom-openclaw-plugin': 'wecom',
|
||||
};
|
||||
|
||||
/**
|
||||
* After a plugin has been copied to ~/.openclaw/extensions/<dir>, fix any
|
||||
* known manifest-ID mismatches so the Gateway can load the plugin.
|
||||
* Also patches package.json fields that the Gateway uses as "entry hints".
|
||||
*/
|
||||
export function fixupPluginManifest(targetDir: string): void {
|
||||
// 1. Fix openclaw.plugin.json id
|
||||
const manifestPath = join(targetDir, 'openclaw.plugin.json');
|
||||
try {
|
||||
const raw = readFileSync(manifestPath, 'utf-8');
|
||||
const manifest = JSON.parse(raw);
|
||||
const oldId = manifest.id as string | undefined;
|
||||
if (oldId && MANIFEST_ID_FIXES[oldId]) {
|
||||
const newId = MANIFEST_ID_FIXES[oldId];
|
||||
manifest.id = newId;
|
||||
writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf-8');
|
||||
logger.info(`[plugin] Fixed manifest ID: ${oldId} → ${newId}`);
|
||||
}
|
||||
} catch {
|
||||
// manifest may not exist yet — ignore
|
||||
}
|
||||
|
||||
// 2. Fix package.json fields that Gateway uses as "entry hints"
|
||||
const pkgPath = join(targetDir, 'package.json');
|
||||
try {
|
||||
const raw = readFileSync(pkgPath, 'utf-8');
|
||||
const pkg = JSON.parse(raw);
|
||||
let modified = false;
|
||||
|
||||
// Check if the package name contains a legacy ID that needs fixing
|
||||
for (const [oldId, newId] of Object.entries(MANIFEST_ID_FIXES)) {
|
||||
if (typeof pkg.name === 'string' && pkg.name.includes(oldId)) {
|
||||
pkg.name = pkg.name.replace(oldId, newId);
|
||||
modified = true;
|
||||
}
|
||||
const install = pkg.openclaw?.install;
|
||||
if (install) {
|
||||
if (typeof install.npmSpec === 'string' && install.npmSpec.includes(oldId)) {
|
||||
install.npmSpec = install.npmSpec.replace(oldId, newId);
|
||||
modified = true;
|
||||
}
|
||||
if (typeof install.localPath === 'string' && install.localPath.includes(oldId)) {
|
||||
install.localPath = install.localPath.replace(oldId, newId);
|
||||
modified = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (modified) {
|
||||
writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + '\n', 'utf-8');
|
||||
logger.info(`[plugin] Fixed package.json entry hints in ${targetDir}`);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
|
||||
// 3. Fix hardcoded plugin IDs in compiled JS entry files.
|
||||
// The Gateway validates that the JS export's `id` matches the manifest.
|
||||
patchPluginEntryIds(targetDir);
|
||||
}
|
||||
|
||||
/**
|
||||
* Patch the compiled JS entry files so the hardcoded `id` field in the
|
||||
* plugin export matches the manifest. Without this, the Gateway rejects
|
||||
* the plugin with "plugin id mismatch".
|
||||
*/
|
||||
function patchPluginEntryIds(targetDir: string): void {
|
||||
const pkgPath = join(targetDir, 'package.json');
|
||||
let pkg: Record<string, unknown>;
|
||||
try {
|
||||
pkg = JSON.parse(readFileSync(pkgPath, 'utf-8'));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
|
||||
const entryFiles = [pkg.main, pkg.module].filter(Boolean) as string[];
|
||||
|
||||
for (const entry of entryFiles) {
|
||||
const entryPath = join(targetDir, entry);
|
||||
if (!existsSync(entryPath)) continue;
|
||||
|
||||
let content: string;
|
||||
try {
|
||||
content = readFileSync(entryPath, 'utf-8');
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
let patched = false;
|
||||
for (const [wrongId, correctId] of Object.entries(MANIFEST_ID_FIXES)) {
|
||||
// Match patterns like: id: "wecom-openclaw-plugin" or id: 'wecom-openclaw-plugin'
|
||||
const escapedWrongId = wrongId.replace(/-/g, '\\-');
|
||||
const pattern = new RegExp(`(\\bid\\s*:\\s*)(["'])${escapedWrongId}\\2`, 'g');
|
||||
const replaced = content.replace(pattern, `$1$2${correctId}$2`);
|
||||
if (replaced !== content) {
|
||||
content = replaced;
|
||||
patched = true;
|
||||
logger.info(`[plugin] Patched plugin ID in ${entry}: "${wrongId}" → "${correctId}"`);
|
||||
}
|
||||
}
|
||||
|
||||
if (patched) {
|
||||
writeFileSync(entryPath, content, 'utf-8');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── Plugin npm name mapping ──────────────────────────────────────────────────
|
||||
|
||||
const PLUGIN_NPM_NAMES: Record<string, string> = {
|
||||
dingtalk: '@soimy/dingtalk',
|
||||
wecom: '@wecom/wecom-openclaw-plugin',
|
||||
'feishu-openclaw-plugin': '@larksuite/openclaw-lark',
|
||||
qqbot: '@sliverp/qqbot',
|
||||
};
|
||||
|
||||
// ── Version helper ───────────────────────────────────────────────────────────
|
||||
|
||||
function readPluginVersion(pkgJsonPath: string): string | null {
|
||||
@@ -23,6 +148,114 @@ function readPluginVersion(pkgJsonPath: string): string | null {
|
||||
}
|
||||
}
|
||||
|
||||
// ── pnpm-aware node_modules copy helpers ─────────────────────────────────────
|
||||
|
||||
/** Walk up from a path until we find a parent named node_modules. */
|
||||
function findParentNodeModules(startPath: string): string | null {
|
||||
let dir = startPath;
|
||||
while (dir !== path.dirname(dir)) {
|
||||
if (path.basename(dir) === 'node_modules') return dir;
|
||||
dir = path.dirname(dir);
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** List packages inside a node_modules dir (handles @scoped packages). */
|
||||
function listPackagesInDir(nodeModulesDir: string): Array<{ name: string; fullPath: string }> {
|
||||
const result: Array<{ name: string; fullPath: string }> = [];
|
||||
if (!existsSync(nodeModulesDir)) return result;
|
||||
const SKIP = new Set(['.bin', '.package-lock.json', '.modules.yaml', '.pnpm']);
|
||||
for (const entry of readdirSync(nodeModulesDir, { withFileTypes: true })) {
|
||||
if (!entry.isDirectory() && !entry.isSymbolicLink()) continue;
|
||||
if (SKIP.has(entry.name)) continue;
|
||||
const entryPath = join(nodeModulesDir, entry.name);
|
||||
if (entry.name.startsWith('@')) {
|
||||
try {
|
||||
for (const sub of readdirSync(entryPath)) {
|
||||
result.push({ name: `${entry.name}/${sub}`, fullPath: join(entryPath, sub) });
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
} else {
|
||||
result.push({ name: entry.name, fullPath: entryPath });
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Copy a plugin from a pnpm node_modules location, including its
|
||||
* transitive runtime dependencies (replicates bundle-openclaw-plugins.mjs
|
||||
* logic).
|
||||
*/
|
||||
export function copyPluginFromNodeModules(npmPkgPath: string, targetDir: string, npmName: string): void {
|
||||
let realPath: string;
|
||||
try {
|
||||
realPath = realpathSync(npmPkgPath);
|
||||
} catch {
|
||||
throw new Error(`Cannot resolve real path for ${npmPkgPath}`);
|
||||
}
|
||||
|
||||
// 1. Copy plugin package itself
|
||||
rmSync(targetDir, { recursive: true, force: true });
|
||||
mkdirSync(targetDir, { recursive: true });
|
||||
cpSync(realPath, targetDir, { recursive: true, dereference: true });
|
||||
|
||||
// 2. Collect transitive deps from pnpm virtual store
|
||||
const rootVirtualNM = findParentNodeModules(realPath);
|
||||
if (!rootVirtualNM) {
|
||||
logger.warn(`[plugin] Cannot find virtual store node_modules for ${npmName}, plugin may lack deps`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Read peer deps to skip (they're provided by the host gateway)
|
||||
const SKIP_PACKAGES = new Set(['typescript', '@playwright/test']);
|
||||
try {
|
||||
const pluginPkg = JSON.parse(readFileSync(join(targetDir, 'package.json'), 'utf-8'));
|
||||
for (const peer of Object.keys(pluginPkg.peerDependencies || {})) {
|
||||
SKIP_PACKAGES.add(peer);
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const collected = new Map<string, string>(); // realPath → packageName
|
||||
const queue: Array<{ nodeModulesDir: string; skipPkg: string }> = [
|
||||
{ nodeModulesDir: rootVirtualNM, skipPkg: npmName },
|
||||
];
|
||||
|
||||
while (queue.length > 0) {
|
||||
const { nodeModulesDir, skipPkg } = queue.shift()!;
|
||||
for (const { name, fullPath } of listPackagesInDir(nodeModulesDir)) {
|
||||
if (name === skipPkg) continue;
|
||||
if (SKIP_PACKAGES.has(name) || name.startsWith('@types/')) continue;
|
||||
let depRealPath: string;
|
||||
try {
|
||||
depRealPath = realpathSync(fullPath);
|
||||
} catch { continue; }
|
||||
if (collected.has(depRealPath)) continue;
|
||||
collected.set(depRealPath, name);
|
||||
const depVirtualNM = findParentNodeModules(depRealPath);
|
||||
if (depVirtualNM && depVirtualNM !== nodeModulesDir) {
|
||||
queue.push({ nodeModulesDir: depVirtualNM, skipPkg: name });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Copy flattened deps into targetDir/node_modules/
|
||||
const outputNM = join(targetDir, 'node_modules');
|
||||
mkdirSync(outputNM, { recursive: true });
|
||||
const copiedNames = new Set<string>();
|
||||
for (const [depRealPath, pkgName] of collected) {
|
||||
if (copiedNames.has(pkgName)) continue;
|
||||
copiedNames.add(pkgName);
|
||||
const dest = join(outputNM, pkgName);
|
||||
try {
|
||||
mkdirSync(path.dirname(dest), { recursive: true });
|
||||
cpSync(depRealPath, dest, { recursive: true, dereference: true });
|
||||
} catch { /* skip individual dep failures */ }
|
||||
}
|
||||
|
||||
logger.info(`[plugin] Copied ${copiedNames.size} deps for ${npmName}`);
|
||||
}
|
||||
|
||||
// ── Core install / upgrade logic ─────────────────────────────────────────────
|
||||
|
||||
export function ensurePluginInstalled(
|
||||
@@ -50,26 +283,57 @@ export function ensurePluginInstalled(
|
||||
);
|
||||
}
|
||||
|
||||
// Fresh install or upgrade
|
||||
if (!sourceDir) {
|
||||
return {
|
||||
installed: false,
|
||||
warning: `Bundled ${pluginLabel} plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
|
||||
};
|
||||
// Fresh install or upgrade — try bundled/build sources first
|
||||
if (sourceDir) {
|
||||
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).` };
|
||||
}
|
||||
fixupPluginManifest(targetDir);
|
||||
logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`);
|
||||
return { installed: true };
|
||||
} catch {
|
||||
return { installed: false, warning: `Failed to install bundled ${pluginLabel} plugin mirror` };
|
||||
}
|
||||
}
|
||||
|
||||
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).` };
|
||||
// Dev mode fallback: copy from node_modules with pnpm-aware dep resolution
|
||||
if (!app.isPackaged) {
|
||||
const npmName = PLUGIN_NPM_NAMES[pluginDirName];
|
||||
if (npmName) {
|
||||
const npmPkgPath = join(process.cwd(), 'node_modules', ...npmName.split('/'));
|
||||
if (existsSync(join(npmPkgPath, 'openclaw.plugin.json'))) {
|
||||
const installedVersion = existsSync(targetPkgJson) ? readPluginVersion(targetPkgJson) : null;
|
||||
const sourceVersion = readPluginVersion(join(npmPkgPath, 'package.json'));
|
||||
if (sourceVersion && (!installedVersion || sourceVersion !== installedVersion)) {
|
||||
logger.info(
|
||||
`[plugin] ${installedVersion ? 'Upgrading' : 'Installing'} ${pluginLabel} plugin` +
|
||||
`${installedVersion ? `: ${installedVersion} → ${sourceVersion}` : `: ${sourceVersion}`} (dev/node_modules)`,
|
||||
);
|
||||
try {
|
||||
mkdirSync(join(homedir(), '.openclaw', 'extensions'), { recursive: true });
|
||||
copyPluginFromNodeModules(npmPkgPath, targetDir, npmName);
|
||||
fixupPluginManifest(targetDir);
|
||||
if (existsSync(join(targetDir, 'openclaw.plugin.json'))) {
|
||||
return { installed: true };
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn(`[plugin] Failed to install ${pluginLabel} plugin from node_modules:`, err);
|
||||
}
|
||||
} else if (existsSync(targetManifest)) {
|
||||
return { installed: true }; // same version, already installed
|
||||
}
|
||||
}
|
||||
}
|
||||
logger.info(`Installed ${pluginLabel} plugin from bundled mirror: ${sourceDir}`);
|
||||
return { installed: true };
|
||||
} catch {
|
||||
return { installed: false, warning: `Failed to install bundled ${pluginLabel} plugin mirror` };
|
||||
}
|
||||
|
||||
return {
|
||||
installed: false,
|
||||
warning: `Bundled ${pluginLabel} plugin mirror not found. Checked: ${candidateSources.join(' | ')}`,
|
||||
};
|
||||
}
|
||||
|
||||
// ── Candidate source path builder ────────────────────────────────────────────
|
||||
|
||||
Reference in New Issue
Block a user