Improve first-run and channel setup for non-technical users: detect newer Gemini key formats, pin Node 22.22.3, add Control Panel Telegram approve flow, and keep channels/models when config is rewritten. Persist uclaw wizard state via uclaw-meta.json so restarts skip language/persona prompts. Co-authored-by: Cursor <cursoragent@cursor.com>
909 lines
35 KiB
JavaScript
909 lines
35 KiB
JavaScript
#!/usr/bin/env node
|
||
const http = require('http');
|
||
const fs = require('fs');
|
||
const path = require('path');
|
||
const { execFileSync } = require('child_process');
|
||
const { deflateSync } = require('zlib');
|
||
const crypto = require('crypto');
|
||
|
||
const PORT_RANGE_START = 18788;
|
||
const PORT_RANGE_END = 18798;
|
||
const DATA_DIR = path.join(__dirname, '../data');
|
||
const CONFIG_PATH = path.join(DATA_DIR, '.openclaw/openclaw.json');
|
||
const RUNTIME_PATH = path.join(DATA_DIR, '.openclaw/runtime.json');
|
||
const UCLAW_META_PATH = path.join(DATA_DIR, '.openclaw/uclaw-meta.json');
|
||
|
||
function getStateDir() {
|
||
return process.env.OPENCLAW_STATE_DIR || path.join(DATA_DIR, '.openclaw');
|
||
}
|
||
|
||
function resolveNodeBinary() {
|
||
const runtimeRoot = path.join(__dirname, '../app/runtime');
|
||
if (process.platform === 'win32') {
|
||
return path.join(runtimeRoot, 'node-win-x64/node.exe');
|
||
}
|
||
if (process.platform === 'darwin') {
|
||
const dir = process.arch === 'arm64' ? 'node-mac-arm64' : 'node-mac-x64';
|
||
return path.join(runtimeRoot, dir, 'bin/node');
|
||
}
|
||
const dir = process.arch === 'arm64' ? 'node-linux-arm64' : 'node-linux-x64';
|
||
return path.join(runtimeRoot, dir, 'bin/node');
|
||
}
|
||
|
||
const OPENCLAW_ENTRY = path.join(__dirname, '../app/core/node_modules/openclaw/openclaw.mjs');
|
||
|
||
function openclawEnv() {
|
||
return {
|
||
...process.env,
|
||
OPENCLAW_HOME: DATA_DIR,
|
||
OPENCLAW_STATE_DIR: getStateDir(),
|
||
OPENCLAW_CONFIG_PATH: CONFIG_PATH,
|
||
};
|
||
}
|
||
|
||
function readConfigFile() {
|
||
if (!fs.existsSync(CONFIG_PATH)) return {};
|
||
return JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf8'));
|
||
}
|
||
|
||
function extractUclawMeta(config) {
|
||
if (!config.uclaw) return config;
|
||
const uclaw = config.uclaw;
|
||
delete config.uclaw;
|
||
try {
|
||
const existing = fs.existsSync(UCLAW_META_PATH)
|
||
? JSON.parse(fs.readFileSync(UCLAW_META_PATH, 'utf8'))
|
||
: {};
|
||
fs.writeFileSync(UCLAW_META_PATH, `${JSON.stringify({ ...existing, ...uclaw }, null, 2)}\n`);
|
||
} catch (err) {
|
||
console.error('[uclaw-meta] write failed:', err.message);
|
||
}
|
||
return config;
|
||
}
|
||
|
||
function mergeConfigPreserve(existing, incoming) {
|
||
const next = { ...incoming };
|
||
if (existing.channels) {
|
||
next.channels = { ...existing.channels, ...(next.channels || {}) };
|
||
}
|
||
if (existing.commands?.ownerAllowFrom?.length) {
|
||
next.commands = {
|
||
...(existing.commands || {}),
|
||
...(next.commands || {}),
|
||
ownerAllowFrom: next.commands?.ownerAllowFrom?.length
|
||
? next.commands.ownerAllowFrom
|
||
: existing.commands.ownerAllowFrom,
|
||
};
|
||
}
|
||
if (existing.plugins && !next.plugins) next.plugins = existing.plugins;
|
||
return next;
|
||
}
|
||
|
||
function writeConfigFile(config) {
|
||
const existing = readConfigFile();
|
||
let next = mergeConfigPreserve(existing, config);
|
||
next = extractUclawMeta(next);
|
||
delete next.agent;
|
||
const dir = path.dirname(CONFIG_PATH);
|
||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||
fs.writeFileSync(CONFIG_PATH, `${JSON.stringify(next, null, 2)}\n`);
|
||
return next;
|
||
}
|
||
|
||
function runOpenclawCli(args) {
|
||
const node = resolveNodeBinary();
|
||
if (!fs.existsSync(node) || !fs.existsSync(OPENCLAW_ENTRY)) {
|
||
throw new Error('OpenClaw runtime not found on this drive');
|
||
}
|
||
return execFileSync(node, [OPENCLAW_ENTRY, ...args], {
|
||
env: openclawEnv(),
|
||
encoding: 'utf8',
|
||
stdio: ['ignore', 'pipe', 'pipe'],
|
||
}).trim();
|
||
}
|
||
|
||
function telegramPairingPath() {
|
||
return path.join(getStateDir(), 'credentials', 'telegram-pairing.json');
|
||
}
|
||
|
||
function readTelegramPairingRequests() {
|
||
const filePath = telegramPairingPath();
|
||
if (!fs.existsSync(filePath)) return [];
|
||
let parsed;
|
||
try {
|
||
parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||
} catch {
|
||
return [];
|
||
}
|
||
const requests = Array.isArray(parsed.requests) ? parsed.requests : [];
|
||
const now = Date.now();
|
||
return requests.filter((req) => {
|
||
const created = Date.parse(req.createdAt || '');
|
||
return Number.isFinite(created) && now - created < 3600 * 1000;
|
||
}).map((req) => ({
|
||
code: req.code,
|
||
id: req.id,
|
||
createdAt: req.createdAt,
|
||
username: req.meta?.username || req.meta?.name || '',
|
||
firstName: req.meta?.firstName || '',
|
||
}));
|
||
}
|
||
|
||
function approveTelegramPairing(code) {
|
||
const snapshot = readConfigFile();
|
||
const channels = snapshot.channels ? { ...snapshot.channels } : null;
|
||
const out = runOpenclawCli(['pairing', 'approve', 'telegram', code, '--notify']);
|
||
const after = readConfigFile();
|
||
if (channels) {
|
||
after.channels = { ...channels, ...(after.channels || {}) };
|
||
writeConfigFile(after);
|
||
}
|
||
return out;
|
||
}
|
||
|
||
// ── 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' };
|
||
}
|
||
|
||
// 状态轮询用 pollBaseUrl(IDC 重定向后会指向新主机);二维码获取/刷新始终用原始
|
||
// apiBaseUrl(与官方插件一致:refresh 回到固定主机,只有 status 轮询跟随重定向)。
|
||
const result = await pollWeChatQrStatus(login.pollBaseUrl || login.apiBaseUrl, login.qrcode);
|
||
// 微信登录状态流转日志(跳过高频的 wait,便于排查"扫码卡死"类问题)。
|
||
if (result.status && result.status !== 'wait') {
|
||
console.log(`[wechat] status=${result.status}` + (result.redirect_host ? ` redirect_host=${result.redirect_host}` : ''));
|
||
}
|
||
|
||
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();
|
||
// 新二维码来自原始主机,重置轮询主机,避免拿新码去轮询旧的重定向主机。
|
||
login.pollBaseUrl = null;
|
||
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.',
|
||
};
|
||
}
|
||
|
||
// IDC 重定向:用户扫码后,ilink 服务端可能要求把后续轮询切换到另一个数据中心主机
|
||
// (status=scaned_but_redirect + redirect_host)。必须跟着切,否则一直轮询旧主机,
|
||
// 扫码后永远等不到 confirmed——表现为「扫了码却卡死不前进」。
|
||
// 同款逻辑见官方插件 openclaw-weixin/src/auth/login-qr.ts 的 scaned_but_redirect 分支。
|
||
if (result.status === 'scaned_but_redirect') {
|
||
if (result.redirect_host) {
|
||
login.pollBaseUrl = 'https://' + result.redirect_host;
|
||
}
|
||
// 对前端按「已扫码」处理:显示提示并继续轮询,下一轮已指向新主机。
|
||
return { status: 'scaned' };
|
||
}
|
||
|
||
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: what the first-run wizard offers, straight from the manifest.
|
||
if (req.url === '/api/personas' && req.method === 'GET') {
|
||
try {
|
||
const manifestPath = path.join(__dirname, '..', 'skills', 'manifest.json');
|
||
const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'));
|
||
const shipping = manifest.skills.filter((s) => s.status === 'shipping');
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify(manifest.personas.map((p) => ({
|
||
...p,
|
||
skills: shipping.filter((s) => (s.personas || []).includes(p.id)).map((s) => s.id),
|
||
}))));
|
||
} catch (err) {
|
||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ error: err.message }));
|
||
}
|
||
return;
|
||
}
|
||
|
||
// API: install the skills a persona needs. Runs the same installer the
|
||
// release build and the one-line installers use, so there is one code path.
|
||
if (req.url === '/api/install-skills' && req.method === 'POST') {
|
||
let body = '';
|
||
req.on('data', (chunk) => { body += chunk; if (body.length > 4096) req.destroy(); });
|
||
req.on('end', async () => {
|
||
const reply = (payload, status = 200) => {
|
||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify(payload));
|
||
};
|
||
let persona = 'general';
|
||
let locale = 'en';
|
||
try {
|
||
const parsed = JSON.parse(body || '{}');
|
||
if (parsed.persona) persona = String(parsed.persona);
|
||
if (parsed.locale) locale = String(parsed.locale);
|
||
} catch { /* fall back to the defaults */ }
|
||
|
||
const target = path.join(__dirname, '..', 'app', 'core', 'node_modules', 'openclaw', 'skills');
|
||
// On the drive the installer sits in lib/ next to us; in a source checkout
|
||
// it lives at the repo root. Same two-place lookup the setup scripts use.
|
||
const installer = [
|
||
path.join(__dirname, '..', 'lib', 'install-skills.mjs'),
|
||
path.join(__dirname, '..', '..', 'lib', 'install-skills.mjs'),
|
||
].find((candidate) => fs.existsSync(candidate));
|
||
if (!installer) return reply({ ok: false, reason: 'installer-missing' });
|
||
|
||
try {
|
||
fs.mkdirSync(target, { recursive: true });
|
||
const { execFile } = require('child_process');
|
||
await new Promise((resolve, reject) => {
|
||
execFile(process.execPath,
|
||
[installer, '--target', target, '--locale', locale, '--persona', persona, '--json'],
|
||
{ timeout: 30000 },
|
||
(err, stdout) => (err ? reject(err) : resolve(stdout)));
|
||
});
|
||
reply({ ok: true, persona });
|
||
} catch (err) {
|
||
// Skills are a bonus, not a gate: the wizard continues either way.
|
||
reply({ ok: false, reason: err.message });
|
||
}
|
||
});
|
||
return;
|
||
}
|
||
|
||
// API: Identify an API key and check that it actually works.
|
||
//
|
||
// Runs server-side on purpose: the browser cannot call most model endpoints
|
||
// cross-origin, and doing it here means the key never leaves this machine by
|
||
// any path the page controls. Sends the smallest possible completion (1 token)
|
||
// and maps the outcome to a message key the page translates -- users get
|
||
// "that key was not accepted, here is what to do", not "401 Unauthorized".
|
||
if (req.url === '/api/test-key' && req.method === 'POST') {
|
||
let body = '';
|
||
req.on('data', (chunk) => {
|
||
body += chunk;
|
||
// A key is short; anything large is not a key.
|
||
if (body.length > 8192) req.destroy();
|
||
});
|
||
req.on('end', async () => {
|
||
const reply = (payload, status = 200) => {
|
||
res.writeHead(status, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify(payload));
|
||
};
|
||
|
||
let parsed;
|
||
try { parsed = JSON.parse(body || '{}'); } catch { return reply({ ok: false, reason: 'key.err_unknown' }, 400); }
|
||
|
||
const apiKey = String(parsed.apiKey || '').trim();
|
||
if (!apiKey) return reply({ ok: false, reason: 'key.err_empty' });
|
||
|
||
const { detectProvider, classifyFailure, modelsToTry, discoverGoogleOpenAiModels } = await import('../lib/provider-detect.mjs');
|
||
const detected = detectProvider(apiKey);
|
||
const baseUrl = String(parsed.baseUrl || detected?.baseUrl || '').replace(/\/+$/, '');
|
||
const model = String(parsed.model || detected?.model || '');
|
||
|
||
if (!baseUrl || !model) {
|
||
return reply({ ok: false, provider: detected, reason: 'key.err_unrecognised' });
|
||
}
|
||
|
||
const startedAt = Date.now();
|
||
const controller = new AbortController();
|
||
const timer = setTimeout(() => controller.abort(), 15000);
|
||
const candidates = modelsToTry(detected, model);
|
||
let lastStatus = null;
|
||
const tryChat = async (tryModel) => {
|
||
const response = await fetch(`${baseUrl}/chat/completions`, {
|
||
method: 'POST',
|
||
signal: controller.signal,
|
||
headers: {
|
||
'Content-Type': 'application/json',
|
||
Authorization: `Bearer ${apiKey}`,
|
||
// Anthropic's endpoint wants its own header; sending both is harmless.
|
||
'x-api-key': apiKey,
|
||
'anthropic-version': '2023-06-01',
|
||
},
|
||
body: JSON.stringify({ model: tryModel, max_tokens: 1, messages: [{ role: 'user', content: 'hi' }] }),
|
||
});
|
||
return response;
|
||
};
|
||
try {
|
||
for (const tryModel of candidates) {
|
||
const response = await tryChat(tryModel);
|
||
if (response.ok) {
|
||
return reply({ ok: true, provider: detected, baseUrl, model: tryModel, latencyMs: Date.now() - startedAt });
|
||
}
|
||
lastStatus = response.status;
|
||
// Only try the next Gemini model when this account cannot see the first one.
|
||
if (!(detected?.id === 'google' && response.status === 404)) {
|
||
return reply({ ok: false, provider: detected, status: response.status, reason: classifyFailure({ status: response.status }) });
|
||
}
|
||
}
|
||
if (detected?.id === 'google' && lastStatus === 404) {
|
||
const discovered = await discoverGoogleOpenAiModels(baseUrl, apiKey);
|
||
const extra = discovered.filter((id) => !candidates.includes(id));
|
||
const rank = (id) => (id.includes('flash') ? 0 : 10) + (id.includes('2.0') ? 0 : 5) + (id.includes('lite') ? 1 : 0);
|
||
extra.sort((a, b) => rank(a) - rank(b));
|
||
for (const tryModel of extra) {
|
||
const response = await tryChat(tryModel);
|
||
if (response.ok) {
|
||
return reply({ ok: true, provider: detected, baseUrl, model: tryModel, latencyMs: Date.now() - startedAt });
|
||
}
|
||
lastStatus = response.status;
|
||
if (response.status !== 404) {
|
||
return reply({ ok: false, provider: detected, status: response.status, reason: classifyFailure({ status: response.status }) });
|
||
}
|
||
}
|
||
}
|
||
return reply({ ok: false, provider: detected, status: lastStatus, reason: classifyFailure({ status: lastStatus }) });
|
||
} catch (err) {
|
||
return reply({ ok: false, provider: detected, reason: classifyFailure({ code: err?.cause?.code || err?.code || err?.name }) });
|
||
} finally {
|
||
clearTimeout(timer);
|
||
}
|
||
});
|
||
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: Telegram pairing — list pending DM approvals (no CLI for the user)
|
||
if (req.url === '/api/telegram/pairing' && req.method === 'GET') {
|
||
try {
|
||
const requests = readTelegramPairingRequests();
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ requests }));
|
||
} catch (err) {
|
||
res.writeHead(500, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ error: err.message }));
|
||
}
|
||
return;
|
||
}
|
||
|
||
// API: Telegram pairing — one-click approve from the Control Panel
|
||
if (req.url === '/api/telegram/pairing/approve' && req.method === 'POST') {
|
||
let body = '';
|
||
req.on('data', chunk => { body += chunk; });
|
||
req.on('end', () => {
|
||
try {
|
||
const { code } = JSON.parse(body || '{}');
|
||
if (!code || typeof code !== 'string') {
|
||
res.writeHead(400, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ error: 'Missing pairing code' }));
|
||
return;
|
||
}
|
||
const message = approveTelegramPairing(code.trim().toUpperCase());
|
||
res.writeHead(200, { 'Content-Type': 'application/json' });
|
||
res.end(JSON.stringify({ ok: true, message }));
|
||
} 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 {
|
||
writeConfigFile(JSON.parse(body));
|
||
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 the shared i18n runtime and the drive's locale from outside public/.
|
||
// Keeping one copy matters: the pages opened from file:// load these same
|
||
// files by relative path, and a second copy under public/ would drift.
|
||
if (req.url.startsWith('/lib/i18n/') || req.url === '/locale.js') {
|
||
const target = req.url === '/locale.js'
|
||
? path.join(__dirname, '../data/.openclaw/locale.js')
|
||
: path.join(__dirname, '..', 'lib', 'i18n', path.basename(req.url));
|
||
if (fs.existsSync(target) && fs.statSync(target).isFile()) {
|
||
res.writeHead(200, { 'Content-Type': 'application/javascript; charset=utf-8' });
|
||
res.end(fs.readFileSync(target));
|
||
} else {
|
||
// locale.js is written by the launcher; before the first run it is absent
|
||
// and the page falls back to the browser language. Not an error.
|
||
res.writeHead(204);
|
||
res.end();
|
||
}
|
||
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);
|