dsh-remote-web 0.7.10-beta.3 → 0.7.11-beta.3

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/index.js CHANGED
@@ -7542,7 +7542,257 @@ export function localRequestBlocked(req) {
7542
7542
  return null;
7543
7543
  }
7544
7544
 
7545
- function registerRoutes(ctx, relayDir) {
7545
+ // ═══════════════════════ 🤖 IM 机器人通道(2026-11-07,企业微信智能机器人)═══════════════════════
7546
+ //
7547
+ // 面板「通知与消息通道」弹层背后的三条本地 API。它**不是**原来那条「微信机器人扫码绑定」通道
7548
+ // (那条跑在 bridge 的 wechat-runtime 里,见 /dsh-remote/wechat/*),而是「用企业微信/飞书机器人的
7549
+ // 凭据让桥主动推送消息」的第二种通道,配置面就两个文件(契约与桥半约定,字段名**不要改**):
7550
+ //
7551
+ // <relayDir>/im-bots.json 插件写、桥读
7552
+ // { version:1, wecom:{ enabled, botId, secret, url, targetChatId, sessionId, sessionTitle, previewCardAt } }
7553
+ // previewCardAt:**发送预览卡片**的触发时间戳(ms;插件写、桥读,值变大桥就发一张预览审批卡)
7554
+ // <relayDir>/im-bot-status.json 桥写、插件**只读**
7555
+ // { updatedAt, wecom:{ enabled, connected, authenticated, lastError,
7556
+ // lastConnectedAt, lastMessageAt, boundChatId } }
7557
+ //
7558
+ // 安全边界(本文件里凡是下发给浏览器的字段都要过这里):
7559
+ // · **secret 永不返回浏览器**(GET/PUT/测试结果里只给 hasSecret:boolean);
7560
+ // · 配置文件按 0600 写 + 临时文件原子替换(半写文件会让桥读到坏 JSON → 通道静默失效);
7561
+ // · 状态文件是桥写的,插件只挑白名单字段转发,绝不整份透传(防止未来桥塞进敏感字段被带出去)。
7562
+ //
7563
+ // 三条 API:
7564
+ // GET /dsh-remote/im-bots → {ok, wecom:{enabled,botId,hasSecret,url,targetChatId,sessionId,sessionTitle}, status}
7565
+ // PUT /dsh-remote/im-bots (body {...}) → 同上(保存后立刻回读;空 secret = 不修改)
7566
+ // POST /dsh-remote/im-bots/test → 写配置后轮询状态 ≤10s,回 {ok, connected, authenticated, lastError}
7567
+ // 失败一律人话 + 稳定错误码:invalid_bot_id / missing_secret / connect_failed / timeout(外加 invalid_url / bad_request)。
7568
+ const IM_BOTS_FILE = "im-bots.json";
7569
+ const IM_BOT_STATUS_FILE = "im-bot-status.json";
7570
+ /** POST test 最多等多久(桥真正连上企业微信是异步的;10s 是 task 契约)。可用 config.imBotsWaitMs 覆盖(测试用)。 */
7571
+ const IM_BOT_STATUS_WAIT_MS = 10_000;
7572
+ const IM_BOT_STATUS_POLL_MS = 300;
7573
+ /** 企业微信智能机器人的 Bot ID 形如 `aib...`(用户抄错时当场给可理解的错误,而不是存下去然后一直连不上)。 */
7574
+ const WECOM_BOT_ID_RE = /^aib[A-Za-z0-9_-]{3,}$/;
7575
+ const IM_BOT_URL_RE = /^https?:\/\/[^\s]+$/i;
7576
+
7577
+ function imBotsPathOf(relayDir) { return join(relayDir, IM_BOTS_FILE); }
7578
+ function imBotStatusPathOf(relayDir) { return join(relayDir, IM_BOT_STATUS_FILE); }
7579
+
7580
+ /** 读一个 JSON 文件;不存在/半写/坏 JSON 一律当空对象(通道没配 ≠ 面板报错)。 */
7581
+ function readJsonFileSoft(file) {
7582
+ try {
7583
+ const raw = readFileSync(file, "utf8");
7584
+ const v = JSON.parse(raw);
7585
+ return v && typeof v === "object" && !Array.isArray(v) ? v : {};
7586
+ } catch { return {}; }
7587
+ }
7588
+
7589
+ function readImBots(relayDir) { return readJsonFileSoft(imBotsPathOf(relayDir)); }
7590
+ function readImBotStatus(relayDir) { return readJsonFileSoft(imBotStatusPathOf(relayDir)); }
7591
+
7592
+ /** 归一化 wecom 配置(读的时候也归一,桥手工编辑过/旧版本文件都不会让面板崩)。 */
7593
+ function wecomCfgOf(bots) {
7594
+ const w = bots && bots.wecom && typeof bots.wecom === "object" ? bots.wecom : {};
7595
+ return {
7596
+ enabled: !!w.enabled,
7597
+ botId: typeof w.botId === "string" ? w.botId : "",
7598
+ secret: typeof w.secret === "string" ? w.secret : "",
7599
+ url: typeof w.url === "string" ? w.url : "",
7600
+ targetChatId: typeof w.targetChatId === "string" ? w.targetChatId : "",
7601
+ // 「转接到哪个会话」:空串 = 跟随当前活跃会话(桥侧默认)。
7602
+ sessionId: typeof w.sessionId === "string" ? w.sessionId : "",
7603
+ sessionTitle: typeof w.sessionTitle === "string" ? w.sessionTitle : "",
7604
+ // 「发送预览卡片」触发时间戳:插件写、桥读;0/缺失 = 从未触发过。
7605
+ previewCardAt: Number.isFinite(Number(w.previewCardAt)) && Number(w.previewCardAt) > 0 ? Number(w.previewCardAt) : 0,
7606
+ };
7607
+ }
7608
+
7609
+ /** 状态文件只转发白名单字段(桥以后往里塞什么都带不出去)。 */
7610
+ function wecomStatusOf(status) {
7611
+ const w = status && status.wecom && typeof status.wecom === "object" ? status.wecom : {};
7612
+ const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
7613
+ const str = (v) => (typeof v === "string" ? v : "");
7614
+ return {
7615
+ enabled: !!w.enabled,
7616
+ connected: !!w.connected,
7617
+ authenticated: !!w.authenticated,
7618
+ lastError: str(w.lastError),
7619
+ lastConnectedAt: num(w.lastConnectedAt),
7620
+ lastMessageAt: num(w.lastMessageAt),
7621
+ boundChatId: str(w.boundChatId),
7622
+ // ── 会话可选绑定(2026-11-07,字段名与桥约定死,不要改)──
7623
+ // 只挑 id/title/updatedAt 三个字段、最多 50 条、title 截到 200 字:
7624
+ // 状态文件是桥写的,插件没权利把它整份原样吐给浏览器(可能越来越大/带内部字段)。
7625
+ activeSessionId: str(w.activeSessionId),
7626
+ activeSessionTitle: str(w.activeSessionTitle),
7627
+ boundSessionId: str(w.boundSessionId),
7628
+ // 「上次预览卡片」已处理到的时间戳(桥写;面板只用来显示「上次预览:HH:MM」)
7629
+ lastPreviewCardAt: num(w.lastPreviewCardAt),
7630
+ sessions: (Array.isArray(w.sessions) ? w.sessions : []).slice(0, 50).map(function (it) {
7631
+ const o = it && typeof it === "object" ? it : {};
7632
+ return {
7633
+ id: str(o.id),
7634
+ title: str(o.title).slice(0, 200),
7635
+ updatedAt: num(o.updatedAt),
7636
+ };
7637
+ }).filter(function (it) { return !!it.id; }),
7638
+ };
7639
+ }
7640
+
7641
+ /**
7642
+ * 原子写 im-bots.json(0600)。
7643
+ * 为什么 tmp + rename:桥是**另一个进程**,写一半被读到就是坏 JSON;rename 在同一目录内是原子的。
7644
+ */
7645
+ function writeImBots(relayDir, data) {
7646
+ mkdirSync(relayDir, { recursive: true });
7647
+ const file = imBotsPathOf(relayDir);
7648
+ const tmp = `${file}.dsh-remote.tmp`;
7649
+ writeFileSync(tmp, JSON.stringify(data, null, 2) + "\n", { mode: 0o600 });
7650
+ try { hardenFile(tmp); } catch { /* Windows ACL 失败不致命 */ }
7651
+ try { chmodSync(tmp, 0o600); } catch { /* 非 POSIX 忽略 */ }
7652
+ renameSync(tmp, file);
7653
+ try { chmodSync(file, 0o600); } catch { /* 忽略 */ }
7654
+ }
7655
+
7656
+ /** 面板可见的通道状态(GET/PUT 共用同一个形状;**绝不含 secret**)。 */
7657
+ function imBotsView(relayDir) {
7658
+ const bots = readImBots(relayDir);
7659
+ const w = wecomCfgOf(bots);
7660
+ const stFile = readImBotStatus(relayDir);
7661
+ return {
7662
+ ok: true,
7663
+ wecom: {
7664
+ enabled: w.enabled,
7665
+ botId: w.botId,
7666
+ hasSecret: w.secret.length > 0,
7667
+ url: w.url,
7668
+ targetChatId: w.targetChatId,
7669
+ sessionId: w.sessionId,
7670
+ sessionTitle: w.sessionTitle,
7671
+ },
7672
+ status: wecomStatusOf(stFile),
7673
+ updatedAt: typeof stFile.updatedAt === "number" ? stFile.updatedAt : 0,
7674
+ };
7675
+ }
7676
+
7677
+ /**
7678
+ * 保存 wecom 配置(PUT 与 POST test 共用;纯校验 + 写盘,不碰网络)。
7679
+ * @returns {{ok:true}|{ok:false, code:string, message:string}}
7680
+ */
7681
+ function saveWecomConfig(relayDir, input) {
7682
+ const src = input && typeof input === "object" ? input : {};
7683
+ const bots = readImBots(relayDir);
7684
+ const cur = wecomCfgOf(bots);
7685
+
7686
+ // 只认契约字段;其余平台(feishu 等)与未知字段原样保留 —— 插件不认识它们,但没有权利删掉。
7687
+ const next = {
7688
+ enabled: cur.enabled,
7689
+ botId: cur.botId,
7690
+ secret: cur.secret,
7691
+ url: cur.url,
7692
+ targetChatId: cur.targetChatId,
7693
+ sessionId: cur.sessionId,
7694
+ sessionTitle: cur.sessionTitle,
7695
+ previewCardAt: cur.previewCardAt,
7696
+ };
7697
+
7698
+ if (Object.prototype.hasOwnProperty.call(src, "botId")) {
7699
+ const botId = String(src.botId ?? "").trim();
7700
+ if (botId && !WECOM_BOT_ID_RE.test(botId)) {
7701
+ return { ok: false, code: "invalid_bot_id", message: "Bot ID 格式不对:应形如 aibxxxxxxxx(在企业微信「智能机器人」页面复制)" };
7702
+ }
7703
+ next.botId = botId;
7704
+ }
7705
+ if (Object.prototype.hasOwnProperty.call(src, "secret")) {
7706
+ const secret = typeof src.secret === "string" ? src.secret.trim() : "";
7707
+ // 空字符串 = **不修改**(面板不回显 secret,用户只改 Bot ID 时不该被迫重填)
7708
+ if (secret) next.secret = secret;
7709
+ }
7710
+ if (Object.prototype.hasOwnProperty.call(src, "url")) {
7711
+ const url = String(src.url ?? "").trim();
7712
+ if (url && !IM_BOT_URL_RE.test(url)) {
7713
+ return { ok: false, code: "invalid_url", message: "服务地址必须以 http:// 或 https:// 开头" };
7714
+ }
7715
+ next.url = url;
7716
+ }
7717
+ if (Object.prototype.hasOwnProperty.call(src, "targetChatId")) {
7718
+ const cid = String(src.targetChatId ?? "").trim();
7719
+ if (cid.length > 200) return { ok: false, code: "invalid_target_chat_id", message: "接收会话 ID 过长(最多 200 字)" };
7720
+ next.targetChatId = cid;
7721
+ }
7722
+ if (Object.prototype.hasOwnProperty.call(src, "sessionId")) {
7723
+ const sid = String(src.sessionId ?? "").trim();
7724
+ if (sid.length > 200) return { ok: false, code: "invalid_session_id", message: "会话 ID 过长(最多 200 字)" };
7725
+ // 空串 = **跟随当前活跃会话** → 同时清掉标题(保留一个"孤零零的标题"只会让 UI 说错话)
7726
+ next.sessionId = sid;
7727
+ next.sessionTitle = sid ? String(src.sessionTitle ?? "").trim().slice(0, 200) : "";
7728
+ }
7729
+ if (Object.prototype.hasOwnProperty.call(src, "previewCardAt")) {
7730
+ // 触发信号:必须是数字/可转数字(前端传 Date.now())。非法值直接拒绝,绝不写进文件 ——
7731
+ // 桥只认"数字变大",写个 NaN 进去会让这条信号永久失效且很难排查。
7732
+ const n = Number(src.previewCardAt);
7733
+ if (!Number.isFinite(n) || n <= 0) {
7734
+ return { ok: false, code: "invalid_preview_card_at", message: "预览卡片触发时间戳必须是正整数(毫秒)" };
7735
+ }
7736
+ next.previewCardAt = Math.floor(n);
7737
+ }
7738
+ if (Object.prototype.hasOwnProperty.call(src, "enabled")) next.enabled = !!src.enabled;
7739
+ else if (!next.enabled && next.botId && next.secret) next.enabled = true; // 首次保存凭据 = 想让它生效
7740
+
7741
+ // 凭据完整性:Bot ID 与 Secret 缺一不可(secret 允许"沿用已保存的",但最终必须存在)。
7742
+ // 不做"只存 Bot ID、enabled=false"的半截配置 —— 那种文件桥读起来只会静默失效,用户以为配好了。
7743
+ if (!next.botId) {
7744
+ return { ok: false, code: "invalid_bot_id", message: "请先填写 Bot ID(在企业微信「智能机器人」页面复制,形如 aibxxxxxxxx)" };
7745
+ }
7746
+ if (!next.secret) {
7747
+ return { ok: false, code: "missing_secret", message: "请先填写 Secret(企业微信智能机器人页面生成的密钥,只保存在本机)" };
7748
+ }
7749
+
7750
+ const out = { ...bots, version: 1, wecom: next };
7751
+ try {
7752
+ writeImBots(relayDir, out);
7753
+ return { ok: true };
7754
+ } catch (e) {
7755
+ return { ok: false, code: "write_failed", message: `配置保存失败:${e?.message || e}` };
7756
+ }
7757
+ }
7758
+
7759
+ /**
7760
+ * POST /dsh-remote/im-bots/test:保存(若带凭据)→ 轮询桥写的状态文件 ≤ waitMs。
7761
+ * 返回面板直接可渲染的结论:连接中 / 已连接 / 失败原因(桥的 lastError 原文照登)。
7762
+ */
7763
+ async function imBotsTest(relayDir, waitMs, input) {
7764
+ const hasInput = input && typeof input === "object" && Object.keys(input).length > 0;
7765
+ if (hasInput) {
7766
+ const saved = saveWecomConfig(relayDir, input);
7767
+ if (!saved.ok) return { httpCode: 400, body: { ok: false, error: saved.code, message: saved.message } };
7768
+ }
7769
+ const w = wecomCfgOf(readImBots(relayDir));
7770
+ if (!w.secret) {
7771
+ return { httpCode: 400, body: { ok: false, error: "missing_secret", message: "还没有保存 Secret:请先填写并保存(Secret 只保存在本机)" } };
7772
+ }
7773
+ const startedAt = Date.now();
7774
+ const total = Math.max(200, Number(waitMs) || IM_BOT_STATUS_WAIT_MS);
7775
+ let last = wecomStatusOf(readImBotStatus(relayDir));
7776
+ while (Date.now() - startedAt < total) {
7777
+ if (last.connected) {
7778
+ return { httpCode: 200, body: { ok: true, connected: true, authenticated: !!last.authenticated, lastError: last.lastError || "" } };
7779
+ }
7780
+ await new Promise((r) => { const t = setTimeout(r, IM_BOT_STATUS_POLL_MS); try { t.unref?.(); } catch { /* 忽略 */ } });
7781
+ last = wecomStatusOf(readImBotStatus(relayDir));
7782
+ if (last.lastError && !last.connected) break; // 桥已经明确报错:不用傻等到超时
7783
+ }
7784
+ if (last.connected) {
7785
+ return { httpCode: 200, body: { ok: true, connected: true, authenticated: !!last.authenticated, lastError: last.lastError || "" } };
7786
+ }
7787
+ if (last.lastError) {
7788
+ return { httpCode: 502, body: { ok: false, error: "connect_failed", lastError: last.lastError, message: `连接失败:${last.lastError}` } };
7789
+ }
7790
+ return { httpCode: 504, body: { ok: false, error: "timeout", message: "等待桥连接超时(10 秒内没有收到连接结果)。请确认电脑端 Bridge 在运行,然后重试。" } };
7791
+ }
7792
+
7793
+ // ═══════════════════════════════════════════════════════════════════════════════════════════════════
7794
+
7795
+ function registerRoutes(ctx, relayDir, opts = {}) {
7546
7796
  // 本插件所在 profile:与配对备份的持久数据目录共用同一份推导(pluginProfileDir),
7547
7797
  // 避免"卸载删的目录"和"备份写的目录"各算各的(2026-10-17 双份备份加固时抽出)。
7548
7798
  const profileDir = pluginProfileDir();
@@ -8337,6 +8587,50 @@ function registerRoutes(ctx, relayDir) {
8337
8587
  await proxyCommunityInvite(relayDir, req, res, url.pathname);
8338
8588
  },
8339
8589
  },
8590
+ // 🤖 IM 机器人通道(2026-11-07):面板「通知与消息通道」弹层的数据面。
8591
+ // 与其它 /dsh-remote/* 一样只在本机 webserver 上注册(跨站请求已被 localRequestBlocked 挡掉)。
8592
+ // ⚠️ secret 永不返回浏览器 —— 三个响应里都只有 hasSecret / lastError 这类非敏感字段。
8593
+ // ⚠️ 用**一条** ALL 路由在 handler 内按 req.method 分发:webserver 的注册是按 path 的,
8594
+ // 同一个 path 注册两条(GET/PUT)只会有一条生效 —— 那是 2026-11-07 本地实测踩到的坑
8595
+ // (面板保存打到了 GET 上,看着"没报错但没保存")。feedback/announcements 用 prefix 同理。
8596
+ {
8597
+ method: "ALL",
8598
+ path: "/dsh-remote/im-bots",
8599
+ handler: async (req, res) => {
8600
+ const method = String(req.method || "GET").toUpperCase();
8601
+ if (method === "GET") {
8602
+ sendJson(res, 200, imBotsView(relayDir));
8603
+ return;
8604
+ }
8605
+ if (method !== "PUT") {
8606
+ sendJson(res, 405, { ok: false, error: "method_not_allowed", message: "只支持 GET / PUT" });
8607
+ return;
8608
+ }
8609
+ const body = await readJsonBody(req);
8610
+ if (body.__parseError) return sendJson(res, 400, { ok: false, error: "bad_request", message: "请求体不是合法 JSON" });
8611
+ const wecom = body && typeof body.wecom === "object" && body.wecom ? body.wecom : null;
8612
+ if (!wecom) return sendJson(res, 400, { ok: false, error: "bad_request", message: "缺少 wecom 配置" });
8613
+ const saved = saveWecomConfig(relayDir, wecom);
8614
+ if (!saved.ok) return sendJson(res, 400, { ok: false, error: saved.code, message: saved.message });
8615
+ sendJson(res, 200, imBotsView(relayDir));
8616
+ },
8617
+ },
8618
+ {
8619
+ method: "ALL",
8620
+ path: "/dsh-remote/im-bots/test",
8621
+ handler: async (req, res) => {
8622
+ const method = String(req.method || "GET").toUpperCase();
8623
+ if (method !== "POST") {
8624
+ sendJson(res, 405, { ok: false, error: "method_not_allowed", message: "只支持 POST" });
8625
+ return;
8626
+ }
8627
+ const body = await readJsonBody(req);
8628
+ // body 可选:不传 = 只测**已保存**的配置;传 = 先保存再测(与面板「保存并连接」一次点完一致)
8629
+ const wecom = body && !body.__parseError && typeof body.wecom === "object" && body.wecom ? body.wecom : null;
8630
+ const r = await imBotsTest(relayDir, opts.imBotsWaitMs, wecom);
8631
+ sendJson(res, r.httpCode, r.body);
8632
+ },
8633
+ },
8340
8634
  ];
