落地空运群内协同与锁舱出站。
群里按 BOT 通道激活当前工单、航线报价和成交跟进;没有 TMS 报价编号时用工单字段补齐仍调锁舱,避免被本地规则拦住。 Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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`。该群须先 @ 过小助手,企微才允许主动发言。
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+36
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,5 +6,8 @@
|
||||
"main": "server.cjs",
|
||||
"scripts": {
|
||||
"start": "node server.cjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"ws": "^8.18.0"
|
||||
}
|
||||
}
|
||||
|
||||
+130
-6
@@ -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,
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user