# 架构与平台无关抽象 ## 一、组件与数据流 ``` ┌─────────────── 出口:发通知 ───────────────┐ │ │ ┌─────┴─────┐ ①任务完成/出错 ┌──────────────┐ │ │ DSH 插件 │ ─────────────────▶ │ 推送 Webhook │──┼──▶ 手机(IM 通知) │ phone- │ │ (任一平台) │ │ │ remote │ └──────────────┘ │ └─────┬─────┘ │ │ ②写 upstream.json / login-link.txt │ ▼ │ ┌──────────────┐ ③转发(改写 Host/Origin) ┌─────────┐ │ 传达室 51130 │ ───────────────────────────▶ │ DSH │ │ phone-proxy │ ◀─────────────────────────── │ 本机端口│ └─────┬────────┘ └─────────┘ ▲ │ ④公网 HTTPS(临时,到点自动关) ┌─────┴──────┐ │ 临时限时门 │◀── 由「开门」指令触发 │ temp-door │ └─────▲──────┘ │ ⑤开门指令(长连接,主动连出去) ┌─────┴──────┐ │ 机器人 │◀── 手机发「开门」 │ wecom-bot │ └────────────┘ 入站方向(⑤)永远不占公网地址;出站方向(①)只发不收。 需要暴露到公网的,只有 ④ 那扇"临时门",且只开短期。 10-19 起:① 和 ④ 之间多了一条"发通知前先确保有门"的连线—— 插件在推通知前会读 `door-state.json`,没有还够用的门就自动叫 `temp-door` 开一扇 30 分钟的 (`--quiet`:不额外推消息,链接由通知本身带出去),然后**把公网地址写进通知**。 所以通知里的链接默认就是"门开着期间可点"的公网地址,不再依赖用户分辨内网/公网。 ``` ## 二、平台无关抽象 本方案把"IM 平台"收敛成**两个接口**,任何满足其一/其二的平台都能适配。 ### 接口 A:推送出口(Push Sink) - **形态**:一个 URL,接受 `POST`,请求体是 JSON。 - **最小契约**: ``` POST Content-Type: application/json { "标题": ..., "正文": ..., "可选链接": ... } ``` - **适配方式**:由于各家 JSON 结构不同,用一个"模板"映射即可,无需改业务代码: | 平台 | 模板(示意) | |---|---| | 企业微信 | `{"msgtype":"markdown","markdown":{"content":"**{title}**\n{body}\n[{linkText}]({link})"}}` | | 飞书 | `{"msg_type":"text","content":{"text":"{title}\n{body}"}}` | | 钉钉 | `{"msgtype":"markdown","markdown":{"title":"{title}","text":"{body}"}}` | | ntfy | `{"topic":"{topic}","title":"{title}","message":"{body}","click":"{link}"}` | - **失败处理**:单条通知失败**不得**影响主流程;记录日志即可(fire-and-forget + 超时)。 ### 接口 B:收信入口(Message Source) - **形态**:**长连接**。客户端用 `Bot ID + Secret` 主动连出去,订阅"收到消息"事件。 - **最小契约**: ``` connect(botId, secret) → 在线 on message(text) → 得到 { 文本, 会话标识 } reply(会话标识, 文本) → 把结果回进同一会话 ``` - **为什么必须是长连接**:回调模式要求一个**固定不变**的公网 HTTPS 地址, 家庭宽带做不到(`PITFALLS.md` 第 1、3 条)。 | 平台 | 连接方式 | 官方 SDK / 协议 | |---|---|---| | 企业微信「智能机器人」 | `wss://openws.work.weixin.qq.com` | `@wecom/aibot-node-sdk` | | 飞书自建应用 | 开放平台事件订阅 → 长连接 | 飞书开放平台 SDK | | QQ 机器人 | 开放平台 WebSocket | QQ 机器人 SDK | - **指令约定(可自定义)**: - 文本包含 `开门` → 触发 `temp-door`,把得到的链接回进会话; - 其他文本 → 先回显确认;若接了大模型/Agent,则转发执行并把结果回会话。 ## 三、进程与文件职责 | 组件 | 进程/文件 | 职责 | 是否需公网地址 | |---|---|---|---| | DSH 插件(Host 半) | `index.js` | 生成带钥匙入口链接;写 `upstream.json`;任务完成/出错推送 | 否 | | DSH 插件(Client 半) | `client.js` | 「📱 指挥台」面板:工作区分区 + 忙闲 + 派活 | 否 | | 传达室 | `phone-proxy.mjs` | 51130 → DSH loopback;改写 `Host`/`Origin`;无票据时续钥匙 | 仅局域网 | | 保活壳 | `phone-proxy-supervisor.mjs` | 子进程挂了自动重启;重复启动自动让位 | — | | 临时门 | `temp-door.mjs` | 起公网 HTTPS 隧道;到点自动关;链接推送 | **临时** | | 机器人 | `bot/wecom-bot.mjs` | 长连接收消息 → 开门/干活 → 回会话 | 否 | **约定文件**(都在运行目录,均应在公开仓库中排除): | 文件 | 作用 | 含敏感信息 | |---|---|---| | `upstream.json` | 代理读取的 DSH 当前地址 | 端口(低敏) | | `login-link.txt` | 带一次性钥匙的入口链接 | **是(凭据)** | | `door-state.json` | 当前临时门状态 | 公网地址(中敏) | | `wecom-bot.json` | 机器人 `botId`/`secret` | **是(凭据)** | | `logs/*.log` | 运行日志 | 可能含链接/用户名 | ## 四、为什么这样切分 1. **插件只管"业务"**(什么时候推、推什么、链接给谁),不掺和传输。 2. **传输各自独立**(局域网代理 / 临时隧道 / 长连接),任一坏掉不影响其余。 3. **不碰宿主程序**:全程不改 DSH 本体,代理只做转发与改写——升级 DSH 不受影响。 4. **可替换**:想换 IM 平台只改接口 A/B 的适配,不碰业务逻辑。 5. **失败可解释**:每个组件独立日志(`logs/`),排障时按"哪一段断"逐段定位。