dsh-remote-web 0.7.11-beta.3 → 0.7.12-beta.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/lib/client.js +703 -115
- package/lib/index.js +487 -31
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/dingtalk-bot.mjs +1318 -0
- package/runtime/clients/dsh-remote/docs/feishu-bot-contract.md +177 -0
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +398 -80
- package/runtime/clients/dsh-remote/feishu-bot.mjs +1668 -0
- package/runtime/clients/dsh-remote/im-format.mjs +372 -0
- package/runtime/clients/dsh-remote/mirror-diag-script.js +53 -14
- package/runtime/clients/dsh-remote/mobile-adapter.mjs +355 -40
- package/runtime/clients/dsh-remote/upstream-discovery.mjs +495 -43
- package/runtime/clients/dsh-remote/wecom-bot.mjs +238 -12
- package/runtime/dsh-setup.mjs +78 -7
|
@@ -122,6 +122,10 @@ import { createAgentRuntime } from "./agent/index.mjs";
|
|
|
122
122
|
// 电脑在线即可双向联动。**默认关闭**:没有 `im-bots.json` / 未启用 / 缺凭据时什么都不起。
|
|
123
123
|
// 与微信机器人通道(iLink 长轮询)互相独立:两条通道可同时开,也可以都不开。
|
|
124
124
|
import { createWecomBotController } from "./wecom-bot.mjs";
|
|
125
|
+
// 钉钉 Stream 模式长连接(2026-11-08):与企微同构、零新增依赖;默认关闭,配置在 im-bots.json 的 dingtalk 段。
|
|
126
|
+
import { createDingtalkBotController } from "./dingtalk-bot.mjs";
|
|
127
|
+
// 飞书通道:官方 SDK 按需下载(41MB,不随包分发)、懒加载;未启用时零副作用
|
|
128
|
+
import { createFeishuBotController } from "./feishu-bot.mjs";
|
|
125
129
|
|
|
126
130
|
// ---------- 强制直连:清除代理环境变量 ----------
|
|
127
131
|
// 家庭网络常配 Clash 等代理(127.0.0.1:7890),node 的 ws/fetch 会继承
|
|
@@ -768,6 +772,19 @@ export function tunnelBulkOptionsFromEnv(env = process.env) {
|
|
|
768
772
|
};
|
|
769
773
|
}
|
|
770
774
|
|
|
775
|
+
// ---------- B0:慢响应日志(2026-11-08) ----------
|
|
776
|
+
// 阈值/窗口读 env(DSH_BRIDGE_SLOW_RESP_MS / DSH_BRIDGE_SLOW_LOG_MS,见 slowRespLogOptions)。
|
|
777
|
+
// 全局节流避免"很多大响应同时慢"时把日志刷爆(工作区的日志铁律同样要求有界)。
|
|
778
|
+
const SLOW_RESP = slowRespLogOptions();
|
|
779
|
+
let slowRespLogLast = -Infinity;
|
|
780
|
+
function slowRespLogAllow() {
|
|
781
|
+
if (SLOW_RESP.windowMs <= 0) return true;
|
|
782
|
+
const t = Date.now();
|
|
783
|
+
if (t - slowRespLogLast < SLOW_RESP.windowMs) return false;
|
|
784
|
+
slowRespLogLast = t;
|
|
785
|
+
return true;
|
|
786
|
+
}
|
|
787
|
+
|
|
771
788
|
/**
|
|
772
789
|
* 构造「控制帧优先」的隧道发送器。返回 { send, close, stats, hasBacklog }。
|
|
773
790
|
*
|
|
@@ -797,12 +814,19 @@ export function makePrioritySender({
|
|
|
797
814
|
// 客户端的游标推进到了前面,重连时服务端按该游标回放就触发 cursor 错乱。
|
|
798
815
|
// 现在:控制帧仍可插队(它们是协议控制帧,不承载有序数据);**数据帧只有一个队列**,
|
|
799
816
|
// 大帧按片就地推进,绝不因大小/类型被后来的帧超越。
|
|
800
|
-
const dataQ = []; // { frames: string[], i: number };frames.length===1 = 未分片小帧
|
|
801
|
-
const stats = { controlSent: 0, dataSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0 };
|
|
817
|
+
const dataQ = []; // { frames: string[], i: number, id?: string|number, onFlushed?: Function, cancelled?: boolean };frames.length===1 = 未分片小帧
|
|
818
|
+
const stats = { controlSent: 0, dataSent: 0, bigFrames: 0, bigChunks: 0, waits: 0, dropped: 0, droppedQueued: 0 };
|
|
802
819
|
|
|
803
|
-
|
|
820
|
+
/** 条目彻底发完时回调(B0 慢响应计时用)。观测回调**绝不允许**影响发送本身。 */
|
|
821
|
+
function notifyFlushed(entry) {
|
|
822
|
+
try { entry?.onFlushed?.(); } catch { /* 观测失败绝不影响发送 */ }
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
function enqueueData(text, meta = {}) {
|
|
826
|
+
const id = meta.id ?? null;
|
|
827
|
+
const onFlushed = typeof meta.onFlushed === "function" ? meta.onFlushed : null;
|
|
804
828
|
if (text.length <= chunkSize) {
|
|
805
|
-
dataQ.push({ frames: [text], i: 0 });
|
|
829
|
+
dataQ.push({ frames: [text], i: 0, id, onFlushed });
|
|
806
830
|
return;
|
|
807
831
|
}
|
|
808
832
|
const cid = `c${++chunkSeq}`;
|
|
@@ -812,7 +836,7 @@ export function makePrioritySender({
|
|
|
812
836
|
frames[i] = JSON.stringify({ __chunk: { id: cid, n, i, data: text.slice(i * chunkSize, (i + 1) * chunkSize) } });
|
|
813
837
|
}
|
|
814
838
|
stats.bigFrames += 1;
|
|
815
|
-
dataQ.push({ frames, i: 0 });
|
|
839
|
+
dataQ.push({ frames, i: 0, id, onFlushed });
|
|
816
840
|
}
|
|
817
841
|
|
|
818
842
|
function scheduleNext(waitMs) {
|
|
@@ -837,17 +861,27 @@ export function makePrioritySender({
|
|
|
837
861
|
for (;;) {
|
|
838
862
|
// ① 控制车道永远先走:哪怕大帧正传着 16MB,控制帧也在**下一轮**第一时间出去。
|
|
839
863
|
while (controlQ.length > 0) {
|
|
840
|
-
const
|
|
841
|
-
|
|
864
|
+
const item = controlQ.shift();
|
|
865
|
+
let ok = true;
|
|
866
|
+
try { rawSend(item.text); stats.controlSent += 1; } catch { ok = false; closed = true; }
|
|
867
|
+
if (!ok) break;
|
|
868
|
+
notifyFlushed(item);
|
|
842
869
|
}
|
|
843
870
|
if (closed) { yieldMode = ""; break; }
|
|
871
|
+
// ② 已取消的条目整条丢出队头(B2:手机已放弃这条响应,别再把剩余分片塞进隧道)。
|
|
872
|
+
// 只做"删除",**绝不重排** —— 其余条目的相对顺序原样保留(数据帧严格 FIFO)。
|
|
873
|
+
while (dataQ.length > 0 && dataQ[0].cancelled) {
|
|
874
|
+
dataQ.shift();
|
|
875
|
+
stats.droppedQueued += 1;
|
|
876
|
+
}
|
|
844
877
|
if (dataQ.length === 0) { yieldMode = ""; break; }
|
|
845
878
|
if (budget <= 0) { yieldMode = "schedule"; break; }
|
|
846
879
|
const head = dataQ[0];
|
|
847
880
|
if (head.frames.length === 1) {
|
|
848
881
|
// 未分片小帧:可连着多发几条,但绝不越过队头(保证 FIFO)
|
|
849
882
|
try { rawSend(head.frames[0]); stats.dataSent += 1; } catch { closed = true; break; }
|
|
850
|
-
dataQ.shift();
|
|
883
|
+
const done = dataQ.shift();
|
|
884
|
+
notifyFlushed(done);
|
|
851
885
|
budget -= 1;
|
|
852
886
|
continue; // 回环顶先查控制车道
|
|
853
887
|
}
|
|
@@ -857,7 +891,10 @@ export function makePrioritySender({
|
|
|
857
891
|
if (pending > bulkHighWater) { stats.waits += 1; yieldMode = "poll"; break; }
|
|
858
892
|
try { rawSend(head.frames[head.i]); stats.dataSent += 1; stats.bigChunks += 1; } catch { closed = true; break; }
|
|
859
893
|
head.i += 1;
|
|
860
|
-
if (head.i >= head.frames.length)
|
|
894
|
+
if (head.i >= head.frames.length) {
|
|
895
|
+
const done = dataQ.shift();
|
|
896
|
+
notifyFlushed(done);
|
|
897
|
+
}
|
|
861
898
|
yieldMode = "schedule";
|
|
862
899
|
break;
|
|
863
900
|
}
|
|
@@ -872,16 +909,43 @@ export function makePrioritySender({
|
|
|
872
909
|
}
|
|
873
910
|
}
|
|
874
911
|
|
|
875
|
-
|
|
912
|
+
/**
|
|
913
|
+
* 发送一帧。
|
|
914
|
+
* @param {object} obj 帧对象(协议字节不变)
|
|
915
|
+
* @param {{id?:string|number, onFlushed?:Function}} [opts] **仅本进程内使用**的元信息,
|
|
916
|
+
* 不进协议:`id` 用于取消(B2),`onFlushed` 在真正发完时回调(B0 计时)。
|
|
917
|
+
*/
|
|
918
|
+
function send(obj, opts = {}) {
|
|
876
919
|
let text;
|
|
877
920
|
try { text = JSON.stringify(obj); } catch { return false; }
|
|
878
921
|
if (closed) { stats.dropped += 1; return false; }
|
|
879
|
-
|
|
880
|
-
|
|
922
|
+
const onFlushed = typeof opts?.onFlushed === "function" ? opts.onFlushed : null;
|
|
923
|
+
if (isControlFrame(obj)) controlQ.push({ text, onFlushed });
|
|
924
|
+
else enqueueData(text, { id: opts?.id ?? null, onFlushed });
|
|
881
925
|
pump();
|
|
882
926
|
return true;
|
|
883
927
|
}
|
|
884
928
|
|
|
929
|
+
/**
|
|
930
|
+
* 丢弃某条响应的**未发分片**(2026-11-08,B2:手机已放弃的响应不许再占隧道)。
|
|
931
|
+
*
|
|
932
|
+
* 只做"标记取消 + 随后整条移出队列",**绝不重排** —— 数据帧严格 FIFO 的语义不变
|
|
933
|
+
* (被取消的条目整条消失,其余条目的相对顺序原样保留)。已被 `rawSend` 发出的分片无法回收。
|
|
934
|
+
* @returns {number} 被标记取消的条目数(0 = 该 id 已不在队列里/已经发完)
|
|
935
|
+
*/
|
|
936
|
+
function drop(id) {
|
|
937
|
+
if (id === undefined || id === null) return 0;
|
|
938
|
+
let n = 0;
|
|
939
|
+
for (let i = dataQ.length - 1; i >= 0; i -= 1) {
|
|
940
|
+
if (dataQ[i].id === id) { dataQ[i].cancelled = true; n += 1; }
|
|
941
|
+
}
|
|
942
|
+
if (n > 0) {
|
|
943
|
+
// 队头若正好是它,立刻腾出来(其余条目顺序不变);否则等轮到它时在 pump 顶部整条丢。
|
|
944
|
+
pump();
|
|
945
|
+
}
|
|
946
|
+
return n;
|
|
947
|
+
}
|
|
948
|
+
|
|
885
949
|
/** 隧道断开:丢掉未发内容并停止调度(否则重连后旧分片会发到新连接上)。 */
|
|
886
950
|
function close() {
|
|
887
951
|
closed = true;
|
|
@@ -891,13 +955,22 @@ export function makePrioritySender({
|
|
|
891
955
|
|
|
892
956
|
return {
|
|
893
957
|
send,
|
|
958
|
+
drop,
|
|
894
959
|
close,
|
|
895
|
-
stats: () =>
|
|
896
|
-
|
|
897
|
-
|
|
898
|
-
|
|
899
|
-
|
|
900
|
-
|
|
960
|
+
stats: () => {
|
|
961
|
+
// queuedBytes = 队列里**还没发出去**的字节(含未发分片)—— 排障时这是"队头有多厚"的直接证据。
|
|
962
|
+
let queuedBytes = 0;
|
|
963
|
+
for (const e of dataQ) {
|
|
964
|
+
for (let i = e.i; i < e.frames.length; i += 1) queuedBytes += e.frames[i].length;
|
|
965
|
+
}
|
|
966
|
+
return {
|
|
967
|
+
...stats,
|
|
968
|
+
queued: dataQ.length,
|
|
969
|
+
queuedBytes,
|
|
970
|
+
controlQueued: controlQ.length,
|
|
971
|
+
closed
|
|
972
|
+
};
|
|
973
|
+
},
|
|
901
974
|
hasBacklog: () => dataQ.length > 0 || controlQ.length > 0
|
|
902
975
|
};
|
|
903
976
|
}
|
|
@@ -913,6 +986,23 @@ function toSender(dchOrSend) {
|
|
|
913
986
|
* const recv = makeFrameReceiver();
|
|
914
987
|
* dch.onmessage = (ev) => recv(ev.data, (frame) => handleFrame(dch, frame));
|
|
915
988
|
*/
|
|
989
|
+
/**
|
|
990
|
+
* 隧道入站分发器:**连接级**单实例的 `makeFrameReceiver()`(2026-11-08,P0-1)。
|
|
991
|
+
*
|
|
992
|
+
* 为什么要有这层包装:`__chunk` 的 n 片是 n 条**独立 WS 消息**,攒片状态(bufs)必须跟
|
|
993
|
+
* **连接**同生命周期。旧写法在 `ws.on("message")` 里每条消息都 `new` 一个接收器 ⇒ 状态随
|
|
994
|
+
* 消息一起丢 ⇒ 跨消息分片永远拼不齐,中继发来的大帧(≥`DSH_ROUTER_BIG_YIELD_MB`,含大
|
|
995
|
+
* 请求体)在桥侧被静默丢弃。中继侧一直是连接级(index.mjs 在 connection 回调里建一次),
|
|
996
|
+
* 这里对齐。协议字节不变,只改状态生命周期。
|
|
997
|
+
*
|
|
998
|
+
* 导出给用例行为级锁住这条契约(test/bridge-tunnel-receiver.test.mjs):
|
|
999
|
+
* 一个 dispatcher 喂多条消息 → 能还原一条完整帧。
|
|
1000
|
+
*/
|
|
1001
|
+
export function createTunnelFrameDispatcher(onFrame) {
|
|
1002
|
+
const receive = makeFrameReceiver();
|
|
1003
|
+
return (raw) => receive(raw, onFrame);
|
|
1004
|
+
}
|
|
1005
|
+
|
|
916
1006
|
export function makeFrameReceiver() {
|
|
917
1007
|
const bufs = new Map(); // chunkId → { n, parts: string[] }
|
|
918
1008
|
return function receive(raw, onFrame) {
|
|
@@ -993,6 +1083,17 @@ export async function handleFrame(dchOrSend, frame) {
|
|
|
993
1083
|
streamAborts.delete(id);
|
|
994
1084
|
try { c.abort(); } catch { /* ignore */ }
|
|
995
1085
|
}
|
|
1086
|
+
// B2(2026-11-08):普通 HTTP 响应也取消 —— 手机已走人,别再把整包读完/gzip/灌进隧道
|
|
1087
|
+
// 占住数据队列队头(2026-11-07 事故的放大器)。老中继从不发这帧 ⇒ 对老链路零行为。
|
|
1088
|
+
if (ABORT_DEAD_ENABLED) {
|
|
1089
|
+
const ha = httpAborts.get(id);
|
|
1090
|
+
if (ha) {
|
|
1091
|
+
httpAborts.delete(id);
|
|
1092
|
+
try { ha.abort(); } catch { /* ignore */ }
|
|
1093
|
+
}
|
|
1094
|
+
// 丢掉隧道里还没发的分片:只删这个 id 的条目,**其余条目顺序原样保留**(FIFO 不破)。
|
|
1095
|
+
try { dchOrSend?.drop?.(id); } catch { /* ignore */ }
|
|
1096
|
+
}
|
|
996
1097
|
return;
|
|
997
1098
|
}
|
|
998
1099
|
if (type === "ws-open") return handleWsOpen(send, frame);
|
|
@@ -1183,6 +1284,31 @@ export function downloadDispositionOf({ method = "GET", path = "", status = 200,
|
|
|
1183
1284
|
*/
|
|
1184
1285
|
const streamAborts = new Map();
|
|
1185
1286
|
|
|
1287
|
+
/**
|
|
1288
|
+
* 进行中的**普通** HTTP 请求:frameId → AbortController(B2,2026-11-08)。
|
|
1289
|
+
*
|
|
1290
|
+
* 为什么需要:以前只有 SSE 能被取消。普通大响应(`/plugins/` 聚合包、大文件预览)在手机
|
|
1291
|
+
* 放弃(nginx 499 / 切页 / 锁屏)之后,桥仍然会把整包读完、gzip 完、**继续往隧道里灌**,
|
|
1292
|
+
* 占住数据队列队头把同设备后续请求堵在后面 —— 2026-11-07 事故里 77 次 499 每次都这么放大。
|
|
1293
|
+
* 现在 `http-abort` 同时掐上游 fetch(AbortController)并调用发送器的 `drop(id)` 丢掉未发分片。
|
|
1294
|
+
* 回滚:`DSH_BRIDGE_ABORT_DEAD=0`(旧中继从不发这个帧,所以默认开对老链路也是零行为)。
|
|
1295
|
+
*/
|
|
1296
|
+
const httpAborts = new Map();
|
|
1297
|
+
|
|
1298
|
+
/** `http-abort` 是否也取消普通 HTTP 请求(纯函数,便于用例钉住;默认开,`0`=关)。 */
|
|
1299
|
+
export function abortDeadEnabled(env = process.env) {
|
|
1300
|
+
const raw = env?.DSH_BRIDGE_ABORT_DEAD;
|
|
1301
|
+
if (raw === undefined || raw === null || raw === "") return true;
|
|
1302
|
+
const v = String(raw).trim().toLowerCase();
|
|
1303
|
+
return !["0", "false", "off", "no", "disabled"].includes(v);
|
|
1304
|
+
}
|
|
1305
|
+
const ABORT_DEAD_ENABLED = abortDeadEnabled();
|
|
1306
|
+
|
|
1307
|
+
/** 测试钩子:当前在飞的普通 HTTP 请求数(不改变任何行为)。 */
|
|
1308
|
+
export function __httpInflightForTest() {
|
|
1309
|
+
return httpAborts.size;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1186
1312
|
/**
|
|
1187
1313
|
* 中继在 `tunnel-register-ok` 里声明支持的**追加帧能力**。
|
|
1188
1314
|
*
|
|
@@ -1268,7 +1394,53 @@ async function doHttpStream(send, id, method, path, reqHeaders) {
|
|
|
1268
1394
|
return { status, chunks: seq, bytes };
|
|
1269
1395
|
}
|
|
1270
1396
|
|
|
1271
|
-
|
|
1397
|
+
/** 从环境变量读「慢响应日志」参数(B0;纯函数,便于用例钉住)。 */
|
|
1398
|
+
export function slowRespLogOptions(env = process.env) {
|
|
1399
|
+
const num = (name, dflt) => {
|
|
1400
|
+
const raw = env?.[name];
|
|
1401
|
+
if (raw === undefined || raw === null || raw === "") return dflt;
|
|
1402
|
+
const n = Number(raw);
|
|
1403
|
+
return Number.isFinite(n) && n >= 0 ? n : dflt;
|
|
1404
|
+
};
|
|
1405
|
+
return {
|
|
1406
|
+
thresholdMs: num("DSH_BRIDGE_SLOW_RESP_MS", 2000), // 总耗时 ≥ 该值才记一条
|
|
1407
|
+
windowMs: num("DSH_BRIDGE_SLOW_LOG_MS", 3000) // 全局节流窗口(0 = 每次都记,排障用)
|
|
1408
|
+
};
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/**
|
|
1412
|
+
* 慢响应日志行(B0,2026-11-08)。
|
|
1413
|
+
*
|
|
1414
|
+
* 为什么必须单独一行:线上"卡 20 分钟"的真问题是**首屏资源从 PC 出来只有 3–16KB/s**,
|
|
1415
|
+
* 而原日志只有 "→ 200 (3200ms, 1700KB)" 这种粒度 —— 分不清慢在
|
|
1416
|
+
* ①宿主拼包/首包、②读上游响应体、③gzip、④注入、⑤**排队等隧道**。
|
|
1417
|
+
* 这条行把五段摊开,是判断"流式化/取消/字节削减"谁该先做的直接依据。
|
|
1418
|
+
* 纯函数(便于单测行格式;不碰任何业务语义)。
|
|
1419
|
+
*/
|
|
1420
|
+
export function formatSlowResponse({
|
|
1421
|
+
method = "GET", path = "/", status = 0, totalMs = 0,
|
|
1422
|
+
timings = {}, queueWaitMs = -1, queue = null, outBytes = 0
|
|
1423
|
+
} = {}) {
|
|
1424
|
+
const ms = (n) => (Number.isFinite(Number(n)) && Number(n) >= 0 ? `${Math.round(Number(n))}ms` : "-");
|
|
1425
|
+
const kb = (n) => (Number.isFinite(Number(n)) && Number(n) >= 0 ? `${(Number(n) / 1024).toFixed(0)}KB` : "-");
|
|
1426
|
+
const q = queue && Number.isFinite(Number(queue.queued))
|
|
1427
|
+
? `${Number(queue.queued)}条/${Number(queue.queuedBytes) || 0}B`
|
|
1428
|
+
: "-";
|
|
1429
|
+
return `[bridge] 慢响应: ${String(method || "GET")} ${String(path || "/").split("?")[0]} → ${Number(status) || 0} `
|
|
1430
|
+
+ `总 ${ms(totalMs)} | 上游首包 ${ms(timings.ttfbMs)} 读体 ${ms(timings.readMs)} 注入 ${ms(timings.injectMs)} gzip ${ms(timings.gzipMs)} `
|
|
1431
|
+
+ `| 入队→发完 ${ms(queueWaitMs)} | 队列 ${q} | 下行 ${kb(outBytes)}`;
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
/** 取隧道发送器挂在 send 上的进程内 stats()(DataChannel 路径没有 → null)。 */
|
|
1435
|
+
function senderStatsOf(dchOrSend) {
|
|
1436
|
+
try {
|
|
1437
|
+
return typeof dchOrSend?.stats === "function" ? dchOrSend.stats() : null;
|
|
1438
|
+
} catch {
|
|
1439
|
+
return null;
|
|
1440
|
+
}
|
|
1441
|
+
}
|
|
1442
|
+
|
|
1443
|
+
async function doHttp(method, path, reqHeaders, body, isB64, opts = {}) {
|
|
1272
1444
|
const safe = safePath(path);
|
|
1273
1445
|
if (safe === null) throw new Error("非法路径");
|
|
1274
1446
|
const url = `${UPSTREAM}${safe}`;
|
|
@@ -1280,8 +1452,23 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
1280
1452
|
// 新协议 http 帧的 body 一律 base64;旧协议 body 是原始文本
|
|
1281
1453
|
init.body = isB64 ? Buffer.from(String(body), "base64") : String(body);
|
|
1282
1454
|
}
|
|
1283
|
-
|
|
1455
|
+
// B0 计时:首包 / 读体 / 注入 / gzip 四段分开记(慢响应日志要能一眼看出是谁慢)。
|
|
1456
|
+
// ⚠️ timings 用**不可枚举**属性挂回结果对象:`JSON.stringify(reply)` 不会把它带进协议帧
|
|
1457
|
+
// (隧道帧字节必须与改动前逐字节一致)。
|
|
1458
|
+
// B2:可被中继的 `http-abort` 取消(手机已放弃这条响应)。
|
|
1459
|
+
// 仍然保留 120s 上游超时语义:两个信号任一触发都中止 fetch。
|
|
1460
|
+
const abortSignal = opts?.signal
|
|
1461
|
+
? (typeof AbortSignal.any === "function"
|
|
1462
|
+
? AbortSignal.any([opts.signal, AbortSignal.timeout(HTTP_TIMEOUT_MS)])
|
|
1463
|
+
: opts.signal) // 极老 Node 没有 AbortSignal.any:退化成"只受取消控制"(超时由中继兜底)
|
|
1464
|
+
: AbortSignal.timeout(HTTP_TIMEOUT_MS);
|
|
1465
|
+
const timings = { ttfbMs: -1, readMs: -1, injectMs: -1, gzipMs: -1 };
|
|
1466
|
+
let tMark = Date.now();
|
|
1467
|
+
let res = await fetch(url, { ...init, signal: abortSignal });
|
|
1468
|
+
timings.ttfbMs = Date.now() - tMark;
|
|
1469
|
+
tMark = Date.now();
|
|
1284
1470
|
let buf = Buffer.from(await res.arrayBuffer());
|
|
1471
|
+
timings.readMs = Date.now() - tMark;
|
|
1285
1472
|
// 手机端 401「dsh web authentication required」自愈:
|
|
1286
1473
|
// dsh web 每次重启都会换签名密钥 → 插件代持的旧 Cookie 立即失效。撞到该 401 时
|
|
1287
1474
|
// ① 写「作废」标记(插件在面板轮询时秒级重换 Cookie);
|
|
@@ -1296,8 +1483,12 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
1296
1483
|
console.log("[bridge] 浏览器会话 Cookie 已失效,用新 Cookie 重试一次:", path);
|
|
1297
1484
|
const retryHdrs = sanitizeRequestHeaders(reqHeaders);
|
|
1298
1485
|
retryHdrs.Cookie = fresh;
|
|
1299
|
-
|
|
1486
|
+
tMark = Date.now();
|
|
1487
|
+
res = await fetch(url, { ...init, headers: retryHdrs, signal: abortSignal });
|
|
1488
|
+
timings.ttfbMs = Date.now() - tMark;
|
|
1489
|
+
tMark = Date.now();
|
|
1300
1490
|
buf = Buffer.from(await res.arrayBuffer());
|
|
1491
|
+
timings.readMs = Date.now() - tMark;
|
|
1301
1492
|
} else {
|
|
1302
1493
|
console.warn("[bridge] 浏览器会话 Cookie 已失效(已标记,等待插件重换):", path);
|
|
1303
1494
|
}
|
|
@@ -1317,6 +1508,7 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
1317
1508
|
if (disposition) headers["content-disposition"] = disposition;
|
|
1318
1509
|
}
|
|
1319
1510
|
if (!contentType.includes("text/event-stream")) { // SSE 长连不缓冲不注入(见模块注释)
|
|
1511
|
+
tMark = Date.now();
|
|
1320
1512
|
const m = maybeInjectMobileAdapter({ buf, contentType });
|
|
1321
1513
|
if (m.injected) {
|
|
1322
1514
|
console.log(`[bridge] mobile-adapter 注入 ${path}: ${(buf.length / 1024).toFixed(0)}KB → ${(m.buf.length / 1024).toFixed(0)}KB`);
|
|
@@ -1340,7 +1532,9 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
1340
1532
|
buf = s.buf;
|
|
1341
1533
|
}
|
|
1342
1534
|
}
|
|
1535
|
+
timings.injectMs = Date.now() - tMark;
|
|
1343
1536
|
}
|
|
1537
|
+
tMark = Date.now();
|
|
1344
1538
|
const compressed = await maybeCompressResponse({
|
|
1345
1539
|
buf,
|
|
1346
1540
|
contentType,
|
|
@@ -1361,12 +1555,17 @@ async function doHttp(method, path, reqHeaders, body, isB64) {
|
|
|
1361
1555
|
buf = compressed.buf;
|
|
1362
1556
|
Object.assign(headers, compressed.headers);
|
|
1363
1557
|
}
|
|
1364
|
-
|
|
1558
|
+
timings.gzipMs = Date.now() - tMark;
|
|
1559
|
+
const reply = {
|
|
1365
1560
|
status: res.status,
|
|
1366
1561
|
headers,
|
|
1367
1562
|
body: buf.toString("base64"),
|
|
1368
1563
|
bodyBase64: true
|
|
1369
1564
|
};
|
|
1565
|
+
// ⚠️ 不可枚举:`JSON.stringify(reply)`(= 真正发出去的隧道帧)不会带上 timings,
|
|
1566
|
+
// 协议字节与改动前逐字节一致;只有本进程的慢响应日志/用例看得到它。
|
|
1567
|
+
Object.defineProperty(reply, "timings", { value: timings, enumerable: false });
|
|
1568
|
+
return reply;
|
|
1370
1569
|
}
|
|
1371
1570
|
|
|
1372
1571
|
/**
|
|
@@ -1437,6 +1636,10 @@ export async function handleHttpFrame(dchOrSend, frame) {
|
|
|
1437
1636
|
if (method === "POST" && path === "/_e2ee/intro") {
|
|
1438
1637
|
return answerIntro(send, id);
|
|
1439
1638
|
}
|
|
1639
|
+
// B2(2026-11-08):每个上游请求一个可取消信号。中继在手机放弃(499/切页/锁屏)时会发
|
|
1640
|
+
// `http-abort`;以前只有 SSE 能被取消,普通大响应会在手机走人后继续读完/gzip/灌隧道。
|
|
1641
|
+
const httpAc = new AbortController();
|
|
1642
|
+
httpAborts.set(id, httpAc);
|
|
1440
1643
|
// E2EE 信封标记即信号(§4.3):有标记=加密,无标记=明文 v1。
|
|
1441
1644
|
// 解封失败绝不静默降级 → 明文错误 + x-dsh-e2ee-error 头 + 日志。
|
|
1442
1645
|
if (hasEnvelopeMarker(headers)) {
|
|
@@ -1462,7 +1665,7 @@ export async function handleHttpFrame(dchOrSend, frame) {
|
|
|
1462
1665
|
// (哪怕客户端给了个我们看不懂的值,也不能原样漏给 dsh web)。
|
|
1463
1666
|
const wantBinPlain = wantsBinaryResponsePlain(req.headers);
|
|
1464
1667
|
const upstreamHeaders = stripPlainWantHeader(req.headers);
|
|
1465
|
-
const reply = await doHttp(req.method, req.path, upstreamHeaders, req.bodyB64, true);
|
|
1668
|
+
const reply = await doHttp(req.method, req.path, upstreamHeaders, req.bodyB64, true, { signal: httpAc.signal });
|
|
1466
1669
|
const bodyBuffer = Buffer.from(reply.body, "base64");
|
|
1467
1670
|
const plain = encodeHttpResponsePlain({ status: reply.status, headers: reply.headers, bodyBuffer, binary: wantBinPlain });
|
|
1468
1671
|
// 响应压缩已发生在 doHttp(明文侧,gzip 在加密前 §4.7);信封用 http-resp kind
|
|
@@ -1472,8 +1675,15 @@ export async function handleHttpFrame(dchOrSend, frame) {
|
|
|
1472
1675
|
console.log(`[bridge] e2ee http ${req.method} ${req.path} → ${reply.status} (${Date.now() - t0}ms, 信封 ${(reply.body.length * 3 / 4 / 1024).toFixed(0)}KB)`);
|
|
1473
1676
|
return;
|
|
1474
1677
|
} catch (e) {
|
|
1678
|
+
if (httpAc.signal.aborted) {
|
|
1679
|
+
// 手机已放弃:不是上游故障,不要打"解封失败",也不要回错误帧(中继侧 pending 已删)。
|
|
1680
|
+
console.log(`[bridge] e2ee http ${method} ${path} 已取消(手机断开)`);
|
|
1681
|
+
return;
|
|
1682
|
+
}
|
|
1475
1683
|
console.error(`[bridge] e2ee http ${method} ${path} 解封失败: ${e.message || e}(不回退明文)`);
|
|
1476
1684
|
return sendE2eeHttpError(send, id, e);
|
|
1685
|
+
} finally {
|
|
1686
|
+
httpAborts.delete(id);
|
|
1477
1687
|
}
|
|
1478
1688
|
}
|
|
1479
1689
|
try {
|
|
@@ -1484,12 +1694,32 @@ export async function handleHttpFrame(dchOrSend, frame) {
|
|
|
1484
1694
|
console.log(`[bridge] ${method} ${path} → SSE ${s.status} (${Date.now() - t0}ms, ${s.chunks} 块 / ${(s.bytes / 1024).toFixed(1)}KB${s.aborted ? ", 手机已断开" : ""})`);
|
|
1485
1695
|
return;
|
|
1486
1696
|
}
|
|
1487
|
-
const reply = await doHttp(method, path, headers, body, !!isB64);
|
|
1697
|
+
const reply = await doHttp(method, path, headers, body, !!isB64, { signal: httpAc.signal });
|
|
1488
1698
|
reply.id = id;
|
|
1489
1699
|
reply.type = "http";
|
|
1490
|
-
const sent = send(reply);
|
|
1491
1700
|
const ms = Date.now() - t0;
|
|
1492
1701
|
const outBytes = Math.floor((reply.body.length * 3) / 4);
|
|
1702
|
+
// B0:入队前先取一次队头厚度("排队等隧道"是 3–16KB/s 现象里最需要被看见的一段)。
|
|
1703
|
+
const queueAtEnqueue = senderStatsOf(dchOrSend);
|
|
1704
|
+
const tEnqueue = Date.now();
|
|
1705
|
+
let queueWaitMs = -1;
|
|
1706
|
+
const sent = send(reply, {
|
|
1707
|
+
id, // B2:让发送器能按 id 取消这条响应的未发分片
|
|
1708
|
+
onFlushed: () => {
|
|
1709
|
+
// 只有真正发完才记"入队→发完";中途被取消/断线不会走到这里(不会假装成功)。
|
|
1710
|
+
queueWaitMs = Date.now() - tEnqueue;
|
|
1711
|
+
const totalMs = Date.now() - t0;
|
|
1712
|
+
if (totalMs < SLOW_RESP.thresholdMs) return;
|
|
1713
|
+
if (!slowRespLogAllow()) return;
|
|
1714
|
+
console.log(formatSlowResponse({
|
|
1715
|
+
method, path, status: reply.status, totalMs,
|
|
1716
|
+
timings: reply.timings || {},
|
|
1717
|
+
queueWaitMs,
|
|
1718
|
+
queue: queueAtEnqueue,
|
|
1719
|
+
outBytes
|
|
1720
|
+
}));
|
|
1721
|
+
}
|
|
1722
|
+
});
|
|
1493
1723
|
if (isLongRequest) {
|
|
1494
1724
|
// 长请求(大文件上传)单独记:上行字节、总耗时、状态码 —— 客服排查"上传失败"最需要这三样。
|
|
1495
1725
|
console.log(`[bridge] ${method} ${path} → ${reply.status} (${ms}ms, 上行 ${(bytesIn / 1024 / 1024).toFixed(2)}MB, 下行 ${(outBytes / 1024).toFixed(0)}KB)`);
|
|
@@ -1498,6 +1728,12 @@ export async function handleHttpFrame(dchOrSend, frame) {
|
|
|
1498
1728
|
}
|
|
1499
1729
|
reportUndelivered(sent, { method, path, phase: "reply", ms, bytesIn, outBytes });
|
|
1500
1730
|
} catch (e) {
|
|
1731
|
+
// B2:被中继取消(手机已放弃)不是上游故障 —— 不要触发"重新发现上游",也不要回 502 帧
|
|
1732
|
+
// (中继侧 pending 已删,回了也没人收)。只留一行证据。
|
|
1733
|
+
if (httpAc.signal.aborted) {
|
|
1734
|
+
console.log(`[bridge] ${method} ${path} 已取消(手机断开): ${Date.now() - t0}ms, 上行 ${bytesIn}B`);
|
|
1735
|
+
return;
|
|
1736
|
+
}
|
|
1501
1737
|
// 上游不可达是「端口可能变了」的最强信号(Desktop 换端口 / dsh web 重启到别的端口):
|
|
1502
1738
|
// 触发一次带冷却的重新发现,后续请求自动跟上,不必重启 bridge。
|
|
1503
1739
|
void refreshUpstream("上游请求失败");
|
|
@@ -1533,6 +1769,8 @@ export async function handleHttpFrame(dchOrSend, frame) {
|
|
|
1533
1769
|
bodyBase64: true
|
|
1534
1770
|
});
|
|
1535
1771
|
reportUndelivered(sent, { method, path, phase: "502", ms, bytesIn, outBytes: 0 });
|
|
1772
|
+
} finally {
|
|
1773
|
+
httpAborts.delete(id); // B2:无论成功/失败/取消,都别把 controller 留在表里
|
|
1536
1774
|
}
|
|
1537
1775
|
}
|
|
1538
1776
|
|
|
@@ -2536,7 +2774,12 @@ function connectTunnel(token) {
|
|
|
2536
2774
|
bufferedAmount: () => ws.bufferedAmount,
|
|
2537
2775
|
...tunnelBulkOptionsFromEnv()
|
|
2538
2776
|
});
|
|
2539
|
-
|
|
2777
|
+
// 把两个**进程内**能力挂到 send 函数上(不是协议字段):
|
|
2778
|
+
// · stats() —— B0 慢响应日志/排障要报"队头有多厚"(queued/queuedBytes);
|
|
2779
|
+
// · drop(id) —— B2 取消:手机已放弃的响应,剩余分片不许再占隧道。
|
|
2780
|
+
// 用函数属性而不是改 handleXxx 签名,是为了不影响 DataChannel 那条调用路径
|
|
2781
|
+
// (`toSender` 对函数输入原样返回,属性自然带得过去)。
|
|
2782
|
+
const send = Object.assign(sender.send, { stats: sender.stats, drop: sender.drop });
|
|
2540
2783
|
let heartbeat;
|
|
2541
2784
|
let pongReceived = true;
|
|
2542
2785
|
let missedPongs = 0;
|
|
@@ -2594,33 +2837,36 @@ function connectTunnel(token) {
|
|
|
2594
2837
|
rttEma = rttEma === null ? rtt : Math.round(rttEma * 0.7 + rtt * 0.3);
|
|
2595
2838
|
}
|
|
2596
2839
|
});
|
|
2840
|
+
// 🔴 2026-11-08(P0-1):接收器是**连接级单实例**(见 createTunnelFrameDispatcher 注释)。
|
|
2841
|
+
// 以前这条 handler 里每收到一条消息就 new 一个 makeFrameReceiver() ⇒ 跨消息 `__chunk`
|
|
2842
|
+
// 永远拼不齐 ⇒ 中继方向的大帧(大请求体等)被静默丢弃。
|
|
2843
|
+
const onTunnelMessage = createTunnelFrameDispatcher((frame) => {
|
|
2844
|
+
if (frame?.type === "tunnel-register-ok") {
|
|
2845
|
+
// 记下中继声明的追加帧能力(旧中继不带 caps → 空集合 → 退回整包缓冲,见 routerCaps 注释)
|
|
2846
|
+
routerCaps.clear();
|
|
2847
|
+
for (const c of Array.isArray(frame.caps) ? frame.caps : []) routerCaps.add(String(c));
|
|
2848
|
+
// 中继注册成功 = 手机端此刻真的能进这台电脑:面板据此把阶段推进到 online(已连接 ✅)
|
|
2849
|
+
persistBridgeState({ device_id: DEVICE_ID, tunnel_registered_at: Date.now(), phase: "online", last_error: null });
|
|
2850
|
+
console.log(`[bridge] ✅ router 注册成功: ${DEVICE_ID},等待手机访问 /remote/${DEVICE_ID}/`);
|
|
2851
|
+
return;
|
|
2852
|
+
}
|
|
2853
|
+
if (frame?.type === "tunnel-register-err") {
|
|
2854
|
+
if (frame.code === "device_locked") {
|
|
2855
|
+
// 契约 §6.1:服务端按套餐额度判定本机不可用。**不退出进程**,关掉这条隧道后进入
|
|
2856
|
+
// 解锁轮询(30s 一次 GET /api/devices);期间不重连、不发心跳(close 处理器见等待态直接 return)。
|
|
2857
|
+
enterLockedWait(token, { message: frame.message, plan: frame.plan, limit: frame.limit, upgrade_to: frame.upgrade_to });
|
|
2858
|
+
try { ws.close(4009, "device locked"); } catch { /* 对端可能已经关了 */ }
|
|
2859
|
+
return;
|
|
2860
|
+
}
|
|
2861
|
+
persistBridgeState({ phase: "error", last_error: { code: frame.code || "register_err", message: frame.message || "router 拒绝注册" } });
|
|
2862
|
+
console.error(`[bridge] router 拒绝注册: ${frame.code} ${frame.message || ""}`);
|
|
2863
|
+
return;
|
|
2864
|
+
}
|
|
2865
|
+
handleFrame(send, frame);
|
|
2866
|
+
});
|
|
2597
2867
|
ws.on("message", (raw) => {
|
|
2598
|
-
const receive = makeFrameReceiver();
|
|
2599
2868
|
try {
|
|
2600
|
-
|
|
2601
|
-
if (frame?.type === "tunnel-register-ok") {
|
|
2602
|
-
// 记下中继声明的追加帧能力(旧中继不带 caps → 空集合 → 退回整包缓冲,见 routerCaps 注释)
|
|
2603
|
-
routerCaps.clear();
|
|
2604
|
-
for (const c of Array.isArray(frame.caps) ? frame.caps : []) routerCaps.add(String(c));
|
|
2605
|
-
// 中继注册成功 = 手机端此刻真的能进这台电脑:面板据此把阶段推进到 online(已连接 ✅)
|
|
2606
|
-
persistBridgeState({ device_id: DEVICE_ID, tunnel_registered_at: Date.now(), phase: "online", last_error: null });
|
|
2607
|
-
console.log(`[bridge] ✅ router 注册成功: ${DEVICE_ID},等待手机访问 /remote/${DEVICE_ID}/`);
|
|
2608
|
-
return;
|
|
2609
|
-
}
|
|
2610
|
-
if (frame?.type === "tunnel-register-err") {
|
|
2611
|
-
if (frame.code === "device_locked") {
|
|
2612
|
-
// 契约 §6.1:服务端按套餐额度判定本机不可用。**不退出进程**,关掉这条隧道后进入
|
|
2613
|
-
// 解锁轮询(30s 一次 GET /api/devices);期间不重连、不发心跳(close 处理器见等待态直接 return)。
|
|
2614
|
-
enterLockedWait(token, { message: frame.message, plan: frame.plan, limit: frame.limit, upgrade_to: frame.upgrade_to });
|
|
2615
|
-
try { ws.close(4009, "device locked"); } catch { /* 对端可能已经关了 */ }
|
|
2616
|
-
return;
|
|
2617
|
-
}
|
|
2618
|
-
persistBridgeState({ phase: "error", last_error: { code: frame.code || "register_err", message: frame.message || "router 拒绝注册" } });
|
|
2619
|
-
console.error(`[bridge] router 拒绝注册: ${frame.code} ${frame.message || ""}`);
|
|
2620
|
-
return;
|
|
2621
|
-
}
|
|
2622
|
-
handleFrame(send, frame);
|
|
2623
|
-
});
|
|
2869
|
+
onTunnelMessage(raw);
|
|
2624
2870
|
} catch (e) { console.log(`[bridge] 隧道帧错误: ${e.message}`); }
|
|
2625
2871
|
});
|
|
2626
2872
|
ws.on("close", (code, reason) => {
|
|
@@ -3097,46 +3343,118 @@ async function stopWeChat() {
|
|
|
3097
3343
|
* 配置热更新由 controller 自己 fs.watchFile(2s) 完成(改凭据/停用都不需要重启 bridge);
|
|
3098
3344
|
* 任何异常都只记日志 —— 绝不影响远程访问主链路。
|
|
3099
3345
|
*/
|
|
3100
|
-
let imBots = null;
|
|
3346
|
+
let imBots = null; // 企业微信控制器
|
|
3347
|
+
let imBotsDingtalk = null; // 钉钉控制器(两路独立:任一失败不影响另一个)
|
|
3348
|
+
let imBotsFeishu = null; // 飞书控制器(同理;SDK 未下载时只写 sdk_missing 状态)
|
|
3349
|
+
|
|
3350
|
+
/** IM 机器人日志(两路共用同一前缀规则)。 */
|
|
3351
|
+
function imBotLog(level, message) {
|
|
3352
|
+
try {
|
|
3353
|
+
if (level === "warn" || level === "error") console.warn(message);
|
|
3354
|
+
else console.log(message);
|
|
3355
|
+
} catch { /* 忽略 */ }
|
|
3356
|
+
}
|
|
3101
3357
|
|
|
3102
3358
|
function startImBots() {
|
|
3359
|
+
// ① 企业微信(现有)
|
|
3103
3360
|
try {
|
|
3104
|
-
if (imBots)
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
3114
|
-
|
|
3115
|
-
|
|
3116
|
-
|
|
3117
|
-
|
|
3118
|
-
|
|
3119
|
-
|
|
3120
|
-
|
|
3121
|
-
|
|
3122
|
-
|
|
3123
|
-
|
|
3124
|
-
|
|
3361
|
+
if (!imBots) {
|
|
3362
|
+
imBots = createWecomBotController({
|
|
3363
|
+
relayDir: RELAY_DIR,
|
|
3364
|
+
// 订阅器要连的本机 dsh web:传取值器,宿主换端口时下一次重连能跟上
|
|
3365
|
+
botOptions: {
|
|
3366
|
+
upstream: () => currentUpstreamForChannel(),
|
|
3367
|
+
cookie: harnessCookieOf,
|
|
3368
|
+
// 会员判定(2026-11-07 业主拍板:通知免费 / 审批免费 / 互动半阉割):
|
|
3369
|
+
// 直接复用微信通道那份档位解析(/api/me → 服务端权益包,含 caps/limits/rev),
|
|
3370
|
+
// 拿不到就由 wecom-bot 按 free fail-closed 处理。
|
|
3371
|
+
capsProvider: resolveWechatTier,
|
|
3372
|
+
// 免费用户越界时的转化入口(与微信通道同一个 App 地址;wecom-bot 拼 /app/promo)
|
|
3373
|
+
appUrl: wechatAppUrl()
|
|
3374
|
+
},
|
|
3375
|
+
// 企微审批卡片点按钮 → 与手机拍板同一条路(协调器 → DSH 回执);失败返回 {ok:false} 让卡片显示"没送达"
|
|
3376
|
+
onApprovalDecision: (requestId, decision) => applyApprovalDecision(requestId, decision),
|
|
3377
|
+
log: imBotLog
|
|
3378
|
+
});
|
|
3379
|
+
// 不 await:配置读取/连接都是可选增强,失败绝不影响隧道
|
|
3380
|
+
void imBots.start().catch((e) => {
|
|
3381
|
+
console.warn(`[bridge] 企业微信机器人通道启动失败(不影响远程访问): ${redactText(e)}`);
|
|
3382
|
+
});
|
|
3383
|
+
}
|
|
3125
3384
|
} catch (e) {
|
|
3126
3385
|
console.warn(`[bridge] 企业微信机器人通道初始化失败(不影响远程访问): ${redactText(e)}`);
|
|
3127
3386
|
imBots = null;
|
|
3128
3387
|
}
|
|
3388
|
+
// ② 钉钉 Stream(2026-11-08):默认关闭;与企微共用 im-bots.json / im-bot-status.json,
|
|
3389
|
+
// 配置各自的段(dingtalk)。任一失败不影响企微通道与远程访问主链路。
|
|
3390
|
+
try {
|
|
3391
|
+
if (!imBotsDingtalk) {
|
|
3392
|
+
imBotsDingtalk = createDingtalkBotController({
|
|
3393
|
+
relayDir: RELAY_DIR,
|
|
3394
|
+
capsProvider: resolveWechatTier,
|
|
3395
|
+
appUrl: wechatAppUrl(),
|
|
3396
|
+
log: imBotLog,
|
|
3397
|
+
botOptions: {
|
|
3398
|
+
upstream: () => currentUpstreamForChannel(),
|
|
3399
|
+
cookie: harnessCookieOf
|
|
3400
|
+
}
|
|
3401
|
+
});
|
|
3402
|
+
void imBotsDingtalk.start().catch((e) => {
|
|
3403
|
+
console.warn(`[bridge] 钉钉机器人通道启动失败(不影响远程访问): ${redactText(e)}`);
|
|
3404
|
+
});
|
|
3405
|
+
}
|
|
3406
|
+
} catch (e) {
|
|
3407
|
+
console.warn(`[bridge] 钉钉机器人通道初始化失败(不影响远程访问): ${redactText(e)}`);
|
|
3408
|
+
imBotsDingtalk = null;
|
|
3409
|
+
}
|
|
3410
|
+
// ③ 飞书(2026-11-08):默认关闭;官方 SDK 按需下载(不随包分发),未下载时控制器只写
|
|
3411
|
+
// status=sdk_missing(面板据此显示「下载依赖」),不建连、不崩、不影响其它通道。
|
|
3412
|
+
try {
|
|
3413
|
+
if (!imBotsFeishu) {
|
|
3414
|
+
imBotsFeishu = createFeishuBotController({
|
|
3415
|
+
relayDir: RELAY_DIR,
|
|
3416
|
+
capsProvider: resolveWechatTier,
|
|
3417
|
+
appUrl: wechatAppUrl(),
|
|
3418
|
+
log: imBotLog,
|
|
3419
|
+
// 飞书卡片点按钮 → 与企微/手机拍板同一条路(协调器 → DSH 回执)
|
|
3420
|
+
onApprovalDecision: (requestId, decision) => applyApprovalDecision(requestId, decision),
|
|
3421
|
+
botOptions: {
|
|
3422
|
+
upstream: () => currentUpstreamForChannel(),
|
|
3423
|
+
cookie: harnessCookieOf
|
|
3424
|
+
}
|
|
3425
|
+
});
|
|
3426
|
+
void imBotsFeishu.start().catch((e) => {
|
|
3427
|
+
console.warn(`[bridge] 飞书机器人通道启动失败(不影响远程访问): ${redactText(e)}`);
|
|
3428
|
+
});
|
|
3429
|
+
}
|
|
3430
|
+
} catch (e) {
|
|
3431
|
+
console.warn(`[bridge] 飞书机器人通道初始化失败(不影响远程访问): ${redactText(e)}`);
|
|
3432
|
+
imBotsFeishu = null;
|
|
3433
|
+
}
|
|
3129
3434
|
}
|
|
3130
3435
|
|
|
3131
|
-
/**
|
|
3436
|
+
/** 优雅退出 IM 机器人通道(企微 + 钉钉,互不影响)。失败只记日志。 */
|
|
3132
3437
|
async function stopImBots() {
|
|
3133
|
-
const
|
|
3438
|
+
const wecom = imBots;
|
|
3439
|
+
const dingtalk = imBotsDingtalk;
|
|
3440
|
+
const feishu = imBotsFeishu;
|
|
3134
3441
|
imBots = null;
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3442
|
+
imBotsDingtalk = null;
|
|
3443
|
+
imBotsFeishu = null;
|
|
3444
|
+
if (wecom) {
|
|
3445
|
+
try { await wecom.stop(); } catch (e) {
|
|
3446
|
+
console.warn(`[bridge] 企业微信机器人通道停止失败(忽略): ${redactText(e)}`);
|
|
3447
|
+
}
|
|
3448
|
+
}
|
|
3449
|
+
if (dingtalk) {
|
|
3450
|
+
try { await dingtalk.stop(); } catch (e) {
|
|
3451
|
+
console.warn(`[bridge] 钉钉机器人通道停止失败(忽略): ${redactText(e)}`);
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
if (feishu) {
|
|
3455
|
+
try { await feishu.stop(); } catch (e) {
|
|
3456
|
+
console.warn(`[bridge] 飞书机器人通道停止失败(忽略): ${redactText(e)}`);
|
|
3457
|
+
}
|
|
3140
3458
|
}
|
|
3141
3459
|
}
|
|
3142
3460
|
|