落地空运群内协同与锁舱出站。

群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-16 17:25:42 +08:00
co-authored by Cursor
parent 12219cacb1
commit d9029dec89
62 changed files with 6772 additions and 159 deletions
+8 -2
View File
@@ -13,7 +13,13 @@ curl http://127.0.0.1:8913/health
curl -X POST http://127.0.0.1:8913/forward -H "Content-Type: application/json" -d "{\"sender_id\":\"userid\",\"message_id\":\"m1\",\"content\":\"hi\",\"chat_type\":\"group\"}"
```
环境变量:`WECOM_AIBOT_INGRESS_URL`(默认 `http://127.0.0.1:8910/internal/aibot/inbound`)、`WECOM_AIBOT_BRIDGE_TOKEN`。
环境变量:`WECOM_AIBOT_INGRESS_URL`(默认 `http://127.0.0.1:8910/internal/aibot/inbound`)、`WECOM_AIBOT_BRIDGE_TOKEN`、`WECOM_AIBOT_BOT_ID`、`WECOM_AIBOT_SECRET`、`WECOM_AIBOT_WS_URL`(默认企微长连接地址)。
主动往群里说话(智能体 Worker 在发文件入账后调用):
```text
curl -X POST http://127.0.0.1:8913/send-group -H "Content-Type: application/json" -d "{\"chat_id\":\"CHATID\",\"content\":\"附件已保存\"}"
```
并存期请保持 `WECOM_AIBOT_ENABLED=false`,不要与现网 8813 同时连同一 Bot。
`long_connection.cjs`:启用时只跑帧循环/心跳壳(默认不连 Bot SDK);正式 SDK 只替换传输段。
启用后订阅长连接:入站转发智能体,出站 `/send-group` 走 `aibot_send_msg`。该群须先 @ 过小助手,企微才允许主动发言。
+463 -24
View File
@@ -1,16 +1,32 @@
/**
* AIBOT 长连接传输壳(无业务)。
* AIBOT 长连接(只传输,无工单状态)。
*
* 本文件职责:
* - 当 WECOM_AIBOT_ENABLED=true 时启动「帧循环」占位(默认只心跳日志,不连真 Bot SDK)
* - 收到已规范化的入站帧时转发到同进程 forwardToAgent(由 server.cjs 注入)
* - WECOM_AIBOT_ENABLED=true 时连 wss://openws.work.weixin.qq.com
* - 订阅后:入站 aibot_msg_callback 转发智能体;出站 /send-group 走 aibot_send_msg
* - 同一 Bot 只能一条长连接;并存期默认关,勿与现网 8813 双连
*
* 禁止:写工单/六态/Graph;并存期勿与现网 8813 双连同一 Bot。
* 正式 SDK 接入:替换 startLongConnectionLoop 内的 connect 段,保持只做传输。
* 禁止:写工单/六态/Graph。密钥只读环境变量,不打日志。
*/
"use strict";
const crypto = require("crypto");
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
@@ -21,38 +37,461 @@ 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(),
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 }) {
const text = String(content || "");
const callbackReqId = state.lastCallback.get(String(chatId || "").trim()) || "";
if (callbackReqId) {
return sendWs(
{
cmd: "aibot_respond_msg",
headers: { req_id: callbackReqId },
body: {
msgtype: "stream",
stream: { id: crypto.randomUUID(), finish: true, content: text },
},
},
callbackReqId
).finally(() => {
state.lastCallback.delete(String(chatId || "").trim());
});
}
const reqId = generateReqId("aibot_send_msg");
return sendWs(
{
cmd: "aibot_send_msg",
headers: { req_id: reqId },
body: {
chatid: chatId,
chat_type: 2,
msgtype: "markdown",
markdown: { content: text },
},
},
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: () => {} };
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 };
}
// eslint-disable-next-line no-console
console.warn(
"[aibot-long-connection] 帧循环壳已启动:当前不连企微 Bot SDK,仅心跳;" +
"请确认现网桥已停,避免双连"
);
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 };
}
let timer = null;
const tick = () => {
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;
if (reqId && state.pending.has(reqId)) {
settlePending(reqId, frame.errcode, frame.errmsg, frame);
}
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.chatid ||
(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);
if (reqId && inbound.chat_id) {
state.lastCallback.set(inbound.chat_id, 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.log("[aibot-long-connection] heartbeat shell (no SDK frame)");
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;
};
timer = setInterval(tick, 60000);
tick();
return {
stop: () => {
if (timer) {
clearInterval(timer);
timer = null;
}
},
stop: state.stop,
sendGroupMessage,
sendGroupFile,
status,
/**
* 测试注入:模拟 SDK 收到一帧已解析 JSON,只做转发。
* @param {object|string} frame
+36
View File
@@ -0,0 +1,36 @@
{
"name": "ytd-aibot-bridge",
"version": "0.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ytd-aibot-bridge",
"version": "0.1.0",
"dependencies": {
"ws": "^8.18.0"
}
},
"node_modules/ws": {
"version": "8.21.3",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.21.3.tgz",
"integrity": "sha512-201TZ/kPWxoPr/OKWjquZR1SWKXcvxdH+e1xrx89b3YbmzLMFCLfnaG1HFIgWzJOEWZ7MvpK++odZufgYR50Rw==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
}
}
}
+3
View File
@@ -6,5 +6,8 @@
"main": "server.cjs",
"scripts": {
"start": "node server.cjs"
},
"dependencies": {
"ws": "^8.18.0"
}
}
+130 -6
View File
@@ -4,7 +4,8 @@
* 本文件职责:
* - /health 存活
* - POST /forward 把已解析消息转到智能体 WECOM_AIBOT_INGRESS_URL(默认 8910/internal/aibot/inbound)
* - 默认不连企微 Bot 长连接(并存期禁止双连);WECOM_AIBOT_ENABLED=true 时仅打警告
* - POST /send-group 走长连接 aibot_send_msg(发文件后马上在群里说话)
* - 默认不连企微(并存期禁止双连);WECOM_AIBOT_ENABLED=true 时订阅长连接
*
* 禁止:在本进程写工单/六态/Graph。
*/
@@ -15,6 +16,20 @@ 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";
@@ -94,10 +109,10 @@ const server = http.createServer(async (req, res) => {
port,
wecomAibotEnabled: enabled,
ingressUrl,
longConnectionShell: true,
longConnection: longConn.status(),
note: enabled
? "enabled:长连接帧循环壳已起(默认无 Bot SDK);勿与现网双连"
: "Bot 长连接默认关闭;可用 POST /forward 测转发",
? "enabled:长连接已尝试订阅;POST /send-group 可主动往群里说话;勿与现网双连"
: "Bot 长连接默认关闭;可用 POST /forward 测转发。主动发言需 WECOM_AIBOT_ENABLED=true",
});
return;
}
@@ -130,9 +145,118 @@ const server = http.createServer(async (req, res) => {
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(
@@ -144,8 +268,8 @@ server.listen(port, bind, () => {
"[aibot-bridge] WECOM_AIBOT_ENABLED=true:请确认已停现网桥且不会双连同一 Bot"
);
}
// 传输壳:enabled 时跑心跳循环;未接 SDK 前不会产生入站帧
startLongConnectionLoop({
// 启用时订阅企微长连接;关闭时 /send-group 会回 ws_offline
longConn = startLongConnectionLoop({
enabled,
forwardToAgent,
});