/** * AIBOT 长连接桥(骨架 + HTTP 转发) * * 本文件职责: * - /health 存活 * - POST /forward 把已解析消息转到智能体 WECOM_AIBOT_INGRESS_URL(默认 8910/internal/aibot/inbound) * - POST /send-group 走长连接 aibot_send_msg(发文件后马上在群里说话) * - 默认不连企微(并存期禁止双连);WECOM_AIBOT_ENABLED=true 时订阅长连接 * * 禁止:在本进程写工单/六态/Graph。 */ "use strict"; const http = require("http"); const { URL } = require("url"); const { startLongConnectionLoop } = require("./long_connection.cjs"); let longConn = { sendGroupMessage: async () => { const err = new Error("ws_offline"); err.code = "ws_offline"; throw err; }, sendGroupFile: async () => { const err = new Error("ws_offline"); err.code = "ws_offline"; throw err; }, status: () => ({ enabled, connected: false, subscribed: false }), }; const port = Number(process.env.AIBOT_BRIDGE_PORT || 8913); const bind = process.env.AIBOT_BRIDGE_BIND || "127.0.0.1"; const enabled = String(process.env.WECOM_AIBOT_ENABLED || "false").toLowerCase() === "true"; const ingressUrl = process.env.WECOM_AIBOT_INGRESS_URL || "http://127.0.0.1:8910/internal/aibot/inbound"; const bridgeToken = process.env.WECOM_AIBOT_BRIDGE_TOKEN || ""; function readBody(req) { return new Promise((resolve, reject) => { const chunks = []; req.on("data", (c) => chunks.push(c)); req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8"))); req.on("error", reject); }); } function sendJson(res, code, obj) { const body = JSON.stringify(obj); res.writeHead(code, { "Content-Type": "application/json; charset=utf-8" }); res.end(body); } function forwardToAgent(payloadText) { return new Promise((resolve, reject) => { let target; try { target = new URL(ingressUrl); } catch (e) { reject(e); return; } const data = Buffer.from(payloadText, "utf8"); const req = http.request( { protocol: target.protocol, hostname: target.hostname, port: target.port || 80, path: target.pathname + (target.search || ""), method: "POST", headers: { "Content-Type": "application/json; charset=utf-8", "Content-Length": data.length, "X-Aibot-Bridge-Token": bridgeToken, }, timeout: 5000, }, (res) => { const chunks = []; res.on("data", (c) => chunks.push(c)); res.on("end", () => { resolve({ status: res.statusCode, body: Buffer.concat(chunks).toString("utf8"), }); }); } ); req.on("error", reject); req.on("timeout", () => { req.destroy(); reject(new Error("ingress_timeout")); }); req.write(data); req.end(); }); } const server = http.createServer(async (req, res) => { const url = (req.url || "").split("?")[0]; if (req.method === "GET" && (url === "/health" || url === "/health/")) { sendJson(res, 200, { status: "UP", service: "aibot-bridge", bind, port, wecomAibotEnabled: enabled, ingressUrl, longConnection: longConn.status(), note: enabled ? "enabled:长连接已尝试订阅;POST /send-group 可主动往群里说话;勿与现网双连" : "Bot 长连接默认关闭;可用 POST /forward 测转发。主动发言需 WECOM_AIBOT_ENABLED=true", }); return; } // 测试/后续 Bot 回调:把 JSON 原样转到智能体(不做业务) if (req.method === "POST" && (url === "/forward" || url === "/forward/")) { try { const raw = await readBody(req); if (!raw) { sendJson(res, 400, { error: "empty_body" }); return; } // 轻量校验 JSON JSON.parse(raw); const out = await forwardToAgent(raw); sendJson(res, 200, { status: "forwarded", ingressStatus: out.status, ingressBody: (() => { try { return JSON.parse(out.body); } catch (_) { return out.body; } })(), }); } catch (e) { sendJson(res, 502, { error: "forward_failed", message: String(e && e.message ? e.message : e) }); } return; } // 官方桥口径:POST /send {chatid, operation:"text", content} if (req.method === "POST" && (url === "/send" || url === "/send/")) { if (bridgeToken) { const got = String( req.headers["x-aibot-bridge-token"] || req.headers["x-ytd-aibot-bridge-token"] || "" ); if (got !== bridgeToken) { sendJson(res, 401, { error: "unauthorized" }); return; } } try { const raw = await readBody(req); const body = raw ? JSON.parse(raw) : {}; const chatId = String(body.chatid || body.chat_id || "").trim(); const content = String(body.content || "").trim(); if (!chatId || !content) { sendJson(res, 400, { error: "missing_chat_or_content" }); return; } await longConn.sendGroupMessage({ chatId, content }); sendJson(res, 200, { status: "sent", chatId }); } catch (e) { const code = e && e.code ? e.code : "send_failed"; const httpCode = code === "ws_offline" ? 503 : 502; sendJson(res, httpCode, { error: code, message: String(e && e.message ? e.message : e), }); } return; } // 空运群发填好的报价模板:上传素材后 aibot_send_msg file if (req.method === "POST" && (url === "/send-file" || url === "/send-file/")) { if (bridgeToken) { const got = String( req.headers["x-aibot-bridge-token"] || req.headers["x-ytd-aibot-bridge-token"] || "" ); if (got !== bridgeToken) { sendJson(res, 401, { error: "unauthorized" }); return; } } try { const raw = await readBody(req); const body = raw ? JSON.parse(raw) : {}; const chatId = String(body.chat_id || body.chatid || "").trim(); const filename = String(body.filename || "quote.xlsx").trim() || "quote.xlsx"; const fileB64 = String(body.file_b64 || body.fileBase64 || "").trim(); if (!chatId || !fileB64) { sendJson(res, 400, { error: "missing_chat_or_file" }); return; } await longConn.sendGroupFile({ chatId, filename, fileB64 }); sendJson(res, 200, { status: "sent", chatId, filename }); } catch (e) { const code = e && e.code ? e.code : "send_failed"; const httpCode = code === "ws_offline" ? 503 : 502; sendJson(res, httpCode, { error: code, message: String(e && e.message ? e.message : e), }); } return; } // 智能体 Worker:存档听到附件后马上在群里说话(aibot_send_msg) if (req.method === "POST" && (url === "/send-group" || url === "/send-group/")) { if (bridgeToken) { const got = String( req.headers["x-aibot-bridge-token"] || req.headers["x-ytd-aibot-bridge-token"] || "" ); if (got !== bridgeToken) { sendJson(res, 401, { error: "unauthorized" }); return; } } try { const raw = await readBody(req); const body = raw ? JSON.parse(raw) : {}; const chatId = String(body.chat_id || body.chatid || "").trim(); const content = String(body.content || "").trim(); if (!chatId || !content) { sendJson(res, 400, { error: "missing_chat_or_content" }); return; } await longConn.sendGroupMessage({ chatId, content }); sendJson(res, 200, { status: "sent", chatId }); } catch (e) { const code = e && e.code ? e.code : "send_failed"; const httpCode = code === "ws_offline" ? 503 : 502; sendJson(res, httpCode, { error: code, message: String(e && e.message ? e.message : e), }); } return; } sendJson(res, 404, { error: "not_found", path: req.url }); }); process.on("uncaughtException", (e) => { // eslint-disable-next-line no-console console.warn("[aibot-bridge] uncaught", String(e && e.stack ? e.stack : e).slice(0, 400)); }); process.on("unhandledRejection", (e) => { // eslint-disable-next-line no-console console.warn("[aibot-bridge] rejection", String(e && e.stack ? e.stack : e).slice(0, 400)); }); server.listen(port, bind, () => { // eslint-disable-next-line no-console console.log( `[aibot-bridge] http://${bind}:${port}/health forward→${ingressUrl} enabled=${enabled}` ); if (enabled) { // eslint-disable-next-line no-console console.warn( "[aibot-bridge] WECOM_AIBOT_ENABLED=true:请确认已停现网桥且不会双连同一 Bot" ); } // 启用时订阅企微长连接;关闭时 /send-group 会回 ws_offline longConn = startLongConnectionLoop({ enabled, forwardToAgent, }); });