botmux 2.26.0 → 2.27.0

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 CHANGED
@@ -71,78 +71,71 @@ Compared to OpenClaw-style approaches built on Agent SDKs:
71
71
 
72
72
  ## 5-Minute Setup
73
73
 
74
+ > 💡 **TL;DR**: run `botmux setup` and pick "scan-to-create" to finish Steps 1+2 in one shot (the official `@larksuiteoapi/node-sdk` device flow gives you the AppID/AppSecret). PersonalAgent apps come with event subscriptions and bot capability pre-configured, so only Step 4 (permissions) + Step 5 (optional redirect URL) + Step 6 (publish) require browser clicks; the setup wizard writes a JSON file with a one-line clipboard copy command and prints deep-links to each remaining step.
75
+
74
76
  ### Step 1: Create a Lark App
75
77
 
76
- Go to the [Lark Open Platform](https://open.larkoffice.com/app) and click "Create Custom App".
78
+ **Recommended**: `botmux setup` → pick "1) Scan-to-create app". Scan with the Lark mobile app and the AppID/AppSecret are persisted automatically; no manual browser navigation. Falls back to manual paste on cancel/timeout/network error.
79
+
80
+ > ⚠️ **Currently only Feishu (feishu.cn) tenants are supported.** If scan detects a Lark international (larksuite.com) tenant, setup aborts — the daemon runtime (Lark Client/WSClient/event-dispatcher) hasn't been wired up for the `larksuite.com` domain yet, so accepting Lark credentials would land users in a half-working state. A follow-up PR will add full Lark support.
81
+
82
+ **Manual**: go to the [Lark Open Platform](https://open.larkoffice.com/app) and click "Create Custom App".
77
83
 
78
84
  ![Create App](docs/setup/create-app.png)
79
85
 
80
86
  ### Step 2: Get Credentials
81
87
 
88
+ > The scan-to-create path completes this step automatically; skip to Step 3.
89
+
82
90
  Open the app details page → "Credentials & Basic Info", and copy the **App ID** and **App Secret**.
83
91
 
84
92
  ![Get Credentials](docs/setup/credentials.png)
85
93
 
86
- ### Step 3: Add Permissions
87
-
88
- Go to "Permissions & Scopes" → "Batch Import/Export", and paste the following JSON to import all permissions at once:
89
-
90
- ![Permissions](docs/setup/permissions.png)
91
-
92
- <details>
93
- <summary>Click to expand batch import JSON</summary>
94
-
95
- ```json
96
- {
97
- "scopes": {
98
- "tenant": [
99
- "contact:user.base:readonly",
100
- "contact:user.id:readonly",
101
- "im:chat:read",
102
- "im:chat.members:bot_access",
103
- "im:chat.members:read",
104
- "im:message",
105
- "im:message:readonly",
106
- "im:message:send_as_bot",
107
- "im:message:update",
108
- "im:message.group_at_msg",
109
- "im:message.group_at_msg:readonly",
110
- "im:message.group_msg",
111
- "im:message.p2p_msg:readonly",
112
- "im:message.reactions:write_only",
113
- "im:resource"
114
- ]
115
- }
116
- }
117
- ```
118
- </details>
119
-
120
- ### Step 4: Install & Start botmux
94
+ ### Step 3: Install & Start botmux
121
95
 
122
96
  ```bash
123
97
  # Install
124
98
  npm install -g botmux
125
99
 
126
- # Interactive setup — enter the App ID and App Secret from Step 2
100
+ # Interactive setup — pick "1) Scan-to-create app" or "2) Paste AppID/Secret manually".
101
+ # Credentials are validated with a tenant_access_token call before bots.json is written.
102
+ # At the end of setup the wizard writes the full scope JSON to ~/.botmux/lark-scopes.json
103
+ # and prints a one-line clipboard copy command for your platform.
127
104
  botmux setup
128
105
 
129
- # Start (must be running before configuring WebSocket subscription Lark checks for an active connection)
106
+ # Start (if you ever need to verify the event subscription, Lark requires the daemon to be running so it can detect the WebSocket connection)
107
+ # Re-validates credentials before forking workers; missing scopes only WARN, do not block the daemon.
130
108
  botmux start
131
109
  ```
132
110
 
133
- ### Step 5: Configure Event Subscription
111
+ ### Step 4: Add Permissions
134
112
 
135
- Back in the Lark Open Platform, go to "Events & Callbacks":
113
+ Run the copy-to-clipboard command setup printed, then go to "Permissions & Scopes" → "Batch Import/Export" and paste. Submit for review — visibility "only me" auto-approves.
136
114
 
137
- 1. **Subscription mode**: Click the edit icon, select "Receive events via persistent connection" (WebSocket) — requires botmux to be running so Lark can detect the connection
115
+ ![Permissions](docs/setup/permissions.png)
138
116
 
139
- ![WebSocket subscription](docs/setup/event-websocket.png)
117
+ The full JSON lives at `~/.botmux/lark-scopes.json` (also tracked in-repo at [src/setup/lark-scopes.json](src/setup/lark-scopes.json), kept in sync with the internal wiki, covers ~290 tenant + user scopes).
140
118
 
141
- 2. **Add event**: Click "Add Event", search and add `im.message.receive_v1` (Receive messages v2.0)
119
+ ```bash
120
+ # macOS
121
+ cat ~/.botmux/lark-scopes.json | pbcopy
122
+ # Linux X
123
+ cat ~/.botmux/lark-scopes.json | xclip -selection clipboard
124
+ # Wayland
125
+ cat ~/.botmux/lark-scopes.json | wl-copy
126
+ ```
142
127
 
143
- ![Add event](docs/setup/event-receive-msg.png)
128
+ > Scan-created PersonalAgent apps have `im.message.receive_v1` + `card.action.trigger` subscribed and the bot capability enabled out of the box, per botmux maintainer testing. Lark hasn't documented this as stable behavior, so **if the bot receives no messages at all after setup**, see "Step 8: Troubleshoot — bot not receiving messages" below for a manual fallback.
144
129
 
145
- 3. **Enable callback**: Switch to the "Callback Configuration" tab, turn on "Card action callback" (`card.action.trigger`)
130
+ ### Step 5: Add Redirect URL (optional)
131
+
132
+ If you plan to use `/login` inside Lark to let botmux act on your behalf for docs / calendar / wiki / sheets, add a redirect URL under "Security Settings" → "Redirect URL":
133
+
134
+ ```
135
+ http://127.0.0.1:9768/callback
136
+ ```
137
+
138
+ Skip this step if you only need bot messaging.
146
139
 
147
140
  ### Step 6: Publish the App
148
141
 
@@ -158,7 +151,16 @@ Go to "Version Management & Release", click "Create Version" and publish. Set av
158
151
 
159
152
  ![Add bot to group](docs/setup/add-bot-to-group.png)
160
153
 
161
- ### Step 8: Enable Boot-time Autostart (recommended)
154
+ ### Step 8: Troubleshoot bot not receiving messages (fallback)
155
+
156
+ PersonalAgent apps come with event subscriptions and bot capability pre-configured; in normal cases you don't touch this. If the bot **receives no messages at all** after setup (not even DMs), verify these two settings:
157
+
158
+ - **Event subscription**: Open Platform → your app → Events & Callbacks → should be subscribed to `im.message.receive_v1` + `card.action.trigger`. If missing, add them manually. Subscription mode must be "Receive via persistent connection" (WebSocket), and the botmux daemon must be running.
159
+ - **Bot capability**: Open Platform → your app → Features → Bot should be enabled (it is by default). Adjust name/avatar if needed.
160
+
161
+ After verifying, restart: `botmux restart`.
162
+
163
+ ### Step 9: Enable Boot-time Autostart (recommended)
162
164
 
163
165
  Once the bot is sending and receiving messages cleanly, run:
164
166
 
package/README.md CHANGED
@@ -173,78 +173,70 @@ CLI 进入 botmux 会话时自动获得 `~/.botmux/bin` 在 PATH 中,以及一
173
173
 
174
174
  ## 5 分钟快速接入
175
175
 
176
+ > 💡 **TL;DR**:跑 `botmux setup` 选「扫码建应用」一步完成 Step 1+2(拿 AppID/AppSecret)。PersonalAgent 应用建出来时事件订阅和 bot 能力都已默认配好,只剩 Step 4 权限申请 + Step 5(按需)重定向 URL + Step 6 发版三步要在浏览器手动点;setup 完成后会自动写 JSON 文件 + 打印一键复制命令 + 各步骤的深链。
177
+
176
178
  ### Step 1: 创建飞书应用
177
179
 
178
- 打开 [飞书开放平台](https://open.larkoffice.com/app),点击「创建企业自建应用」。
180
+ **推荐路径**:`botmux setup` 选「1) 扫码建应用」,飞书扫码完成后自动落盘 AppID/AppSecret,无需手动浏览器创建。底层走 `@larksuiteoapi/node-sdk` 的官方 device flow。
181
+
182
+ > ⚠️ **目前仅支持飞书 (feishu.cn) 租户**。扫码检测到 Lark 国际版 (larksuite.com) 会中止 setup —— daemon runtime (Lark Client/WSClient/event-dispatcher 等) 需要一并接入 lark 域,会在单独 PR 跟进。
183
+
184
+ **手动路径**:打开 [飞书开放平台](https://open.larkoffice.com/app),点击「创建企业自建应用」。
179
185
 
180
186
  ![创建应用](docs/setup/create-app.png)
181
187
 
182
188
  ### Step 2: 获取凭证
183
189
 
190
+ > 扫码路径自动完成此步,可直接跳到 Step 3。
191
+
184
192
  进入应用详情 →「凭证与基础信息」,复制 **App ID** 和 **App Secret**。
185
193
 
186
194
  ![获取凭证](docs/setup/credentials.png)
187
195
 
188
- ### Step 3: 添加权限
189
-
190
- 进入「权限管理」→「批量导入/导出权限」,粘贴以下 JSON 一次性导入所有权限:
191
-
192
- ![权限管理](docs/setup/permissions.png)
193
-
194
- <details>
195
- <summary>点击展开批量导入 JSON</summary>
196
-
197
- ```json
198
- {
199
- "scopes": {
200
- "tenant": [
201
- "contact:user.base:readonly",
202
- "contact:user.id:readonly",
203
- "im:chat:read",
204
- "im:chat.members:bot_access",
205
- "im:chat.members:read",
206
- "im:message",
207
- "im:message:readonly",
208
- "im:message:send_as_bot",
209
- "im:message:update",
210
- "im:message.group_at_msg",
211
- "im:message.group_at_msg:readonly",
212
- "im:message.group_msg",
213
- "im:message.p2p_msg:readonly",
214
- "im:message.reactions:write_only",
215
- "im:resource"
216
- ]
217
- }
218
- }
219
- ```
220
- </details>
221
-
222
- ### Step 4: 安装 & 启动 botmux
196
+ ### Step 3: 安装 & 启动 botmux
223
197
 
224
198
  ```bash
225
199
  # 安装
226
200
  npm install -g botmux
227
201
 
228
- # 交互式配置 — 输入 Step 2 App ID 和 App Secret
202
+ # 交互式配置 — 选「1) 扫码建应用」或「2) 手动粘 AppID/Secret
203
+ # 凭证拿到后自动取一次 tenant_access_token 校验,通过才落盘 bots.json
204
+ # setup 末尾会把完整权限 JSON 写到 ~/.botmux/lark-scopes.json 并打印一键复制命令
229
205
  botmux setup
230
206
 
231
- # 启动(飞书后台配置长连接订阅前需要先启动,否则无法检测到连接)
207
+ # 启动(如果之后需要确认事件订阅,飞书后台会要求 daemon 已在跑才能识别长连接)
208
+ # start 前再校验一次凭证;权限未配齐不会阻塞 daemon,只 WARN
232
209
  botmux start
233
210
  ```
234
211
 
235
- ### Step 5: 配置事件订阅
212
+ ### Step 4: 添加权限
236
213
 
237
- 回到飞书开放平台,进入「事件与回调」:
214
+ setup 完成后,按 terminal 提示的一键复制命令把权限 JSON 复制到剪贴板,进入「权限管理」→「批量导入/导出权限」粘贴 → 提交审批。可用性范围选「仅自己可见」会自动通过:
238
215
 
239
- 1. **订阅方式**:点击编辑图标,选择「使用长连接接收事件」(需要 botmux 已启动,飞书会检测长连接是否建立)
216
+ ![权限管理](docs/setup/permissions.png)
240
217
 
241
- ![配置长连接](docs/setup/event-websocket.png)
218
+ 完整 JSON 已经写到 `~/.botmux/lark-scopes.json`,源仓库版本在 [src/setup/lark-scopes.json](src/setup/lark-scopes.json)(与本仓库内部 wiki 文档同步,覆盖 tenant + user 双套域 ≈ 290 项)。
242
219
 
243
- 2. **添加事件**:点击「添加事件」,搜索添加 `im.message.receive_v1`(接收消息 v2.0)
220
+ ```bash
221
+ # macOS
222
+ cat ~/.botmux/lark-scopes.json | pbcopy
223
+ # Linux X
224
+ cat ~/.botmux/lark-scopes.json | xclip -selection clipboard
225
+ # Wayland
226
+ cat ~/.botmux/lark-scopes.json | wl-copy
227
+ ```
244
228
 
245
- ![添加事件](docs/setup/event-receive-msg.png)
229
+ > 扫码建出来的 PersonalAgent 应用,botmux 维护者实测默认已订阅 `im.message.receive_v1` + `card.action.trigger` 并开通 bot 能力,所以主线流程不再要求手动配。但飞书没在公开文档里承诺这是稳定行为,**如果配好后机器人完全收不到消息**,参见下方「Step 8: 机器人收不到消息时的自查」。
246
230
 
247
- 3. **启用回调**:切换到「回调配置」tab,开启「卡片回传交互」(`card.action.trigger`)
231
+ ### Step 5: 添加重定向 URL(按需)
232
+
233
+ 如果之后要在飞书里 `/login` 让 botmux 以你的身份调云文档/日历/Wiki 等 API,进入「安全设置」→「重定向 URL」填入:
234
+
235
+ ```
236
+ http://127.0.0.1:9768/callback
237
+ ```
238
+
239
+ 只用 bot 收发消息的话这一步可以跳过。
248
240
 
249
241
  ### Step 6: 发版
250
242
 
@@ -260,7 +252,16 @@ botmux start
260
252
 
261
253
  ![添加机器人到群](docs/setup/add-bot-to-group.png)
262
254
 
263
- ### Step 8: 开机自启(推荐)
255
+ ### Step 8: 机器人收不到消息时的自查(fallback)
256
+
257
+ PersonalAgent 默认配好事件订阅 + bot 能力,正常情况下不用动。如果按上面步骤走完 bot **完全收不到任何消息**(连私聊都不回),分别确认这两项:
258
+
259
+ - **事件订阅**:开放平台 → 你的应用 → 事件与回调 → 应当订阅 `im.message.receive_v1` + `card.action.trigger`(默认已订阅,如缺失就手动添加)。订阅方式必须是「使用长连接接收事件」(WebSocket),且 botmux daemon 已经在跑。
260
+ - **机器人能力**:开放平台 → 你的应用 → 应用功能 → 机器人 应当已开通(默认开通),名字/头像可以改。
261
+
262
+ 确认后重启 daemon:`botmux restart`。
263
+
264
+ ### Step 9: 开机自启(推荐)
264
265
 
265
266
  确认机器人能正常收发消息之后,跑一次:
266
267
 
package/dist/cli.js CHANGED
@@ -17,7 +17,7 @@
17
17
  * botmux autostart enable|disable|status — manage boot-time autostart (launchd / user systemd)
18
18
  */
19
19
  import { execSync, spawnSync, spawn } from 'node:child_process';
20
- import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, readdirSync, readlinkSync, appendFileSync, statSync, unlinkSync } from 'node:fs';
20
+ import { existsSync, mkdirSync, copyFileSync, readFileSync, writeFileSync, renameSync, readdirSync, readlinkSync, appendFileSync, statSync, unlinkSync } from 'node:fs';
21
21
  import { join, dirname } from 'node:path';
22
22
  import { homedir } from 'node:os';
23
23
  import { fileURLToPath } from 'node:url';
@@ -26,6 +26,7 @@ import { createRequire } from 'node:module';
26
26
  import { createHmac, randomBytes } from 'node:crypto';
27
27
  import { enableAutostart, disableAutostart, autostartStatus, refreshAutostart } from './autostart.js';
28
28
  import { tmuxEnv } from './setup/ensure-tmux.js';
29
+ import { writeBotsJsonAtomic as writeBotsAtomic } from './setup/bots-store.js';
29
30
  import { logger } from './utils/logger.js';
30
31
  import { firstPositional } from './cli/arg-utils.js';
31
32
  // CLI subcommands (send/thread/bots/list/etc) print JSON to stdout for
@@ -144,28 +145,189 @@ function ask(rl, question) {
144
145
  return new Promise(resolve => rl.question(question, resolve));
145
146
  }
146
147
  // ─── Setup helpers ──────────────────────────────────────────────────────────
147
- function printLarkPermissions() {
148
- console.log('请先在飞书开放平台创建应用: https://open.feishu.cn/app\n');
149
- console.log('需要的权限:');
150
- console.log(' - im:message (发送/接收消息)');
151
- console.log(' - im:message.group_at_msg (群消息)');
152
- console.log(' - im:resource (文件下载)');
153
- console.log(' - im:chat (群信息)');
154
- console.log(' - contact:user.base:readonly (用户信息)\n');
155
- console.log('启用事件订阅 (WebSocket 模式):');
156
- console.log(' - im.message.receive_v1');
157
- console.log(' - card.action.trigger\n');
148
+ // Thin wrapper around setup/bots-store.writeBotsJsonAtomic so call-sites keep
149
+ // the same name without passing BOTS_JSON_FILE explicitly each time.
150
+ function writeBotsJsonAtomic(bots) {
151
+ writeBotsAtomic(BOTS_JSON_FILE, bots);
158
152
  }
153
+ /**
154
+ * 从 bot 配置里取 brand. 旧的 bots.json (1.0 之前) 没这个字段, default 到 feishu
155
+ * 保留向后兼容. cmdStart 凭证校验 + printRemainingSteps 深链都靠它选 host.
156
+ */
157
+ function botBrand(b) {
158
+ return b?.brand === 'lark' ? 'lark' : 'feishu';
159
+ }
160
+ /**
161
+ * 把 botmux 推荐的完整 scope JSON (从 src/setup/lark-scopes.json) 写到
162
+ * 用户配置目录, 同时给出跨平台一键复制命令. JSON 长 (293 项, 297 行),
163
+ * terminal 直接打印用户也复制不了, 写文件 + pbcopy/xclip 才是顺手的姿势.
164
+ *
165
+ * Returns: 写出的 JSON 文件绝对路径.
166
+ */
167
+ function writeScopesJsonToConfigDir() {
168
+ // build script 会把 src/setup/lark-scopes.json copy 到 dist/setup/.
169
+ // dist 模式下 __dirname 是 dist/, 找 ./setup/lark-scopes.json; dev (tsx)
170
+ // 模式找 src/setup/lark-scopes.json 在源码同目录也成立.
171
+ const here = dirname(fileURLToPath(import.meta.url));
172
+ const srcCandidates = [
173
+ join(here, 'setup', 'lark-scopes.json'),
174
+ join(here, '..', 'src', 'setup', 'lark-scopes.json'),
175
+ ];
176
+ let scopesPath = srcCandidates[0];
177
+ for (const p of srcCandidates) {
178
+ if (existsSync(p)) {
179
+ scopesPath = p;
180
+ break;
181
+ }
182
+ }
183
+ const destPath = join(CONFIG_DIR, 'lark-scopes.json');
184
+ copyFileSync(scopesPath, destPath);
185
+ return destPath;
186
+ }
187
+ function printCopyHint(filePath) {
188
+ console.log(' 一键复制 JSON 到剪贴板:');
189
+ console.log(` macOS: cat ${filePath} | pbcopy`);
190
+ console.log(` Linux: cat ${filePath} | xclip -selection clipboard`);
191
+ console.log(` Wayland: cat ${filePath} | wl-copy`);
192
+ console.log(` 远程 SSH: scp 拉到本地, 或 less ${filePath} 终端选中复制`);
193
+ }
194
+ function printRemainingSteps(appId, brand) {
195
+ // 数据源: 飞书内部 wiki UBOXwH01CixfxfkqxUpcKgvQnsg "[Botmux] 5分钟创建一个
196
+ // 真正好用的飞书助理" 的"权限申请"段, 加 botmux 维护者实测确认 PersonalAgent
197
+ // 应用扫码建出来时已经默认订阅 im.message.receive_v1 / card.action.trigger
198
+ // 事件并开通 bot 能力. 但 lark-channel-bridge README 当前仍要求用户手动补
199
+ // 事件订阅, 跟我们结论不一致 — 不排除飞书最近升级了 PersonalAgent 预配 (那个
200
+ // README 是旧版), 也不排除存在租户/版本差异让某些用户的 PersonalAgent 没预配.
201
+ //
202
+ // 折中: 主线流程只列"必须手动"的两步 (权限 + 重定向 URL), 末尾再给"如果
203
+ // bot 收不到消息" 的兜底 fallback 链接, 让用户能自查事件订阅 / bot 能力.
204
+ const host = brand === 'lark' ? 'open.larksuite.com' : 'open.feishu.cn';
205
+ const home = `https://${host}/app/${appId}`;
206
+ let scopesJsonPath = '';
207
+ try {
208
+ scopesJsonPath = writeScopesJsonToConfigDir();
209
+ }
210
+ catch (err) {
211
+ // 不应阻止 setup 完成, 只 WARN
212
+ console.log(`\n⚠️ 写权限 JSON 失败 (${err.message}), 请手动从仓库源码 src/setup/lark-scopes.json 拷.`);
213
+ }
214
+ console.log('\n⚠️ 扫码 / 粘贴只完成了"建应用 + 拿凭证". 还有这些步骤要在开放平台浏览器里点:\n');
215
+ console.log(' 1. 申请权限 (一次性导入完整 JSON 提交审批)');
216
+ console.log(` 深链: ${home}/auth → 进入「权限管理」→「批量导入/导出权限」→ 粘贴 → 提交`);
217
+ if (scopesJsonPath) {
218
+ console.log(` 权限 JSON: ${scopesJsonPath}`);
219
+ printCopyHint(scopesJsonPath);
220
+ }
221
+ console.log('');
222
+ console.log(' 2. 添加重定向 URL (用于 botmux 内 `/login` 拿用户 UAT 调云文档/日历等)');
223
+ console.log(` 深链: ${home}/safe → 进入「安全设置」→「重定向 URL」`);
224
+ console.log(' 填入: http://127.0.0.1:9768/callback');
225
+ console.log(' 不打算用 `/login` 跨用户调 API 的话, 这一步可以跳过.\n');
226
+ console.log(' 完成后 `botmux start` (或 `botmux restart`),启动检查不会卡住,');
227
+ console.log(' 缺权限只 WARN,去开放平台补齐后 daemon 自动恢复。\n');
228
+ // Fallback 自查清单 — 维护者实测 PersonalAgent 默认配好下面两项, 但飞书
229
+ // 没承诺过这是稳定行为. 收不到消息时让用户能自查.
230
+ console.log(' ─── 如果机器人配置好后收不到消息, 自查下面两点 ───');
231
+ console.log(' a. 事件订阅: PersonalAgent 默认订阅 im.message.receive_v1 + card.action.trigger,');
232
+ console.log(` 如缺失, 请到 ${home}/dev-config/event-sub 手动添加`);
233
+ console.log(' b. 机器人能力: PersonalAgent 默认已开通,');
234
+ console.log(` 如缺失, 请到 ${home}/feature/bot 启用 (应用功能 → 机器人)`);
235
+ console.log('');
236
+ }
237
+ /**
238
+ * 让用户选"扫码建应用"还是"手动粘 AppID/Secret".
239
+ *
240
+ * 默认走扫码: 调 SDK `registerApp` → 拿 client_id/client_secret. 失败 (用户拒绝/
241
+ * 超时/网络/取消) 一律降级到手动, 不阻塞流程.
242
+ *
243
+ * Codex review 边界:
244
+ * - secret 不进 argv / 日志 / 错误链 (registerApp 内部 safeMsg 已做; 手动模式下
245
+ * AppSecret 通过 rl.question 异步读取, 不会出现在 process.argv)
246
+ * - 任何失败都返回结构化对象, 不抛 (调用方根据 ok=false 回退)
247
+ */
248
+ async function obtainCredentials(rl) {
249
+ console.log('── 飞书应用建立 ──\n');
250
+ console.log('1) 扫码建应用(推荐,一步拿到 AppID/Secret,需要飞书 App 扫码)');
251
+ console.log('2) 手动粘 AppID/Secret(已经在开放平台创建好应用了)\n');
252
+ const choice = (await ask(rl, '选择 [1]: ')).trim();
253
+ if (choice !== '2') {
254
+ // 动态导入避免冷启动加载 SDK
255
+ const { tryRegisterApp } = await import('./setup/register-app.js');
256
+ const result = await tryRegisterApp();
257
+ if (result.ok) {
258
+ // Lark 国际版需要 daemon 链路全程走 larksuite.com 域 (Client domain /
259
+ // WSClient / event-dispatcher 的 fetch URL / scope 深链 host). 当前
260
+ // botmux runtime 这几处都硬编码 feishu.cn, 所以即使扫码成功了也无法
261
+ // 真正跑起来. 干净做法是 setup 阶段就拒绝, 让用户用 feishu 租户. 单
262
+ // 独 PR 完整接入 lark 后再去掉这个分支.
263
+ if (result.brand === 'lark') {
264
+ console.log(`\n❌ 检测到 Lark 国际版 (larksuite.com) 租户。`);
265
+ console.log(` botmux 当前 daemon 运行链路仅支持飞书 (feishu.cn) 租户,`);
266
+ console.log(` Lark 国际版完整接入会在单独 PR 跟进 (BotConfig / Client domain /`);
267
+ console.log(` WSClient / event-dispatcher 等需要一并支持).`);
268
+ console.log(` 请用飞书 (feishu.cn) 租户重试 setup。\n`);
269
+ return { ok: false, reason: 'lark_unsupported' };
270
+ }
271
+ console.log(`\n✅ 应用创建成功`);
272
+ console.log(` App ID: ${result.appId}`);
273
+ console.log(` 租户类型: ${result.brand}`);
274
+ return { ok: true, appId: result.appId, appSecret: result.appSecret, brand: result.brand };
275
+ }
276
+ console.log(`\n⚠️ 扫码失败 (${result.error}): ${result.message}`);
277
+ if (result.error === 'aborted') {
278
+ // 用户主动取消整个 setup, 不再问手动 fallback
279
+ return { ok: false, reason: 'cancelled' };
280
+ }
281
+ console.log(' 降级到手动输入 AppID/Secret。\n');
282
+ }
283
+ else {
284
+ console.log('\n请在浏览器打开 https://open.feishu.cn/app 创建应用,然后回来粘 ID/Secret。\n');
285
+ }
286
+ // 手动 fallback. 不再提问租户类型 — 当前 daemon runtime 只支持 feishu,
287
+ // 让用户选 lark 是误导. 等 lark 完整接入再加回来.
288
+ const appId = (await ask(rl, 'AppID (cli_xxx): ')).trim();
289
+ const appSecret = (await ask(rl, 'AppSecret: ')).trim();
290
+ if (!appId || !appSecret) {
291
+ console.log('\n❌ AppID/AppSecret 不能为空,setup 中止。');
292
+ return { ok: false, reason: 'cancelled' };
293
+ }
294
+ return { ok: true, appId, appSecret, brand: 'feishu' };
295
+ }
296
+ /**
297
+ * 收集一个机器人完整配置 (凭证 + CLI/工作目录/allowedUsers).
298
+ *
299
+ * 顺序: 拿凭证 → tenant_access_token 验证 → 通过才返回 bot 对象. 验证失败
300
+ * 直接返回 null, 调用方负责"不写 bots.json". Codex review 边界 #2.
301
+ */
159
302
  async function promptBotConfig(rl) {
160
- const appId = await ask(rl, 'LARK_APP_ID: ');
161
- const appSecret = await ask(rl, 'LARK_APP_SECRET: ');
162
- console.log('\n支持的 CLI: 1) claude-code 2) aiden 3) coco 4) codex 5) gemini 6) opencode');
303
+ const creds = await obtainCredentials(rl);
304
+ if (!creds.ok)
305
+ return null;
306
+ // 凭证立刻验证. 通不过不写 bots.json.
307
+ console.log('\n校验凭证(取 tenant_access_token)…');
308
+ const { validateCredentials } = await import('./setup/verify-permissions.js');
309
+ const v = await validateCredentials(creds.appId, creds.appSecret, creds.brand);
310
+ if (!v.ok) {
311
+ console.log(`\n❌ 凭证校验失败 (${v.error}): ${v.message}`);
312
+ console.log(' 不写 bots.json。请重新运行 botmux setup。');
313
+ return null;
314
+ }
315
+ console.log('✅ 凭证有效(tenant_access_token 已成功获取)\n');
316
+ console.log('支持的 CLI: 1) claude-code 2) aiden 3) coco 4) codex 5) gemini 6) opencode');
163
317
  const cliChoice = await ask(rl, 'CLI 适配器 [1]: ');
164
318
  const cliIdMap = { '1': 'claude-code', '2': 'aiden', '3': 'coco', '4': 'codex', '5': 'gemini', '6': 'opencode' };
165
319
  const cliId = cliIdMap[cliChoice] ?? (cliChoice || 'claude-code');
166
320
  const workingDir = await ask(rl, '默认工作目录 [~]: ');
167
321
  const allowedUsers = await ask(rl, '允许的用户 (邮箱或 open_id,逗号分隔,留空=不限制): ');
168
- const bot = { larkAppId: appId, larkAppSecret: appSecret, cliId };
322
+ // brand 必须持久化: cmdStart validate / event-dispatcher 走的 deep link
323
+ // 都看这个字段; 不写就只能硬编码 feishu, lark 租户用户会被打成凭证无效.
324
+ // 为了向后兼容 (旧 bots.json 没 brand 字段), reader 应当 default 到 'feishu'.
325
+ const bot = {
326
+ larkAppId: creds.appId,
327
+ larkAppSecret: creds.appSecret,
328
+ brand: creds.brand,
329
+ cliId,
330
+ };
169
331
  if (workingDir)
170
332
  bot.workingDir = workingDir;
171
333
  if (allowedUsers)
@@ -203,18 +365,25 @@ function parseDotEnvToBotConfig() {
203
365
  bot.projectScanDir = vars.PROJECT_SCAN_DIR;
204
366
  return bot;
205
367
  }
206
- /** Write single-bot config to bots.json (fresh install or reconfigure) */
368
+ /**
369
+ * 收集一个机器人配置并写盘 (单机器人 fresh install / 重新配置).
370
+ *
371
+ * 失败路径 (扫码取消 / 凭证校验不通过): 不创建任何配置文件, 不动旧 .env.
372
+ * Codex review 边界 #2: 中途失败一律不留半截 JSON.
373
+ */
207
374
  async function writeSingleBotConfig() {
208
- console.log('── 飞书应用配置 ──\n');
209
- printLarkPermissions();
210
375
  const rl = createInterface({ input: process.stdin, output: process.stdout });
211
376
  const bot = await promptBotConfig(rl);
212
377
  rl.close();
213
- writeFileSync(BOTS_JSON_FILE, JSON.stringify([bot], null, 2) + '\n');
378
+ if (!bot)
379
+ return false;
380
+ writeBotsJsonAtomic([bot]);
214
381
  console.log(`\n✅ 配置已写入: ${BOTS_JSON_FILE}`);
215
- console.log(`\n下一步:`);
216
- console.log(` 1. botmux start 启动 daemon(飞书后台配长连接前必须先启动)`);
382
+ printRemainingSteps(bot.larkAppId, botBrand(bot));
383
+ console.log(`下一步:`);
384
+ console.log(` 1. botmux start 启动 daemon`);
217
385
  console.log(` 2. botmux autostart enable 注册开机自启(推荐:${process.platform === 'darwin' ? 'mac launchd' : process.platform === 'linux' ? 'linux user systemd' : '当前平台暂不支持'},无需 sudo)`);
386
+ return true;
218
387
  }
219
388
  // ─── Commands ────────────────────────────────────────────────────────────────
220
389
  async function cmdSetup() {
@@ -235,26 +404,36 @@ async function cmdSetup() {
235
404
  const rl = createInterface({ input: process.stdin, output: process.stdout });
236
405
  const action = await ask(rl, '操作: 1) 添加新机器人 2) 重新配置 (1/2) [1]: ');
237
406
  if (action === '2') {
238
- renameSync(BOTS_JSON_FILE, BOTS_JSON_FILE + '.bak');
239
- console.log(`旧配置已备份: ${BOTS_JSON_FILE}.bak\n`);
240
407
  console.log('\n── 重新配置 ──\n');
241
- printLarkPermissions();
242
408
  const newBot = await promptBotConfig(rl);
243
409
  rl.close();
244
- writeFileSync(BOTS_JSON_FILE, JSON.stringify([newBot], null, 2) + '\n');
245
- console.log(`\n 配置已写入: ${BOTS_JSON_FILE}`);
246
- console.log(`\n下一步: botmux restart`);
410
+ if (!newBot) {
411
+ console.log('\n⚠️ setup 中止,旧配置保留不动。');
412
+ return;
413
+ }
414
+ // Codex review #1: 先 copyFileSync 备份, 再原子写新文件. 之前先 rename
415
+ // 旧文件再 write, 一旦 write 失败 (磁盘/权限/进程被 kill) 用户就丢了
416
+ // bots.json. copy 之后写失败旧文件原地不动, .bak 是无害的同名副本.
417
+ copyFileSync(BOTS_JSON_FILE, BOTS_JSON_FILE + '.bak');
418
+ console.log(`旧配置已备份: ${BOTS_JSON_FILE}.bak`);
419
+ writeBotsJsonAtomic([newBot]);
420
+ console.log(`✅ 配置已写入: ${BOTS_JSON_FILE}`);
421
+ printRemainingSteps(newBot.larkAppId, botBrand(newBot));
422
+ console.log(`下一步: botmux restart\n`);
247
423
  return;
248
424
  }
249
425
  console.log('\n── 添加新机器人 ──\n');
250
- printLarkPermissions();
251
426
  const newBot = await promptBotConfig(rl);
252
427
  rl.close();
253
- bots.push(newBot);
254
- writeFileSync(BOTS_JSON_FILE, JSON.stringify(bots, null, 2) + '\n');
255
- console.log(`\n✅ 已添加机器人 ${newBot.larkAppId},共 ${bots.length} 个`);
428
+ if (!newBot) {
429
+ console.log('\n⚠️ setup 中止,bots.json 不动。');
430
+ return;
431
+ }
432
+ writeBotsJsonAtomic([...bots, newBot]);
433
+ console.log(`\n✅ 已添加机器人 ${newBot.larkAppId},共 ${bots.length + 1} 个`);
256
434
  console.log(` 配置文件: ${BOTS_JSON_FILE}`);
257
- console.log(`\n下一步: botmux restart`);
435
+ printRemainingSteps(newBot.larkAppId, botBrand(newBot));
436
+ console.log(`下一步: botmux restart\n`);
258
437
  }
259
438
  else if (hasEnv) {
260
439
  // --- Single-bot mode (.env exists) ---
@@ -263,9 +442,11 @@ async function cmdSetup() {
263
442
  const action = await ask(rl, '操作: 1) 添加新机器人 2) 覆盖当前配置 (1/2): ');
264
443
  if (action === '2') {
265
444
  rl.close();
266
- await writeSingleBotConfig();
267
- renameSync(ENV_FILE, ENV_FILE + '.bak');
268
- console.log(` 旧 .env 已备份: ${ENV_FILE}.bak`);
445
+ const ok = await writeSingleBotConfig();
446
+ if (ok) {
447
+ renameSync(ENV_FILE, ENV_FILE + '.bak');
448
+ console.log(` 旧 .env 已备份: ${ENV_FILE}.bak`);
449
+ }
269
450
  return;
270
451
  }
271
452
  // Migrate .env → bots.json
@@ -278,16 +459,20 @@ async function cmdSetup() {
278
459
  }
279
460
  console.log(`\n当前机器人: ${existingBot.larkAppId} (${existingBot.cliId ?? 'claude-code'})`);
280
461
  console.log('\n── 添加新机器人 ──\n');
281
- printLarkPermissions();
282
462
  const newBot = await promptBotConfig(rl);
283
463
  rl.close();
284
- const bots = [existingBot, newBot];
285
- writeFileSync(BOTS_JSON_FILE, JSON.stringify(bots, null, 2) + '\n');
464
+ if (!newBot) {
465
+ console.log('\n⚠️ setup 中止,.env bots.json 都不动。');
466
+ return;
467
+ }
468
+ // 写新文件成功后才备份 .env. 失败不动两边.
469
+ writeBotsJsonAtomic([existingBot, newBot]);
286
470
  renameSync(ENV_FILE, ENV_FILE + '.bak');
287
471
  console.log(`\n✅ 已迁移到多机器人配置`);
288
472
  console.log(` 配置文件: ${BOTS_JSON_FILE}`);
289
473
  console.log(` 旧配置已备份: ${ENV_FILE}.bak`);
290
- console.log(`\n下一步: botmux restart`);
474
+ printRemainingSteps(newBot.larkAppId, botBrand(newBot));
475
+ console.log(`下一步: botmux restart\n`);
291
476
  }
292
477
  else {
293
478
  // --- Fresh install ---
@@ -379,6 +564,40 @@ async function cmdStart() {
379
564
  ensureConfigDir();
380
565
  preflightNodeSanity();
381
566
  await ensureSystemDependencies();
567
+ // 启动前快速校验每个 bot 的凭证. Codex review 边界 #5: 凭证无效是
568
+ // 唯一应该阻塞 start 的情况; scope/event 缺失在 daemon 起来后用 WARN
569
+ // + 私信处理 (event-dispatcher.checkRequiredScopes).
570
+ //
571
+ // 失败时打印明确的 appId 前缀和错误码, 不打印 secret, 不 spawn pm2 进程.
572
+ const botsForCheck = loadBotsJson();
573
+ if (botsForCheck.length > 0) {
574
+ const { validateCredentials } = await import('./setup/verify-permissions.js');
575
+ const invalid = [];
576
+ for (const b of botsForCheck) {
577
+ if (!b.larkAppId || !b.larkAppSecret) {
578
+ invalid.push({ appId: b.larkAppId || '(空 appId)', reason: 'larkAppId/larkAppSecret 缺失' });
579
+ continue;
580
+ }
581
+ const v = await validateCredentials(b.larkAppId, b.larkAppSecret, botBrand(b));
582
+ if (!v.ok) {
583
+ if (v.error === 'invalid_credentials') {
584
+ invalid.push({ appId: b.larkAppId, reason: v.message });
585
+ }
586
+ else {
587
+ // network / unknown — 不应该拦下启动, 走 WARN
588
+ console.warn(`⚠️ [${b.larkAppId}] 启动前凭证验证未成功(${v.error}): ${v.message}`);
589
+ console.warn(` daemon 仍会启动;启动后 dispatcher 会自行重试。`);
590
+ }
591
+ }
592
+ }
593
+ if (invalid.length > 0) {
594
+ console.error('\n❌ 以下机器人凭证无效,botmux start 中止:\n');
595
+ for (const e of invalid)
596
+ console.error(` - ${e.appId}: ${e.reason}`);
597
+ console.error('\n 修复方式: 运行 `botmux setup` 选 "重新配置" 重新走扫码/手动流程。');
598
+ process.exit(1);
599
+ }
600
+ }
382
601
  cleanupLegacyPm2();
383
602
  const cfg = ecosystemConfig();
384
603
  runPm2(['start', cfg]);