Files
u-claw/portable/config-server/server.js
hfshfg 4fab64e877 fix(wechat): 便携包打包微信插件 + 修连接成功却报「插件未安装」的矛盾提示
根因:微信插件 @tencent-weixin/openclaw-weixin 是独立 npm 包,但 setup/CI
从没装过它,portable/app/ 又被 gitignore —— 发布包 app/extensions/ 是空的,
客户扫码连上后 config-server 找不到插件源,报「插件未安装」。

- release.yml: 装 @tencent-weixin/openclaw-weixin@latest 到 app/extensions/
  (npm 包自带 dist/index.js 无需 build;显式把 qrcode-terminal 放进插件
   node_modules 以满足 server.js:52 的硬要求)
- config-server/public/index.html: 去掉「连接成功」却又「插件未安装」的自相
  矛盾提示。后端已自动装插件+写好 openclaw.json+存账号,只需重启即可,
  不再暴露 pluginInstalled 内部状态吓客户,改为清晰的「重启 U-Claw 即可用」
- config-server/server.js: 插件 copy 失败时容错(不抛错中断 confirmed 流程)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 19:33:25 +08:00

518 lines
19 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/usr/bin/env node
const http = require('http');
const fs = require('fs');
const path = require('path');
const { deflateSync } = require('zlib');
const crypto = require('crypto');
const PORT_RANGE_START = 18788;
const PORT_RANGE_END = 18798;
const CONFIG_PATH = path.join(__dirname, '../data/.openclaw/openclaw.json');
const RUNTIME_PATH = path.join(__dirname, '../data/.openclaw/runtime.json');
// ── WeChat Login State ──────────────────────────────────────────────────────
const DEFAULT_WECHAT_BASE_URL = 'https://ilinkai.weixin.qq.com';
const DEFAULT_ILINK_BOT_TYPE = '3';
const ACTIVE_LOGIN_TTL_MS = 5 * 60000;
const QR_POLL_TIMEOUT_MS = 35000;
const MAX_QR_REFRESH_COUNT = 3;
// Resolve ~/.openclaw/ directory
const OPENCLAW_DIR = process.env.OPENCLAW_STATE_DIR ||
path.join(process.env.USERPROFILE || process.env.HOME || require('os').homedir(), '.openclaw');
const WECHAT_STATE_DIR = path.join(OPENCLAW_DIR, 'openclaw-weixin');
const WECHAT_ACCOUNTS_DIR = path.join(WECHAT_STATE_DIR, 'accounts');
const WECHAT_ACCOUNT_INDEX_FILE = path.join(WECHAT_STATE_DIR, 'accounts.json');
// Plugin source on USB
const USB_PLUGIN_DIR = path.join(__dirname, '../app/extensions/openclaw-weixin');
const INSTALLED_PLUGIN_DIR = path.join(OPENCLAW_DIR, 'extensions', 'openclaw-weixin');
const activeLogins = new Map();
// ── QR Code PNG Renderer (pure Node.js, no external deps) ───────────────────
function getQrRenderDeps() {
// Try to load QR lib from openclaw's bundled qrcode-terminal
const corePath = path.join(__dirname, '../app/core/node_modules');
const candidates = [
path.join(corePath, 'qrcode-terminal/vendor/QRCode/index.js'),
path.join(corePath, 'openclaw/node_modules/qrcode-terminal/vendor/QRCode/index.js'),
];
const errCandidates = [
path.join(corePath, 'qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js'),
path.join(corePath, 'openclaw/node_modules/qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js'),
];
for (let i = 0; i < candidates.length; i++) {
if (fs.existsSync(candidates[i])) {
return { QRCode: require(candidates[i]), QRErrorCorrectLevel: require(errCandidates[i]) };
}
}
// Fallback: try WeChat plugin's own node_modules
const pluginQr = path.join(USB_PLUGIN_DIR, 'node_modules/qrcode-terminal/vendor/QRCode/index.js');
const pluginQrErr = path.join(USB_PLUGIN_DIR, 'node_modules/qrcode-terminal/vendor/QRCode/QRErrorCorrectLevel.js');
if (fs.existsSync(pluginQr)) {
return { QRCode: require(pluginQr), QRErrorCorrectLevel: require(pluginQrErr) };
}
throw new Error('QR code library not found');
}
function createQrMatrix(input) {
const { QRCode, QRErrorCorrectLevel } = getQrRenderDeps();
const qr = new QRCode(-1, QRErrorCorrectLevel.L);
qr.addData(input);
qr.make();
return qr;
}
function fillPixel(buf, x, y, width, r, g, b, a) {
const idx = (y * width + x) * 4;
buf[idx] = r; buf[idx + 1] = g; buf[idx + 2] = b; buf[idx + 3] = (a === undefined ? 255 : a);
}
const CRC_TABLE = (function() {
const table = new Uint32Array(256);
for (let i = 0; i < 256; i++) {
let c = i;
for (let k = 0; k < 8; k++) c = c & 1 ? 0xedb88320 ^ (c >>> 1) : c >>> 1;
table[i] = c >>> 0;
}
return table;
})();
function crc32(buf) {
let crc = 0xffffffff;
for (let i = 0; i < buf.length; i++) crc = CRC_TABLE[(crc ^ buf[i]) & 0xff] ^ (crc >>> 8);
return (crc ^ 0xffffffff) >>> 0;
}
function pngChunk(type, data) {
const typeBuf = Buffer.from(type, 'ascii');
const len = Buffer.alloc(4); len.writeUInt32BE(data.length, 0);
const crc = crc32(Buffer.concat([typeBuf, data]));
const crcBuf = Buffer.alloc(4); crcBuf.writeUInt32BE(crc, 0);
return Buffer.concat([len, typeBuf, data, crcBuf]);
}
function encodePngRgba(buffer, width, height) {
const stride = width * 4;
const raw = Buffer.alloc((stride + 1) * height);
for (let row = 0; row < height; row++) {
const offset = row * (stride + 1);
raw[offset] = 0;
buffer.copy(raw, offset + 1, row * stride, row * stride + stride);
}
const compressed = deflateSync(raw);
const signature = Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]);
const ihdr = Buffer.alloc(13);
ihdr.writeUInt32BE(width, 0); ihdr.writeUInt32BE(height, 4);
ihdr[8] = 8; ihdr[9] = 6;
return Buffer.concat([signature, pngChunk('IHDR', ihdr), pngChunk('IDAT', compressed), pngChunk('IEND', Buffer.alloc(0))]);
}
function renderQrPngDataUrl(input) {
const scale = 6, margin = 4;
const qr = createQrMatrix(input);
const modules = qr.getModuleCount();
const size = (modules + margin * 2) * scale;
const buf = Buffer.alloc(size * size * 4, 255);
for (let row = 0; row < modules; row++) {
for (let col = 0; col < modules; col++) {
if (!qr.isDark(row, col)) continue;
const sx = (col + margin) * scale, sy = (row + margin) * scale;
for (let y = 0; y < scale; y++) for (let x = 0; x < scale; x++)
fillPixel(buf, sx + x, sy + y, size, 0, 0, 0, 255);
}
}
return 'data:image/png;base64,' + encodePngRgba(buf, size, size).toString('base64');
}
// ── WeChat API helpers ──────────────────────────────────────────────────────
async function fetchWeChatQrCode(apiBaseUrl) {
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : apiBaseUrl + '/';
const url = base + 'ilink/bot/get_bot_qrcode?bot_type=' + encodeURIComponent(DEFAULT_ILINK_BOT_TYPE);
const response = await fetch(url);
if (!response.ok) {
const body = await response.text().catch(() => '');
throw new Error('Failed to fetch QR: ' + response.status + ' ' + body);
}
return await response.json();
}
async function pollWeChatQrStatus(apiBaseUrl, qrcode) {
const base = apiBaseUrl.endsWith('/') ? apiBaseUrl : apiBaseUrl + '/';
const url = base + 'ilink/bot/get_qrcode_status?qrcode=' + encodeURIComponent(qrcode);
const controller = new AbortController();
const timer = setTimeout(() => controller.abort(), QR_POLL_TIMEOUT_MS);
try {
const response = await fetch(url, {
headers: { 'iLink-App-ClientVersion': '1' },
signal: controller.signal
});
clearTimeout(timer);
const text = await response.text();
if (!response.ok) throw new Error('Poll failed: ' + response.status + ' ' + text);
return JSON.parse(text);
} catch (err) {
clearTimeout(timer);
if (err.name === 'AbortError') return { status: 'wait' };
throw err;
}
}
function normalizeAccountId(raw) {
return String(raw).toLowerCase().replace(/[^a-z0-9._-]/g, '-');
}
async function saveWeChatAccount(rawAccountId, payload) {
const accountId = normalizeAccountId(rawAccountId);
fs.mkdirSync(WECHAT_ACCOUNTS_DIR, { recursive: true });
const filePath = path.join(WECHAT_ACCOUNTS_DIR, accountId + '.json');
const data = {
token: payload.token.trim(),
savedAt: new Date().toISOString(),
};
if (payload.baseUrl) data.baseUrl = payload.baseUrl.trim();
if (payload.userId) data.userId = payload.userId.trim();
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
// Update account index
let accounts = [];
try { accounts = JSON.parse(fs.readFileSync(WECHAT_ACCOUNT_INDEX_FILE, 'utf-8')); } catch {}
if (!Array.isArray(accounts)) accounts = [];
if (!accounts.includes(accountId)) {
accounts.push(accountId);
fs.mkdirSync(WECHAT_STATE_DIR, { recursive: true });
fs.writeFileSync(WECHAT_ACCOUNT_INDEX_FILE, JSON.stringify(accounts, null, 2));
}
return accountId;
}
function ensureWeChatPluginInstalled() {
if (!fs.existsSync(USB_PLUGIN_DIR) || !fs.existsSync(path.join(USB_PLUGIN_DIR, 'openclaw.plugin.json'))) {
return { installed: false, warning: 'WeChat plugin not found on USB' };
}
if (fs.existsSync(path.join(INSTALLED_PLUGIN_DIR, 'openclaw.plugin.json'))) {
return { installed: true };
}
// Copy from USB to ~/.openclaw/extensions/
// 容错copy 失败不抛错中断整个 confirmed 流程(账号保存 + openclaw.json 已/将写好)。
try {
const extDir = path.join(OPENCLAW_DIR, 'extensions');
fs.mkdirSync(extDir, { recursive: true });
copyDirSync(USB_PLUGIN_DIR, INSTALLED_PLUGIN_DIR);
} catch (e) {
console.error('WeChat plugin copy failed:', e.message);
return { installed: false, warning: e.message };
}
return { installed: fs.existsSync(path.join(INSTALLED_PLUGIN_DIR, 'openclaw.plugin.json')) };
}
function copyDirSync(src, dest) {
fs.mkdirSync(dest, { recursive: true });
for (const entry of fs.readdirSync(src, { withFileTypes: true })) {
const s = path.join(src, entry.name);
const d = path.join(dest, entry.name);
if (entry.isDirectory()) copyDirSync(s, d);
else fs.copyFileSync(s, d);
}
}
// ── WeChat login session management ─────────────────────────────────────────
async function handleWeChatStart() {
const sessionKey = crypto.randomUUID();
const apiBaseUrl = DEFAULT_WECHAT_BASE_URL;
const qrResponse = await fetchWeChatQrCode(apiBaseUrl);
const qrDataUrl = renderQrPngDataUrl(qrResponse.qrcode_img_content);
activeLogins.set(sessionKey, {
sessionKey,
qrcode: qrResponse.qrcode,
qrcodeUrl: qrDataUrl,
startedAt: Date.now(),
apiBaseUrl,
});
return { sessionKey, qrcodeUrl: qrDataUrl };
}
async function handleWeChatStatus(sessionKey) {
const login = activeLogins.get(sessionKey);
if (!login) return { status: 'expired', message: 'No active session' };
if (Date.now() - login.startedAt > ACTIVE_LOGIN_TTL_MS) {
activeLogins.delete(sessionKey);
return { status: 'expired', message: 'Session expired' };
}
const result = await pollWeChatQrStatus(login.apiBaseUrl, login.qrcode);
if (result.status === 'expired') {
// Try to refresh QR code
if (!login.refreshCount) login.refreshCount = 1;
login.refreshCount++;
if (login.refreshCount > MAX_QR_REFRESH_COUNT) {
activeLogins.delete(sessionKey);
return { status: 'expired', message: 'QR expired too many times' };
}
const refreshed = await fetchWeChatQrCode(login.apiBaseUrl);
const newQr = renderQrPngDataUrl(refreshed.qrcode_img_content);
login.qrcode = refreshed.qrcode;
login.qrcodeUrl = newQr;
login.startedAt = Date.now();
return { status: 'refreshed', qrcodeUrl: newQr };
}
if (result.status === 'confirmed') {
activeLogins.delete(sessionKey);
if (!result.ilink_bot_id || !result.bot_token) {
return { status: 'error', message: 'Server did not return credentials' };
}
// 1. Install plugin
const pluginResult = ensureWeChatPluginInstalled();
// 2. Save account
const accountId = await saveWeChatAccount(result.ilink_bot_id, {
token: result.bot_token,
baseUrl: result.baseurl,
userId: result.ilink_user_id,
});
// 3. Update openclaw.json to enable the plugin
try {
const configRaw = fs.existsSync(CONFIG_PATH) ? fs.readFileSync(CONFIG_PATH, 'utf-8') : '{}';
const config = JSON.parse(configRaw);
if (!config.plugins) config.plugins = {};
if (!config.plugins.entries) config.plugins.entries = {};
config.plugins.entries['openclaw-weixin'] = { enabled: true };
const dir = path.dirname(CONFIG_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
} catch (e) {
console.error('Failed to update config:', e.message);
}
return {
status: 'confirmed',
accountId,
pluginInstalled: pluginResult.installed,
message: 'WeChat connected! Restart Gateway to activate.',
};
}
return { status: result.status };
}
function handleWeChatCancel(sessionKey) {
if (sessionKey) activeLogins.delete(sessionKey);
else activeLogins.clear();
}
const server = http.createServer((req, res) => {
// CORS headers
res.setHeader('Access-Control-Allow-Origin', '*');
res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
if (req.method === 'OPTIONS') {
res.writeHead(200);
res.end();
return;
}
// API: WeChat start login
if (req.url === '/api/wechat/start' && req.method === 'POST') {
handleWeChatStart()
.then(result => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
})
.catch(err => {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
});
return;
}
// API: WeChat poll status
if (req.url && req.url.startsWith('/api/wechat/status') && req.method === 'GET') {
const urlObj = new URL(req.url, 'http://localhost');
const session = urlObj.searchParams.get('session');
if (!session) {
res.writeHead(400, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: 'Missing session parameter' }));
return;
}
handleWeChatStatus(session)
.then(result => {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
})
.catch(err => {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
});
return;
}
// API: WeChat cancel
if (req.url === '/api/wechat/cancel' && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const data = body ? JSON.parse(body) : {};
handleWeChatCancel(data.session);
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});
return;
}
// API: WeChat plugin status
if (req.url === '/api/wechat/plugin-status' && req.method === 'GET') {
const hasPlugin = fs.existsSync(path.join(USB_PLUGIN_DIR, 'openclaw.plugin.json'));
const installed = fs.existsSync(path.join(INSTALLED_PLUGIN_DIR, 'openclaw.plugin.json'));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ hasPlugin, installed }));
return;
}
// API: Get config
if (req.url === '/api/config' && req.method === 'GET') {
try {
const config = fs.existsSync(CONFIG_PATH)
? JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'))
: {};
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(config));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
return;
}
// API: Update status — read update-available.json written by check-update.mjs
// Returns { available: false } if no info or stale; otherwise the manifest payload.
if (req.url === '/api/update-status' && req.method === 'GET') {
try {
const stateDir = process.env.OPENCLAW_STATE_DIR
|| path.join(__dirname, '../data/.openclaw');
const updateFile = path.join(stateDir, 'update-available.json');
if (!fs.existsSync(updateFile)) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ available: false, reason: 'no-check-yet' }));
return;
}
const payload = JSON.parse(fs.readFileSync(updateFile, 'utf8'));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(payload));
} catch (err) {
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ available: false, reason: 'read-failed', error: err.message }));
}
return;
}
// API: Trigger update check on demand (so users can press a "Check now" button)
if (req.url === '/api/update-check' && req.method === 'POST') {
(async () => {
try {
const mod = await import('../lib/check-update.mjs');
const portableRoot = path.join(__dirname, '..');
const versionFilePath = fs.existsSync(path.join(portableRoot, 'OPENCLAW_VERSION'))
? path.join(portableRoot, 'OPENCLAW_VERSION')
: path.join(portableRoot, '..', 'OPENCLAW_VERSION');
const stateDir = process.env.OPENCLAW_STATE_DIR
|| path.join(portableRoot, 'data/.openclaw');
const result = await mod.checkUpdate({ versionFilePath, stateDir });
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify(result));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
})();
return;
}
// API: Save config
if (req.url === '/api/config' && req.method === 'POST') {
let body = '';
req.on('data', chunk => body += chunk);
req.on('end', () => {
try {
const config = JSON.parse(body);
// 清除旧版废弃键,防止 OpenClaw 报 "agent.* was moved" 错误
delete config.agent;
const dir = path.dirname(CONFIG_PATH);
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ ok: true }));
} catch (err) {
res.writeHead(500, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ error: err.message }));
}
});
return;
}
// Serve static files
const filePath = req.url === '/'
? path.join(__dirname, 'public/index.html')
: path.join(__dirname, 'public', req.url);
if (fs.existsSync(filePath) && fs.statSync(filePath).isFile()) {
const ext = path.extname(filePath);
const contentType = {
'.html': 'text/html',
'.css': 'text/css',
'.js': 'application/javascript',
'.json': 'application/json'
}[ext] || 'text/plain';
res.writeHead(200, { 'Content-Type': contentType });
fs.createReadStream(filePath).pipe(res);
} else {
res.writeHead(404);
res.end('Not Found');
}
});
function listenWithFallback(port) {
server.once('error', (err) => {
if (err && err.code === 'EADDRINUSE' && port < PORT_RANGE_END) {
console.log(` Port ${port} busy, trying ${port + 1}`);
setImmediate(() => listenWithFallback(port + 1));
return;
}
console.error(`Config server failed to bind: ${err && err.message ? err.message : err}`);
process.exit(1);
});
server.listen(port, '127.0.0.1', () => {
console.log(`\n🦞 U-Claw Config Center`);
console.log(` http://127.0.0.1:${port}`);
console.log(`\n Config file: ${CONFIG_PATH}\n`);
// Persist the live port so Config.html / launchers can discover it after restarts.
try {
fs.mkdirSync(path.dirname(RUNTIME_PATH), { recursive: true });
const existing = fs.existsSync(RUNTIME_PATH) ? JSON.parse(fs.readFileSync(RUNTIME_PATH, 'utf8')) : {};
existing.configServerPort = port;
existing.configServerUpdatedAt = new Date().toISOString();
fs.writeFileSync(RUNTIME_PATH, JSON.stringify(existing, null, 2));
} catch (err) {
console.warn(` Warning: could not write ${RUNTIME_PATH}: ${err.message}`);
}
});
}
listenWithFallback(PORT_RANGE_START);