/** * AIBOT 长连接(只传输,无工单状态)。 * * 本文件职责: * - WECOM_AIBOT_ENABLED=true 时连 wss://openws.work.weixin.qq.com * - 订阅后:入站 aibot_msg_callback 转发智能体;出站 /send-group 走 aibot_send_msg * - 同一 Bot 只能一条长连接;并存期默认关,勿与现网 8813 双连 * * 禁止:写工单/六态/Graph。密钥只读环境变量,不打日志。 */ "use strict"; const crypto = require("crypto"); const { buildGroupMessageFrame, pickReplyReqId } = require("./group_message_frame.cjs"); function generateReqId(prefix) { const timestamp = Date.now(); const random = crypto.randomBytes(4).toString("hex"); return `${prefix}_${timestamp}_${random}`; } function loadWs() { try { return require("ws"); } catch (_) { return null; } } /** * @param {object} opts * @param {boolean} opts.enabled * @param {(payloadText: string) => Promise} opts.forwardToAgent * @param {() => boolean} [opts.shouldStop] */ function startLongConnectionLoop(opts) { const enabled = !!opts.enabled; const forwardToAgent = opts.forwardToAgent; const shouldStop = opts.shouldStop || (() => false); const botId = String(process.env.WECOM_AIBOT_BOT_ID || "").trim(); const secret = String(process.env.WECOM_AIBOT_SECRET || "").trim(); const wsUrl = process.env.WECOM_AIBOT_WS_URL || "wss://openws.work.weixin.qq.com"; const state = { ws: null, connected: false, subscribed: false, lastError: "", pending: new Map(), lastCallback: new Map(), lastAnyReq: "", heartbeatTimer: null, reconnectTimer: null, stop: () => {}, }; function status() { return { enabled, connected: state.connected, subscribed: state.subscribed, lastError: state.lastError, hasCredentials: Boolean(botId && secret), }; } function sendWs(payload, reqId) { return new Promise((resolve, reject) => { if (!state.ws || state.ws.readyState !== 1 || !state.subscribed) { const err = new Error("ws_offline"); err.code = "ws_offline"; reject(err); return; } const timer = setTimeout(() => { state.pending.delete(reqId); const err = new Error("send_timeout"); err.code = "send_timeout"; reject(err); }, 8000); state.pending.set(reqId, { resolve, reject, timer }); state.ws.send(JSON.stringify(payload)); }); } function sendGroupMessage({ chatId, content, callbackReqId }) { const text = String(content || ""); const fromCaller = String(callbackReqId || "").trim(); const chatKey = String(chatId || "").trim(); const fromChat = state.lastCallback.get(chatKey) || ""; const replyReqId = pickReplyReqId({ fromCaller, fromChat, lastAny: state.lastAnyReq || "", }); // 用这次 @ 的 req_id 回 aibot_respond_msg,群里才会引用原消息。 const reqId = replyReqId || generateReqId("aibot_send_msg"); const frame = buildGroupMessageFrame({ chatId, content: text, callbackReqId: replyReqId, reqId, }); // eslint-disable-next-line no-console console.log( "[aibot-long-connection] 出站 cmd=", frame.cmd, "chat=", chatKey || "-", "hasReq=", Boolean(replyReqId) ); return sendWs(frame, reqId); } /** * 空运群发报价模板:分片上传后 aibot_send_msg file。 * 单个分片不超过 512KB(编码前)。 */ async function sendGroupFile({ chatId, filename, fileB64, fileBytes }) { const buf = Buffer.isBuffer(fileBytes) ? fileBytes : Buffer.from(String(fileB64 || ""), "base64"); const name = String(filename || "quote.xlsx").trim() || "quote.xlsx"; if (!chatId || !buf.length) { const err = new Error("file_empty"); err.code = "file_empty"; throw err; } const chunkSize = 400 * 1024; const totalChunks = Math.max(1, Math.ceil(buf.length / chunkSize)); const md5 = crypto.createHash("md5").update(buf).digest("hex"); const initReq = generateReqId("aibot_upload_media_init"); const init = await sendWs( { cmd: "aibot_upload_media_init", headers: { req_id: initReq }, body: { type: "file", filename: name, total_size: buf.length, total_chunks: totalChunks, md5, }, }, initReq ); const uploadId = String((init.body && init.body.upload_id) || "").trim(); if (!uploadId) { const err = new Error("upload_init_failed"); err.code = "upload_init_failed"; throw err; } for (let i = 0; i < totalChunks; i += 1) { const slice = buf.subarray(i * chunkSize, Math.min(buf.length, (i + 1) * chunkSize)); const chunkReq = generateReqId("aibot_upload_media_chunk"); await sendWs( { cmd: "aibot_upload_media_chunk", headers: { req_id: chunkReq }, body: { upload_id: uploadId, chunk_index: i, base64_data: slice.toString("base64"), }, }, chunkReq ); } const finishReq = generateReqId("aibot_upload_media_finish"); const finished = await sendWs( { cmd: "aibot_upload_media_finish", headers: { req_id: finishReq }, body: { upload_id: uploadId }, }, finishReq ); const mediaId = String((finished.body && finished.body.media_id) || "").trim(); if (!mediaId) { const err = new Error("upload_finish_failed"); err.code = "upload_finish_failed"; throw err; } const sendReq = generateReqId("aibot_send_msg"); return sendWs( { cmd: "aibot_send_msg", headers: { req_id: sendReq }, body: { chatid: chatId, chat_type: 2, msgtype: "file", file: { media_id: mediaId }, }, }, sendReq ); } if (!enabled) { // eslint-disable-next-line no-console console.log("[aibot-long-connection] 未启用(WECOM_AIBOT_ENABLED=false),不连企微"); return { stop: () => {}, sendGroupMessage, sendGroupFile, status }; } if (!botId || !secret) { state.lastError = "missing_bot_credentials"; // eslint-disable-next-line no-console console.warn( "[aibot-long-connection] 已启用但缺少 WECOM_AIBOT_BOT_ID / WECOM_AIBOT_SECRET,无法订阅" ); return { stop: () => {}, sendGroupMessage, sendGroupFile, status }; } const WebSocket = loadWs(); if (!WebSocket) { state.lastError = "ws_package_missing"; // eslint-disable-next-line no-console console.warn("[aibot-long-connection] 未安装 ws 包,请在 aibot-bridge 目录执行 npm install"); return { stop: () => {}, sendGroupMessage, sendGroupFile, status }; } function startHeartbeat() { stopHeartbeat(); state.heartbeatTimer = setInterval(() => { if (!state.ws || state.ws.readyState !== 1) { return; } try { state.ws.send( JSON.stringify({ cmd: "ping", headers: { req_id: generateReqId("ping") }, }) ); } catch (_) { /* ignore */ } }, 25000); } function stopHeartbeat() { if (state.heartbeatTimer) { clearInterval(state.heartbeatTimer); state.heartbeatTimer = null; } } function scheduleReconnect() { if (shouldStop() || state.reconnectTimer) { return; } state.reconnectTimer = setTimeout(() => { state.reconnectTimer = null; connect(); }, 5000); } function settlePending(reqId, errcode, errmsg, frame) { const wait = state.pending.get(reqId); if (!wait) { return; } clearTimeout(wait.timer); state.pending.delete(reqId); if (Number(errcode) === 0) { wait.resolve({ ok: true, body: (frame && frame.body) || {}, frame: frame || {} }); return; } const err = new Error(errmsg || "send_failed"); err.code = "wecom_error"; err.errcode = errcode; wait.reject(err); } function normalizeInbound(frame) { const body = frame.body || {}; const from = body.from || {}; const text = (body.text && body.text.content) || ""; return { sender_id: String(from.userid || "").trim(), message_id: String(body.msgid || (frame.headers && frame.headers.req_id) || "").trim(), content: text, msg_type: String(body.msgtype || "text"), chat_id: String(body.chatid || "").trim(), chat_type: body.chattype === "single" ? "c2c" : "group", raw: { source: "aibot", aibot_req_id: frame.headers && frame.headers.req_id, }, }; } function handleFrame(raw) { let frame; try { frame = JSON.parse(raw); } catch (_) { return; } const reqId = frame.headers && frame.headers.req_id; // 出站 ACK 带 chatid,不能当成入站,否则会盖掉这次 @ 的 req_id,群里就没有引用。 if (reqId && state.pending.has(reqId)) { settlePending(reqId, frame.errcode, frame.errmsg, frame); return; } if (frame.cmd === "aibot_event_callback") { const ev = (frame.body && frame.body.event && frame.body.event.eventtype) || ""; // eslint-disable-next-line no-console console.warn("[aibot-long-connection] 事件", ev || "-"); if (ev === "disconnected_event") { state.lastError = "kicked_by_new_connection"; } return; } const bodyHint = frame.body || {}; const looksLikeInbound = Boolean( frame.cmd === "aibot_msg_callback" || (bodyHint.msgid && (bodyHint.from || (bodyHint.text && bodyHint.text.content))) ); if (looksLikeInbound) { // eslint-disable-next-line no-console console.log( "[aibot-long-connection] 收到入站 cmd=", frame.cmd || "-", "msgid=", bodyHint.msgid || "-", "chat=", bodyHint.chatid || "-" ); const inbound = normalizeInbound(frame); const chatKey = inbound.chat_id || String(bodyHint.chatid || "").trim(); if (reqId && chatKey) { state.lastCallback.set(chatKey, reqId); } if (reqId) { state.lastAnyReq = reqId; } const officialBody = Object.assign({}, frame.body || {}, { aibot_direct: true, aibot_req_id: reqId || "", }); const payload = inbound.sender_id ? Object.assign({}, officialBody, { sender_id: inbound.sender_id, message_id: inbound.message_id, content: inbound.content, chat_id: inbound.chat_id, chat_type: inbound.chat_type, }) : officialBody; if (!payload.sender_id && !payload.from) { return; } Promise.resolve(forwardToAgent(JSON.stringify(payload))).catch((e) => { // eslint-disable-next-line no-console console.warn("[aibot-long-connection] 转发入站失败", String(e && e.message ? e.message : e)); }); } } function connect() { if (shouldStop()) { return; } // eslint-disable-next-line no-console console.warn( "[aibot-long-connection] 即将连接企微长连接;请确认现网 8813 已停,避免双连" ); const ws = new WebSocket(wsUrl); state.ws = ws; ws.on("open", () => { state.connected = true; state.lastError = ""; const reqId = generateReqId("aibot_subscribe"); ws.send( JSON.stringify({ cmd: "aibot_subscribe", headers: { req_id: reqId }, body: { bot_id: botId, secret }, }) ); // eslint-disable-next-line no-console console.log("[aibot-long-connection] 已发订阅 req_id前缀=aibot_subscribe"); }); ws.on("message", (buf) => { const text = Buffer.isBuffer(buf) ? buf.toString("utf8") : String(buf); let frame; try { frame = JSON.parse(text); } catch (_) { // eslint-disable-next-line no-console console.warn("[aibot-long-connection] 非JSON帧 len=", text.length); handleFrame(text); return; } // eslint-disable-next-line no-console const reqPrefix = String((frame.headers && frame.headers.req_id) || "").split("_").slice(0, 2).join("_"); console.log( "[aibot-long-connection] 帧 cmd=", frame.cmd || "-", "req=", reqPrefix || "-", "keys=", Object.keys(frame).join(","), "errcode=", frame.errcode, "errmsg=", frame.errmsg || "" ); if (frame.cmd === "ping") { try { ws.send( JSON.stringify({ cmd: "pong", headers: frame.headers || {}, }) ); } catch (_) { /* ignore */ } } const ackReq = String((frame.headers && frame.headers.req_id) || ""); if (ackReq.startsWith("aibot_subscribe") && Number(frame.errcode) === 0 && !state.subscribed) { state.subscribed = true; startHeartbeat(); // eslint-disable-next-line no-console console.log("[aibot-long-connection] 订阅成功"); } else if ( ackReq.startsWith("aibot_subscribe") && frame.errcode !== undefined && Number(frame.errcode) !== 0 ) { state.lastError = "subscribe_failed_" + String(frame.errcode); // eslint-disable-next-line no-console console.warn("[aibot-long-connection] 订阅失败 errcode=", frame.errcode); } handleFrame(text); }); ws.on("close", (code, reason) => { stopHeartbeat(); state.connected = false; state.subscribed = false; state.ws = null; // eslint-disable-next-line no-console console.warn( "[aibot-long-connection] 连接关闭 code=", code, "reason=", reason ? String(reason).slice(0, 120) : "" ); if (!shouldStop()) { scheduleReconnect(); } }); ws.on("error", (e) => { state.lastError = "ws_error"; // eslint-disable-next-line no-console console.warn("[aibot-long-connection] 连接错误", String(e && e.message ? e.message : e)); }); ws.on("ping", () => { try { ws.pong(); } catch (_) { /* ignore */ } }); } connect(); state.stop = () => { stopHeartbeat(); if (state.reconnectTimer) { clearTimeout(state.reconnectTimer); state.reconnectTimer = null; } if (state.ws) { try { state.ws.close(); } catch (_) { /* ignore */ } state.ws = null; } state.connected = false; state.subscribed = false; }; return { stop: state.stop, sendGroupMessage, sendGroupFile, status, /** * 测试注入:模拟 SDK 收到一帧已解析 JSON,只做转发。 * @param {object|string} frame */ injectFrameForTest: async (frame) => { const text = typeof frame === "string" ? frame : JSON.stringify(frame); return forwardToAgent(text); }, }; } module.exports = { startLongConnectionLoop };