#!/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);