| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251 |
- 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
- }
|