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:
@@ -139,6 +139,16 @@ if "!USE_OPENCLAW!"=="usb" (
|
|||||||
echo OpenClaw 下载安装完成!
|
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 ----
|
REM ---- Default config ----
|
||||||
if not exist "%INSTALL_TARGET%\data\.openclaw\openclaw.json" (
|
if not exist "%INSTALL_TARGET%\data\.openclaw\openclaw.json" (
|
||||||
echo {"gateway":{"mode":"local","auth":{"token":"uclaw"}}} > "%INSTALL_TARGET%\data\.openclaw\openclaw.json"
|
echo {"gateway":{"mode":"local","auth":{"token":"uclaw"}}} > "%INSTALL_TARGET%\data\.openclaw\openclaw.json"
|
||||||
|
|||||||
@@ -71,6 +71,19 @@ if not exist "%CORE_DIR%\node_modules" (
|
|||||||
echo.
|
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
|
REM Find available port
|
||||||
set PORT=18789
|
set PORT=18789
|
||||||
:check_port
|
:check_port
|
||||||
|
|||||||
@@ -322,6 +322,16 @@ input:focus { border-color: #ff6b35; }
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</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>
|
||||||
</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;">✔</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 ---
|
// --- Save channels and open dashboard ---
|
||||||
async function saveChannelsAndOpen() {
|
async function saveChannelsAndOpen() {
|
||||||
var channels = {};
|
var channels = {};
|
||||||
|
|||||||
@@ -2,10 +2,306 @@
|
|||||||
const http = require('http');
|
const http = require('http');
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
|
const { deflateSync } = require('zlib');
|
||||||
|
const crypto = require('crypto');
|
||||||
|
|
||||||
const PORT = 18788;
|
const PORT = 18788;
|
||||||
const CONFIG_PATH = path.join(__dirname, '../data/.openclaw/openclaw.json');
|
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) => {
|
const server = http.createServer((req, res) => {
|
||||||
// CORS headers
|
// CORS headers
|
||||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||||
@@ -18,6 +314,68 @@ const server = http.createServer((req, res) => {
|
|||||||
return;
|
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
|
// API: Get config
|
||||||
if (req.url === '/api/config' && req.method === 'GET') {
|
if (req.url === '/api/config' && req.method === 'GET') {
|
||||||
try {
|
try {
|
||||||
|
|||||||
Reference in New Issue
Block a user