Bug 上报(方便服务器侧排查便携版问题): - 新增 lib/report-bug.mjs:算设备指纹→sk-uc apiKey,收集 openclaw 版本/ 系统信息/最近日志(gzip+base64),POST 到 api.u-claw.org/recharge/bug/submit。 静默失败,绝不影响主流程。复用 fingerprint.mjs + xiapan-client.mjs。 - config-server 加 POST /api/report-bug:本地补齐信息后转发,配置中心加 「🐛 报 Bug」表单(标题/描述/是否附日志)。 - Windows-Start.bat / Mac-Start.command:gateway 端口耗尽或异常退出时 自动后台上报(Ctrl+C 正常停止不上报)。 openclaw 升级: - OPENCLAW_VERSION 2026.4.29 → 2026.6.6(X 盘真实 USB 实测 gateway 启动/ready、Dashboard 200、旧配置兼容、虾盘云 status 均通过)。 便携性改进(借鉴 OpenClaw-Windows-Portable): - setup.sh / 启动脚本的 npm install 设 npm_config_cache 指向盘内 app/.npm-cache,避免污染系统缓存,拔盘不留痕。 Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
608 lines
23 KiB
JavaScript
608 lines
23 KiB
JavaScript
#!/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/
|
|
const extDir = path.join(OPENCLAW_DIR, 'extensions');
|
|
fs.mkdirSync(extDir, { recursive: true });
|
|
copyDirSync(USB_PLUGIN_DIR, INSTALLED_PLUGIN_DIR);
|
|
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: Xiapan Cloud status (fingerprint + apiKey + balance)
|
|
if (req.url === '/api/xiapan/status' && req.method === 'GET') {
|
|
(async () => {
|
|
try {
|
|
const fpMod = await import('../lib/fingerprint.mjs');
|
|
const xpMod = await import('../lib/xiapan-client.mjs');
|
|
const portableRoot = path.join(__dirname, '..');
|
|
const fp = await fpMod.getFingerprint(portableRoot);
|
|
const apiKey = xpMod.buildApiKey(fp.fingerprint);
|
|
const balance = await xpMod.getBalance(apiKey);
|
|
const rechargeUrl = xpMod.getRechargeUrl(apiKey);
|
|
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({
|
|
source: fp.source,
|
|
apiKey,
|
|
rechargeUrl,
|
|
balance,
|
|
}));
|
|
} 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: Re-run bootstrap to inject the uclaw-cloud provider
|
|
if (req.url === '/api/xiapan/bind' && req.method === 'POST') {
|
|
(async () => {
|
|
try {
|
|
const mod = await import('../lib/bootstrap-xiapan.mjs');
|
|
const portableRoot = path.join(__dirname, '..');
|
|
const result = await mod.bootstrapXiapan({
|
|
configPath: CONFIG_PATH,
|
|
appRoot: portableRoot,
|
|
});
|
|
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: Remove uclaw-cloud provider so the next bind regenerates it
|
|
if (req.url === '/api/xiapan/unbind' && req.method === 'POST') {
|
|
try {
|
|
if (fs.existsSync(CONFIG_PATH)) {
|
|
const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
|
if (config.models && config.models.providers && config.models.providers['uclaw-cloud']) {
|
|
delete config.models.providers['uclaw-cloud'];
|
|
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;
|
|
}
|
|
|
|
// 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;
|
|
}
|
|
|
|
// API: Report a bug — user fills title/description in the web UI; this endpoint
|
|
// attaches fingerprint/version/logs locally and forwards to api.u-claw.org.
|
|
if (req.url === '/api/report-bug' && req.method === 'POST') {
|
|
let body = '';
|
|
req.on('data', chunk => {
|
|
body += chunk;
|
|
if (body.length > 2 * 1024 * 1024) req.destroy(); // 2MB cap on user input
|
|
});
|
|
req.on('end', () => {
|
|
(async () => {
|
|
try {
|
|
const data = body ? JSON.parse(body) : {};
|
|
const mod = await import('../lib/report-bug.mjs');
|
|
const portableRoot = path.join(__dirname, '..');
|
|
const result = await mod.submitBugReport({
|
|
title: data.title,
|
|
description: data.description,
|
|
appRoot: portableRoot,
|
|
includeLogs: data.includeLogs !== false, // 默认带日志,用户可勾掉
|
|
});
|
|
res.writeHead(result.ok ? 200 : 400, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify(result));
|
|
} catch (err) {
|
|
res.writeHead(500, { 'Content-Type': 'application/json' });
|
|
res.end(JSON.stringify({ ok: false, 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);
|