Files
u-claw/portable/config-server/server.js
hfshfg 8bffa5d9d7 feat(portable): 预装国内渠道+本地模型自动发现+4个新技能
- release.yml 便携包离线预装 钉钉/飞书中国版/企业微信 插件(借鉴 openclaw-china 生态)
- config-server 新增 /api/local-models:自动探测 Ollama/LM Studio 本地模型(借鉴 RealShocky/openclaw-windows)
- Config.html 检测到本地模型自动生成卡片、免 Key 点选即用;新增钉钉渠道卡片
- skills-cn 新增 pdf-toolkit/web-to-markdown/qrcode-maker/image-compress(13→17)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-17 22:45:59 +08:00

552 lines
21 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: Discover local models (Ollama / LM Studio)
// 借鉴 RealShocky/openclaw-windows自动探测本机已装的本地模型
// 用户无需手填 baseUrl/模型名,直接点选即可(便携版纯离线推理卖点)。
// 静默失败:探测不到就返回空数组,不影响 Config 页面。
if (req.url === '/api/local-models' && req.method === 'GET') {
(async () => {
const probes = [
{ provider: 'ollama', label: 'Ollama', base: 'http://127.0.0.1:11434/v1', api: 'http://127.0.0.1:11434/api/tags' },
{ provider: 'lmstudio', label: 'LM Studio', base: 'http://127.0.0.1:1234/v1', api: 'http://127.0.0.1:1234/v1/models' },
];
const found = [];
await Promise.all(probes.map(async (p) => {
try {
const ctrl = new AbortController();
const t = setTimeout(() => ctrl.abort(), 1200);
const r = await fetch(p.api, { signal: ctrl.signal });
clearTimeout(t);
if (!r.ok) return;
const data = await r.json();
// Ollama: { models:[{name}] } | LM Studio (OpenAI-style): { data:[{id}] }
const models = Array.isArray(data.models)
? data.models.map(m => m.name).filter(Boolean)
: Array.isArray(data.data)
? data.data.map(m => m.id).filter(Boolean)
: [];
if (models.length) found.push({ provider: p.provider, label: p.label, base: p.base, models });
} catch { /* 探测失败:该 provider 未运行,跳过 */ }
}));
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ providers: found }));
})();
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);