WeixinBotClient.js 8.4 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. const crypto = require('crypto')
  2. const axios = require('axios')
  3. const config = require('../../config.json')
  4. const DEFAULT_BASE_URL = 'https://ilinkai.weixin.qq.com'
  5. const CHANNEL_VERSION = '1.0.2'
  6. const MAX_REDIRECTS = 3
  7. const COMMON_HEADERS = {
  8. Accept: 'application/json, text/plain, */*',
  9. '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'
  10. }
  11. const COMMON_AXIOS_OPTIONS = {
  12. proxy: false
  13. }
  14. function randomUinHeader() {
  15. const n = crypto.randomInt(1, 0xffffffff)
  16. return Buffer.from(String(n)).toString('base64')
  17. }
  18. function randomClientId() {
  19. return `openclaw-weixin-${crypto.randomBytes(4).toString('hex')}`
  20. }
  21. function baseInfo() {
  22. return { channel_version: config.weixinBot?.channelVersion || CHANNEL_VERSION }
  23. }
  24. function authHeaders(botToken) {
  25. return {
  26. ...COMMON_HEADERS,
  27. 'Content-Type': 'application/json',
  28. AuthorizationType: 'ilink_bot_token',
  29. 'X-WECHAT-UIN': randomUinHeader(),
  30. Authorization: `Bearer ${botToken}`
  31. }
  32. }
  33. function normalizeBaseUrl(baseUrl) {
  34. const raw = String(baseUrl || DEFAULT_BASE_URL).trim()
  35. const value = /^https?:\/\//i.test(raw) ? raw : `https://${raw}`
  36. try {
  37. const url = new URL(value)
  38. if (url.protocol === 'http:' && url.hostname === 'ilinkai.weixin.qq.com') url.protocol = 'https:'
  39. url.search = ''
  40. url.hash = ''
  41. url.pathname = url.pathname.replace(/\/ilink\/bot\/.*$/i, '').replace(/\/+$/, '')
  42. return url.toString().replace(/\/+$/, '')
  43. } catch (_) {
  44. return DEFAULT_BASE_URL
  45. }
  46. }
  47. function buildUrl(baseUrl, path, params = {}) {
  48. const url = new URL(`${normalizeBaseUrl(baseUrl)}${path}`)
  49. Object.entries(params).forEach(([key, value]) => {
  50. if (value !== undefined && value !== null && value !== '') url.searchParams.set(key, String(value))
  51. })
  52. return url.toString()
  53. }
  54. function createWeixinError(message, userMessage) {
  55. const err = new Error(message)
  56. err.userMessage = userMessage || message
  57. return err
  58. }
  59. function responseMeta(res) {
  60. const headers = res.headers || {}
  61. return JSON.stringify({
  62. status: res.status,
  63. location: headers.location || '',
  64. server: headers.server || '',
  65. via: headers.via || '',
  66. contentType: headers['content-type'] || '',
  67. contentLength: headers['content-length'] || '',
  68. setCookieCount: Array.isArray(headers['set-cookie']) ? headers['set-cookie'].length : 0
  69. })
  70. }
  71. function assertIlinkResult(action, data) {
  72. if (!data || typeof data !== 'object') return data
  73. if (data.ret !== undefined && Number(data.ret) !== 0) {
  74. const msg = data.err_msg || data.errmsg || data.message || data.wording || JSON.stringify(data)
  75. throw new Error(`Weixin iLink ${action} failed: ret=${data.ret}, ${msg}`)
  76. }
  77. return data
  78. }
  79. async function getWithRedirectGuard(url, userMessage) {
  80. let currentUrl = url
  81. const visited = new Set()
  82. const cookies = new Map()
  83. for (let i = 0; i <= MAX_REDIRECTS; i++) {
  84. const cookieHeader = Array.from(cookies.entries()).map(([key, value]) => `${key}=${value}`).join('; ')
  85. const res = await axios.get(currentUrl, {
  86. ...COMMON_AXIOS_OPTIONS,
  87. timeout: 15000,
  88. maxRedirects: 0,
  89. validateStatus: () => true,
  90. headers: cookieHeader ? { ...COMMON_HEADERS, Cookie: cookieHeader } : COMMON_HEADERS
  91. })
  92. let cookieChanged = false
  93. const setCookies = Array.isArray(res.headers['set-cookie']) ? res.headers['set-cookie'] : []
  94. for (const item of setCookies) {
  95. const pair = String(item).split(';')[0]
  96. const index = pair.indexOf('=')
  97. if (index <= 0) continue
  98. const key = pair.slice(0, index).trim()
  99. const value = pair.slice(index + 1)
  100. if (cookies.get(key) !== value) {
  101. cookies.set(key, value)
  102. cookieChanged = true
  103. }
  104. }
  105. if (res.status >= 300 && res.status < 400 && res.headers.location) {
  106. const nextUrl = new URL(res.headers.location, currentUrl).toString()
  107. const stateKey = `${nextUrl}|${Array.from(cookies.entries()).map(([key, value]) => `${key}=${value}`).join(';')}`
  108. if (visited.has(stateKey) || (nextUrl === currentUrl && !cookieChanged)) {
  109. throw createWeixinError(
  110. `Weixin iLink redirect loop: ${currentUrl} -> ${nextUrl}, meta=${responseMeta(res)}`,
  111. userMessage
  112. )
  113. }
  114. visited.add(stateKey)
  115. currentUrl = nextUrl
  116. continue
  117. }
  118. if (res.status < 200 || res.status >= 300) {
  119. throw createWeixinError(
  120. `Weixin iLink request failed: ${currentUrl}, meta=${responseMeta(res)}`,
  121. userMessage
  122. )
  123. }
  124. return res.data
  125. }
  126. throw createWeixinError(`Weixin iLink redirects exceeded: ${url}`, userMessage)
  127. }
  128. async function getQrCode() {
  129. return getWithRedirectGuard(
  130. buildUrl(config.weixinBot?.apiBaseUrl, '/ilink/bot/get_bot_qrcode', { bot_type: 3 }),
  131. '获取微信二维码失败,请检查微信服务配置'
  132. )
  133. }
  134. async function getQrCodeStatus(qrcode) {
  135. return getWithRedirectGuard(
  136. buildUrl(config.weixinBot?.apiBaseUrl, '/ilink/bot/get_qrcode_status', { qrcode }),
  137. '获取微信扫码状态失败,请检查微信服务配置'
  138. )
  139. }
  140. async function getUpdates({ baseUrl, botToken, getUpdatesBuf = '' }) {
  141. const res = await axios.post(
  142. `${normalizeBaseUrl(baseUrl)}/ilink/bot/getupdates`,
  143. {
  144. get_updates_buf: getUpdatesBuf || '',
  145. base_info: baseInfo()
  146. },
  147. {
  148. ...COMMON_AXIOS_OPTIONS,
  149. headers: authHeaders(botToken),
  150. timeout: 40000
  151. }
  152. )
  153. return assertIlinkResult('getupdates', res.data)
  154. }
  155. async function getConfig({ baseUrl, botToken, toUserId, contextToken }) {
  156. const res = await axios.post(
  157. `${normalizeBaseUrl(baseUrl)}/ilink/bot/getconfig`,
  158. {
  159. ilink_user_id: toUserId,
  160. context_token: contextToken || '',
  161. base_info: baseInfo()
  162. },
  163. {
  164. ...COMMON_AXIOS_OPTIONS,
  165. headers: authHeaders(botToken),
  166. timeout: 15000
  167. }
  168. )
  169. return assertIlinkResult('getconfig', res.data)
  170. }
  171. async function sendTyping({ baseUrl, botToken, toUserId, typingTicket, status }) {
  172. if (!typingTicket) return null
  173. const res = await axios.post(
  174. `${normalizeBaseUrl(baseUrl)}/ilink/bot/sendtyping`,
  175. {
  176. ilink_user_id: toUserId,
  177. typing_ticket: typingTicket,
  178. status,
  179. base_info: baseInfo()
  180. },
  181. {
  182. ...COMMON_AXIOS_OPTIONS,
  183. headers: authHeaders(botToken),
  184. timeout: 10000
  185. }
  186. )
  187. return assertIlinkResult('sendtyping', res.data)
  188. }
  189. async function sendText({ baseUrl, botToken, toUserId, contextToken, text }) {
  190. const content = String(text || '').trim()
  191. if (!content) return null
  192. const configData = await getConfig({ baseUrl, botToken, toUserId, contextToken })
  193. const typingTicket = configData.typing_ticket || configData.data?.typing_ticket || ''
  194. if (typingTicket) await sendTyping({ baseUrl, botToken, toUserId, typingTicket, status: 1 })
  195. let sendResult
  196. try {
  197. const res = await axios.post(
  198. `${normalizeBaseUrl(baseUrl)}/ilink/bot/sendmessage`,
  199. {
  200. msg: {
  201. from_user_id: '',
  202. to_user_id: toUserId,
  203. client_id: randomClientId(),
  204. message_type: 2,
  205. message_state: 2,
  206. context_token: contextToken || '',
  207. item_list: [{ type: 1, text_item: { text: content.slice(0, 1800) } }]
  208. },
  209. base_info: baseInfo()
  210. },
  211. {
  212. ...COMMON_AXIOS_OPTIONS,
  213. headers: authHeaders(botToken),
  214. timeout: 15000
  215. }
  216. )
  217. sendResult = assertIlinkResult('sendmessage', res.data)
  218. } finally {
  219. if (typingTicket) {
  220. await sendTyping({ baseUrl, botToken, toUserId, typingTicket, status: 2 }).catch(() => null)
  221. }
  222. }
  223. return sendResult
  224. }
  225. module.exports = {
  226. DEFAULT_BASE_URL,
  227. getQrCode,
  228. getQrCodeStatus,
  229. getUpdates,
  230. sendText
  231. }