Files
inquiry_robot/aibot-bridge/server.cjs
jillion886andCursor cfbce2fd69 落地空运单聊询价字段与线路选择。
必填核对后再建单;多条线路走 H5 点选;费用只展示该线路 TMS 回包,不套用整单空运费。

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-22 15:02:32 +08:00

285 lines
8.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* AIBOT 长连接桥(骨架 + HTTP 转发)
*
* 本文件职责:
* - /health 存活
* - POST /forward 把已解析消息转到智能体 WECOM_AIBOT_INGRESS_URL(默认 8910/internal/aibot/inbound)
* - POST /send、/send-group:有 aibot_req_id 走 aibot_respond_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,
callbackReqId: String(body.aibot_req_id || body.callback_req_id || "").trim(),
});
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,
callbackReqId: String(body.aibot_req_id || body.callback_req_id || "").trim(),
});
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,
});
});