'use strict'; /** * channel.weixin — WeChat login via ilinkai.weixin.qq.com API. * * Ported from the reference weixin-login.js script. * Requires Node.js >= 18 (global fetch). * * method: login * params : { callId, timeout, emit } * returns: { abort } * * emit(payload) sends a sys-call reply upstream: * { action:'event', event:'qrcode', data:{ url, expire:30, index } } * { action:'progress', event:'scanned', data:{ status:'waiting_confirm' } } * { action:'finish', event:'success', data:{ accountId } } * { action:'finish', event:'failed', code, message } */ const crypto = require('crypto'); const fs = require('fs'); const path = require('path'); const { execFileSync } = require('child_process'); const log = require('../logger'); // ── Constants (from reference script) ──────────────────────────────────────── const FIXED_BASE_URL = 'https://ilinkai.weixin.qq.com'; const DEFAULT_BOT_TYPE = '3'; const QR_LONG_POLL_TIMEOUT_MS = 35_000; const ACTIVE_LOGIN_TTL_MS = 5 * 60_000; const MAX_QR_REFRESH_COUNT = 3; const CHANNEL_VERSION = '2.4.3'; const ILINK_APP_ID = 'bot'; const ILINK_APP_CLIENT_VERSION = String(_buildClientVersion(CHANNEL_VERSION)); function _buildClientVersion(version) { const parts = String(version).split('.').map(p => parseInt(p, 10)); const major = parts[0] || 0; const minor = parts[1] || 0; const patch = parts[2] || 0; return ((major & 0xff) << 16) | ((minor & 0xff) << 8) | (patch & 0xff); } // ── State-dir helpers (mirrors reference script) ───────────────────────────── function _resolveStateDir() { return path.join('/home/sts', '.openclaw'); } function _ensureStsOwnership(filePath) { try { execFileSync('chown', ['sts:sts', filePath], { stdio: 'ignore' }); } catch (err) { log.warn('weixin', `chown sts:sts failed for ${filePath}: ${err.message}`); } } function _resolveWeixinStateDir() { return path.join(_resolveStateDir(), 'openclaw-weixin'); } function _resolveAccountIndexPath(){ return path.join(_resolveWeixinStateDir(), 'accounts.json'); } function _resolveAccountsDir() { return path.join(_resolveWeixinStateDir(), 'accounts'); } function _resolveAccountPath(id) { return path.join(_resolveAccountsDir(), `${id}.json`); } const VALID_ID_RE = /^[a-z0-9][a-z0-9_-]{0,63}$/i; const INVALID_CHARS = /[^a-z0-9_-]+/g; function _normalizeAccountId(value) { const trimmed = String(value || '').trim(); if (!trimmed) return 'default'; const lower = trimmed.toLowerCase(); if (VALID_ID_RE.test(trimmed)) return lower; return lower.replace(INVALID_CHARS, '-').replace(/^-+/, '').replace(/-+$/, '').slice(0, 64) || 'default'; } function _listIndexedAccountIds() { try { const p = _resolveAccountIndexPath(); if (!fs.existsSync(p)) return []; const parsed = JSON.parse(fs.readFileSync(p, 'utf8')); return Array.isArray(parsed) ? parsed.filter(id => typeof id === 'string' && id.trim()) : []; } catch (_) { return []; } } function _registerAccountId(accountId) { fs.mkdirSync(_resolveWeixinStateDir(), { recursive: true }); const existing = _listIndexedAccountIds(); if (existing.includes(accountId)) return; const indexPath = _resolveAccountIndexPath(); fs.writeFileSync(indexPath, JSON.stringify([...existing, accountId], null, 2), 'utf8'); _ensureStsOwnership(indexPath); } function _loadAccount(accountId) { try { const p = _resolveAccountPath(accountId); if (fs.existsSync(p)) return JSON.parse(fs.readFileSync(p, 'utf8')); } catch (_) {} // legacy token fallback try { const legacyPath = path.join(_resolveStateDir(), 'credentials', 'openclaw-weixin', 'credentials.json'); if (fs.existsSync(legacyPath)) { const parsed = JSON.parse(fs.readFileSync(legacyPath, 'utf8')); if (typeof parsed.token === 'string') return { token: parsed.token }; } } catch (_) {} return null; } function _saveAccount(accountId, update) { fs.mkdirSync(_resolveAccountsDir(), { recursive: true }); const existing = _loadAccount(accountId) || {}; const token = (update.token || '').trim() || existing.token; const baseUrl = (update.baseUrl || '').trim() || existing.baseUrl; const userId = (update.userId || '').trim() || (existing.userId || '').trim() || undefined; const data = { ...(token ? { token, savedAt: new Date().toISOString() } : {}), ...(baseUrl ? { baseUrl } : {}), ...(userId ? { userId } : {}), }; const filePath = _resolveAccountPath(accountId); fs.writeFileSync(filePath, JSON.stringify(data, null, 2), 'utf8'); _ensureStsOwnership(filePath); try { fs.chmodSync(filePath, 0o600); } catch (_) {} return filePath; } function _clearStaleAccountsForUserId(currentAccountId, userId) { if (!userId) return; for (const id of _listIndexedAccountIds()) { if (id === currentAccountId) continue; const data = _loadAccount(id); if (data && (data.userId || '').trim() === userId) { log.info('weixin', `removing stale account with same userId: ${id}`); try { fs.unlinkSync(_resolveAccountPath(id)); } catch (_) {} const existing = _listIndexedAccountIds(); const indexPath = _resolveAccountIndexPath(); fs.writeFileSync(indexPath, JSON.stringify(existing.filter(x => x !== id), null, 2), 'utf8'); _ensureStsOwnership(indexPath); } } } function _getLocalBotTokenList() { const tokens = []; const ids = _listIndexedAccountIds(); for (let i = ids.length - 1; i >= 0 && tokens.length < 10; i--) { const token = (_loadAccount(ids[i]) || {}).token; if (token && token.trim()) tokens.push(token.trim()); } return tokens; } // ── HTTP helpers ────────────────────────────────────────────────────────────── function _commonHeaders() { return { 'iLink-App-Id': ILINK_APP_ID, 'iLink-App-ClientVersion': ILINK_APP_CLIENT_VERSION, }; } function _randomWechatUin() { const uint32 = crypto.randomBytes(4).readUInt32BE(0); return Buffer.from(String(uint32), 'utf8').toString('base64'); } function _postHeaders() { return { 'Content-Type': 'application/json', 'AuthorizationType': 'ilink_bot_token', 'X-WECHAT-UIN': _randomWechatUin(), ..._commonHeaders(), }; } async function _postJson(baseUrl, endpoint, body, timeoutMs) { const url = `${baseUrl.replace(/\/$/, '')}/${endpoint}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs || 15_000); try { const res = await fetch(url, { method: 'POST', headers: _postHeaders(), body: JSON.stringify(body), signal: controller.signal, }); const text = await res.text(); if (!res.ok) throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`); return JSON.parse(text); } finally { clearTimeout(timer); } } async function _getJson(baseUrl, endpoint, timeoutMs) { const url = `${baseUrl.replace(/\/$/, '')}/${endpoint}`; const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs || QR_LONG_POLL_TIMEOUT_MS + 5_000); try { const res = await fetch(url, { method: 'GET', headers: _commonHeaders(), signal: controller.signal, }); const text = await res.text(); if (!res.ok) throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`); return JSON.parse(text); } catch (err) { if (err && err.name === 'AbortError') return { status: 'wait' }; throw err; } finally { clearTimeout(timer); } } // ── WeChat API calls ────────────────────────────────────────────────────────── async function _fetchQRCode(botType) { const localTokenList = _getLocalBotTokenList(); const data = await _postJson( FIXED_BASE_URL, `ilink/bot/get_bot_qrcode?bot_type=${encodeURIComponent(botType)}`, { local_token_list: localTokenList }, 15_000 ); if (data.ret !== undefined && data.ret !== 0) { throw new Error(`get_bot_qrcode ret=${data.ret} errmsg=${data.errmsg || ''}`); } if (!data.qrcode || !data.qrcode_img_content) { throw new Error(`get_bot_qrcode response missing qrcode/qrcode_img_content`); } return data; } async function _pollQRStatus(apiBaseUrl, qrcode) { try { const endpoint = `ilink/bot/get_qrcode_status?qrcode=${encodeURIComponent(qrcode)}`; const data = await _getJson(apiBaseUrl, endpoint, QR_LONG_POLL_TIMEOUT_MS); if (data.ret !== undefined && data.ret !== 0) { throw new Error(`get_qrcode_status ret=${data.ret} errmsg=${data.errmsg || ''}`); } return data; } catch (err) { if (err && err.name === 'AbortError') return { status: 'wait' }; log.warn('weixin', `pollQRStatus error (will retry): ${err.message}`); return { status: 'wait' }; } } function _bumpOpenClawConfigTimestamp() { const stateDir = _resolveStateDir(); const candidates = [ process.env.OPENCLAW_CONFIG || '', path.join(stateDir, 'openclaw.json'), ].filter(Boolean); const configPath = candidates.find(p => { try { return fs.existsSync(p); } catch (_) { return false; } }); if (!configPath) return; try { const cfg = JSON.parse(fs.readFileSync(configPath, 'utf8')); if (!cfg.channels) cfg.channels = {}; if (!cfg.channels['openclaw-weixin']) cfg.channels['openclaw-weixin'] = {}; cfg.channels['openclaw-weixin'].channelConfigUpdatedAt = new Date().toISOString(); fs.writeFileSync(configPath, JSON.stringify(cfg, null, 2), 'utf8'); log.info('weixin', `bumped openclaw.json timestamp: ${configPath}`); } catch (err) { log.warn('weixin', `bump timestamp failed: ${err.message}`); } } // ── login() ─────────────────────────────────────────────────────────────────── /** * Start WeChat QR login. * * @param {object} opts * @param {string} opts.callId - sys-call id (for logging) * @param {number} opts.timeout - overall timeout in seconds (default 180) * @param {string} opts.botType - ilink bot type (default '3') * @param {function} opts.emit - emit(payload) send event upstream * @returns {{ abort: function, onReply: function }} task handle */ function login({ callId, timeout = 180, botType = DEFAULT_BOT_TYPE, emit }) { let aborted = false; let finished = false; function abort() { aborted = true; log.info('weixin', `callId=${callId} aborted`); } const timeoutMs = Math.max(timeout * 1000, 30_000); // Run async without blocking caller _runLogin({ callId, timeoutMs, botType, emit, isAborted: () => aborted }) .then(() => { finished = true; }) .catch((err) => { if (finished) return; finished = true; log.error('weixin', `callId=${callId} login error: ${err.message}`); emit({ action: 'finish', event: 'failed', code: 500, message: err.message }); }); return { abort }; } async function _runLogin({ callId, timeoutMs, botType, emit, isAborted }) { let qrRefreshCount = 1; let scannedEmitted = false; let activeLogin = null; const deadline = Date.now() + timeoutMs; async function startOrRefreshQr() { const qrData = await _fetchQRCode(botType); activeLogin = { qrcode: qrData.qrcode, qrcodeUrl: qrData.qrcode_img_content, startedAt: Date.now(), apiBaseUrl: FIXED_BASE_URL, }; emit({ action: 'event', event: 'qrcode', // url = qrcode_img_content: the WeChat URL encoded inside the QR image (use this for QR rendering) // code = qrcode: the polling ticket used by _pollQRStatus (NOT for QR rendering) data: { url: activeLogin.qrcodeUrl, code: activeLogin.qrcode, expire: 30, index: qrRefreshCount }, }); log.info('weixin', `callId=${callId} qrcode emitted index=${qrRefreshCount}`); } await startOrRefreshQr(); while (!isAborted() && Date.now() < deadline) { // Refresh if local TTL exceeded if (Date.now() - activeLogin.startedAt >= ACTIVE_LOGIN_TTL_MS) { log.info('weixin', `callId=${callId} QR TTL expired, refreshing`); qrRefreshCount++; if (qrRefreshCount > MAX_QR_REFRESH_COUNT) { emit({ action: 'finish', event: 'failed', code: 1003, message: 'QR expired too many times' }); return; } await startOrRefreshQr(); scannedEmitted = false; continue; } const status = await _pollQRStatus(activeLogin.apiBaseUrl, activeLogin.qrcode); if (isAborted()) return; switch (status.status) { case 'wait': break; case 'scaned': case 'scaned_but_redirect': { if (status.redirect_host) { activeLogin.apiBaseUrl = `https://${status.redirect_host}`; } if (!scannedEmitted) { scannedEmitted = true; emit({ action: 'progress', event: 'scanned', data: { status: 'waiting_confirm' } }); log.info('weixin', `callId=${callId} scanned`); } break; } case 'need_verifycode': { // Verify code entry not supported; treat as failure log.warn('weixin', `callId=${callId} need_verifycode not supported`); emit({ action: 'finish', event: 'failed', code: 1005, message: 'verify code required (not supported)' }); return; } case 'expired': { log.info('weixin', `callId=${callId} QR expired, refreshing`); qrRefreshCount++; if (qrRefreshCount > MAX_QR_REFRESH_COUNT) { emit({ action: 'finish', event: 'failed', code: 1001, message: 'qrcode expired' }); return; } await startOrRefreshQr(); scannedEmitted = false; break; } case 'verify_code_blocked': { log.warn('weixin', `callId=${callId} verify_code_blocked`); qrRefreshCount++; if (qrRefreshCount > MAX_QR_REFRESH_COUNT) { emit({ action: 'finish', event: 'failed', code: 1002, message: 'verify_code blocked' }); return; } await startOrRefreshQr(); scannedEmitted = false; break; } case 'binded_redirect': { // WeChat server considers this OpenClaw already bound. // Check if local token exists — if yes, treat as success; if not, local state was // cleared (e.g. logout) but remote binding wasn't revoked → state inconsistency. const indexedIds = _listIndexedAccountIds(); const hasLocalToken = indexedIds.some(id => { const acct = _loadAccount(id); return acct && acct.token; }); if (hasLocalToken) { log.info('weixin', `callId=${callId} already connected, local token intact`); emit({ action: 'finish', event: 'success', data: { accountId: indexedIds[0] } }); } else { log.warn('weixin', `callId=${callId} binded_redirect but local token missing — state inconsistency`); emit({ action: 'finish', event: 'failed', code: 1010, message: '此微信账号已绑定过本设备,但本地登录记录已丢失。请换用另一个微信账号扫码重新登录。', }); } return; } case 'confirmed': { if (!status.ilink_bot_id) throw new Error('confirmed: missing ilink_bot_id'); if (!status.bot_token) throw new Error('confirmed: missing bot_token'); const accountId = _normalizeAccountId(status.ilink_bot_id); const filePath = _saveAccount(accountId, { token: status.bot_token, baseUrl: status.baseurl, userId: status.ilink_user_id, }); _registerAccountId(accountId); if (status.ilink_user_id) _clearStaleAccountsForUserId(accountId, status.ilink_user_id); _bumpOpenClawConfigTimestamp(); log.info('weixin', `callId=${callId} login success accountId=${accountId} file=${filePath}`); emit({ action: 'finish', event: 'success', data: { accountId } }); return; } default: log.warn('weixin', `callId=${callId} unknown status: ${JSON.stringify(status)}`); break; } // Brief pause between polls to avoid tight looping on 'wait' if (status.status === 'wait') { await new Promise(resolve => setTimeout(resolve, 1000)); } } if (!isAborted()) { emit({ action: 'finish', event: 'failed', code: 1004, message: 'login timeout' }); } } module.exports = { login };