8341
8635
 
8342
8636
  const disposers = [];
@@ -8680,7 +8974,10 @@ export function apply(ctx, config = {}) {
8680
8974
  markRestartPending(relayDir, "refresh", "插件已更新,刷新页面即可生效");
8681
8975
  ctx.logger?.info?.("dsh-remote-web: 插件文件晚于本次进程启动 → 提示刷新页面");
8682
8976
  }
8683
- ctx.effect(() => registerRoutes(ctx, relayDir), "dsh-remote-web: /dsh-remote routes");
8977
+ ctx.effect(() => registerRoutes(ctx, relayDir, {
8978
+ // 测试钩子:POST /dsh-remote/im-bots/test 的等待上限(默认 10s,见 imBotsTest 注释)。
8979
+ imBotsWaitMs: Number(config.imBotsWaitMs) || undefined,
8980
+ }), "dsh-remote-web: /dsh-remote routes");
8684
8981
  // 0.1.2-rc.1+ 浏览器会话代持:换取 Harness 会话 Cookie 供 bridge 上游携带(手机点设备不再 401 白页)
8685
8982
  ctx.effect(() => scheduleHarnessMint(ctx, relayDir), "dsh-remote-web: harness browser-session mint");
8686
8983
  // 目录选择器:远程控制下必须用浏览器内实现(否则手机点「添加工作区」像没反应) —— 见上方长注释
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-remote-web",
3
- "version": "0.7.10-beta.3",
3
+ "version": "0.7.11-beta.3",
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",