@wenbin_wb/dsh-bridge 2.5.0 → 2.5.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/README.en.md +122 -19
- package/README.md +122 -21
- package/client/client.js +308 -28
- package/client/index.js +283 -32
- package/docs/banner.jpg +0 -0
- package/docs/feishu-usage.md +5 -0
- package/docs/platform-abstraction-design.md +473 -0
- package/docs/screenshots/admin-lock-screen.jpg +0 -0
- package/docs/screenshots/feishu-bot-config.jpg +0 -0
- package/docs/screenshots/feishu-chat.jpg +0 -0
- package/docs/screenshots/lan-access.jpg +0 -0
- package/docs/screenshots/qq-bot-config.jpg +0 -0
- package/docs/screenshots/qq-chat.jpg +0 -0
- package/docs/screenshots/qq-group.jpg +0 -0
- package/docs/screenshots/qr-scan.jpg +0 -0
- package/docs/screenshots/remote-auth-login.jpg +0 -0
- package/docs/screenshots/remote-web-mobile.jpg +0 -0
- package/docs/screenshots/security-auth-config.jpg +0 -0
- package/docs/screenshots/telegram-bot-config.jpg +0 -0
- package/docs/screenshots/tunnel-access.jpg +0 -0
- package/docs/screenshots/wechat-bot-config.jpg +0 -0
- package/docs/screenshots/wechat-chat.jpg +0 -0
- package/docs/telegram-usage.md +2 -0
- package/docs/wechat-bot-plan.md +294 -0
- package/lib/auth/manager.js +63 -18
- package/lib/bridge-rpc.js +95 -8
- package/lib/feishu/node.js +4 -4
- package/lib/index.js +91 -22
- package/lib/qq/node.js +9 -2
- package/lib/telegram/node.js +4 -4
- package/lib/tunnel-client.mjs +11 -2
- package/lib/wechat/media.js +8 -2
- package/lib/wechat/node.js +2 -2
- package/package.json +11 -12
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
# 微信 ClawBot / iLink Bot 接入方案设计
|
|
2
|
+
|
|
3
|
+
> 目标:在本插件 `dsh-bridge`(`@wenbin_wb/dsh-bridge`)的「远程访问」页面里新增一个**微信 Bot** 入口,
|
|
4
|
+
> 让用户扫码登录微信个人号后,直接在微信里对话、驱动和控制本地 DSH agent(会话切换、新建、停止、审批)。
|
|
5
|
+
> 底座为腾讯官方的 **iLink Bot API**(微信 ClawBot 插件功能)。方案同时预留 QQ / 飞书多平台扩展。**本轮只做方案,不写代码。**
|
|
6
|
+
|
|
7
|
+
---
|
|
8
|
+
|
|
9
|
+
## 一、结论先行(TL;DR)
|
|
10
|
+
|
|
11
|
+
1. **iLink 是「拉取式长轮询」协议,不是「推送式回调」**。这与微信公众平台/飞书事件回调完全不同,
|
|
12
|
+
因此**完全不需要** dsh-bridge 现有的自建隧道 / Cloudflare / webhook 入口 —— 机器人像"隧道客户端"一样
|
|
13
|
+
从 DSH 所在的电脑主动连出到腾讯服务器 `ilinkai.weixin.qq.com`,天然打洞,无需公网 IP/端口。
|
|
14
|
+
2. 市面上已有一个**成熟、可直接参考/复用的开源实现**:`Jesse-njx/dsh-chatnode-wechat`,它已经完整实现了
|
|
15
|
+
"微信 iLink 网关 + DSH 会话桥",与我们的需求几乎一致。**建议在其协议实现上二次开发/精简,而非从零逆向。**
|
|
16
|
+
3. 本项目 nicest 的做法:把「iLink 网关」与「DSH 会话桥」做成 dsh-bridge 内的独立模块(`lib/wechat/`),
|
|
17
|
+
网关层 Platform-agnostic,以后 QQ / 飞书只需各写一个薄网关适配器,复用同一套会话桥 / 命令 / 审批逻辑。
|
|
18
|
+
4. **安全是首要约束**:强制 `allowFrom` 白名单,非白名单发件人的消息绝不喂给模型(防 prompt injection)。
|
|
19
|
+
5. **风险提示(必须先对用户讲清)**:
|
|
20
|
+
- iLink 是腾讯官方开放通道,仍有内容过滤/限速/条款约束;
|
|
21
|
+
- 每条 bot token 只允许**一个** poller,与 hermes-agent / openclaw 同账号并存会互相 403 掉线;
|
|
22
|
+
- 建议用**专用微信小号**承载,避免主号被封影响日常使用。
|
|
23
|
+
|
|
24
|
+
---
|
|
25
|
+
|
|
26
|
+
## 二、iLink Bot API 协议速览(已调研确认)
|
|
27
|
+
|
|
28
|
+
**接入域名**:`https://ilinkai.weixin.qq.com`(HTTP/JSON,无需 SDK,直接 fetch)
|
|
29
|
+
**CDN**:`https://novac2c.cdn.weixin.qq.com/c2c`(媒体 AES-128-ECB 加密)
|
|
30
|
+
|
|
31
|
+
### 关键 Endpoint
|
|
32
|
+
|
|
33
|
+
| Endpoint | 方法 | 用途 |
|
|
34
|
+
|---|---|---|
|
|
35
|
+
| `/ilink/bot/get_bot_qrcode` | GET | 获取登录二维码(`?bot_type=3`) |
|
|
36
|
+
| `/ilink/bot/get_qrcode_status` | GET | 轮询扫码状态,返回 `bot_token` + `baseurl` |
|
|
37
|
+
| `/ilink/bot/getupdates` | POST | **长轮询收消息**(核心,hold 最多 35s) |
|
|
38
|
+
| `/ilink/bot/sendmessage` | POST | 发消息(文本/图片/文件/视频/语音) |
|
|
39
|
+
| `/ilink/bot/sendtyping` | POST | 显示"正在输入" |
|
|
40
|
+
| `/ilink/bot/getconfig` | POST | 获取 typing_ticket(每用户缓存 24h) |
|
|
41
|
+
| `/ilink/bot/getuploadurl` | POST | 媒体 CDN 预签名上传 |
|
|
42
|
+
|
|
43
|
+
### 鉴权头(每个请求都带)
|
|
44
|
+
|
|
45
|
+
```
|
|
46
|
+
Content-Type: application/json
|
|
47
|
+
AuthorizationType: ilink_bot_token
|
|
48
|
+
X-WECHAT-UIN: base64(randomUint32) # 每次随机,防重放
|
|
49
|
+
iLink-App-Id: bot
|
|
50
|
+
iLink-App-ClientVersion: <2.x>
|
|
51
|
+
Authorization: Bearer <bot_token> # 扫码后才有
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
### 消息收发核心
|
|
55
|
+
|
|
56
|
+
- 收:`POST getupdates { get_updates_buf: "<游标,首次空>", base_info }`,
|
|
57
|
+
返回 `{ msgs, get_updates_buf, longpolling_timeout_ms }`。
|
|
58
|
+
`get_updates_buf` 是**游标**,必须每次回传更新,否则重复收消息。
|
|
59
|
+
- 发:`POST sendmessage`,**必须原样回带最新 `context_token`**(取自收到的消息),
|
|
60
|
+
缺少字段会导致 HTTP 200 但消息静默丢失。
|
|
61
|
+
`sendmessage` 结构见下。
|
|
62
|
+
- 消息里 `message_type`:1=用户消息;`item_list[].type`:1=文本 2=图片 3=语音 4=文件 5=视频
|
|
63
|
+
|
|
64
|
+
### sendmessage 必填结构
|
|
65
|
+
|
|
66
|
+
```json
|
|
67
|
+
{
|
|
68
|
+
"msg": {
|
|
69
|
+
"from_user_id": "",
|
|
70
|
+
"to_user_id": "<用户ID@im.wechat>",
|
|
71
|
+
"client_id": "openclaw-weixin-<随机hex>",
|
|
72
|
+
"message_type": 2,
|
|
73
|
+
"message_state": 2,
|
|
74
|
+
"context_token": "<原样取回>",
|
|
75
|
+
"item_list": [{ "type": 1, "text_item": { "text": "回复" } }]
|
|
76
|
+
},
|
|
77
|
+
"base_info": { "channel_version": "2.4.3", "bot_agent": "..." }
|
|
78
|
+
}
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
**用户 ID 格式**:`xxx@im.wechat`;**Bot ID**:`xxx@im.bot`;登录后每次扫码 Bot ID 会变。
|
|
82
|
+
|
|
83
|
+
### 媒体(v0.2 再做)
|
|
84
|
+
|
|
85
|
+
图片/文件/语音:随机 AES-128-ECB key → 加密 → `getuploadurl` 拿预签名 URL → PUT 到 CDN →
|
|
86
|
+
`sendmessage` 带 `aes_key`(base64) + CDN 引用。语音 silk 编码,接收时可附带转文字。
|
|
87
|
+
|
|
88
|
+
---
|
|
89
|
+
|
|
90
|
+
## 三、架构设计
|
|
91
|
+
|
|
92
|
+
### 3.1 整体数据流
|
|
93
|
+
|
|
94
|
+
```
|
|
95
|
+
微信用户
|
|
96
|
+
│ 发消息
|
|
97
|
+
▼
|
|
98
|
+
ilinkai.weixin.qq.com(腾讯,长轮询)
|
|
99
|
+
│ getupdates (主动拉) / sendmessage (主动发)
|
|
100
|
+
▼
|
|
101
|
+
dsh-bridge (本地电脑,本插件内)
|
|
102
|
+
├── lib/wechat/gateway/ iLink 网关:扫码登录+长轮询+重连+typing+媒体CDN
|
|
103
|
+
│ → 暴露 ctx.wechat 服务 + 'wechat/message' 事件
|
|
104
|
+
└── lib/wechat/node/ 微信⇄DSH 会话桥:白名单/会话/命令/审批/digest 输出
|
|
105
|
+
→ 消费 ctx.sessions / ctx.agents / ctx.approval
|
|
106
|
+
▼
|
|
107
|
+
DSH (运行在 127.0.0.1:3080,agent 会话)
|
|
108
|
+
```
|
|
109
|
+
|
|
110
|
+
**关键点:这是纯 Outbound 连接**,类似现有 `lib/tunnel-client.mjs` 的"本地主动连出"模式,
|
|
111
|
+
所以不需要自建隧道也可以工作;即使配合隧道,隧道也只在"手机浏览器远程访问 DSH 网页"时才需要,
|
|
112
|
+
与微信机器人链路**相互独立**。
|
|
113
|
+
|
|
114
|
+
### 3.2 模块划分(可插拔,为 QQ/飞书预留)
|
|
115
|
+
|
|
116
|
+
```
|
|
117
|
+
lib/
|
|
118
|
+
wechat/
|
|
119
|
+
gateway/
|
|
120
|
+
ilink-client.ts # 纯协议:qrLogin/getupdates/sendmessage/sendtyping/getconfig/media CDN
|
|
121
|
+
gateway.ts # Cordis 服务(Service<WechatGateway>):轮询循环/重连退避/403独占锁/上下文token表
|
|
122
|
+
media.ts # AES-128-ECB 加解密 + CDN 上传下载
|
|
123
|
+
types.ts # 消息结构/常量/配置 schema
|
|
124
|
+
node/ # 微信⇄DSH 会话桥(平台无关部分尽量抽到通用 layer)
|
|
125
|
+
core.ts # 编排:活跃会话/白名单/审批队列
|
|
126
|
+
inbound.ts # 入站:提取文本/群聊识别/只放行白名单
|
|
127
|
+
outbound.ts # 出站:digest 式摘要/分块/限流/心跳
|
|
128
|
+
commands.ts # /sessions /use /new /stop /status /yes /no /help
|
|
129
|
+
approvals.ts # DSH 审批请求 ⇄ 微信文本问答(1/2 或 /yes /no)
|
|
130
|
+
index.ts # 插件入口
|
|
131
|
+
bots/ # 未来:qq/, feishu/ 等网关适配器,复用 node/ 会话桥
|
|
132
|
+
```
|
|
133
|
+
|
|
134
|
+
**多平台策略**:把 `node/`(会话桥)设计成**平台无关**的上层,网关通过统一接口注入收/发能力。
|
|
135
|
+
未来 QQ / 飞书各自写一个 `gateway/` 适配器即可复用整套会话/命令/审批逻辑,这正是用户后续诉求。
|
|
136
|
+
|
|
137
|
+
### 3.3 与 DSH 内部服务的对接(沿用 dsh-chatnode-wechat 已验证的注入面)
|
|
138
|
+
|
|
139
|
+
```
|
|
140
|
+
inject = ['wechat', 'sessions', 'agents', 'approval']
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
- `ctx.sessions` / `ctx.agents`:会话列表、新建 agent(`ctx.agents.create({sessionId, meta:{cwd,agentPreset}, agentOptions})`)、
|
|
144
|
+
`agent.followup(createUserMessage(...))` 驱动、`agent.stop()`。
|
|
145
|
+
- `ctx.approval`:DSH 在 agent 需要权限时发审批请求;桥把「工具名+原因」渲染成微信文本问询,
|
|
146
|
+
用户回 `/yes` `/no`(或唯一待审批时回 1/2),超时默认拒绝(`allowed-once` / deny)。
|
|
147
|
+
参考 `dsh-chatnode-wechat` 的 `approvals.ts`。
|
|
148
|
+
|
|
149
|
+
### 3.4 出站策略(微信无富文本/按钮,用文本摘要,不刷屏)
|
|
150
|
+
|
|
151
|
+
- 回合开始:`⏳ 收到,开始处理…`
|
|
152
|
+
- 进行中:每 `digestIntervalSec` 报一次 `🔄 仍在处理中…` 心跳
|
|
153
|
+
- 产出:assistant 实际文本,按 `maxMessageChars`(微信气泡上限 ~2000)分块、`sendChunkDelayMs` 限流
|
|
154
|
+
- 结束:`✅ 完成` / `❌ 出错…` / `⏹ 已停止`
|
|
155
|
+
|
|
156
|
+
(注:项目偏好不用 emoji,上述标志仅是参考实现 dsh-chatnode 的风格,落地时换成纯文本标记,如 `[收到]`、`[处理中]`、`[完成]` 等。)
|
|
157
|
+
|
|
158
|
+
---
|
|
159
|
+
|
|
160
|
+
## 四、UI 集成(远程访问页新增「微信 Bot」卡片)
|
|
161
|
+
|
|
162
|
+
现有 `client/index.js` 是设置页「远程访问」面板,`bridge-rpc.js` 走 loopback RPC。
|
|
163
|
+
新增入口沿用同一套模式:
|
|
164
|
+
|
|
165
|
+
### 4.1 RPC 新增 endpoint(`lib/bridge-rpc.js`)
|
|
166
|
+
|
|
167
|
+
| Endpoint | 用途 |
|
|
168
|
+
|---|---|
|
|
169
|
+
| `wechatGetStatus` | 状态:idle/starting/connected/reconnecting/paused/error、账号、白名单、会话数 |
|
|
170
|
+
| `wechatLogin` | 发起扫码(返回二维码 dataURL,浏览器可直接展示,复用现有 `QrCache`) |
|
|
171
|
+
| `wechatSetAllowFrom` | 设置白名单(安全,必填) |
|
|
172
|
+
| `wechatStop` | 断开网关 |
|
|
173
|
+
| `wechatSetConfig` | 心跳间隔/审批超时/每气泡字数等 |
|
|
174
|
+
|
|
175
|
+
### 4.2 卡片 UI(`client/index.js` 新增 React 组件)
|
|
176
|
+
|
|
177
|
+
```
|
|
178
|
+
┌─ 微信 Bot ─────────────────────────────────────┐
|
|
179
|
+
│ 状态: ● 已连接 (账号: xxx, 会话: 3) [断开] │
|
|
180
|
+
│ │
|
|
181
|
+
│ [首次] 扫码授权: <二维码 dataURL> │
|
|
182
|
+
│ 白名单(你的微信ID): [o9cq...@im.wechat] │
|
|
183
|
+
│ 提示: 只有白名单账号能驱动 agent │
|
|
184
|
+
│ [保存白名单] [重新扫码] │
|
|
185
|
+
└─────────────────────────────────────────────────┘
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
### 4.3 UI 重新设计的思考(用户提到"现在的 UI 可以考虑重新设计")
|
|
189
|
+
|
|
190
|
+
- 现状是单页多个独立卡片(LAN 二维码、Cloudflare、自建隧道、微信 Bot)。
|
|
191
|
+
- 建议演进为**分 Tab / 步骤式导航**:`局域网 | 公网隧道 | IM 机器人`。
|
|
192
|
+
- IM 机器人 Tab 内做成**平台列表**(微信 / QQ / 飞书,未接入的置灰),选中微信后进入上图卡片。
|
|
193
|
+
- 复用现有 `QrCache`(已带 TTL+LRU)与 `s` 样式变量,保持视觉一致。
|
|
194
|
+
- UI 重新设计可单独作为一个子任务,优先保证微信 Bot 功能链路先通。
|
|
195
|
+
|
|
196
|
+
---
|
|
197
|
+
|
|
198
|
+
## 五、安全设计(必须项)
|
|
199
|
+
|
|
200
|
+
1. **强制白名单** `allowFrom`:无默认、空则拒绝启动。非白名单发件人的消息仅记录日志,绝不喂给模型。
|
|
201
|
+
这是防 prompt-injection 的前门。
|
|
202
|
+
2. **审批默认拒绝 + 超时自动拒绝**:`approvalTimeoutSec`(默认 600s)内未回 `/yes` 则 deny。
|
|
203
|
+
3. bot token 存 DSH 凭证服务(`$DSH_HOME/.credentials.yaml`),**不落 patch 文件、不提交仓库**。
|
|
204
|
+
与现有 `.credentials` 偏好一致。
|
|
205
|
+
4. 仅回应当前由该微信用户驱动的 agent 的审批,其余沿 Cordis answerer 链下放。
|
|
206
|
+
5. **独占锁提示**:同一 bot token 只允许一个 poller。若用户同时跑 hermes-agent / openclaw,
|
|
207
|
+
检测到 403 时**响亮报错并停止轮询**,而不是无限重试。
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
211
|
+
## 六、命名 / 依赖 / 配置
|
|
212
|
+
|
|
213
|
+
### 依赖(新增)
|
|
214
|
+
- 纯实现无需新三方依赖(Node 18+ 内置 fetch)。媒体可选:`node:crypto` 内置 AES。
|
|
215
|
+
|
|
216
|
+
### 配置示例(`cordis.patch.yml` / 设置页持久化)
|
|
217
|
+
|
|
218
|
+
```yaml
|
|
219
|
+
plugins:
|
|
220
|
+
dsh-bridge:
|
|
221
|
+
wechat:
|
|
222
|
+
allowFrom: ["<your-wechat-id>"] # 必填,web UI 里设置并持久化到 $DSH_HOME/dsh-bridge/config.json
|
|
223
|
+
digestIntervalSec: 300
|
|
224
|
+
approvalTimeoutSec: 600
|
|
225
|
+
maxMessageChars: 2000
|
|
226
|
+
sendChunkDelayMs: 1500
|
|
227
|
+
# agentPreset / agentProvider / agentModel / cwd # 用于 /new 会话
|
|
228
|
+
```
|
|
229
|
+
|
|
230
|
+
持久化沿用现有 `loadConfig/saveConfig`(`$DSH_HOME/dsh-bridge/config.json`),凭证单独走 DSH 凭证服务。
|
|
231
|
+
|
|
232
|
+
---
|
|
233
|
+
|
|
234
|
+
## 七、里程碑(Roadmap)
|
|
235
|
+
|
|
236
|
+
**v0.1(微信 Bot 最小可用)— 已实施并完成真机验证**
|
|
237
|
+
- [x] iLink 网关:扫码登录、长轮询收消息、sendmessage、sendtyping、自动重连/退避、403 独占锁
|
|
238
|
+
- [x] 会话桥:白名单、`/sessions /use /new /stop /status /help`、文本双向
|
|
239
|
+
- [x] 审批桥:`/yes /no`(1/2)、超时默认拒绝
|
|
240
|
+
- [x] 出站 digest:`[收到]/[处理中]/[完成]/[出错]`,分块+限流
|
|
241
|
+
- [x] RPC + UI 卡片:「微信 Bot」入口(扫码、状态、白名单管理)
|
|
242
|
+
- [x] README / README.zh-CN 增加微信接入章节
|
|
243
|
+
- [x] 真机验证:微信扫码登录 → `/new` 创建会话 → agent 正常回复(routing-suite preset 模板变量 `{{cwd}}`/`{{model}}` 已补默认值)
|
|
244
|
+
|
|
245
|
+
**v0.2(媒体双向支持)— 实施中,入站下载解密已修通,待真机最终验证**
|
|
246
|
+
- [x] 媒体入站:图片/文件接收(AES-128-ECB 解密 + 保存到 `.wechat-media/` + 路径附加到消息通知 agent)
|
|
247
|
+
- [x] `lib/wechat/media.js`:AES 加解密、PKCS#7 填充、CDN 上传下载、SSRF 防护、aes_key 归一化
|
|
248
|
+
- [x] `gateway.js` 扩展:`getUploadUrl` + `sendMedia` 方法支持媒体消息收发
|
|
249
|
+
- [x] `node.js` 扩展:`_processMediaItems` / `_downloadMediaItem` 处理媒体入站
|
|
250
|
+
- [x] 正确的媒体字段结构:`image_item.media` / `file_item.media` / `video_item.media`(含 `encrypt_query_param`)
|
|
251
|
+
- [x] AES key 归一化:兼容裸 hex(`image_item.aeskey`)与 base64 编码
|
|
252
|
+
- [x] 语音转文字:`extractText` 提取 `voice_item.text`
|
|
253
|
+
- [x] 单元测试:16 个媒体测试(含 normalizeAesKey)
|
|
254
|
+
- [x] 优化:去掉回合开始 `[OK] 收到,开始处理…` 刷屏,改 typing 指示 + 心跳;活动会话持久化
|
|
255
|
+
- [x] 真机验证:微信发图片 → 下载解密保存到 `.wechat-media/`(文件头 `FFD8` 确认为标准 JPEG)→ 路径通知 agent → agent 响应。全链路打通
|
|
256
|
+
- [ ] 媒体出站:agent 自动发送文件功能(API 已就绪,需监控 agent 文件输出并自动上传,待后续实现)
|
|
257
|
+
|
|
258
|
+
**v0.3(多平台 + 群聊)**
|
|
259
|
+
- [ ] 群聊支持(风险高,可选)
|
|
260
|
+
- [ ] 抽象 `node/` 会话桥为平台无关 layer
|
|
261
|
+
- [ ] 增加 QQ(NapCat/Mirai)、飞书(官方 push API)网关适配器,复用同一会话桥
|
|
262
|
+
- [ ] UI 重构为「局域网 | 公网隧道 | IM 机器人」分 Tab 结构
|
|
263
|
+
|
|
264
|
+
---
|
|
265
|
+
|
|
266
|
+
## 八、参考实现(可复用/对照)
|
|
267
|
+
|
|
268
|
+
| 项目 | 链接 | 说明 |
|
|
269
|
+
|---|---|---|
|
|
270
|
+
| dsh-chatnode-wechat | https://github.com/Jesse-njx/dsh-chatnode-wechat | **最相关**:微信 iLink 网关 + DSH 会话桥,含 35 个测试、fake-iLink-server、fixtures,可直接参考或精简复用 |
|
|
271
|
+
| weixin-ClawBot-API | https://github.com/SiverKing/weixin-ClawBot-API | Python + Node 双实现,含 24h 自动重连、config.json 多 provider |
|
|
272
|
+
| openclaw-weixin 逆向文档 | https://github.com/hao-ji-xing/openclaw-weixin/blob/main/weixin-bot-api.md | iLink 协议完整技术解析 |
|
|
273
|
+
| hermes-agent weixin.py | https://github.com/NousResearch/hermes-agent/blob/main/gateway/platforms/weixin.py | 生产级 Python 网关,含证书/重连/媒体细节,协议参考价值高 |
|
|
274
|
+
| OpenClaw 官方 | https://docs.openclaw.ai 、npm `@tencent-weixin/openclaw-weixin` | 官方通道背景 |
|
|
275
|
+
|
|
276
|
+
---
|
|
277
|
+
|
|
278
|
+
## 九、决策记录(用户已拍板,实施前无需再问)
|
|
279
|
+
|
|
280
|
+
| # | 议题 | 决定 | 说明 |
|
|
281
|
+
|---|---|---|---|
|
|
282
|
+
| 1 | 实现底座 | **复用/精简 dsh-chatnode-wechat 协议层** | 在其 MIT 协议层上二次开发,改造成 dsh-bridge 内置 `lib/wechat/` 模块(含测试/fixtures 一并利用),不从零逆向 |
|
|
283
|
+
| 2 | UI 结构 | **允许演进为分 Tab** | 「局域网 \| 公网隧道 \| IM 机器人」,拆成 v0.3 独立迭代,v0.1 先以卡片形式嵌入现有面板 |
|
|
284
|
+
| 3 | 白名单 | **扫码即自动加入(一步到位)** | 扫码登录成功后,把返回的"我的微信 ID"自动写入 `allowFrom`;同时 UI 允许手动增删维护 |
|
|
285
|
+
| 4 | v0.1 范围 | **只做文本** | 媒体/群聊放 v0.2 |
|
|
286
|
+
|
|
287
|
+
> 以上即用户对第八节外遗留问题的最终拍板,新会话实施时直接按此执行,不要再让用户重复。
|
|
288
|
+
|
|
289
|
+
---
|
|
290
|
+
|
|
291
|
+
## 十、遗留问题(其余需实施时注意)
|
|
292
|
+
|
|
293
|
+
- 白名单"扫码自动加入"仍需在 UI 上提供**手动增删白名单**入口,便于增补其他微信用户或移除。所有非白名单发件人消息仅记日志、绝不喂模型。
|
|
294
|
+
- UI 分 Tab 重构(v0.3)与微信 Bot 卡片(v0.1)的关系:v0.1 先在现有面板加卡片,不做整体布局调整。
|
package/lib/auth/manager.js
CHANGED
|
@@ -34,6 +34,8 @@ export class AuthManager {
|
|
|
34
34
|
this.adminPasswordSalt = config.adminPasswordSalt || ''
|
|
35
35
|
this.secretToken = config.secretToken || this._generateToken()
|
|
36
36
|
this.allowLoopback = config.allowLoopback !== false
|
|
37
|
+
// 内部隧道专用鉴权密钥(内存生成,用于辨别本地自建隧道转发流量与真实本机访问)
|
|
38
|
+
this.internalTunnelSecret = randomBytes(24).toString('hex')
|
|
37
39
|
|
|
38
40
|
// Session 内存存储:sessionToken -> { createdAt, expiresAt }
|
|
39
41
|
this.sessions = new Map()
|
|
@@ -63,7 +65,43 @@ export class AuthManager {
|
|
|
63
65
|
return Boolean(this.adminPasswordHash && this.adminPasswordSalt)
|
|
64
66
|
}
|
|
65
67
|
|
|
66
|
-
|
|
68
|
+
/**
|
|
69
|
+
* 获取安全认证状态(默认脱敏保护,防止普通接口泄露完整 Secret Token)
|
|
70
|
+
*/
|
|
71
|
+
getStatus({ masked = true } = {}) {
|
|
72
|
+
let token = this.secretToken;
|
|
73
|
+
if (masked && token) {
|
|
74
|
+
token = `${token.slice(0, 8)}****************`;
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
enabled: this.enabled,
|
|
78
|
+
mode: this.mode,
|
|
79
|
+
scope: this.scope,
|
|
80
|
+
adminPolicy: this.adminPolicy,
|
|
81
|
+
hasPassword: this.hasPassword,
|
|
82
|
+
hasAdminPassword: this.hasAdminPassword,
|
|
83
|
+
secretToken: token,
|
|
84
|
+
allowLoopback: this.allowLoopback,
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* 仅限经过鉴权的管理员会话或公开策略下获取原始未脱敏 Secret Token
|
|
90
|
+
*/
|
|
91
|
+
getRawSecretToken(adminToken) {
|
|
92
|
+
if (this.adminPolicy !== 'open') {
|
|
93
|
+
const hasAnyPassword = this.hasAdminPassword || this.hasPassword
|
|
94
|
+
if (hasAnyPassword && (!adminToken || !this.validateAdminSession(adminToken))) {
|
|
95
|
+
return null
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return this.secretToken
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* 公开状态查询(供未认证访客/登录页使用,严格不包含 secretToken)
|
|
103
|
+
*/
|
|
104
|
+
getPublicStatus() {
|
|
67
105
|
return {
|
|
68
106
|
enabled: this.enabled,
|
|
69
107
|
mode: this.mode,
|
|
@@ -71,7 +109,6 @@ export class AuthManager {
|
|
|
71
109
|
adminPolicy: this.adminPolicy,
|
|
72
110
|
hasPassword: this.hasPassword,
|
|
73
111
|
hasAdminPassword: this.hasAdminPassword,
|
|
74
|
-
secretToken: this.secretToken,
|
|
75
112
|
allowLoopback: this.allowLoopback,
|
|
76
113
|
}
|
|
77
114
|
}
|
|
@@ -171,6 +208,10 @@ export class AuthManager {
|
|
|
171
208
|
return true
|
|
172
209
|
}
|
|
173
210
|
|
|
211
|
+
revokeAdminSession(adminToken) {
|
|
212
|
+
if (adminToken) this.adminSessions.delete(adminToken)
|
|
213
|
+
}
|
|
214
|
+
|
|
174
215
|
verifyAdminPassword(inputPassword, clientIp = '') {
|
|
175
216
|
if (this.isIpBlocked(clientIp)) {
|
|
176
217
|
return { success: false, error: '尝试次数过多,请稍后再试' }
|
|
@@ -244,6 +285,11 @@ export class AuthManager {
|
|
|
244
285
|
return { success: false, error: '尝试次数过多,请稍后再试' }
|
|
245
286
|
}
|
|
246
287
|
|
|
288
|
+
// 严禁 token_only 模式下通过密码登录接口获取 Session
|
|
289
|
+
if (this.mode === 'token_only') {
|
|
290
|
+
return { success: false, error: '当前仅允许专属安全 Token 扫码访问,不支持密码登录' }
|
|
291
|
+
}
|
|
292
|
+
|
|
247
293
|
if (!this.hasPassword) {
|
|
248
294
|
// 若处于仅密码模式但尚未设置密码,不允许直接空白登录
|
|
249
295
|
if (this.mode === 'password_only') {
|
|
@@ -347,7 +393,7 @@ export class AuthManager {
|
|
|
347
393
|
/**
|
|
348
394
|
* 检查请求是否已授权
|
|
349
395
|
* @param {import('node:http').IncomingMessage} req
|
|
350
|
-
* @returns {{ authenticated: boolean, fromToken?: boolean, sessionToken?: string, loopback?: boolean, bypass?: boolean }}
|
|
396
|
+
* @returns {{ authenticated: boolean, fromToken?: boolean, sessionToken?: string, loopback?: boolean, bypass?: boolean, lanBypass?: boolean, publicBypass?: boolean }}
|
|
351
397
|
*/
|
|
352
398
|
verifyRequest(req) {
|
|
353
399
|
// 1. 未开启认证 -> 允许直通
|
|
@@ -355,25 +401,24 @@ export class AuthManager {
|
|
|
355
401
|
return { authenticated: true, bypass: true }
|
|
356
402
|
}
|
|
357
403
|
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
404
|
+
const remote = req.socket?.remoteAddress || ''
|
|
405
|
+
const isLoopback = (remote === '127.0.0.1' || remote === '::1' || remote === '::ffff:127.0.0.1')
|
|
406
|
+
|
|
407
|
+
// 辨别是否来自自建隧道或 Cloudflare 隧道 (只有从 127.0.0.1 传入且带有合法内部凭据/Cloudflare 标头才算)
|
|
408
|
+
const internalTunnelHeader = req.headers?.['x-dsh-internal-tunnel']
|
|
409
|
+
const isCustomTunnel = Boolean(isLoopback && internalTunnelHeader && internalTunnelHeader === this.internalTunnelSecret)
|
|
410
|
+
const isCloudflare = Boolean(isLoopback && (req.headers?.['cf-ray'] || req.headers?.['cf-connecting-ip']))
|
|
411
|
+
const isPublicTunnel = isCustomTunnel || isCloudflare
|
|
412
|
+
|
|
413
|
+
// 2. 本地环回免认证(真正的宿主机物理浏览器 127.0.0.1 访问,非 Tunnel 转发)
|
|
414
|
+
if (this.allowLoopback && isLoopback && !isPublicTunnel) {
|
|
415
|
+
const host = String(req.headers?.host || '')
|
|
416
|
+
if (host.startsWith('127.0.0.1') || host.startsWith('localhost') || host === '') {
|
|
417
|
+
return { authenticated: true, loopback: true }
|
|
366
418
|
}
|
|
367
419
|
}
|
|
368
420
|
|
|
369
421
|
// 3. 检查防护范围 (scope: 'all' | 'public_only' | 'lan_only')
|
|
370
|
-
const isPublicTunnel = Boolean(
|
|
371
|
-
req.headers?.['cf-connecting-ip'] ||
|
|
372
|
-
req.headers?.['cf-ray'] ||
|
|
373
|
-
req.headers?.['x-dsh-tunnel'] ||
|
|
374
|
-
(req.headers?.['x-forwarded-proto'] && req.headers?.['x-forwarded-host'])
|
|
375
|
-
)
|
|
376
|
-
|
|
377
422
|
if (this.scope === 'public_only' && !isPublicTunnel) {
|
|
378
423
|
return { authenticated: true, lanBypass: true }
|
|
379
424
|
}
|