OneBotV11.js 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443
  1. const path = require('path')
  2. const WebSocket = require('ws')
  3. const config = require('../../config.json')
  4. const Logger = require('../../lib/Logger')
  5. const db = require('../DataBase/db.js')
  6. const EmailTemplate = require('../Email/emailTemplate')
  7. const logger = new Logger(path.join(__dirname, '../../logs/OneBotV11.log'), 'INFO')
  8. let wsClient = null
  9. let connectingPromise = null
  10. let reconnectTimer = null
  11. let wsApiClient = null
  12. let connectingApiPromise = null
  13. let apiReconnectTimer = null
  14. function stringifySafe(data) {
  15. try {
  16. return JSON.stringify(data)
  17. } catch (_) {
  18. return '[unserializable]'
  19. }
  20. }
  21. function getOneBotConfig() {
  22. const onebot = config.onebotv11 || {}
  23. return {
  24. enabled: onebot.enabled === true,
  25. transport: onebot.transport || 'reverse_ws',
  26. reverseWsUrl: onebot.reverseWsUrl || '',
  27. reverseWsHost: onebot.reverseWsHost || '127.0.0.1',
  28. reverseWsPort: Number(onebot.reverseWsPort || 15700),
  29. /** AstrBot / aiocqhttp 反向 WS 常见路径为 /ws;根路径 / 常返回 405 */
  30. reverseWsPath: onebot.reverseWsPath || '',
  31. reverseWsToken: onebot.reverseWsToken || '',
  32. callbackToken: onebot.callbackToken || '',
  33. ticketSenderNickname: onebot.ticketSenderNickname || '工单系统',
  34. selfId: onebot.selfId || '',
  35. botName: onebot.botName || '小妍助理',
  36. botUuid: onebot.botUuid || 'onebot-v11-xiaoyan-assistant'
  37. }
  38. }
  39. function getReverseWsUrl(onebot) {
  40. const defaultPath = '/ws'
  41. const explicitPath = onebot.reverseWsPath
  42. ? (onebot.reverseWsPath.startsWith('/') ? onebot.reverseWsPath : `/${onebot.reverseWsPath}`)
  43. : null
  44. if (onebot.reverseWsUrl) {
  45. try {
  46. const u = new URL(onebot.reverseWsUrl)
  47. if (explicitPath) {
  48. u.pathname = explicitPath
  49. return u.href
  50. }
  51. if (u.pathname && u.pathname !== '/') return u.href
  52. u.pathname = defaultPath
  53. return u.href
  54. } catch (_) {
  55. return onebot.reverseWsUrl
  56. }
  57. }
  58. const pathPart = explicitPath || defaultPath
  59. return `ws://${onebot.reverseWsHost}:${onebot.reverseWsPort}${pathPart}`
  60. }
  61. function scheduleReconnect() {
  62. if (reconnectTimer) return
  63. logger.info('OneBot v11 ws 3秒后尝试重连')
  64. reconnectTimer = setTimeout(() => {
  65. reconnectTimer = null
  66. connectReverseWs().catch(err => {
  67. logger.error(`OneBot v11 重连失败:${err.message}`)
  68. })
  69. }, 3000)
  70. }
  71. function scheduleApiReconnect() {
  72. if (apiReconnectTimer) return
  73. logger.info('OneBot v11 API ws 3秒后尝试重连')
  74. apiReconnectTimer = setTimeout(() => {
  75. apiReconnectTimer = null
  76. connectApiReverseWs().catch(err => {
  77. logger.error(`OneBot v11 API ws 重连失败:${err.message}`)
  78. })
  79. }, 3000)
  80. }
  81. async function connectReverseWs() {
  82. if (wsClient && wsClient.readyState === WebSocket.OPEN) return wsClient
  83. if (connectingPromise) return connectingPromise
  84. const onebot = getOneBotConfig()
  85. if (!onebot.enabled) throw new Error('OneBot v11 未启用')
  86. if (onebot.transport !== 'reverse_ws') throw new Error('仅支持 reverse_ws 传输模式')
  87. const wsUrl = getReverseWsUrl(onebot)
  88. logger.info(`OneBot v11 准备连接 reverse ws:${wsUrl}`)
  89. if (!onebot.selfId) {
  90. throw new Error('OneBot v11 未配置 selfId:握手头 X-Self-ID 必填')
  91. }
  92. const selfIdStr = String(onebot.selfId).trim()
  93. if (!selfIdStr) {
  94. throw new Error('OneBot v11 selfId 不能为空')
  95. }
  96. const headers = {
  97. 'User-Agent': 'runforge/1.0 (OneBot-v11-reverse-ws-client)',
  98. 'X-Client-Role': 'Universal',
  99. 'X-Self-ID': selfIdStr
  100. }
  101. if (onebot.reverseWsToken) headers['Authorization'] = `Bearer ${onebot.reverseWsToken}`
  102. connectingPromise = new Promise((resolve, reject) => {
  103. const client = new WebSocket(wsUrl, { headers })
  104. let opened = false
  105. const connTimeout = setTimeout(() => {
  106. if (!opened) {
  107. try { client.terminate() } catch (_) { }
  108. reject(new Error(`连接 OneBot v11 超时:${wsUrl}`))
  109. }
  110. }, 5000)
  111. client.on('unexpected-response', (_req, res) => {
  112. const statusCode = res && res.statusCode
  113. const statusMessage = res && res.statusMessage
  114. const rh = res && typeof res.getHeaders === 'function' ? res.getHeaders() : {}
  115. const rawPreview = res && Array.isArray(res.rawHeaders) ? res.rawHeaders.slice(0, 24) : []
  116. logger.error(`OneBot v11 ws unexpected-response: ${statusCode} ${statusMessage}`)
  117. logger.error(`OneBot v11 ws response headers: ${stringifySafe({ rh, rawPreview })}`)
  118. })
  119. client.on('open', () => {
  120. clearTimeout(connTimeout)
  121. opened = true
  122. wsClient = client
  123. logger.info(`OneBot v11 reverse ws 已连接:${wsUrl},selfId=${onebot.selfId || '未配置'}`)
  124. const lifecycleConnect = buildLifecycleConnectEvent(onebot)
  125. client.send(JSON.stringify(lifecycleConnect), (err) => {
  126. if (err) {
  127. logger.error(`OneBot 生命周期事件发送失败:${err.message}`)
  128. return
  129. }
  130. logger.info('OneBot 生命周期事件已发送:meta_event.lifecycle.connect')
  131. })
  132. resolve(client)
  133. })
  134. client.on('error', (err) => {
  135. clearTimeout(connTimeout)
  136. if (!opened) reject(err)
  137. logger.error(`OneBot v11 ws 异常:${err.message}`)
  138. })
  139. client.on('close', () => {
  140. wsClient = null
  141. connectingPromise = null
  142. logger.error('OneBot v11 ws 连接已关闭')
  143. if (onebot.enabled) scheduleReconnect()
  144. })
  145. client.on('message', (raw) => {
  146. const text = typeof raw === 'string' ? raw : raw.toString('utf8')
  147. logger.info(`OneBot v11 收到消息:${text.slice(0, 1000)}`)
  148. handleWsIncomingFrame(text, client).catch((err) => {
  149. logger.error(`OneBot v11 处理收到消息失败:${err.message}`)
  150. })
  151. })
  152. })
  153. try {
  154. return await connectingPromise
  155. } finally {
  156. connectingPromise = null
  157. }
  158. }
  159. async function connectApiReverseWs() {
  160. if (wsApiClient && wsApiClient.readyState === WebSocket.OPEN) return wsApiClient
  161. if (connectingApiPromise) return connectingApiPromise
  162. const onebot = getOneBotConfig()
  163. if (!onebot.enabled) throw new Error('OneBot v11 未启用')
  164. if (onebot.transport !== 'reverse_ws') throw new Error('仅支持 reverse_ws 传输模式')
  165. const wsUrl = getReverseWsUrl(onebot)
  166. if (!onebot.selfId) throw new Error('API ws 缺少 selfId')
  167. const selfIdStr = String(onebot.selfId).trim()
  168. if (!selfIdStr) throw new Error('API ws selfId 不能为空')
  169. const headers = {
  170. 'User-Agent': 'runforge/1.0 (OneBot-v11-reverse-ws-api-client)',
  171. 'X-Client-Role': 'Api',
  172. 'X-Self-ID': selfIdStr
  173. }
  174. if (onebot.reverseWsToken) headers['Authorization'] = `Bearer ${onebot.reverseWsToken}`
  175. connectingApiPromise = new Promise((resolve, reject) => {
  176. const client = new WebSocket(wsUrl, { headers })
  177. let opened = false
  178. const connTimeout = setTimeout(() => {
  179. if (!opened) {
  180. try { client.terminate() } catch (_) { }
  181. reject(new Error(`连接 OneBot v11 API ws 超时:${wsUrl}`))
  182. }
  183. }, 5000)
  184. client.on('open', () => {
  185. clearTimeout(connTimeout)
  186. opened = true
  187. wsApiClient = client
  188. logger.info(`OneBot v11 API ws 已连接:${wsUrl}`)
  189. resolve(client)
  190. })
  191. client.on('error', (err) => {
  192. clearTimeout(connTimeout)
  193. if (!opened) reject(err)
  194. logger.error(`OneBot v11 API ws 异常:${err.message}`)
  195. })
  196. client.on('close', () => {
  197. wsApiClient = null
  198. connectingApiPromise = null
  199. logger.error('OneBot v11 API ws 连接已关闭')
  200. if (onebot.enabled) scheduleApiReconnect()
  201. })
  202. client.on('message', (raw) => {
  203. const text = typeof raw === 'string' ? raw : raw.toString('utf8')
  204. logger.info(`OneBot v11 API ws 收到消息:${text.slice(0, 1000)}`)
  205. handleWsIncomingFrame(text, client).catch((err) => {
  206. logger.error(`OneBot v11 API ws 消息处理失败:${err.message}`)
  207. })
  208. })
  209. })
  210. try {
  211. return await connectingApiPromise
  212. } finally {
  213. connectingApiPromise = null
  214. }
  215. }
  216. function buildSyntheticPrivateMessageEvent(text, onebot, orderId) {
  217. const now = Math.floor(Date.now() / 1000)
  218. const selfId = String(onebot.selfId).trim()
  219. const senderIdStr = String(orderId || '').trim()
  220. if (!senderIdStr || !/^\d+$/.test(senderIdStr) || senderIdStr === selfId) {
  221. throw new Error('orderId 无效,无法作为 OneBot user_id')
  222. }
  223. // AstrBot aiocqhttp 消息链路要求 message 为消息段数组(字符串会走失败分支)
  224. const messageArr = [{ type: 'text', data: { text } }]
  225. const messageId = Math.floor(Math.random() * 2000000000) + 1
  226. return {
  227. time: now,
  228. self_id: selfId,
  229. post_type: 'message',
  230. message_type: 'private',
  231. sub_type: 'friend',
  232. message_id: messageId,
  233. user_id: senderIdStr,
  234. message: messageArr,
  235. raw_message: text,
  236. font: 0,
  237. sender: {
  238. user_id: senderIdStr,
  239. nickname: onebot.ticketSenderNickname || '工单系统',
  240. sex: 'unknown',
  241. age: 0
  242. }
  243. }
  244. }
  245. function buildLifecycleConnectEvent(onebot) {
  246. return {
  247. time: Math.floor(Date.now() / 1000),
  248. self_id: String(onebot.selfId).trim(),
  249. post_type: 'meta_event',
  250. meta_event_type: 'lifecycle',
  251. sub_type: 'connect'
  252. }
  253. }
  254. async function sendImplementationEvent(eventObj) {
  255. const client = await connectReverseWs()
  256. const line = stringifySafe(eventObj)
  257. logger.info(`OneBot v11 上报事件 post_type=${eventObj.post_type} message_type=${eventObj.message_type} len=${line.length}`)
  258. return new Promise((resolve, reject) => {
  259. client.send(line, (err) => {
  260. if (err) {
  261. logger.error(`OneBot 事件发送失败:${err.message}`)
  262. return reject(err)
  263. }
  264. logger.info('OneBot v11 事件已写入 WebSocket')
  265. resolve(true)
  266. })
  267. })
  268. }
  269. function decodeMessageText(message) {
  270. if (!message) return ''
  271. if (typeof message === 'string') return message
  272. if (Array.isArray(message)) {
  273. return message.map(seg => {
  274. if (typeof seg === 'string') return seg
  275. if (seg && seg.type === 'text' && seg.data && typeof seg.data.text === 'string') return seg.data.text
  276. return ''
  277. }).join('')
  278. }
  279. return String(message)
  280. }
  281. async function persistAiReplyAsServerReply(orderId, plainText) {
  282. const onebot = getOneBotConfig()
  283. const parsedOrderId = Number(orderId)
  284. if (!Number.isInteger(parsedOrderId) || parsedOrderId <= 0) return
  285. const rows = await db.query('SELECT msg, state, email FROM work_order WHERE id = ?', [parsedOrderId])
  286. if (!rows || rows.length !== 1 || rows[0].state === 2) return
  287. const now = Date.now()
  288. const msg = rows[0].msg || []
  289. msg.push({
  290. time: now,
  291. content: plainText,
  292. files: [],
  293. uuid: 'e4fe0277-0b1a-41a1-b25f-8b6e4cec3281',
  294. type: 'ai'
  295. })
  296. await db.query('UPDATE work_order SET msg = ?, update_time = ?, state = 3 WHERE id = ?', [msg, now, parsedOrderId])
  297. if (rows[0].email) {
  298. await EmailTemplate.orderNewReply(rows[0].email, { id: parsedOrderId, content: plainText, files: [] })
  299. }
  300. }
  301. async function handleWsIncomingFrame(text, client) {
  302. let j
  303. try {
  304. j = JSON.parse(text)
  305. } catch (_) {
  306. return
  307. }
  308. if (j == null || typeof j !== 'object') return
  309. if (j.action === 'send_private_msg' && j.params) {
  310. const plainText = decodeMessageText(j.params.message)
  311. logger.info(`OneBot v11 收到中文消息(解码):${plainText}`)
  312. await persistAiReplyAsServerReply(j.params.user_id, plainText)
  313. }
  314. /**
  315. * 反向 WS 上「应用端 → 实现端」的 API 请求(aiocqhttp 使用 echo: { seq })。
  316. * 若不回包,AstrBot 侧 ResultStore 会超时,事件处理卡住,表现为服务端「收不到」工单事件。
  317. */
  318. if (j.action !== undefined && j.echo !== undefined && typeof j.echo === 'object' && j.echo !== null && j.echo.seq !== undefined) {
  319. const resp = {
  320. status: 'ok',
  321. retcode: 0,
  322. data: null,
  323. echo: j.echo
  324. }
  325. try {
  326. client.send(JSON.stringify(resp))
  327. logger.info(`OneBot v11 已应答应用端 API:action=${j.action} seq=${JSON.stringify(j.echo.seq)}`)
  328. } catch (err) {
  329. logger.error(`OneBot v11 应答 API 失败:${err.message}`)
  330. }
  331. return
  332. }
  333. if ('post_type' in j) return
  334. if (j.echo !== undefined && j.status !== undefined) {
  335. if (j.retcode !== 0 && j.retcode !== undefined) {
  336. const wording = j.wording || j.message || stringifySafe(j.data)
  337. logger.error(`OneBot API 返回失败 echo=${j.echo} retcode=${j.retcode} ${wording}`)
  338. } else {
  339. logger.info(`OneBot API 成功 echo=${j.echo}`)
  340. }
  341. }
  342. }
  343. function buildOrderText(payload) {
  344. const {
  345. orderId,
  346. title,
  347. role,
  348. content,
  349. files = [],
  350. senderUuid
  351. } = payload
  352. const fileText = files.length > 0 ? `\n附件:\n${files.join('\n')}` : ''
  353. return [
  354. `[工单#${orderId}] ${title ? title : '工单消息更新'}`,
  355. `发送方:${role === '用户' ? '用户' : '系统'}`,
  356. `用户uuid:${senderUuid || '-'}`,
  357. '消息内容:',
  358. content
  359. ].join('\n') + fileText
  360. }
  361. async function sendOrderMessage(payload) {
  362. const onebot = getOneBotConfig()
  363. if (!onebot.enabled) return false
  364. if (onebot.transport !== 'reverse_ws') {
  365. logger.error('OneBot v11 传输模式错误,仅支持 reverse_ws')
  366. return false
  367. }
  368. const text = buildOrderText(payload)
  369. const eventObj = buildSyntheticPrivateMessageEvent(text, onebot, payload.orderId)
  370. await sendImplementationEvent(eventObj)
  371. logger.info(`OneBot 工单事件已上报:orderId=${payload.orderId}, userId=${eventObj.user_id}, messageId=${eventObj.message_id}`)
  372. return true
  373. }
  374. async function initOneBotWs() {
  375. const onebot = getOneBotConfig()
  376. if (!onebot.enabled) {
  377. logger.info('OneBot v11 未启用,跳过 ws 初始化')
  378. return false
  379. }
  380. if (onebot.transport !== 'reverse_ws') {
  381. logger.error('OneBot v11 初始化失败:传输模式不是 reverse_ws')
  382. return false
  383. }
  384. await connectReverseWs()
  385. await connectApiReverseWs()
  386. return true
  387. }
  388. module.exports = {
  389. sendOrderMessage,
  390. getOneBotConfig,
  391. initOneBotWs
  392. }