feat: add WeChat QR code login to config-server

Add in-app WeChat scanning via Tencent iLink API:
- server.js: QR code PNG renderer, WeChat API proxy, plugin auto-install
- index.html: WeChat channel card with QR display and polling UI
- Windows-Start.bat: auto-install WeChat plugin on startup
- Windows-Install.bat: copy WeChat plugin during installation

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
hfshfg
2026-04-06 21:44:15 +08:00
parent 65f9bc3df0
commit 220599584e
4 changed files with 481 additions and 0 deletions

View File

@@ -322,6 +322,16 @@ input:focus { border-color: #ff6b35; }
</div>
</div>
</div>
<div class="model-card channel-card" style="grid-column: 1 / -1; border-color: #07c160;" onclick="startWeChatLogin(event)">
<h4>💬 微信 <span class="tag" style="background:rgba(7,193,96,0.2);color:#07c160;">个人微信</span></h4>
<p>扫码接入个人微信,用微信聊天控制 AI</p>
<div class="channel-form" id="form-wechat" onclick="event.stopPropagation()" style="max-height:none;overflow:visible;">
<div id="wechat-qr" style="text-align:center;padding:16px;">
<p style="color:#888;font-size:0.85em;">点击此卡片开始扫码</p>
</div>
<div id="wechat-status" style="text-align:center;color:#888;font-size:0.85em;"></div>
</div>
</div>
</div>
</div>
@@ -519,6 +529,96 @@ function toggleChannel(card, type) {
}
}
// --- WeChat login ---
var wechatSession = null;
var wechatPolling = false;
async function startWeChatLogin(e) {
var form = document.getElementById('form-wechat');
// If form is already open and showing QR, don't restart
if (form.classList.contains('open') && wechatPolling) {
return;
}
// Toggle form open
var card = form.closest('.channel-card');
if (!form.classList.contains('open')) {
form.classList.add('open');
card.classList.add('selected');
}
var qrDiv = document.getElementById('wechat-qr');
var statusDiv = document.getElementById('wechat-status');
qrDiv.innerHTML = '<p style="color:#888;">正在获取二维码...</p>';
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 = '<img src="' + data.qrcodeUrl + '" style="width:200px;height:200px;image-rendering:pixelated;border-radius:8px;">';
statusDiv.textContent = '请用微信扫描上方二维码';
statusDiv.style.color = '#07c160';
// Start polling
wechatPolling = true;
pollWeChatStatus();
} catch (err) {
qrDiv.innerHTML = '<p style="color:#f44336;">获取二维码失败</p>';
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 = '<div style="font-size:3em;">&#10004;</div>';
statusDiv.innerHTML = '<span style="color:#4caf50;font-weight:600;">微信连接成功!</span><br><span style="font-size:0.8em;">账号: ' +
(data.accountId || '') + (data.pluginInstalled ? '' : '<br>⚠️ 插件未安装,请重启 Gateway') + '<br>需重启 Gateway 生效</span>';
statusDiv.style.color = '#4caf50';
showToast('微信连接成功!');
return;
}
if (data.status === 'refreshed') {
qrDiv.innerHTML = '<img src="' + data.qrcodeUrl + '" style="width:200px;height:200px;image-rendering:pixelated;border-radius:8px;">';
statusDiv.textContent = '二维码已刷新,请重新扫码';
statusDiv.style.color = '#ff6b35';
} else if (data.status === 'scaned') {
statusDiv.textContent = '已扫码,请在手机上确认...';
statusDiv.style.color = '#07c160';
} else if (data.status === 'expired') {
wechatPolling = false;
qrDiv.innerHTML = '<p style="color:#888;">二维码已过期,<a href="#" onclick="startWeChatLogin(event);return false;" style="color:#ff6b35;">重新获取</a></p>';
statusDiv.textContent = data.message || '';
statusDiv.style.color = '#f44336';
return;
} else if (data.status === 'error') {
wechatPolling = false;
qrDiv.innerHTML = '<p style="color:#f44336;">连接失败</p>';
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 = {};

View File

@@ -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 {