Browse Source

✨ feat: 工单新增ai回复

Pchen0 1 week ago
parent
commit
24d76add36

+ 13 - 0
apis/Kefu/Order/Admin/ReplyOrder.js

@@ -3,6 +3,7 @@ const db = require("../../../../plugin/DataBase/db.js");
 const { BaseStdResponse } = require("../../../../BaseStdResponse.js");
 const { BaseStdResponse } = require("../../../../BaseStdResponse.js");
 const AccessControl = require("../../../../lib/AccessControl.js");
 const AccessControl = require("../../../../lib/AccessControl.js");
 const EmailTemplate = require('../../../../plugin/Email/emailTemplate')
 const EmailTemplate = require('../../../../plugin/Email/emailTemplate')
+const OneBotV11 = require('../../../../plugin/OneBot/OneBotV11')
 
 
 class ReplyOrder extends API {
 class ReplyOrder extends API {
     constructor() {
     constructor() {
@@ -67,6 +68,18 @@ class ReplyOrder extends API {
                 if (selectRows[0].email) {
                 if (selectRows[0].email) {
                     await EmailTemplate.orderNewReply(selectRows[0].email, { id, content, files })
                     await EmailTemplate.orderNewReply(selectRows[0].email, { id, content, files })
                 }
                 }
+
+                // try {
+                //     await OneBotV11.sendOrderMessage({
+                //         orderId: id,
+                //         role: '客服',
+                //         senderUuid: uuid,
+                //         content,
+                //         files: files ?? []
+                //     })
+                // } catch (botErr) {
+                //     this.logger.error(`OneBot v11 转发客服回复失败:${botErr.stack}`)
+                // }
             } else {
             } else {
                 return res.json({ ...BaseStdResponse.ERR, msg: '回复工单失败!数据库错误' })
                 return res.json({ ...BaseStdResponse.ERR, msg: '回复工单失败!数据库错误' })
             }
             }

+ 64 - 0
apis/Kefu/Order/Bot/AIReply.js

@@ -0,0 +1,64 @@
+const API = require("../../../../lib/API.js");
+const db = require("../../../../plugin/DataBase/db.js");
+const { BaseStdResponse } = require("../../../../BaseStdResponse.js");
+const OneBotV11 = require("../../../../plugin/OneBot/OneBotV11.js");
+
+class AIReply extends API {
+    constructor() {
+        super();
+        this.setPath('/Kefu/Order/Bot/AIReply')
+        this.setMethod('POST')
+        this.noEncrypt()
+    }
+
+    async onRequest(req, res) {
+        const onebot = OneBotV11.getOneBotConfig()
+        if (!onebot.enabled) {
+            return res.json({ ...BaseStdResponse.ERR, msg: 'OneBot v11 未启用' })
+        }
+
+        const token = req.headers['x-onebot-access-token'] || req.headers['authorization'] || ''
+        if (onebot.callbackToken && !String(token).includes(onebot.callbackToken)) {
+            return res.status(401).json({
+                ...BaseStdResponse.ACCESS_DENIED,
+                msg: 'AI 回调 token 校验失败'
+            })
+        }
+
+        const { id, content } = req.body || {}
+        if ([id, content].some(value => value === '' || value === null || value === undefined)) {
+            return res.json({
+                ...BaseStdResponse.MISSING_PARAMETER,
+                msg: '缺少 id 或 content'
+            })
+        }
+
+        const selectSql = 'SELECT msg, state FROM work_order WHERE id = ?'
+        const rows = await db.query(selectSql, [id])
+        if (!rows || rows.length !== 1 || rows[0].state === 2) {
+            return res.json({
+                ...BaseStdResponse.ERR,
+                msg: '工单不存在或已关闭'
+            })
+        }
+
+        const now = Date.now()
+        const msg = rows[0].msg || []
+        msg.push({
+            time: now,
+            content: String(content).trim(),
+            uuid: 'e4fe0277-0b1a-41a1-b25f-8b6e4cec3281',
+            type: 'ai',
+            files: []
+        })
+
+        const updateSql = 'UPDATE work_order SET msg = ?, update_time = ?, state = 3 WHERE id = ?'
+        await db.query(updateSql, [msg, now, id])
+
+        return res.json({
+            ...BaseStdResponse.OK
+        })
+    }
+}
+
+module.exports.AIReply = AIReply

+ 21 - 3
apis/Kefu/Order/CreateOrder.js

@@ -3,6 +3,7 @@ const db = require("../../../plugin/DataBase/db.js");
 const { BaseStdResponse } = require("../../../BaseStdResponse.js");
 const { BaseStdResponse } = require("../../../BaseStdResponse.js");
 const AccessControl = require("../../../lib/AccessControl.js");
 const AccessControl = require("../../../lib/AccessControl.js");
 const EmailTemplate = require('../../../plugin/Email/emailTemplate')
 const EmailTemplate = require('../../../plugin/Email/emailTemplate')
+const OneBotV11 = require('../../../plugin/OneBot/OneBotV11')
 
 
 class CreateOrder extends API {
 class CreateOrder extends API {
     constructor() {
     constructor() {
@@ -15,15 +16,17 @@ class CreateOrder extends API {
     async onRequest(req, res) {
     async onRequest(req, res) {
         let { uuid, session, id, title, content, files, email } = req.body
         let { uuid, session, id, title, content, files, email } = req.body
 
 
-        if ([uuid, session, content].some(value => value === '' || value === null || value === undefined))
+        if ([uuid, session, content].some(value => value === '' || value === null || value === undefined)) {
             return res.json({
             return res.json({
                 ...BaseStdResponse.MISSING_PARAMETER
                 ...BaseStdResponse.MISSING_PARAMETER
             })
             })
+        }
 
 
-        if (!await AccessControl.checkSession(uuid, session))
+        if (!await AccessControl.checkSession(uuid, session)) {
             return res.status(401).json({
             return res.status(401).json({
                 ...BaseStdResponse.ACCESS_DENIED
                 ...BaseStdResponse.ACCESS_DENIED
             })
             })
+        }
 
 
         const time = new Date().getTime()
         const time = new Date().getTime()
 
 
@@ -78,6 +81,21 @@ class CreateOrder extends API {
                     data: r.insertId
                     data: r.insertId
                 })
                 })
 
 
+                try {
+                    this.logger.info(`工单转发OneBot开始:orderId=${r.insertId || id}`)
+                    await OneBotV11.sendOrderMessage({
+                        orderId: r.insertId || id,
+                        title,
+                        role: '用户',
+                        senderUuid: uuid,
+                        content,
+                        files: files ?? []
+                    })
+                    this.logger.info(`工单转发OneBot结束:orderId=${r.insertId || id}`)
+                } catch (botErr) {
+                    this.logger.error(`OneBot v11 转发工单消息失败:${botErr.stack}`)
+                }
+
                 let kefuSql = `
                 let kefuSql = `
                     SELECT email FROM users
                     SELECT email FROM users
                     WHERE JSON_CONTAINS(permission, '"admin"') OR JSON_CONTAINS(permission, '"service"')
                     WHERE JSON_CONTAINS(permission, '"admin"') OR JSON_CONTAINS(permission, '"service"')
@@ -88,7 +106,7 @@ class CreateOrder extends API {
                 let emails = [...new Set(kefuRows.map(row => row.email))]
                 let emails = [...new Set(kefuRows.map(row => row.email))]
 
 
                 for (const email of emails) {
                 for (const email of emails) {
-                    if(!email) break
+                    if (!email) break
                     await EmailTemplate.orderNewReply(email, { id: r.insertId || id, content, files })
                     await EmailTemplate.orderNewReply(email, { id: r.insertId || id, content, files })
                 }
                 }
             } else {
             } else {

+ 1 - 1
apis/MCP/GetMcp.js → apis/MCP/userMcp/GetMcp.js

@@ -1,4 +1,4 @@
-const API = require("../../lib/API")
+const API = require("../../../lib/API")
 
 
 class GetMcp extends API {
 class GetMcp extends API {
     constructor() {
     constructor() {

+ 2 - 2
apis/MCP/McpRPC.js → apis/MCP/userMcp/McpRPC.js

@@ -1,5 +1,5 @@
-const API = require("../../lib/API")
-const MCP = require("../../lib/Lepao/Mcp.js").MCP
+const API = require("../../../lib/API.js")
+const MCP = require("../../../lib/Lepao/Mcp.js").MCP
 
 
 class McpRpc extends API {
 class McpRpc extends API {
     constructor() {
     constructor() {

+ 17 - 0
apis/MCP/workorderMcp/GetMcp.js

@@ -0,0 +1,17 @@
+const API = require("../../../lib/API")
+
+class GetMcp extends API {
+    constructor() {
+        super()
+
+        this.noEncrypt()
+        this.setPath('/workorderMcp')
+        this.setMethod('GET')
+    }
+
+    async onRequest(req, res) {
+        res.json({"status": "ok"})
+    }
+}
+
+module.exports.GetMcp = GetMcp

+ 104 - 0
apis/MCP/workorderMcp/McpRPC.js

@@ -0,0 +1,104 @@
+const API = require("../../../lib/API.js")
+const MCP = require("../../../lib/Lepao/WorkOrderMcp.js").WORKORDERMCP
+
+class McpRpc extends API {
+    constructor() {
+        super()
+
+        this.noEncrypt()
+        this.setPath('/workorderMcp')
+        this.setMethod('POST')
+    }
+
+    async onRequest(req, res) {
+        const { method, params = {}, id: req_id } = req.body
+
+        try {
+            let result
+
+            if (method === 'initialize') {
+                result = {
+                    protocolVersion: "2024-11-05",
+                    capabilities: { "tools": {} },
+                    serverInfo: { name: "runforgeWorkOrder", version: "1.0" }
+                }
+            } else if (method === 'tools/list') {
+                result = {
+                    "tools": [
+                        
+                        {
+                            "name": "unbind_account",
+                            "description": "解绑被他人绑定的乐跑账号",
+                            "inputSchema": {
+                                "type": "object",
+                                "properties": {
+                                    "sender": {
+                                        "type": "string",
+                                        "description": "Unique user identifier from the chat platform"
+                                    },
+                                    "student_num": {
+                                        "type": "integer",
+                                        "description": "Student number of the account to unbind"
+                                    }
+                                },
+                                "required": ["sender", "student_num"]
+                            }
+                        },
+                        {
+                            "name": "get_account_info",
+                            "description": "按学号获取乐跑账号信息",
+                            "inputSchema": {
+                                "type": "object",
+                                "properties": {
+                                    "sender": {
+                                        "type": "string",
+                                        "description": "Unique user identifier from the chat platform"
+                                    },
+                                    "student_num": {
+                                        "type": "integer",
+                                        "description": "Student number of the account to get info"
+                                    }
+                                },
+                                "required": ["sender", "student_num"]
+                            }
+                        }
+                    ]
+                }
+            } else if (method === 'tools/call') {
+                const { name, arguments: args = {} } = params
+
+                let output
+
+                switch (name) {
+                    case "get_account_info":
+                        output = await MCP.get_account_info(args)
+                        break
+                    case "unbind_account":
+                        output = await MCP.unbind_account(args)
+                        break
+                    default:
+                        output = "未知工具"
+                }
+
+                result = { content: [{ type: "text", text: output }] }
+            } else {
+                result = { error: `未知方法: ${method}` }
+            }
+
+            // 返回标准 JSON-RPC 响应
+            return res.json({
+                jsonrpc: "2.0",
+                id: req_id ?? null,
+                result
+            })
+        } catch (e) {
+            return res.json({
+                jsonrpc: "2.0",
+                id: req_id ?? null,
+                error: { code: -32000, message: e.message }
+            })
+        }
+    }
+}
+
+module.exports.McpRpc = McpRpc

+ 145 - 0
lib/Lepao/WorkOrderMcp.js

@@ -0,0 +1,145 @@
+const db = require('../../plugin/DataBase/db')
+const path = require('path')
+const Logger = require('../Logger')
+const mq = require('../../plugin/mq')
+const { mq: mqName } = require('../../plugin/mq/mqPrefix')
+const EmailTemplate = require('../../plugin/Email/emailTemplate')
+
+class WorkOrderMcp {
+    constructor() {
+        this.logger = new Logger(path.join(__dirname, '../logs/MCP.log'), 'INFO')
+
+        this.messageQueue = mqName('runforge_message_queue')
+
+        this.auto_day = [
+            { label: '周一', value: 1 },
+            { label: '周二', value: 2 },
+            { label: '周三', value: 3 },
+            { label: '周四', value: 4 },
+            { label: '周五', value: 5 },
+            { label: '周六', value: 6 },
+            { label: '周日', value: 0 }
+        ]
+
+        this.area = ["兰花湖校区跑区", "主校区北跑区", "主校区南跑区", "重庆工商大学茶园校区", "随机分配"]
+
+        this.auto_time = [
+            { label: '随机分配', value: -1 },
+            ...Array.from({ length: 17 }, (_, i) => {
+                const hour = i + 7
+                return { label: `${hour} ~ ${hour + 1}时`, value: hour }
+            })
+        ]
+
+        this.autoTimeLabel = (record) => {
+            if (record.auto_time === -1) {
+                if (record.today_auto_time)
+                    return `随机-今日${record.today_auto_time}时`
+                return '随机-待分配'
+            }
+            const match = this.auto_time.find(item => item.value === record.auto_time)
+            return match ? match.label : '-'
+        }
+
+        this.emailRegex = /^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\.[a-zA-Z]{2,}$/
+        this.banEmailList = ['icloud.com']
+    }
+
+
+    async get_account_info({ sender, student_num }) {
+        try {
+            if ([sender, student_num].some(value => value === '' || value === null || value === undefined))
+                return '缺少参数'
+
+            this.logger.info(`MCP接收获取账号信息请求:${sender},student_num:${student_num}`)
+            let sql = `
+                SELECT 
+                    l.name,
+                    l.student_num,
+                    l.state,
+                    l.area,
+                    l.auto_time,
+                    l.total_num,
+                    l.term_num,
+                    l.academy_name,
+                    l.sex,
+                    l.grade_id,
+                    l.email,
+                    l.auto_run,
+                    l.today_auto_time,
+                    l.target_count,
+                    l.auto_day,
+                    l.notice_type
+                FROM 
+                    lepao_account l
+                WHERE 
+                    l.student_num = ? AND l.create_user = ?
+            `
+            const rows = await db.query(sql, [student_num, sender])
+            if (!rows || rows.length == 0) return '该账号未被您绑定,无法查看'
+
+            let data = rows[0]
+            let returnMsg = `
+                姓名:${data.name ?? '未更新,请使用乐跑登录器更新账号信息'};学号:${data.student_num};账号状态:${data.state === 1 ? '正常' : '需使用乐跑登录器更新账号信息'};乐跑跑区:${data.area == '' ? "随机分配" : data.area};自动乐跑状态:${data.auto_run === 1 ? '开启' : '关闭'}
+                `
+            if (data.auto_run === 1) {
+                returnMsg += `自动乐跑时间:${this.autoTimeLabel(data)};`
+                returnMsg += `自动乐跑星期:${data.auto_day.slice().sort((a, b) => {
+                    if (a === 0) return 1; if (b === 0) return -1; return a - b;
+                }).map(day => this.auto_day.find(item => item.value === day)?.label).join(',')};`
+            }
+            if (data.sex) returnMsg += `性别:${data.sex === 1 ? '男' : '女'};`
+            if (data.email) returnMsg += `邮箱:${data.email};`
+            if (data.grade) returnMsg += `邮箱:${data.grade};`
+            if (data.academy_name) returnMsg += `学院:${data.academy_name};`
+            if (data.grade_id) returnMsg += `年级:${data.grade_id}级;`
+            if (data.target_count) returnMsg += `目标乐跑次数:${data.target_count};`
+            if (data.total_num) returnMsg += `累计乐跑次数:${data.total_num};`
+            if (data.notice_type) returnMsg += `通知方式:${data.notice_type};`
+
+            return returnMsg
+        } catch (error) {
+            this.logger.error(`MCP查询账号信息出错:${error.stack}`)
+            return '系统出错,请稍后再试'
+        }
+    }
+
+    getSemesterTimestamps() {
+        const now = new Date()
+        const year = now.getFullYear()
+        const feb1ThisYear = new Date(year, 1, 1, 0, 0, 0, 0)     // 当年 2-01
+        const aug31ThisYear = new Date(year, 7, 31, 0, 0, 0, 0)  // 当年 8-31
+        // 下学期:2 月 1 日 ~ 8 月 31 日
+        // 上学期:8 月 31 日 ~ 次年 2 月 1 日
+        // 1 月属于上一年的上学期
+        return [(now >= feb1ThisYear && now < aug31ThisYear) ? feb1ThisYear.getTime() : new Date(now < feb1ThisYear ? year - 1 : year, 7, 31, 0, 0, 0, 0).getTime(), now.getTime() + 86400000]
+      }
+
+    async unbind_account({ sender, student_num }) {
+        try {
+            if ([sender, student_num].some(value => value === '' || value === null || value === undefined))
+                return '缺少参数'
+
+            this.logger.info(`工单MCP接收解绑账号请求:${sender}`)
+            let selectSql = `SELECT create_user, auto_run, update_time FROM lepao_account WHERE student_num = ? AND create_user IS NOT NULL`
+            let selectRows = await db.query(selectSql, [student_num])
+            if (!selectRows || selectRows.length === 0 || !selectRows[0]?.create_user) return '该账号未绑定,无需解绑'
+
+            if (selectRows[0].auto_run === 1) return '该账号已开启自动乐跑,请用原绑定账号关闭自动乐跑后再解绑或联系人工客服处理'
+            if (selectRows[0].update_time && selectRows[0].update_time > this.getSemesterTimestamps()[0]) return '该账号在本学期存在更新记录,请等待人工处理'
+            let updateSql = `UPDATE lepao_account SET create_user = NULL, auto_run = 0, update_time = ? WHERE student_num = ?`
+            let updateRows = await db.query(updateSql, [Date.now(), student_num])
+            if (!updateRows || updateRows.affectedRows !== 1)
+                return '系统出错,请稍后再试'
+            return `解绑成功,您现在可绑定该账号`
+        } catch (error) {
+            this.logger.error(`工单MCP解绑账号出错:${error.stack}`)
+            return '系统出错,请稍后再试'
+        }
+    }
+
+   
+}
+
+const WORKORDERMCP = new WorkOrderMcp()
+module.exports.WORKORDERMCP = WORKORDERMCP

+ 23 - 2
lib/Server.js

@@ -9,6 +9,7 @@ const Worker = require('./Lepao/Worker')
 const mq = require('../plugin/mq')
 const mq = require('../plugin/mq')
 const { mq: mqName } = require('../plugin/mq/mqPrefix')
 const { mq: mqName } = require('../plugin/mq/mqPrefix')
 const { startLepaoSchedulePublisher } = require('../plugin/mq/lepaoSchedulePublisher')
 const { startLepaoSchedulePublisher } = require('../plugin/mq/lepaoSchedulePublisher')
+const OneBotV11 = require('../plugin/OneBot/OneBotV11')
 
 
 class SERVER {
 class SERVER {
     constructor() {
     constructor() {
@@ -75,6 +76,19 @@ class SERVER {
         }
         }
     }
     }
 
 
+    async initOneBot() {
+        try {
+            const ok = await OneBotV11.initOneBotWs()
+            if (ok) {
+                this.logger.info('OneBot v11 ws 初始化成功,已开始监听消息')
+            } else {
+                this.logger.info('OneBot v11 ws 未初始化(可能未启用)')
+            }
+        } catch (err) {
+            this.logger.error(`OneBot v11 ws 初始化失败: ${err.message}`)
+        }
+    }
+
     loadAPIs(directory) {
     loadAPIs(directory) {
         const items = fs.readdirSync(directory)
         const items = fs.readdirSync(directory)
 
 
@@ -116,8 +130,15 @@ class SERVER {
         // 初始化数据库连接
         // 初始化数据库连接
         this.initDB().then(() => {
         this.initDB().then(() => {
             this.initMQ().then(() => {
             this.initMQ().then(() => {
-                this.app.listen(this.port, () => {
-                    this.logger.info(`==========服务器正在 ${this.port} 端口上运行==========`)
+                this.initOneBot().then(() => {
+                    this.app.listen(this.port, () => {
+                        this.logger.info(`==========服务器正在 ${this.port} 端口上运行==========`)
+                    })
+                }).catch(err => {
+                    this.logger.error(`OneBot 初始化异常: ${err.message}`)
+                    this.app.listen(this.port, () => {
+                        this.logger.info(`==========服务器正在 ${this.port} 端口上运行==========`)
+                    })
                 })
                 })
             }).catch(err => {
             }).catch(err => {
                 this.logger.error(`启动服务器失败: ${err.message}`)
                 this.logger.error(`启动服务器失败: ${err.message}`)

+ 2 - 1
package.json

@@ -25,6 +25,7 @@
     "redis": "^4.7.0",
     "redis": "^4.7.0",
     "svg-captcha": "^1.4.0",
     "svg-captcha": "^1.4.0",
     "ua-parser-js": "^2.0.4",
     "ua-parser-js": "^2.0.4",
-    "uuid": "^11.0.3"
+    "uuid": "^11.0.3",
+    "ws": "^8.20.0"
   }
   }
 }
 }

+ 7 - 1
plugin/Email/Email.js

@@ -19,6 +19,11 @@ async function sendEmail(email, subject, content) {
             html: content
             html: content
         }
         }
 
 
+        if (!Array.isArray(config.email) || config.email.length === 0) {
+            logger.error('邮件发送失败:未配置可用邮箱账号')
+            return resolve(false)
+        }
+
         // 遍历所有邮箱配置
         // 遍历所有邮箱配置
         for (let i = 0; i < config.email.length; i++) {
         for (let i = 0; i < config.email.length; i++) {
             const currentConfig = config.email[i];
             const currentConfig = config.email[i];
@@ -43,11 +48,12 @@ async function sendEmail(email, subject, content) {
                 if (i === config.email.length - 1) {
                 if (i === config.email.length - 1) {
                     // 最后一个配置也失败了
                     // 最后一个配置也失败了
                     logger.error(`邮件发送失败,所有邮箱配置均不可用`)
                     logger.error(`邮件发送失败,所有邮箱配置均不可用`)
-                    return
+                    return resolve(false)
                 }
                 }
                 // 否则继续下一个配置
                 // 否则继续下一个配置
             }
             }
         }
         }
+        return resolve(false)
     })
     })
 }
 }
 
 

+ 443 - 0
plugin/OneBot/OneBotV11.js

@@ -0,0 +1,443 @@
+const path = require('path')
+const WebSocket = require('ws')
+const config = require('../../config.json')
+const Logger = require('../../lib/Logger')
+const db = require('../DataBase/db.js')
+const EmailTemplate = require('../Email/emailTemplate')
+
+const logger = new Logger(path.join(__dirname, '../../logs/OneBotV11.log'), 'INFO')
+let wsClient = null
+let connectingPromise = null
+let reconnectTimer = null
+let wsApiClient = null
+let connectingApiPromise = null
+let apiReconnectTimer = null
+
+function stringifySafe(data) {
+    try {
+        return JSON.stringify(data)
+    } catch (_) {
+        return '[unserializable]'
+    }
+}
+
+function getOneBotConfig() {
+    const onebot = config.onebotv11 || {}
+    return {
+        enabled: onebot.enabled === true,
+        transport: onebot.transport || 'reverse_ws',
+        reverseWsUrl: onebot.reverseWsUrl || '',
+        reverseWsHost: onebot.reverseWsHost || '127.0.0.1',
+        reverseWsPort: Number(onebot.reverseWsPort || 15700),
+        /** AstrBot / aiocqhttp 反向 WS 常见路径为 /ws;根路径 / 常返回 405 */
+        reverseWsPath: onebot.reverseWsPath || '',
+        reverseWsToken: onebot.reverseWsToken || '',
+        callbackToken: onebot.callbackToken || '',
+        ticketSenderNickname: onebot.ticketSenderNickname || '工单系统',
+        selfId: onebot.selfId || '',
+        botName: onebot.botName || '小妍助理',
+        botUuid: onebot.botUuid || 'onebot-v11-xiaoyan-assistant'
+    }
+}
+
+function getReverseWsUrl(onebot) {
+    const defaultPath = '/ws'
+    const explicitPath = onebot.reverseWsPath
+        ? (onebot.reverseWsPath.startsWith('/') ? onebot.reverseWsPath : `/${onebot.reverseWsPath}`)
+        : null
+
+    if (onebot.reverseWsUrl) {
+        try {
+            const u = new URL(onebot.reverseWsUrl)
+            if (explicitPath) {
+                u.pathname = explicitPath
+                return u.href
+            }
+            if (u.pathname && u.pathname !== '/') return u.href
+            u.pathname = defaultPath
+            return u.href
+        } catch (_) {
+            return onebot.reverseWsUrl
+        }
+    }
+
+    const pathPart = explicitPath || defaultPath
+    return `ws://${onebot.reverseWsHost}:${onebot.reverseWsPort}${pathPart}`
+}
+
+function scheduleReconnect() {
+    if (reconnectTimer) return
+    logger.info('OneBot v11 ws 3秒后尝试重连')
+    reconnectTimer = setTimeout(() => {
+        reconnectTimer = null
+        connectReverseWs().catch(err => {
+            logger.error(`OneBot v11 重连失败:${err.message}`)
+        })
+    }, 3000)
+}
+
+function scheduleApiReconnect() {
+    if (apiReconnectTimer) return
+    logger.info('OneBot v11 API ws 3秒后尝试重连')
+    apiReconnectTimer = setTimeout(() => {
+        apiReconnectTimer = null
+        connectApiReverseWs().catch(err => {
+            logger.error(`OneBot v11 API ws 重连失败:${err.message}`)
+        })
+    }, 3000)
+}
+
+async function connectReverseWs() {
+    if (wsClient && wsClient.readyState === WebSocket.OPEN) return wsClient
+    if (connectingPromise) return connectingPromise
+
+    const onebot = getOneBotConfig()
+    if (!onebot.enabled) throw new Error('OneBot v11 未启用')
+    if (onebot.transport !== 'reverse_ws') throw new Error('仅支持 reverse_ws 传输模式')
+
+    const wsUrl = getReverseWsUrl(onebot)
+    logger.info(`OneBot v11 准备连接 reverse ws:${wsUrl}`)
+    if (!onebot.selfId) {
+        throw new Error('OneBot v11 未配置 selfId:握手头 X-Self-ID 必填')
+    }
+    const selfIdStr = String(onebot.selfId).trim()
+    if (!selfIdStr) {
+        throw new Error('OneBot v11 selfId 不能为空')
+    }
+    const headers = {
+        'User-Agent': 'runforge/1.0 (OneBot-v11-reverse-ws-client)',
+        'X-Client-Role': 'Universal',
+        'X-Self-ID': selfIdStr
+    }
+    if (onebot.reverseWsToken) headers['Authorization'] = `Bearer ${onebot.reverseWsToken}`
+
+    connectingPromise = new Promise((resolve, reject) => {
+        const client = new WebSocket(wsUrl, { headers })
+        let opened = false
+
+        const connTimeout = setTimeout(() => {
+            if (!opened) {
+                try { client.terminate() } catch (_) { }
+                reject(new Error(`连接 OneBot v11 超时:${wsUrl}`))
+            }
+        }, 5000)
+
+        client.on('unexpected-response', (_req, res) => {
+            const statusCode = res && res.statusCode
+            const statusMessage = res && res.statusMessage
+            const rh = res && typeof res.getHeaders === 'function' ? res.getHeaders() : {}
+            const rawPreview = res && Array.isArray(res.rawHeaders) ? res.rawHeaders.slice(0, 24) : []
+            logger.error(`OneBot v11 ws unexpected-response: ${statusCode} ${statusMessage}`)
+            logger.error(`OneBot v11 ws response headers: ${stringifySafe({ rh, rawPreview })}`)
+        })
+
+        client.on('open', () => {
+            clearTimeout(connTimeout)
+            opened = true
+            wsClient = client
+            logger.info(`OneBot v11 reverse ws 已连接:${wsUrl},selfId=${onebot.selfId || '未配置'}`)
+            const lifecycleConnect = buildLifecycleConnectEvent(onebot)
+            client.send(JSON.stringify(lifecycleConnect), (err) => {
+                if (err) {
+                    logger.error(`OneBot 生命周期事件发送失败:${err.message}`)
+                    return
+                }
+                logger.info('OneBot 生命周期事件已发送:meta_event.lifecycle.connect')
+            })
+            resolve(client)
+        })
+
+        client.on('error', (err) => {
+            clearTimeout(connTimeout)
+            if (!opened) reject(err)
+            logger.error(`OneBot v11 ws 异常:${err.message}`)
+        })
+
+        client.on('close', () => {
+            wsClient = null
+            connectingPromise = null
+            logger.error('OneBot v11 ws 连接已关闭')
+            if (onebot.enabled) scheduleReconnect()
+        })
+
+        client.on('message', (raw) => {
+            const text = typeof raw === 'string' ? raw : raw.toString('utf8')
+            logger.info(`OneBot v11 收到消息:${text.slice(0, 1000)}`)
+            handleWsIncomingFrame(text, client).catch((err) => {
+                logger.error(`OneBot v11 处理收到消息失败:${err.message}`)
+            })
+        })
+    })
+
+    try {
+        return await connectingPromise
+    } finally {
+        connectingPromise = null
+    }
+}
+
+async function connectApiReverseWs() {
+    if (wsApiClient && wsApiClient.readyState === WebSocket.OPEN) return wsApiClient
+    if (connectingApiPromise) return connectingApiPromise
+
+    const onebot = getOneBotConfig()
+    if (!onebot.enabled) throw new Error('OneBot v11 未启用')
+    if (onebot.transport !== 'reverse_ws') throw new Error('仅支持 reverse_ws 传输模式')
+
+    const wsUrl = getReverseWsUrl(onebot)
+    if (!onebot.selfId) throw new Error('API ws 缺少 selfId')
+    const selfIdStr = String(onebot.selfId).trim()
+    if (!selfIdStr) throw new Error('API ws selfId 不能为空')
+
+    const headers = {
+        'User-Agent': 'runforge/1.0 (OneBot-v11-reverse-ws-api-client)',
+        'X-Client-Role': 'Api',
+        'X-Self-ID': selfIdStr
+    }
+    if (onebot.reverseWsToken) headers['Authorization'] = `Bearer ${onebot.reverseWsToken}`
+
+    connectingApiPromise = new Promise((resolve, reject) => {
+        const client = new WebSocket(wsUrl, { headers })
+        let opened = false
+        const connTimeout = setTimeout(() => {
+            if (!opened) {
+                try { client.terminate() } catch (_) { }
+                reject(new Error(`连接 OneBot v11 API ws 超时:${wsUrl}`))
+            }
+        }, 5000)
+
+        client.on('open', () => {
+            clearTimeout(connTimeout)
+            opened = true
+            wsApiClient = client
+            logger.info(`OneBot v11 API ws 已连接:${wsUrl}`)
+            resolve(client)
+        })
+
+        client.on('error', (err) => {
+            clearTimeout(connTimeout)
+            if (!opened) reject(err)
+            logger.error(`OneBot v11 API ws 异常:${err.message}`)
+        })
+
+        client.on('close', () => {
+            wsApiClient = null
+            connectingApiPromise = null
+            logger.error('OneBot v11 API ws 连接已关闭')
+            if (onebot.enabled) scheduleApiReconnect()
+        })
+
+        client.on('message', (raw) => {
+            const text = typeof raw === 'string' ? raw : raw.toString('utf8')
+            logger.info(`OneBot v11 API ws 收到消息:${text.slice(0, 1000)}`)
+            handleWsIncomingFrame(text, client).catch((err) => {
+                logger.error(`OneBot v11 API ws 消息处理失败:${err.message}`)
+            })
+        })
+    })
+
+    try {
+        return await connectingApiPromise
+    } finally {
+        connectingApiPromise = null
+    }
+}
+
+function buildSyntheticPrivateMessageEvent(text, onebot, orderId) {
+    const now = Math.floor(Date.now() / 1000)
+    const selfId = String(onebot.selfId).trim()
+    const senderIdStr = String(orderId || '').trim()
+    if (!senderIdStr || !/^\d+$/.test(senderIdStr) || senderIdStr === selfId) {
+        throw new Error('orderId 无效,无法作为 OneBot user_id')
+    }
+    // AstrBot aiocqhttp 消息链路要求 message 为消息段数组(字符串会走失败分支)
+    const messageArr = [{ type: 'text', data: { text } }]
+    const messageId = Math.floor(Math.random() * 2000000000) + 1
+    return {
+        time: now,
+        self_id: selfId,
+        post_type: 'message',
+        message_type: 'private',
+        sub_type: 'friend',
+        message_id: messageId,
+        user_id: senderIdStr,
+        message: messageArr,
+        raw_message: text,
+        font: 0,
+        sender: {
+            user_id: senderIdStr,
+            nickname: onebot.ticketSenderNickname || '工单系统',
+            sex: 'unknown',
+            age: 0
+        }
+    }
+}
+
+function buildLifecycleConnectEvent(onebot) {
+    return {
+        time: Math.floor(Date.now() / 1000),
+        self_id: String(onebot.selfId).trim(),
+        post_type: 'meta_event',
+        meta_event_type: 'lifecycle',
+        sub_type: 'connect'
+    }
+}
+
+async function sendImplementationEvent(eventObj) {
+    const client = await connectReverseWs()
+    const line = stringifySafe(eventObj)
+    logger.info(`OneBot v11 上报事件 post_type=${eventObj.post_type} message_type=${eventObj.message_type} len=${line.length}`)
+    return new Promise((resolve, reject) => {
+        client.send(line, (err) => {
+            if (err) {
+                logger.error(`OneBot 事件发送失败:${err.message}`)
+                return reject(err)
+            }
+            logger.info('OneBot v11 事件已写入 WebSocket')
+            resolve(true)
+        })
+    })
+}
+
+function decodeMessageText(message) {
+    if (!message) return ''
+    if (typeof message === 'string') return message
+    if (Array.isArray(message)) {
+        return message.map(seg => {
+            if (typeof seg === 'string') return seg
+            if (seg && seg.type === 'text' && seg.data && typeof seg.data.text === 'string') return seg.data.text
+            return ''
+        }).join('')
+    }
+    return String(message)
+}
+
+async function persistAiReplyAsServerReply(orderId, plainText) {
+    const onebot = getOneBotConfig()
+    const parsedOrderId = Number(orderId)
+    if (!Number.isInteger(parsedOrderId) || parsedOrderId <= 0) return
+
+    const rows = await db.query('SELECT msg, state, email FROM work_order WHERE id = ?', [parsedOrderId])
+    if (!rows || rows.length !== 1 || rows[0].state === 2) return
+
+    const now = Date.now()
+    const msg = rows[0].msg || []
+    msg.push({
+        time: now,
+        content: plainText,
+        files: [],
+        uuid: 'e4fe0277-0b1a-41a1-b25f-8b6e4cec3281',
+        type: 'ai'
+    })
+
+    await db.query('UPDATE work_order SET msg = ?, update_time = ?, state = 3 WHERE id = ?', [msg, now, parsedOrderId])
+
+    if (rows[0].email) {
+        await EmailTemplate.orderNewReply(rows[0].email, { id: parsedOrderId, content: plainText, files: [] })
+    }
+}
+
+async function handleWsIncomingFrame(text, client) {
+    let j
+    try {
+        j = JSON.parse(text)
+    } catch (_) {
+        return
+    }
+    if (j == null || typeof j !== 'object') return
+
+    if (j.action === 'send_private_msg' && j.params) {
+        const plainText = decodeMessageText(j.params.message)
+        logger.info(`OneBot v11 收到中文消息(解码):${plainText}`)
+        await persistAiReplyAsServerReply(j.params.user_id, plainText)
+    }
+
+    /**
+     * 反向 WS 上「应用端 → 实现端」的 API 请求(aiocqhttp 使用 echo: { seq })。
+     * 若不回包,AstrBot 侧 ResultStore 会超时,事件处理卡住,表现为服务端「收不到」工单事件。
+     */
+    if (j.action !== undefined && j.echo !== undefined && typeof j.echo === 'object' && j.echo !== null && j.echo.seq !== undefined) {
+        const resp = {
+            status: 'ok',
+            retcode: 0,
+            data: null,
+            echo: j.echo
+        }
+        try {
+            client.send(JSON.stringify(resp))
+            logger.info(`OneBot v11 已应答应用端 API:action=${j.action} seq=${JSON.stringify(j.echo.seq)}`)
+        } catch (err) {
+            logger.error(`OneBot v11 应答 API 失败:${err.message}`)
+        }
+        return
+    }
+
+    if ('post_type' in j) return
+
+    if (j.echo !== undefined && j.status !== undefined) {
+        if (j.retcode !== 0 && j.retcode !== undefined) {
+            const wording = j.wording || j.message || stringifySafe(j.data)
+            logger.error(`OneBot API 返回失败 echo=${j.echo} retcode=${j.retcode} ${wording}`)
+        } else {
+            logger.info(`OneBot API 成功 echo=${j.echo}`)
+        }
+    }
+}
+
+function buildOrderText(payload) {
+    const {
+        orderId,
+        title,
+        role,
+        content,
+        files = [],
+        senderUuid
+    } = payload
+
+    const fileText = files.length > 0 ? `\n附件:\n${files.join('\n')}` : ''
+    return [
+        `[工单#${orderId}] ${title ? title : '工单消息更新'}`,
+        `发送方:${role === '用户' ? '用户' : '系统'}`,
+        `用户uuid:${senderUuid || '-'}`,
+        '消息内容:',
+        content
+    ].join('\n') + fileText
+}
+
+async function sendOrderMessage(payload) {
+    const onebot = getOneBotConfig()
+    if (!onebot.enabled) return false
+    if (onebot.transport !== 'reverse_ws') {
+        logger.error('OneBot v11 传输模式错误,仅支持 reverse_ws')
+        return false
+    }
+
+    const text = buildOrderText(payload)
+
+    const eventObj = buildSyntheticPrivateMessageEvent(text, onebot, payload.orderId)
+    await sendImplementationEvent(eventObj)
+    logger.info(`OneBot 工单事件已上报:orderId=${payload.orderId}, userId=${eventObj.user_id}, messageId=${eventObj.message_id}`)
+    return true
+}
+
+async function initOneBotWs() {
+    const onebot = getOneBotConfig()
+    if (!onebot.enabled) {
+        logger.info('OneBot v11 未启用,跳过 ws 初始化')
+        return false
+    }
+    if (onebot.transport !== 'reverse_ws') {
+        logger.error('OneBot v11 初始化失败:传输模式不是 reverse_ws')
+        return false
+    }
+
+    await connectReverseWs()
+    await connectApiReverseWs()
+    return true
+}
+
+module.exports = {
+    sendOrderMessage,
+    getOneBotConfig,
+    initOneBotWs
+}