const crypto = require('crypto') const axios = require('axios') const config = require('../../config.json') const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com' const CHANNEL_VERSION = '1.0.2' const MAX_REDIRECTS = 3 const COMMON_HEADERS = { Accept: 'application/json, text/plain, */*', 'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) LepaoBackend/1.0 Chrome/127.0.0.0 Safari/537.36' } const COMMON_AXIOS_OPTIONS = { proxy: false } function randomUinHeader() { const n = crypto.randomInt(1, 0xffffffff) return Buffer.from(String(n)).toString('base64') } function randomClientId() { return `openclaw-weixin-${crypto.randomBytes(4).toString('hex')}` } function baseInfo() { return { channel_version: config.weixinBot?.channelVersion || CHANNEL_VERSION } } function authHeaders(botToken) { return { ...COMMON_HEADERS, 'Content-Type': 'application/json', AuthorizationType: 'ilink_bot_token', 'X-WECHAT-UIN': randomUinHeader(), Authorization: `Bearer ${botToken}` } } function normalizeBaseUrl(baseUrl) { const raw = String(baseUrl || DEFAULT_BASE_URL).trim() const value = /^https?:\/\//i.test(raw) ? raw : `https://${raw}` try { const url = new URL(value) if (url.protocol === 'http:' && url.hostname === 'ilinkai.weixin.qq.com') url.protocol = 'https:' url.search = '' url.hash = '' url.pathname = url.pathname.replace(/\/ilink\/bot\/.*$/i, '').replace(/\/+$/, '') return url.toString().replace(/\/+$/, '') } catch (_) { return DEFAULT_BASE_URL } } function buildUrl(baseUrl, path, params = {}) { const url = new URL(`${normalizeBaseUrl(baseUrl)}${path}`) Object.entries(params).forEach(([key, value]) => { if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value)) }) return url.toString() } function createWeixinError(message, userMessage) { const err = new Error(message) err.userMessage = userMessage || message return err } function responseMeta(res) { const headers = res.headers || {} return JSON.stringify({ status: res.status, location: headers.location || '', server: headers.server || '', via: headers.via || '', contentType: headers['content-type'] || '', contentLength: headers['content-length'] || '', setCookieCount: Array.isArray(headers['set-cookie']) ? headers['set-cookie'].length : 0 }) } function assertIlinkResult(action, data) { if (!data || typeof data !== 'object') return data if (data.ret !== undefined && Number(data.ret) !== 0) { const msg = data.err_msg || data.errmsg || data.message || data.wording || JSON.stringify(data) throw new Error(`Weixin iLink ${action} failed: ret=${data.ret}, ${msg}`) } return data } async function getWithRedirectGuard(url, userMessage) { let currentUrl = url const visited = new Set() const cookies = new Map() for (let i = 0; i <= MAX_REDIRECTS; i++) { const cookieHeader = Array.from(cookies.entries()).map(([key, value]) => `${key}=${value}`).join('; ') const res = await axios.get(currentUrl, { ...COMMON_AXIOS_OPTIONS, timeout: 15000, maxRedirects: 0, validateStatus: () => true, headers: cookieHeader ? { ...COMMON_HEADERS, Cookie: cookieHeader } : COMMON_HEADERS }) let cookieChanged = false const setCookies = Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'] : [] for (const item of setCookies) { const pair = String(item).split(';')[0] const index = pair.indexOf('=') if (index <= 0) continue const key = pair.slice(0, index).trim() const value = pair.slice(index + 1) if (cookies.get(key) !== value) { cookies.set(key, value) cookieChanged = true } } if (res.status >= 300 && res.status < 400 && res.headers.location) { const nextUrl = new URL(res.headers.location, currentUrl).toString() const stateKey = `${nextUrl}|${Array.from(cookies.entries()).map(([key, value]) => `${key}=${value}`).join(';')}` if (visited.has(stateKey) || (nextUrl === currentUrl && !cookieChanged)) { throw createWeixinError( `Weixin iLink redirect loop: ${currentUrl} -> ${nextUrl}, meta=${responseMeta(res)}`, userMessage ) } visited.add(stateKey) currentUrl = nextUrl continue } if (res.status < 200 || res.status >= 300) { throw createWeixinError( `Weixin iLink request failed: ${currentUrl}, meta=${responseMeta(res)}`, userMessage ) } return res.data } throw createWeixinError(`Weixin iLink redirects exceeded: ${url}`, userMessage) } async function getQrCode() { return getWithRedirectGuard( buildUrl(config.weixinBot?.apiBaseUrl, '/ilink/bot/get_bot_qrcode', { bot_type: 3 }), '获取微信二维码失败,请检查微信服务配置' ) } async function getQrCodeStatus(qrcode) { return getWithRedirectGuard( buildUrl(config.weixinBot?.apiBaseUrl, '/ilink/bot/get_qrcode_status', { qrcode }), '获取微信扫码状态失败,请检查微信服务配置' ) } async function getUpdates({ baseUrl, botToken, getUpdatesBuf = '' }) { const res = await axios.post( `${normalizeBaseUrl(baseUrl)}/ilink/bot/getupdates`, { get_updates_buf: getUpdatesBuf || '', base_info: baseInfo() }, { ...COMMON_AXIOS_OPTIONS, headers: authHeaders(botToken), timeout: 40000 } ) return assertIlinkResult('getupdates', res.data) } async function getConfig({ baseUrl, botToken, toUserId, contextToken }) { const res = await axios.post( `${normalizeBaseUrl(baseUrl)}/ilink/bot/getconfig`, { ilink_user_id: toUserId, context_token: contextToken || '', base_info: baseInfo() }, { ...COMMON_AXIOS_OPTIONS, headers: authHeaders(botToken), timeout: 15000 } ) return assertIlinkResult('getconfig', res.data) } async function sendTyping({ baseUrl, botToken, toUserId, typingTicket, status }) { if (!typingTicket) return null const res = await axios.post( `${normalizeBaseUrl(baseUrl)}/ilink/bot/sendtyping`, { ilink_user_id: toUserId, typing_ticket: typingTicket, status, base_info: baseInfo() }, { ...COMMON_AXIOS_OPTIONS, headers: authHeaders(botToken), timeout: 10000 } ) return assertIlinkResult('sendtyping', res.data) } async function sendText({ baseUrl, botToken, toUserId, contextToken, text }) { const content = String(text || '').trim() if (!content) return null const configData = await getConfig({ baseUrl, botToken, toUserId, contextToken }) const typingTicket = configData.typing_ticket || configData.data?.typing_ticket || '' if (typingTicket) await sendTyping({ baseUrl, botToken, toUserId, typingTicket, status: 1 }) let sendResult try { const res = await axios.post( `${normalizeBaseUrl(baseUrl)}/ilink/bot/sendmessage`, { msg: { from_user_id: '', to_user_id: toUserId, client_id: randomClientId(), message_type: 2, message_state: 2, context_token: contextToken || '', item_list: [{ type: 1, text_item: { text: content.slice(0, 1800) } }] }, base_info: baseInfo() }, { ...COMMON_AXIOS_OPTIONS, headers: authHeaders(botToken), timeout: 15000 } ) sendResult = assertIlinkResult('sendmessage', res.data) } finally { if (typingTicket) { await sendTyping({ baseUrl, botToken, toUserId, typingTicket, status: 2 }).catch(() => null) } } return sendResult } module.exports = { DEFAULT_BASE_URL, getQrCode, getQrCodeStatus, getUpdates, sendText }