diff --git a/portable/Windows-Install.bat b/portable/Windows-Install.bat index 07c994a..90e9da2 100644 --- a/portable/Windows-Install.bat +++ b/portable/Windows-Install.bat @@ -139,6 +139,16 @@ if "!USE_OPENCLAW!"=="usb" ( echo OpenClaw 下载安装完成! ) +REM ---- Copy extensions (WeChat plugin etc.) ---- +if exist "%APP_DIR%\extensions\openclaw-weixin\openclaw.plugin.json" ( + echo Installing WeChat plugin... + xcopy /s /e /q /y "%APP_DIR%\extensions\openclaw-weixin" "%INSTALL_TARGET%\extensions\openclaw-weixin\" >nul + REM Also install to ~/.openclaw/extensions/ for Gateway + mkdir "%USERPROFILE%\.openclaw\extensions" 2>nul + xcopy /s /e /q /y "%APP_DIR%\extensions\openclaw-weixin" "%USERPROFILE%\.openclaw\extensions\openclaw-weixin\" >nul + echo WeChat plugin installed! +) + REM ---- Default config ---- if not exist "%INSTALL_TARGET%\data\.openclaw\openclaw.json" ( echo {"gateway":{"mode":"local","auth":{"token":"uclaw"}}} > "%INSTALL_TARGET%\data\.openclaw\openclaw.json" diff --git a/portable/Windows-Start.bat b/portable/Windows-Start.bat index 0cc5ffd..b70b893 100644 --- a/portable/Windows-Start.bat +++ b/portable/Windows-Start.bat @@ -71,6 +71,19 @@ if not exist "%CORE_DIR%\node_modules" ( echo. ) +REM Auto-install WeChat plugin if available +set "WECHAT_PLUGIN_SRC=%APP_DIR%\extensions\openclaw-weixin" +set "WECHAT_PLUGIN_DST=%USERPROFILE%\.openclaw\extensions\openclaw-weixin" +if exist "%WECHAT_PLUGIN_SRC%\openclaw.plugin.json" ( + if not exist "%WECHAT_PLUGIN_DST%\openclaw.plugin.json" ( + echo Installing WeChat plugin... + mkdir "%USERPROFILE%\.openclaw\extensions" 2>nul + xcopy /s /e /q /y "%WECHAT_PLUGIN_SRC%" "%WECHAT_PLUGIN_DST%\" >nul + echo WeChat plugin installed! + echo. + ) +) + REM Find available port set PORT=18789 :check_port diff --git a/portable/config-server/public/index.html b/portable/config-server/public/index.html index 0ea2544..508aa2f 100644 --- a/portable/config-server/public/index.html +++ b/portable/config-server/public/index.html @@ -322,6 +322,16 @@ input:focus { border-color: #ff6b35; } +
扫码接入个人微信,用微信聊天控制 AI
+点击此卡片开始扫码
+正在获取二维码...
'; + statusDiv.textContent = ''; + + try { + var res = await fetch('/api/wechat/start', { method: 'POST' }); + var data = await res.json(); + if (data.error) throw new Error(data.error); + + wechatSession = data.sessionKey; + qrDiv.innerHTML = '获取二维码失败
'; + statusDiv.textContent = err.message; + statusDiv.style.color = '#f44336'; + } +} + +async function pollWeChatStatus() { + if (!wechatSession || !wechatPolling) return; + + try { + var res = await fetch('/api/wechat/status?session=' + encodeURIComponent(wechatSession)); + var data = await res.json(); + + var qrDiv = document.getElementById('wechat-qr'); + var statusDiv = document.getElementById('wechat-status'); + + if (data.status === 'confirmed') { + wechatPolling = false; + qrDiv.innerHTML = '二维码已过期,重新获取
'; + statusDiv.textContent = data.message || ''; + statusDiv.style.color = '#f44336'; + return; + } else if (data.status === 'error') { + wechatPolling = false; + qrDiv.innerHTML = '连接失败
'; + statusDiv.textContent = data.message || 'Unknown error'; + statusDiv.style.color = '#f44336'; + return; + } + // wait -> continue polling + setTimeout(pollWeChatStatus, 1500); + } catch (err) { + setTimeout(pollWeChatStatus, 3000); + } +} + // --- Save channels and open dashboard --- async function saveChannelsAndOpen() { var channels = {}; diff --git a/portable/config-server/server.js b/portable/config-server/server.js index 63ece20..2383874 100644 --- a/portable/config-server/server.js +++ b/portable/config-server/server.js @@ -2,10 +2,306 @@ const http = require('http'); const fs = require('fs'); const path = require('path'); +const { deflateSync } = require('zlib'); +const crypto = require('crypto'); const PORT = 18788; const CONFIG_PATH = path.join(__dirname, '../data/.openclaw/openclaw.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', '*'); @@ -18,6 +314,68 @@ const server = http.createServer((req, res) => { 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 {