dsh-remote-web 0.7.11-beta.3 → 0.7.12-beta.1
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 +371 -111
- package/lib/index.js +120 -10
- package/package.json +1 -1
- package/runtime/clients/dsh-remote/dsh-bridge.mjs +7 -1
- package/runtime/clients/dsh-remote/im-format.mjs +372 -0
- 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 +77 -6
package/lib/index.js
CHANGED
|
@@ -210,6 +210,38 @@ async function discoverUpstreamSelf(relayDir, { force = false } = {}) {
|
|
|
210
210
|
return upstreamDiscoveryInFlight;
|
|
211
211
|
}
|
|
212
212
|
|
|
213
|
+
/**
|
|
214
|
+
* 上游地址是否已经"够用"(不需要在**响应路径里现扫**)。
|
|
215
|
+
* 【B1 · 2026-11-07】登录/首屏慢的主因之一就是在响应路径里 await 端口发现。
|
|
216
|
+
* 只要下面任一成立就够用:显式 env / 已知监听端口 / TTL 内的发现结果 / 测试隔离 / 平台模拟。
|
|
217
|
+
*/
|
|
218
|
+
function upstreamDiscoverySettled() {
|
|
219
|
+
// 测试钩子**最优先**(true=强制"未 settled",false=强制"已 settled";null=不干预):
|
|
220
|
+
// DSH 宿主里跑测试时环境常带 DSH_WEB_URL(会被 boundWebPort 认成"已知端口"),
|
|
221
|
+
// 不放在最前面就没法模拟"端口未知、需要发现"的现场。
|
|
222
|
+
if (upstreamForceUnsettled === true) return false;
|
|
223
|
+
if (upstreamForceUnsettled === false) return true;
|
|
224
|
+
if (String(process.env.DSH_BRIDGE_UPSTREAM || "").trim()) return true;
|
|
225
|
+
if (boundWebPort()) return true;
|
|
226
|
+
if (discoveredUpstream.url && Date.now() - discoveredUpstream.at < UPSTREAM_DISCOVERY_TTL_MS) return true;
|
|
227
|
+
if (skipsSystemOps()) return true;
|
|
228
|
+
if (osPlatform() !== process.platform) return true;
|
|
229
|
+
return false;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** 测试钩子:替换发现实现 / 强制"未 settled"(只给用例用,生产永远走真实实现)。 */
|
|
233
|
+
let discoverImpl = discoverUpstreamSelf;
|
|
234
|
+
let upstreamForceUnsettled = null;
|
|
235
|
+
function setUpstreamTestState(opts = {}) {
|
|
236
|
+
if (typeof opts.discover === "function") discoverImpl = opts.discover;
|
|
237
|
+
if (typeof opts.unsettled === "boolean") upstreamForceUnsettled = opts.unsettled;
|
|
238
|
+
return { discover: discoverImpl, unsettled: upstreamForceUnsettled };
|
|
239
|
+
}
|
|
240
|
+
function resetUpstreamTestState() {
|
|
241
|
+
discoverImpl = discoverUpstreamSelf;
|
|
242
|
+
upstreamForceUnsettled = null;
|
|
243
|
+
}
|
|
244
|
+
|
|
213
245
|
/**
|
|
214
246
|
* 把上游地址落盘(`<relayDir>/.dsh-upstream`),供**没有环境变量**的那些守护读取:
|
|
215
247
|
* 开机自启的 watcher(Windows 登录任务 / launchd)由系统拉起,拿不到本进程的 env,
|
|
@@ -1461,7 +1493,7 @@ function writeLinuxUnit(relayDir) {
|
|
|
1461
1493
|
if (!unitPath) return null;
|
|
1462
1494
|
const setup = join(relayDir, "dsh-setup.mjs");
|
|
1463
1495
|
const unit = `[Unit]
|
|
1464
|
-
Description=dsh-remote bridge (auto-starts with dsh web)
|
|
1496
|
+
Description=dsh-remote-web bridge (auto-starts with dsh web)
|
|
1465
1497
|
|
|
1466
1498
|
[Service]
|
|
1467
1499
|
ExecStart=${NODE_BIN} ${setup} run
|
|
@@ -1648,6 +1680,31 @@ function launchTarget() {
|
|
|
1648
1680
|
*/
|
|
1649
1681
|
const NO_LAUNCHD = { running: false, pid: null, state: "", runs: null, lastExitCode: null, crashing: false };
|
|
1650
1682
|
|
|
1683
|
+
// ── B1:shell 探测短 TTL 缓存(2026-11-07 登录慢优化)─────────────────────────────
|
|
1684
|
+
// launchctl / ps / systemctl 每次调用都是一次进程 spawn(实测 30~120ms,慢机器更久),
|
|
1685
|
+
// 而状态轮询 2.5s 一次会反复重跑同一份"没变"的事实。这里给 10s TTL:
|
|
1686
|
+
// · 结论按 key 缓存;**变更动作(启动/停止/卸载/账号切换)会主动清缓存**,不会让面板显示过期状态。
|
|
1687
|
+
const STATUS_PROBE_TTL_MS = 10 * 1000;
|
|
1688
|
+
const statusProbeCache = new Map();
|
|
1689
|
+
function memoStatusProbe(key, fn) {
|
|
1690
|
+
// 测试/诊断隔离下**不缓存**:这类环境里 shell 探测是假命令/假结果(skipsSystemOps 或 DSH_TEST_*
|
|
1691
|
+
// 注入的 fake PATH),缓存只会让用例互相污染(一个用例伪造的 pid/服务状态被下一个用例读到 → 假红)。
|
|
1692
|
+
// 生产路径(真实 launchctl/ps)才走 10s TTL。
|
|
1693
|
+
if (skipsSystemOps()) return fn();
|
|
1694
|
+
try {
|
|
1695
|
+
if (Object.keys(process.env).some((k) => k.startsWith("DSH_TEST_"))) return fn();
|
|
1696
|
+
} catch { /* 忽略 */ }
|
|
1697
|
+
const now = Date.now();
|
|
1698
|
+
const hit = statusProbeCache.get(key);
|
|
1699
|
+
if (hit && now - hit.at < STATUS_PROBE_TTL_MS) return hit.value;
|
|
1700
|
+
const value = fn();
|
|
1701
|
+
statusProbeCache.set(key, { at: now, value });
|
|
1702
|
+
return value;
|
|
1703
|
+
}
|
|
1704
|
+
function clearStatusProbeCache() {
|
|
1705
|
+
try { statusProbeCache.clear(); } catch { /* 忽略 */ }
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1651
1708
|
function launchdStatus() {
|
|
1652
1709
|
// 非 macOS 没有 launchctl(Windows 上连 process.getuid 都没有):直接返回「无此服务」,
|
|
1653
1710
|
// 不再去 shell 里调一个不存在的命令——这既避免 500,也避免每次轮询白起一个 cmd.exe。
|
|
@@ -2655,6 +2712,7 @@ function stopBridgeDetached(relayDir, opts = {}) {
|
|
|
2655
2712
|
|
|
2656
2713
|
/** 启动 bridge:确保运行环境就绪 + plist 存在 → launchctl bootstrap(回退 load -w)。 */
|
|
2657
2714
|
function startBridge(relayDir) {
|
|
2715
|
+
clearStatusProbeCache(); // B1:状态变更 → 立刻作废 shell 探测缓存,避免显示过期状态
|
|
2658
2716
|
if (UNINSTALLED_DIRS.has(relayDir)) {
|
|
2659
2717
|
// 已彻底卸载:面板/自愈在重启前可能仍在内存中,禁止再把自启动与 plist 拉回来
|
|
2660
2718
|
return { ok: false, status: "uninstalled", detail: "插件已彻底卸载,重启 dsh web 后生效" };
|
|
@@ -2872,6 +2930,7 @@ const lastSelfhealReadyLine = new Map();
|
|
|
2872
2930
|
|
|
2873
2931
|
/** 停止 bridge:macOS 走 launchctl bootout;Linux 走 systemd/脱离进程;Windows 结束 watcher/bridge 进程。 */
|
|
2874
2932
|
function stopBridge(relayDir) {
|
|
2933
|
+
clearStatusProbeCache(); // B1:状态变更 → 立刻作废 shell 探测缓存,避免显示过期状态
|
|
2875
2934
|
if (isWindows()) return stopBridgeDetached(relayDir);
|
|
2876
2935
|
// 测试隔离:置位时绝不对真实 launchd/systemd 下手(切换账号也会走到这里,
|
|
2877
2936
|
// 少了这一条,用例就会去 bootout 开发者本机上真实运行的 bridge —— 实测过的污染风险)。
|
|
@@ -4008,8 +4067,9 @@ function maskPhone(p) {
|
|
|
4008
4067
|
|
|
4009
4068
|
async function composeStatus(relayDir) {
|
|
4010
4069
|
const cfg = loadConfig(relayDir);
|
|
4011
|
-
|
|
4012
|
-
const
|
|
4070
|
+
// B1:同样的 10s 缓存(状态轮询时不重复 spawn launchctl/ps)。
|
|
4071
|
+
const launchd = memoStatusProbe("launchd", launchdStatus);
|
|
4072
|
+
const manual = memoStatusProbe("manual:" + relayDir, () => manualStatus(relayDir));
|
|
4013
4073
|
// 注册/绑定失败提示(bridge 写 .bind-error.json;面板据此展示“已达上限/需解绑”引导)
|
|
4014
4074
|
let bindError = null;
|
|
4015
4075
|
try {
|
|
@@ -5704,12 +5764,18 @@ async function accountDeviceBound(relayDir, cfg) {
|
|
|
5704
5764
|
*/
|
|
5705
5765
|
async function composeConnect(relayDir, opts = {}) {
|
|
5706
5766
|
const cfg = opts.cfg || loadConfig(relayDir);
|
|
5707
|
-
// 【
|
|
5708
|
-
//
|
|
5709
|
-
//
|
|
5710
|
-
|
|
5711
|
-
|
|
5712
|
-
const
|
|
5767
|
+
// 【B1 · 2026-11-07 登录慢优化】端口发现**绝不再阻塞状态/登录响应**:
|
|
5768
|
+
// · 已有可用结论(显式 env / 已知监听端口 / TTL 内的发现结果 / 测试隔离)→ 直接用;
|
|
5769
|
+
// · 否则**放后台**跑(void),本次先用 hint/回退地址把状态返回 —— 首屏不再等信息;
|
|
5770
|
+
// 发现完成后的结果会在下一次状态轮询自然带上(面板每 2.5~30s 轮询,无需推送)。
|
|
5771
|
+
// 旧实现的 await 会把 lsof/netstat + 数百端口探测整个塞进首屏路径(业主实测 7~8 秒)。
|
|
5772
|
+
const discoverySettled = upstreamDiscoverySettled();
|
|
5773
|
+
if (!discoverySettled) {
|
|
5774
|
+
void discoverImpl(relayDir).catch(() => FALLBACK_UPSTREAM);
|
|
5775
|
+
}
|
|
5776
|
+
// B1:这两次 shell 探测走 10s TTL 缓存(见 memoStatusProbe);变更动作会清缓存。
|
|
5777
|
+
const launchd = opts.launchd || memoStatusProbe("launchd", launchdStatus);
|
|
5778
|
+
const manual = opts.manual || memoStatusProbe("manual:" + relayDir, () => manualStatus(relayDir));
|
|
5713
5779
|
const book = connectBook(relayDir);
|
|
5714
5780
|
const now = Date.now();
|
|
5715
5781
|
const hasAcct = hasAccountCreds(cfg);
|
|
@@ -5958,6 +6024,8 @@ async function composeConnect(relayDir, opts = {}) {
|
|
|
5958
6024
|
selfhealExhausted: selfhealGuardOf(relayDir).count >= SELFHEAL_MAX_RESTARTS,
|
|
5959
6025
|
upstreamUrl: upstreamUrl(),
|
|
5960
6026
|
upstreamSource: upstreamSourceLabel(),
|
|
6027
|
+
// B1:这次响应是否"端口发现还没完成、已放后台跑"(只作诊断/文案;下一次轮询会自然收敛)
|
|
6028
|
+
discoveryPending: Boolean(!discoverySettled),
|
|
5961
6029
|
// 宿主形态:桌面端(Electron)与 Web 版(node)在若干动作上语义不同 ——
|
|
5962
6030
|
// 最典型的是「重启 DeepSeek harness」在桌面端做不到也不该做(Host 由 Electron 托管,
|
|
5963
6031
|
// 强行退出会弹原生致命错误)。面板据此改文案/去掉无意义的按钮,而不是等用户点了才失败。
|
|
@@ -7627,6 +7695,24 @@ function wecomStatusOf(status) {
|
|
|
7627
7695
|
boundSessionId: str(w.boundSessionId),
|
|
7628
7696
|
// 「上次预览卡片」已处理到的时间戳(桥写;面板只用来显示「上次预览:HH:MM」)
|
|
7629
7697
|
lastPreviewCardAt: num(w.lastPreviewCardAt),
|
|
7698
|
+
// ── 会员档位 / 额度(2026-11-07 桥侧新增,字段名锁死;只白名单,不整包吐)──
|
|
7699
|
+
// tier 只认三个枚举值;拿不到就是空串(面板据此**不渲染**档位条,绝不猜)。
|
|
7700
|
+
tier: (() => {
|
|
7701
|
+
const t = str(w.tier);
|
|
7702
|
+
return (t === "free" || t === "pro" || t === "pro_max") ? t : "";
|
|
7703
|
+
})(),
|
|
7704
|
+
// caps 是字符串数组:逐个转字符串 + 截断 + 上限 50 条(桥以后往里塞对象也别透出去)
|
|
7705
|
+
caps: (Array.isArray(w.caps) ? w.caps : []).slice(0, 50).map((c) => str(c).slice(0, 80)).filter(Boolean),
|
|
7706
|
+
capsCount: num(w.capsCount),
|
|
7707
|
+
bindingGated: !!w.bindingGated,
|
|
7708
|
+
messagesPerMonth: num(w.messagesPerMonth),
|
|
7709
|
+
// null = 不限或未知(**必须和 0 区分**:0 条剩余 ≠ 不限量)
|
|
7710
|
+
remainingMessages: typeof w.remainingMessages === "number" && Number.isFinite(w.remainingMessages) ? w.remainingMessages : null,
|
|
7711
|
+
usage: (() => {
|
|
7712
|
+
const u = w.usage && typeof w.usage === "object" ? w.usage : null;
|
|
7713
|
+
if (!u) return null;
|
|
7714
|
+
return { month: str(u.month), messages: num(u.messages) };
|
|
7715
|
+
})(),
|
|
7630
7716
|
sessions: (Array.isArray(w.sessions) ? w.sessions : []).slice(0, 50).map(function (it) {
|
|
7631
7717
|
const o = it && typeof it === "object" ? it : {};
|
|
7632
7718
|
return {
|
|
@@ -8290,7 +8376,26 @@ function registerRoutes(ctx, relayDir, opts = {}) {
|
|
|
8290
8376
|
const bridgeRestart = startBridge(relayDir);
|
|
8291
8377
|
// 登录成功即上报本机安装信息(企业端用于「升级插件后刷新版本号」约定);失败静默。
|
|
8292
8378
|
maybeReportInstall(relayDir, { force: true });
|
|
8293
|
-
|
|
8379
|
+
// 【B2 · 2026-11-07】登录响应**立即返回**:旧实现 `...(await composeStatus())` 会把
|
|
8380
|
+
// 端口发现 / launchctl+ps / 中继探测全塞进登录路径(业主实测 7~8 秒)。
|
|
8381
|
+
// 这里只回"保存成功 + bridge 重启结果 + 最小配置回显",衍生信息由面板登录后异步拉
|
|
8382
|
+
// (GET /dsh-remote/status & /bridge-status),UI 已经先切到已登录态。
|
|
8383
|
+
const cfgEcho = loadConfig(relayDir);
|
|
8384
|
+
sendJson(res, 200, {
|
|
8385
|
+
ok: true,
|
|
8386
|
+
bridgeRestart,
|
|
8387
|
+
statusPending: true,
|
|
8388
|
+
config: {
|
|
8389
|
+
phone: maskPhone(cfgEcho.phone || ""),
|
|
8390
|
+
hasPhone: Boolean(cfgEcho.phone),
|
|
8391
|
+
hasPassword: Boolean(cfgEcho.password),
|
|
8392
|
+
deviceId: cfgEcho.device_id || "",
|
|
8393
|
+
apiUrl: cfgEcho.api_url || DEFAULT_API,
|
|
8394
|
+
mode: cfgEcho.local_key ? "local" : "saas",
|
|
8395
|
+
selfHostUrl: cfgEcho.tunnel_url ? String(cfgEcho.tunnel_url).replace(/^wss?:\/\//, "").replace(/\/+$/, "") : "",
|
|
8396
|
+
hasLocalKey: Boolean(cfgEcho.local_key)
|
|
8397
|
+
}
|
|
8398
|
+
});
|
|
8294
8399
|
},
|
|
8295
8400
|
},
|
|
8296
8401
|
{
|
|
@@ -9025,6 +9130,11 @@ export { pinBrowseDirectoryPicker as __pinBrowseDirectoryPicker, schedulePickerP
|
|
|
9025
9130
|
setupStateOf as __setupStateOf, ensureConnection as __ensureConnection,
|
|
9026
9131
|
// 设备超额锁定(契约 §8):面板文案 / 插件半设备绑定退避都要能被单测钉住
|
|
9027
9132
|
composeConnect as __composeConnect, readBindError as __readBindError, bindFailGate as __bindFailGate,
|
|
9133
|
+
// B1(2026-11-07 登录慢):端口发现是否阻塞、shell 探测 TTL 缓存、状态组装都要能被单测钉住
|
|
9134
|
+
composeStatus as __composeStatus, launchdStatus as __launchdStatus,
|
|
9135
|
+
clearStatusProbeCache as __clearStatusProbeCache, upstreamDiscoverySettled as __upstreamDiscoverySettled,
|
|
9136
|
+
setUpstreamTestState as __setUpstreamTestState, resetUpstreamTestState as __resetUpstreamTestState,
|
|
9137
|
+
STATUS_PROBE_TTL_MS as __STATUS_PROBE_TTL_MS,
|
|
9028
9138
|
bindFailSignatureOf as __bindFailSignatureOf, isPermanentBindFailure as __isPermanentBindFailure,
|
|
9029
9139
|
bindCapBackoffMs as __bindCapBackoffMs, connectBook as __connectBook, noteBindFailAttempt as __noteBindFailAttempt,
|
|
9030
9140
|
DEVICE_BIND_CAP_EXIT_CODE as __DEVICE_BIND_CAP_EXIT_CODE, BIND_CAP_BACKOFF_MAX_MS as __BIND_CAP_BACKOFF_MAX_MS,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-remote-web",
|
|
3
|
-
"version": "0.7.
|
|
3
|
+
"version": "0.7.12-beta.1",
|
|
4
4
|
"description": "公网远程控制 DeepSeek Harness(dsh web):安装即得专属加密地址,人在外面也能用手机访问电脑上的 dsh——无需同一局域网/WiFi、无需公网 IP 与内网穿透,全程加密;手机端 100% 还原电脑体验(对话/工具/审批/设置)。技术用户可选自建服务,流量走自己的服务器。Remote control DeepSeek Harness (dsh web) from anywhere over the public internet — install, get an encrypted URL, use it from your phone on any network (dual-half cordis plugin: Settings panel + same-origin /dsh-remote routes). (2026-09 由 dsh-remote-ui 更名 / renamed from dsh-remote-ui; dsh-remote 的 dsh web 插件半,不是纯 UI/皮肤插件)",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "lib/index.js",
|
|
@@ -3107,7 +3107,13 @@ function startImBots() {
|
|
|
3107
3107
|
// 订阅器要连的本机 dsh web:传取值器,宿主换端口时下一次重连能跟上
|
|
3108
3108
|
botOptions: {
|
|
3109
3109
|
upstream: () => currentUpstreamForChannel(),
|
|
3110
|
-
cookie: harnessCookieOf
|
|
3110
|
+
cookie: harnessCookieOf,
|
|
3111
|
+
// 会员判定(2026-11-07 业主拍板:通知免费 / 审批免费 / 互动半阉割):
|
|
3112
|
+
// 直接复用微信通道那份档位解析(/api/me → 服务端权益包,含 caps/limits/rev),
|
|
3113
|
+
// 拿不到就由 wecom-bot 按 free fail-closed 处理。
|
|
3114
|
+
capsProvider: resolveWechatTier,
|
|
3115
|
+
// 免费用户越界时的转化入口(与微信通道同一个 App 地址;wecom-bot 拼 /app/promo)
|
|
3116
|
+
appUrl: wechatAppUrl()
|
|
3111
3117
|
},
|
|
3112
3118
|
// 企微审批卡片点按钮 → 与手机拍板同一条路(协调器 → DSH 回执);失败返回 {ok:false} 让卡片显示"没送达"
|
|
3113
3119
|
onApprovalDecision: (requestId, decision) => applyApprovalDecision(requestId, decision),
|
|
@@ -0,0 +1,372 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* im-format — **通道感知的消息格式化**(2026-11-08 业主反馈「企微/飞书推回来的表格都是乱的」)。
|
|
3
|
+
*
|
|
4
|
+
* 背景
|
|
5
|
+
* -----------------------------------------------------------------
|
|
6
|
+
* agent 的最终回答常带 Markdown 表格(`| a | b |`)、标题、引用、任务列表…… 而企业微信 / 钉钉的
|
|
7
|
+
* markdown **不支持表格**:原样发出去就是一堆竖线,用户看到的就是「乱」。不同 IM 通道的能力也不同
|
|
8
|
+
* (企微/钉钉支持 markdown 子集;飞书 markdown 能力弱、卡片强;个人微信机器人有自己的一套特殊格式),
|
|
9
|
+
* 所以"发之前按通道能力适配一次"应该是一个**独立的、可单测的纯函数模块**,而不是散在 wecom-bot 里。
|
|
10
|
+
*
|
|
11
|
+
* 用法
|
|
12
|
+
* -----------------------------------------------------------------
|
|
13
|
+
* ```js
|
|
14
|
+
* import { formatForChannel, CHANNEL_CAPS } from "./im-format.mjs";
|
|
15
|
+
* const text = formatForChannel("wecom", { title: "", text: agentAnswer });
|
|
16
|
+
* ```
|
|
17
|
+
* 规则(确定性、可测,见 formatForChannel 注释):
|
|
18
|
+
* · 表格 → 「• 列1:值1 · 列2:值2」多行(列多/超宽时按「• 记录 N」+ 每列一行拆开)
|
|
19
|
+
* · `#` 标题 → 加粗(markdown 通道)/ 纯文本
|
|
20
|
+
* · `---` 分隔线 → 空行;`>` 引用 → `› `;`- [ ]` / `- [x]` → `☐` / `☑`;嵌套列表压平一层
|
|
21
|
+
* · 代码块**原样保留**;inline code / bold / 链接不动(企微 markdown 支持)
|
|
22
|
+
* · wecom 默认按 **4096 字节(UTF-8)** 截断,优先在段落/行边界,附「…(已截断,完整内容看电脑)」
|
|
23
|
+
* · `wechat` 走现有特殊格式(本模块只做 **plain 兜底**,不重复实现微信那套富文本)
|
|
24
|
+
* · 未知通道 → `plain`(fail-safe:宁可少用格式,也不发坏的 markdown)
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
/** 各通道能力(钉钉/飞书实现时直接复用;wechat = 现有特殊格式,不经本模块排版)。 */
|
|
28
|
+
export const CHANNEL_CAPS = Object.freeze({
|
|
29
|
+
wecom: Object.freeze({ markdown: true, tables: false, card: true }),
|
|
30
|
+
dingtalk: Object.freeze({ markdown: true, tables: false, card: true }),
|
|
31
|
+
feishu: Object.freeze({ markdown: false, tables: false, card: true }),
|
|
32
|
+
wechat: Object.freeze({ markdown: false, special: true }),
|
|
33
|
+
plain: Object.freeze({ markdown: false })
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/** 截断提示(与 wecom-bot 的 ANSWER_TRUNCATED_NOTE 同文,便于去重)。 */
|
|
37
|
+
export const TRUNCATED_NOTE = "…(已截断,完整内容看电脑)";
|
|
38
|
+
|
|
39
|
+
/** 各通道默认字节上限(wecom 官方 4096;其它先给宽松值,留 maxBytes 覆盖)。 */
|
|
40
|
+
const DEFAULT_MAX_BYTES = Object.freeze({
|
|
41
|
+
wecom: 4096,
|
|
42
|
+
dingtalk: 4096,
|
|
43
|
+
feishu: 8192,
|
|
44
|
+
wechat: Infinity,
|
|
45
|
+
plain: Infinity
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** UTF-8 字节数(中文 3 字节;emoji 4 字节)。 */
|
|
49
|
+
export function utf8Bytes(value) {
|
|
50
|
+
try {
|
|
51
|
+
return Buffer.byteLength(String(value == null ? "" : value), "utf8");
|
|
52
|
+
} catch {
|
|
53
|
+
return String(value == null ? "" : value).length * 3;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** 终端/聊天窗口的显示宽度(CJK 与 emoji 记 2 列;只用于"这行会不会太宽"的确定性判断)。 */
|
|
58
|
+
export function displayWidth(value) {
|
|
59
|
+
let width = 0;
|
|
60
|
+
for (const ch of String(value == null ? "" : value)) {
|
|
61
|
+
const cp = ch.codePointAt(0) || 0;
|
|
62
|
+
const wide = cp === 0x2329 || cp === 0x232a
|
|
63
|
+
|| (cp >= 0x1100 && cp <= 0x115f)
|
|
64
|
+
|| (cp >= 0x2e80 && cp <= 0xa4cf)
|
|
65
|
+
|| (cp >= 0xac00 && cp <= 0xd7a3)
|
|
66
|
+
|| (cp >= 0xf900 && cp <= 0xfaff)
|
|
67
|
+
|| (cp >= 0xfe30 && cp <= 0xfe4f)
|
|
68
|
+
|| (cp >= 0xff00 && cp <= 0xff60)
|
|
69
|
+
|| (cp >= 0xffe0 && cp <= 0xffe6)
|
|
70
|
+
|| (cp >= 0x1f300 && cp <= 0x1f64f)
|
|
71
|
+
|| (cp >= 0x1f900 && cp <= 0x1f9ff)
|
|
72
|
+
|| (cp >= 0x20000 && cp <= 0x3fffd);
|
|
73
|
+
width += wide ? 2 : 1;
|
|
74
|
+
}
|
|
75
|
+
return width;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** 通道名规范化:未知/空 → `plain`(fail-safe)。 */
|
|
79
|
+
export function normalizeChannelName(channel) {
|
|
80
|
+
const name = String(channel || "").trim().toLowerCase();
|
|
81
|
+
if (Object.prototype.hasOwnProperty.call(CHANNEL_CAPS, name)) return name;
|
|
82
|
+
// 常见别名(调用方拼错/叫法不同时不至于掉进 markdown 分支)
|
|
83
|
+
if (name === "weixin" || name === "wx" || name === "wechat-bot" || name === "personal") return "wechat";
|
|
84
|
+
if (name === "workbench" || name === "wxwork" || name === "qywx" || name === "wecom-bot") return "wecom";
|
|
85
|
+
if (name === "ding" || name === "dingding") return "dingtalk";
|
|
86
|
+
if (name === "lark") return "feishu";
|
|
87
|
+
return "plain";
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// ---------- Markdown 表格解析(确定性:只按未转义的 `|` 切) ----------
|
|
91
|
+
|
|
92
|
+
/** 是否是表格行(以 `|` 开头且至少 2 个竖线)。 */
|
|
93
|
+
function isTableLine(line) {
|
|
94
|
+
const t = String(line || "").trim();
|
|
95
|
+
if (!t.startsWith("|")) return false;
|
|
96
|
+
const count = (t.match(/\|/g) || []).length;
|
|
97
|
+
return count >= 2;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** 把一行表格拆成单元格(支持 `\|` 转义;不会把转义竖线当分隔符)。 */
|
|
101
|
+
export function splitTableRow(line) {
|
|
102
|
+
let s = String(line || "").trim();
|
|
103
|
+
if (s.startsWith("|")) s = s.slice(1);
|
|
104
|
+
// 结尾的竖线是边框(`\|` 结尾不算)
|
|
105
|
+
if (s.endsWith("|") && !s.endsWith("\\|")) s = s.slice(0, -1);
|
|
106
|
+
const cells = [];
|
|
107
|
+
let cur = "";
|
|
108
|
+
for (let i = 0; i < s.length; i += 1) {
|
|
109
|
+
const ch = s[i];
|
|
110
|
+
if (ch === "\\" && s[i + 1] === "|") { cur += "|"; i += 1; continue; }
|
|
111
|
+
if (ch === "|") { cells.push(cur.trim()); cur = ""; continue; }
|
|
112
|
+
cur += ch;
|
|
113
|
+
}
|
|
114
|
+
cells.push(cur.trim());
|
|
115
|
+
return cells;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** 分隔行(`|---|---|` / `|:--|--:|`)。 */
|
|
119
|
+
function isSeparatorRow(cells) {
|
|
120
|
+
return Array.isArray(cells) && cells.length > 0 && cells.every((c) => /^:?-{1,}:?$/.test(String(c || "").trim()));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* 表格 → 可读文本。
|
|
125
|
+
*
|
|
126
|
+
* 规则(写死,便于单测):
|
|
127
|
+
* · 每条数据行 = 「• 列1:值1 · 列2:值2 …」;
|
|
128
|
+
* · 列数 ≤3 且整行显示宽度 ≤60 → 单行;否则拆成「• 记录 N」+ 每列一行(避免超宽/被客户端折行成乱码);
|
|
129
|
+
* · 表头作为**列名**逐行带上(不是另起一行表格头——用户要的是"看得懂哪列是什么")。
|
|
130
|
+
* @param {string[]} lines 连续的表格行
|
|
131
|
+
* @returns {string[]} 转换后的行(不含 `|---`)
|
|
132
|
+
*/
|
|
133
|
+
export function convertMarkdownTable(lines) {
|
|
134
|
+
const rows = (Array.isArray(lines) ? lines : []).map(splitTableRow);
|
|
135
|
+
if (!rows.length) return [];
|
|
136
|
+
let header = null;
|
|
137
|
+
let dataRows = rows;
|
|
138
|
+
if (rows.length >= 2 && isSeparatorRow(rows[1])) { header = rows[0]; dataRows = rows.slice(2); }
|
|
139
|
+
else if (rows.length >= 1 && isSeparatorRow(rows[0])) { dataRows = rows.slice(1); }
|
|
140
|
+
else { header = rows[0]; dataRows = rows.slice(1); }
|
|
141
|
+
const colCount = Math.max(
|
|
142
|
+
header ? header.length : 0,
|
|
143
|
+
...dataRows.map((r) => r.length),
|
|
144
|
+
0
|
|
145
|
+
);
|
|
146
|
+
const out = [];
|
|
147
|
+
let index = 0;
|
|
148
|
+
for (const row of dataRows) {
|
|
149
|
+
if (!row || isSeparatorRow(row)) continue;
|
|
150
|
+
index += 1;
|
|
151
|
+
const cells = [];
|
|
152
|
+
for (let c = 0; c < colCount; c += 1) {
|
|
153
|
+
const rawName = header && header[c] ? String(header[c]).trim() : "";
|
|
154
|
+
const name = rawName || `列${c + 1}`;
|
|
155
|
+
const val = row[c] == null ? "" : String(row[c]).trim();
|
|
156
|
+
if (!val) continue;
|
|
157
|
+
cells.push({ name, val });
|
|
158
|
+
}
|
|
159
|
+
if (!cells.length) continue;
|
|
160
|
+
const oneLine = cells.map((c) => `${c.name}:${c.val}`).join(" · ");
|
|
161
|
+
if (cells.length <= 3 && displayWidth(oneLine) <= 60) {
|
|
162
|
+
out.push(`• ${oneLine}`);
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
out.push(`• 记录 ${index}`);
|
|
166
|
+
for (const c of cells) out.push(` ${c.name}:${c.val}`);
|
|
167
|
+
}
|
|
168
|
+
return out;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ---------- 行级转换 ----------
|
|
172
|
+
|
|
173
|
+
const RE_FENCE = /^\s{0,3}(```|~~~)/;
|
|
174
|
+
const RE_HEADING = /^\s{0,3}(#{1,6})\s+(.*?)\s*#*\s*$/;
|
|
175
|
+
const RE_HR = /^\s{0,3}([-*_])(?:\s*\1){2,}\s*$/;
|
|
176
|
+
const RE_QUOTE = /^\s{0,3}>\s?(.*)$/;
|
|
177
|
+
const RE_TASK = /^(\s*)([-*+]|\d+\.)\s+\[( |x|X)\]\s+(.*)$/;
|
|
178
|
+
const RE_NESTED_LIST = /^(\s{2,})([-*+]|\d+\.)\s+(.*)$/;
|
|
179
|
+
const RE_BULLET = /^(\s*)([-*+])\s+(.*)$/;
|
|
180
|
+
|
|
181
|
+
/** 把一段"非代码块"文本按行转换(markdown 通道保留 inline 语法;非 markdown 通道降级)。 */
|
|
182
|
+
function convertLines(lines, markdown) {
|
|
183
|
+
const out = [];
|
|
184
|
+
let table = [];
|
|
185
|
+
const flushTable = () => {
|
|
186
|
+
if (!table.length) return;
|
|
187
|
+
const converted = convertMarkdownTable(table);
|
|
188
|
+
if (converted.length) {
|
|
189
|
+
out.push("", ...converted, "");
|
|
190
|
+
}
|
|
191
|
+
table = [];
|
|
192
|
+
};
|
|
193
|
+
for (const rawLine of lines) {
|
|
194
|
+
const line = String(rawLine == null ? "" : rawLine);
|
|
195
|
+
if (isTableLine(line)) { table.push(line); continue; }
|
|
196
|
+
flushTable();
|
|
197
|
+
// 标题
|
|
198
|
+
const heading = RE_HEADING.exec(line);
|
|
199
|
+
if (heading) {
|
|
200
|
+
const text = heading[2].trim();
|
|
201
|
+
out.push(markdown ? `**${text}**` : text);
|
|
202
|
+
continue;
|
|
203
|
+
}
|
|
204
|
+
// 分隔线 → 空行
|
|
205
|
+
if (RE_HR.test(line)) { out.push(""); continue; }
|
|
206
|
+
// 引用
|
|
207
|
+
const quote = RE_QUOTE.exec(line);
|
|
208
|
+
if (quote) { out.push(`› ${quote[1].trim()}`); continue; }
|
|
209
|
+
// 任务列表(☐/☑,不再保留 `- [ ]`)
|
|
210
|
+
const task = RE_TASK.exec(line);
|
|
211
|
+
if (task) {
|
|
212
|
+
const done = String(task[3]).toLowerCase() === "x";
|
|
213
|
+
const box = done ? "☑" : "☐";
|
|
214
|
+
out.push(markdown ? `- ${box} ${task[4].trim()}` : `${box} ${task[4].trim()}`);
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
217
|
+
// 嵌套列表:压平一层(更深缩进统一成 2 空格 + `·`,不再出现多层缩进)
|
|
218
|
+
const nested = RE_NESTED_LIST.exec(line);
|
|
219
|
+
if (nested) { out.push(` · ${nested[3].trim()}`); continue; }
|
|
220
|
+
// 普通列表:非 markdown 通道把 `-` 换成 `•`(markdown 通道保持)
|
|
221
|
+
const bullet = RE_BULLET.exec(line);
|
|
222
|
+
if (bullet && !markdown) { out.push(`${bullet[1]}• ${bullet[3].trim()}`); continue; }
|
|
223
|
+
out.push(markdown ? line : line.replace(/\*\*(.+?)\*\*/g, "$1").replace(/`([^`]+)`/g, "$1"));
|
|
224
|
+
}
|
|
225
|
+
flushTable();
|
|
226
|
+
return out;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** 整段 Markdown → 目标通道文本(代码块原样保留)。 */
|
|
230
|
+
function convertMarkdownLike(input, markdown) {
|
|
231
|
+
const text = String(input == null ? "" : input);
|
|
232
|
+
if (!text.trim()) return "";
|
|
233
|
+
const lines = text.split("\n");
|
|
234
|
+
const out = [];
|
|
235
|
+
let code = [];
|
|
236
|
+
let inCode = false;
|
|
237
|
+
const flushCode = () => {
|
|
238
|
+
if (!code.length) return;
|
|
239
|
+
if (markdown) out.push(...code);
|
|
240
|
+
else {
|
|
241
|
+
// 非 markdown 通道:去掉围栏,内容缩进两格(保持"是代码"的观感,不出现 ```)
|
|
242
|
+
for (const line of code) {
|
|
243
|
+
if (RE_FENCE.test(line)) continue;
|
|
244
|
+
out.push(line ? ` ${line}` : "");
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
code = [];
|
|
248
|
+
};
|
|
249
|
+
let plainBuffer = [];
|
|
250
|
+
const flushPlain = () => {
|
|
251
|
+
if (!plainBuffer.length) return;
|
|
252
|
+
out.push(...convertLines(plainBuffer, markdown));
|
|
253
|
+
plainBuffer = [];
|
|
254
|
+
};
|
|
255
|
+
for (const line of lines) {
|
|
256
|
+
if (RE_FENCE.test(line)) {
|
|
257
|
+
if (!inCode) { flushPlain(); inCode = true; code = [line]; continue; }
|
|
258
|
+
code.push(line); flushCode(); inCode = false; continue;
|
|
259
|
+
}
|
|
260
|
+
if (inCode) { code.push(line); continue; }
|
|
261
|
+
plainBuffer.push(line);
|
|
262
|
+
}
|
|
263
|
+
if (inCode) flushCode(); // 未闭合的代码块也原样保留(不吞内容)
|
|
264
|
+
flushPlain();
|
|
265
|
+
// 收尾清理:行尾空格 / 连续空行压成一个 / 首尾空行去掉
|
|
266
|
+
const cleaned = [];
|
|
267
|
+
for (const line of out) {
|
|
268
|
+
const trimmedRight = String(line).replace(/[ \t]+$/, "");
|
|
269
|
+
if (trimmedRight === "" && (cleaned.length === 0 || cleaned[cleaned.length - 1] === "")) continue;
|
|
270
|
+
cleaned.push(trimmedRight);
|
|
271
|
+
}
|
|
272
|
+
while (cleaned.length && cleaned[cleaned.length - 1] === "") cleaned.pop();
|
|
273
|
+
return cleaned.join("\n");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// ---------- 截断(按 UTF-8 字节 / 字符,优先段落边界) ----------
|
|
277
|
+
|
|
278
|
+
function fitsBoth(value, maxBytes, maxChars) {
|
|
279
|
+
if (utf8Bytes(value) > maxBytes) return false;
|
|
280
|
+
if (maxChars !== Infinity && [...String(value)].length > maxChars) return false;
|
|
281
|
+
return true;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
/**
|
|
285
|
+
* 安全截断:优先整段 → 整行 → 逐码点;绝不切在 UTF-8 中间(用 `for...of` 按码点累加)。
|
|
286
|
+
* 结果末尾附 `suffix`(调用方保证 suffix 也计入上限)。
|
|
287
|
+
* @returns {string}
|
|
288
|
+
*/
|
|
289
|
+
export function truncateUtf8Safe(text, { maxBytes = Infinity, maxChars = Infinity, suffix = TRUNCATED_NOTE } = {}) {
|
|
290
|
+
const body = String(text == null ? "" : text);
|
|
291
|
+
if (fitsBoth(body, maxBytes, maxChars)) return body;
|
|
292
|
+
const tail = `\n${suffix}`;
|
|
293
|
+
const budgetBytes = maxBytes - utf8Bytes(tail);
|
|
294
|
+
const budgetChars = maxChars === Infinity ? Infinity : maxChars - [...tail].length;
|
|
295
|
+
if (budgetBytes <= 0 || (budgetChars !== Infinity && budgetChars <= 0)) return suffix;
|
|
296
|
+
// ① 段落
|
|
297
|
+
let out = "";
|
|
298
|
+
for (const para of body.split(/\n{2,}/)) {
|
|
299
|
+
const cand = out ? `${out}\n\n${para}` : para;
|
|
300
|
+
if (!fitsBoth(cand, budgetBytes, budgetChars)) break;
|
|
301
|
+
out = cand;
|
|
302
|
+
}
|
|
303
|
+
// ② 行
|
|
304
|
+
if (!out) {
|
|
305
|
+
for (const line of body.split("\n")) {
|
|
306
|
+
const cand = out ? `${out}\n${line}` : line;
|
|
307
|
+
if (!fitsBoth(cand, budgetBytes, budgetChars)) break;
|
|
308
|
+
out = cand;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// ③ 逐码点(不会切坏代理对/中文)
|
|
312
|
+
if (!out) {
|
|
313
|
+
for (const ch of body) {
|
|
314
|
+
const cand = out + ch;
|
|
315
|
+
if (!fitsBoth(cand, budgetBytes, budgetChars)) break;
|
|
316
|
+
out = cand;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
return `${out.replace(/[ \t]+$/, "")}${tail}`;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
// ---------- 对外主函数 ----------
|
|
323
|
+
|
|
324
|
+
/**
|
|
325
|
+
* 按通道能力格式化消息。
|
|
326
|
+
*
|
|
327
|
+
* 支持的选项:
|
|
328
|
+
* · `text` 正文(Markdown 源码)
|
|
329
|
+
* · `title` 可选标题(会作为加粗/纯文本首行;正文里的 `#` 标题也会各自转换)
|
|
330
|
+
* · `kind` 预留:目前不影响输出(钉钉/飞书将来可按 kind 分样式)
|
|
331
|
+
* · `maxChars` 字符上限(可选)
|
|
332
|
+
* · `maxBytes` 字节上限(默认按通道:wecom/dingtalk 4096;feishu 8192;plain/wechat 不限)
|
|
333
|
+
*
|
|
334
|
+
* @param {"wecom"|"dingtalk"|"feishu"|"wechat"|"plain"|string} channel
|
|
335
|
+
* @param {{title?:string, text?:string, kind?:string, maxChars?:number, maxBytes?:number}} [options]
|
|
336
|
+
* @returns {string}
|
|
337
|
+
*/
|
|
338
|
+
export function formatForChannel(channel, options = {}) {
|
|
339
|
+
const name = normalizeChannelName(channel);
|
|
340
|
+
const caps = CHANNEL_CAPS[name] || CHANNEL_CAPS.plain;
|
|
341
|
+
const markdown = caps.markdown === true;
|
|
342
|
+
const rawText = String(options.text == null ? "" : options.text);
|
|
343
|
+
const maxBytes = Number.isFinite(Number(options.maxBytes)) && Number(options.maxBytes) > 0
|
|
344
|
+
? Number(options.maxBytes) : DEFAULT_MAX_BYTES[name];
|
|
345
|
+
const maxChars = Number.isFinite(Number(options.maxChars)) && Number(options.maxChars) > 0
|
|
346
|
+
? Number(options.maxChars) : Infinity;
|
|
347
|
+
|
|
348
|
+
// 调用方可能已经自己截断过并附了 suffix(wecom-bot 的回答回传会先按字符截 1500):
|
|
349
|
+
// 先摘掉,统一由本模块决定是否需要再按字节截,避免出现两个「已截断」。
|
|
350
|
+
let preTruncated = false;
|
|
351
|
+
let bodyText = rawText;
|
|
352
|
+
if (bodyText.endsWith(TRUNCATED_NOTE)) {
|
|
353
|
+
preTruncated = true;
|
|
354
|
+
bodyText = bodyText.slice(0, -TRUNCATED_NOTE.length);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
let body = convertMarkdownLike(bodyText, markdown);
|
|
358
|
+
const title = String(options.title == null ? "" : options.title).trim();
|
|
359
|
+
if (title) {
|
|
360
|
+
const titleLine = markdown ? `**${title}**` : title;
|
|
361
|
+
body = body ? `${titleLine}\n\n${body}` : titleLine;
|
|
362
|
+
}
|
|
363
|
+
// 微信通道有其自己的特殊格式(wechat-runtime 负责):这里只做 plain 兜底,且不截断(由微信侧管)。
|
|
364
|
+
if (name === "wechat") return body;
|
|
365
|
+
|
|
366
|
+
const fitted = truncateUtf8Safe(body, { maxBytes, maxChars, suffix: TRUNCATED_NOTE });
|
|
367
|
+
if (fitted === body) {
|
|
368
|
+
// 原本就在上限内:如果调用方已标过截断,保留它的提示(不要悄悄丢掉;保持原样拼接)
|
|
369
|
+
return preTruncated ? `${body}${TRUNCATED_NOTE}` : body;
|
|
370
|
+
}
|
|
371
|
+
return fitted;
|
|
372
|
+
}
|