群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。 Co-authored-by: Cursor <cursoragent@cursor.com>
277 lines
8.7 KiB
JavaScript
277 lines
8.7 KiB
JavaScript
/**
|
||
* 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,
|
||
});
|
||
});
|