Files
clawd/lib/channel/weixin.js
2026-05-15 15:08:56 +08:00

427 lines
15 KiB
JavaScript

'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 os = require('os');
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 (
(process.env.OPENCLAW_STATE_DIR || '').trim() ||
(process.env.CLAWDBOT_STATE_DIR || '').trim() ||
path.join(os.homedir(), '.openclaw')
);
}
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;
fs.writeFileSync(_resolveAccountIndexPath(), JSON.stringify([...existing, accountId], null, 2), 'utf8');
}
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');
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();
fs.writeFileSync(_resolveAccountIndexPath(), JSON.stringify(existing.filter(x => x !== id), null, 2), 'utf8');
}
}
}
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',
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':
log.info('weixin', `callId=${callId} already connected`);
emit({ action: 'finish', event: 'success', data: { accountId: 'already_connected' } });
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 };