@mrrisega/dsh-remote 0.7.12-beta.2 → 0.7.12-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.
@@ -210,7 +210,11 @@ export function normalizeDingtalkConfig(raw) {
210
210
  enabled: src.enabled === true,
211
211
  clientId: str(src.clientId),
212
212
  clientSecret: typeof src.clientSecret === "string" ? src.clientSecret : "",
213
+ // robotCode **可选**(扫码一键创建机器人可能不返回 robot_code):只影响 openapi 主动发送/卡片;
214
+ // 接收消息 + sessionWebhook 回复完全不需要它(capabilities 里会如实降级)。
213
215
  robotCode: str(src.robotCode),
216
+ // 凭据来源(插件扫码授权流程写入 "scan",手工填默认 "manual")——仅用于状态排障/面板展示
217
+ credentialSource: str(src.credentialSource).toLowerCase() === "scan" ? "scan" : "manual",
214
218
  replyMode,
215
219
  sessionWebhook: str(src.sessionWebhook),
216
220
  boundChatId: str(src.boundChatId),
@@ -239,7 +243,10 @@ export function readDingtalkConfig(relayDir) {
239
243
 
240
244
  /**
241
245
  * @param {{
242
- * clientId: string, clientSecret: string, robotCode?: string,
246
+ * clientId: string, clientSecret: string,
247
+ * robotCode?: string, // 可选:只影响 openapi 主动发送/卡片;缺省时降级(接收+sessionWebhook 回复仍可用)
248
+ * cardTemplateId?: string, // 可选:卡片拍板模板(缺省 card 能力降级)
249
+ * credentialSource?: "scan"|"manual", // 凭据来源(状态透出;扫码授权写入 scan)
243
250
  * replyMode?: "sessionWebhook"|"openapi",
244
251
  * gatewayUrl?: string, // 覆盖 open 端点(私有化/测试)
245
252
  * tokenUrl?: string, o2oSendUrl?: string, groupSendUrl?: string,
@@ -260,6 +267,8 @@ export function createDingtalkBot(options = {}) {
260
267
  const clientId = String(options.clientId || "").trim();
261
268
  const clientSecret = typeof options.clientSecret === "string" ? options.clientSecret : "";
262
269
  const robotCode = String(options.robotCode || "").trim();
270
+ const cardTemplateId = String(options.cardTemplateId || "").trim();
271
+ const credentialSource = String(options.credentialSource || "").toLowerCase() === "scan" ? "scan" : "manual";
263
272
  const replyMode = String(options.replyMode || "sessionWebhook").toLowerCase() === "openapi" ? "openapi" : "sessionWebhook";
264
273
  const gatewayUrl = String(options.gatewayUrl || "").trim() || DINGTALK_OPEN_URL;
265
274
  const tokenUrl = String(options.tokenUrl || "").trim() || DINGTALK_TOKEN_URL;
@@ -317,6 +326,17 @@ export function createDingtalkBot(options = {}) {
317
326
  };
318
327
  const membership = { tier: "", entitlements: null, checkedAt: 0, usage: { month: "", count: 0 } };
319
328
 
329
+ /**
330
+ * 能力降级表(写进状态,插件面板据此禁用按钮 + 提示去补配置):
331
+ * · openapi:主动发送需要 robotCode(扫码授权不一定返回 robot_code)→ 缺省 = false
332
+ * · card:卡片拍板需要 robotCode + 卡片平台模板 ID → 缺一即 false
333
+ * ⚠️ 接收消息 / sessionWebhook 回复**不依赖**这两项,所以机器人仍可正常用(产品主链路不受影响)。
334
+ */
335
+ function capabilitiesNow() {
336
+ const openapi = Boolean(robotCode);
337
+ return { openapi, card: Boolean(openapi && cardTemplateId) };
338
+ }
339
+
320
340
  let ws = null;
321
341
  let stopped = true;
322
342
  let reconnectTimer = null;
@@ -478,6 +498,10 @@ export function createDingtalkBot(options = {}) {
478
498
  connectedAt: Number(state.lastConnectedAt) || 0,
479
499
  openCount: Number(state.openCount) || 0,
480
500
  reconnectAttempts: Number(state.reconnectAttempts) || 0,
501
+ // 凭据来源 + 能力降级(robotCode 可选:缺省时只降级 openapi/卡片)
502
+ credentialSource,
503
+ robotCodeConfigured: Boolean(robotCode),
504
+ capabilities: capabilitiesNow(),
481
505
  // 会员 + 用量(与企微对齐)
482
506
  tier: tierNow(),
483
507
  caps: capsNow().slice(),
@@ -884,14 +908,23 @@ export function createDingtalkBot(options = {}) {
884
908
 
885
909
  /** 主动发消息(openapi 模式;标 UNVERIFIED:msgKey/msgParam 形状来自官方文档,未经真机核验)。 */
886
910
  async function sendOpenapi(text, { title } = {}) {
887
- if (!state.authenticated) return { ok: false, reason: "not_connected" };
911
+ // ★ robotCode 可选:缺省时明确拒绝(不崩、不白打 token 请求),并说清"哪些能力不可用/怎么恢复"
912
+ if (!robotCode) {
913
+ return {
914
+ ok: false,
915
+ reason: "no_robot_code",
916
+ detail: "未配置 robotCode:openapi 主动发送与卡片拍板不可用(接收消息、sessionWebhook 回复不受影响)。"
917
+ + "请在插件「钉钉机器人」里补 robotCode,或改用 sessionWebhook 接收回复。",
918
+ capabilities: capabilitiesNow()
919
+ };
920
+ }
921
+ if (!state.authenticated) return { ok: false, reason: "not_connected", capabilities: capabilitiesNow() };
888
922
  const token = await getAccessToken();
889
- if (!token) return { ok: false, reason: "no_access_token" };
923
+ if (!token) return { ok: false, reason: "no_access_token", capabilities: capabilitiesNow() };
890
924
  const formatted = formatForChannel(formatChannel, { title, text: text == null ? "" : String(text) });
891
925
  const msgParam = JSON.stringify({ title: truncateText(title || "DeepSeek Harness", 64), text: formatted });
892
926
  const isGroup = state.boundConversationId && state.boundChatId === state.boundConversationId;
893
- const useRobot = robotCode || "";
894
- if (!useRobot) return { ok: false, reason: "no_robot_code" };
927
+ const useRobot = robotCode;
895
928
  const url = isGroup ? groupSendUrl : o2oSendUrl;
896
929
  const body = isGroup
897
930
  ? { robotCode: useRobot, openConversationId: state.boundConversationId, msgKey: "sampleMarkdown", msgParam }
@@ -1094,6 +1127,10 @@ export function createDingtalkBot(options = {}) {
1094
1127
  openCount: Number(state.openCount) || 0,
1095
1128
  reconnectAttempts: Number(state.reconnectAttempts) || 0,
1096
1129
  nextRetryInMs: state.nextRetryInMs,
1130
+ // 凭据来源 + 能力降级(robotCode 可选:缺省时只降级 openapi/卡片)
1131
+ credentialSource,
1132
+ robotCodeConfigured: Boolean(robotCode),
1133
+ capabilities: capabilitiesNow(),
1097
1134
  subscriptions: subscriptions.map((s) => String(s.topic || "")).filter(Boolean),
1098
1135
  messages: state.messages,
1099
1136
  gated: state.gated,
@@ -1121,6 +1158,10 @@ export function createDingtalkBot(options = {}) {
1121
1158
  stopped = false;
1122
1159
  state.running = true;
1123
1160
  state.phase = "connecting";
1161
+ if (!robotCode) {
1162
+ log.info("未配置 robotCode:机器人仍可正常接收消息并用 sessionWebhook 回复;"
1163
+ + "openapi 主动发送/卡片拍板降级为不可用(状态 capabilities 会如实标记)");
1164
+ }
1124
1165
  writeStatus();
1125
1166
  ensureSubscriber();
1126
1167
  void connect().catch((e) => {
@@ -1199,7 +1240,7 @@ export function createDingtalkBotController(options = {}) {
1199
1240
  function digestOf(cfg) {
1200
1241
  if (!cfg) return "missing";
1201
1242
  const secretHash = cfg.clientSecret ? crypto.createHash("sha1").update(String(cfg.clientSecret)).digest("hex") : "";
1202
- return JSON.stringify([cfg.enabled, cfg.clientId || "", secretHash, cfg.robotCode || "", cfg.replyMode || "", cfg.sessionId || "", cfg.sessionTitle || "", cfg.allowFrom || [], notifyAllowed()]);
1243
+ return JSON.stringify([cfg.enabled, cfg.clientId || "", secretHash, cfg.robotCode || "", cfg.cardTemplateId || "", cfg.credentialSource || "", cfg.replyMode || "", cfg.sessionId || "", cfg.sessionTitle || "", cfg.allowFrom || [], notifyAllowed()]);
1203
1244
  }
1204
1245
  function writeDisabledStatus(reason) {
1205
1246
  if (!relayDir) return;
@@ -1247,6 +1288,8 @@ export function createDingtalkBotController(options = {}) {
1247
1288
  clientId: cfg.clientId,
1248
1289
  clientSecret: cfg.clientSecret,
1249
1290
  robotCode: cfg.robotCode,
1291
+ cardTemplateId: cfg.cardTemplateId,
1292
+ credentialSource: cfg.credentialSource,
1250
1293
  replyMode: cfg.replyMode,
1251
1294
  gatewayUrl: cfg.streamUrl || undefined,
1252
1295
  relayDir,
@@ -8,6 +8,7 @@
8
8
 
9
9
  飞书「企业自建应用」长连接机器人:**收消息 / 发通知 / 卡片拍板**,与企微、钉钉、微信并列的第四条通道;
10
10
  **官方 SDK 不随安装包分发**(约 41MB),用户在面板点「下载依赖」后才懒加载;`package.json` 里**没有**这个依赖。
11
+ **接收人不需要用户填**:默认自动绑定(连上后用 `chat.list` 选机器人所在的群;或用户第一句话即绑定)。
11
12
 
12
13
  ---
13
14
 
@@ -39,9 +40,9 @@
39
40
  "appId": "", // cli_ 开头;SDK 有严格正则(cli_+16 hex),不匹配会"保存成功但连不上"
40
41
  "appSecret": "",
41
42
  "domain": "feishu", // feishu=open.feishu.cn | lark=open.larksuite.com(国际版)
42
- "receiveId": "", // 主动推送目标(open_id 或 chat_id);空 = 用最近一次入站的 chat_id
43
- "receiveIdType": "", // "open_id" | "chat_id"(空 = 按有无绑定推导)
44
- "boundChatId": "", // 桥写回:最近一次收到消息的 chat_id(面板只读展示)
43
+ "receiveId": "", // **可选/高级场景**:手工指定推送目标;空 = 自动绑定(推荐留空)
44
+ "receiveIdType": "", // **可选**:"open_id" | "chat_id";空 = 按 boundChatType 自动判断
45
+ "boundChatId": "", // 桥写回/可预填:自动绑定的目标(面板只读展示)
45
46
  "allowFrom": [], // open_id 白名单(空 = 不限制;建议填写)
46
47
  "cardApproval": true, // 审批用卡片按钮(false = 纯文本 + 回电脑拍板)
47
48
  "sdkVersion": "", // 下载成功后回写(面板展示)
@@ -63,7 +64,13 @@
63
64
  "feishu": {
64
65
  "enabled": true, "state": "idle|connecting|connected|reconnecting|failed|sdk_missing",
65
66
  "connected": true, "authenticated": true,
66
- "lastError": "", "lastConnectedAt": 0, "lastMessageAt": 0, "boundChatId": "",
67
+ "lastError": "", "lastConnectedAt": 0, "lastMessageAt": 0,
68
+ "boundChatId": "oc_xxx 或 ou_xxx", // 自动绑定的目标
69
+ "boundChatType": "chat_id|open_id", // 决定发送用的 receive_id_type
70
+ "boundChatName": "项目群", // 群名(面板显示「已自动绑定:<群名>」)
71
+ "chatCount": 1, // 机器人所在群数(chat.list;null=没权限/没查到)
72
+ "receiveIdSource": "auto_group|auto|config|none", // 接收人来源
73
+ "receiveIdConfigured": false, // 用户是否手工填了 receiveId
67
74
  "appId": "cli_9f42***b1", "domain": "feishu", "receiveIdType": "open_id",
68
75
  "cardApproval": true, "sdkVersion": "1.76.0",
69
76
  "tier": "free", "caps": ["..."], "limits": { "messages_per_month": 20 },
@@ -122,13 +129,39 @@ import {
122
129
 
123
130
  `allow` 只对应「允许一次」,不会变成永久放行;无决策来源时只 ACK 不生效(并记日志)。
124
131
 
132
+ ### 4.2 接收人自动绑定(业主口径:不用用户填)
133
+
134
+ ```
135
+ 连接成功 ──▶ chat.list({page_size:20}) ← 需要可选权限 im:chat:readonly
136
+ ├─ 1 个群 → 绑定它(receiveIdSource=auto_group,状态写 boundChatId/boundChatType=chat_id/boundChatName)
137
+ ├─ 多个群 → 取第一个并计 chatCount(用户发消息即改绑;面板可提示"多个群")
138
+ ├─ 空列表 → 不绑定,等首条消息
139
+ └─ 无权限/失败 → 只记日志(连接、发消息、消息绑定都不受影响)
140
+ 首次收到消息 ──▶ 绑定该会话(单聊=sender.open_id;群=chat_id),覆盖 auto_group(receiveIdSource=auto)
141
+ 配置里填了 receiveId ──▶ 永远优先(receiveIdSource=config;chat.list 只记录 chatCount,不覆盖)
142
+ 未绑定 ──▶ 主动通知/审批卡跳过 + 记一条可读日志(不报错、不发请求);绑定成功后立即写状态
143
+ ```
144
+
145
+ - 发送时 `receive_id_type` 优先级:显式传参(内部回复)> **配置 `receiveId` 存在时**的 `receiveIdType` > 按 `boundChatType` 自动判断(`chat_id`/`open_id`)。
146
+ 注意:`receiveId` 为空时,插件写的默认 `receiveIdType:"open_id"` **不生效**(否则会把群 `oc_…` 当 open_id 发)。
147
+ - chat.list 拿到群列表后,即使不覆盖绑定(配置优先/预填),也会按 `boundChatId` 补上 `boundChatName`(面板显示群名)。
148
+ - 配置里可预填 `boundChatId`(兼容旧配置/排障):`oc_` 开头按群、其余按 open_id;
149
+ **预填优先于 chat.list**(不会被"第一个群"覆盖,群数仍记录)。
150
+
125
151
  ---
126
152
 
127
- ## 5. 配置页 8 步向导(插件半照抄 `FEISHU_SETUP_GUIDE`)
153
+ ## 5. 配置页向导(插件半照抄 `FEISHU_SETUP_GUIDE`)
154
+
155
+ **主路径只有两件事**(展示在第一层):
156
+ 1. 创建一个企业自建应用,复制 **App ID / App Secret**(接收人不用填)
157
+ 2. 填进面板 → 下载依赖 → 在飞书里给机器人**发一句话**(或机器人已在群里则自动绑定)
128
158
 
129
- 顶部固定两条官方链接(**业主明确要求**):
159
+ 官方链接(业主 2026-11-08 提供,一字不改):
160
+ - 创建企业自建应用:<https://open.feishu.cn/app?lang=zh-CN>
161
+ - 卡片交互机器人文档:<https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/quick-start/develop-a-card-interactive-bot>
130
162
  - 使用长连接接收事件:<https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case>
131
- - 注册/创建应用:<https://open.feishu.cn/app>
163
+
164
+ 其余后台步骤折叠在「**需要更细的步骤**」里(首次配置仍需做完):
132
165
 
133
166
  | # | 步骤 |
134
167
  |---|---|
@@ -147,18 +180,21 @@ import {
147
180
 
148
181
  ---
149
182
 
150
- ## 6. 真实联调步骤(需要业主:建应用 + 发版)
183
+ ## 6. 真实联调步骤(最小路径:只填 App ID/Secret)
151
184
 
152
- 前置:Windows/macOS 任一真机、能访问公网(**长连接不支持代理**)、企业管理员权限(发版)。
185
+ > Lead 2026-11-08 用业主真实凭据实测:SDK 动态加载成功、`tenant_access_token` OK(781ms)、
186
+ > `WSClient` `state:"connected"`(**只要 App ID/Secret 就能连上,不需要额外配置**)、
187
+ > `client.im.v1.chat.list()` 能列出机器人所在群、`im.v1.message.create({receive_id_type:"chat_id"})` 发送成功。
153
188
 
154
- 1. **建应用并配置**(按上面 8 步,先只做 1–4 与 7 的权限/能力部分,事件/回调等第 4 步做完再回来配)。
189
+ 1. **建应用**(企业自建)→ 复制 **App ID / App Secret**;开机器人能力、申请收发消息权限、事件/回调切长连接、发版(按 §5 折叠的步骤)。
155
190
  2. **本机下载依赖**(面板按钮等价命令):
156
191
  `npm install --prefix ~/.dsh-remote/im-deps --no-save --no-audit --no-fund @larksuiteoapi/node-sdk`
157
- 3. **写配置**(面板写入或手工):`im-bots.json` 的 `feishu.{enabled:true,appId,appSecret,allowFrom:[你的 open_id]}`。
158
- 4. **看状态**:bridge 日志出现 `[feishu] 长连接已发起`;`im-bot-status.json` 的 `feishu.state → connected`。
159
- 5. **回去配事件/回调**(第 5、6 步)→ 发布版本(第 7 步)。
160
- 6. **绑定**:在飞书里给机器人发一句话 → 状态 `boundChatId` 出现 → 面板显示已绑定。
161
- 7. **验收**:
192
+ 3. **写配置(只需要两样)**:`im-bots.json` 的 `feishu.{enabled:true, appId, appSecret}`。
193
+ **`receiveId` / `receiveIdType` 留空** —— 不需要填。
194
+ 4. **启动 bridge** → 状态 `feishu.state → connected`;随后自动绑定:
195
+ - 机器人已在群里 → `chat.list` 自动选中并写 `boundChatId`(`receiveIdSource:"auto_group"`,面板显示「已自动绑定:<群名>」);
196
+ - 机器人还没进群 → 状态 `boundChatId:""`,此时通知会跳过(只记日志),**在飞书里给机器人发一句话**即自动绑定(私聊记 `open_id`、群聊记 `chat_id`)。
197
+ 5. **验收**:
162
198
  - 出站:电脑上让 agent 跑一个任务 → 飞书收到「任务完成/出错」通知;
163
199
  - 入站:飞书发一句话 → 电脑端当前会话收到(免费档每月 20 条,超出有升级提示);
164
200
  - 拍板:触发一次需要审批的操作 → 飞书收到卡片 → 点「允许一次」→ 卡片原地变「已允许」+ 电脑端继续;点「拒绝」→ 电脑端拒绝;**不回** → 90s 后按拒绝处理。
@@ -170,8 +206,12 @@ import {
170
206
 
171
207
  ## 7. 已知限制 / 未核实
172
208
 
173
- 1. **本模块未做真机联调**:SDK/网络全部注入假实现单测(42 条);真实建连、卡片回调、发版生效需业主按 §6 验一次。
209
+ 1. **本模块未做真机联调**:SDK/网络全部注入假实现单测(51 条);真实建连、卡片回调、发版生效需按 §6 验一次。
210
+ (已做只读验证:本机真实 SDK 1.76.0 能被 `loadFeishuSdk` 动态加载,`Client/WSClient/EventDispatcher` 均存在。)
174
211
  2. **卡片回调能否走长连接**:官方文档说可以;SDK README 有一句相反表述(调研 R8)。真机点一次按钮确认。
175
212
  3. `patchCard`(原地更新)用 REST PATCH,官方权限/版本限制未核实;失败只记日志(不影响 ACK)。
176
213
  4. 流式回复:飞书没有该能力(调研 §4.3),本模块不提供;通知/审批不需要。
177
214
  5. 群聊只处理 @机器人 的消息由飞书侧决定(我们按收到的消息处理);多设备抢事件需「一个应用一台电脑」。
215
+ 6. **自动选群**(`chat.list`)需要可选权限 `im:chat:readonly`:**没这个权限也能用**(回落到"首条消息绑定"),
216
+ 只是不能"连上即绑定群";多个群时取第一个(用户发消息会改绑),面板可用 `chatCount` 提示。
217
+ 7. 私聊绑定记的是 `sender.open_id`(不是 chat_id):用户换群/换会话不会发错地方;群绑定记 `chat_id`。
@@ -1360,13 +1360,15 @@ async function doHttpStream(send, id, method, path, reqHeaders) {
1360
1360
 
1361
1361
  const status = res.status;
1362
1362
  // 头部帧:告诉中继"这是一条流",随后才逐块推 body
1363
+ // 这些帧都带上 `id` 元信息(不进协议字节)—— B2 取消时 `drop(id)` 才能把还没发出去的分片
1364
+ // 整条丢掉(手机断开 SSE 后,别让已入队的块继续占隧道队头)。
1363
1365
  send({
1364
1366
  id,
1365
1367
  type: "http",
1366
1368
  status,
1367
1369
  headers: sanitizeResponseHeaders(Object.fromEntries(res.headers.entries())),
1368
1370
  streaming: true
1369
- });
1371
+ }, { id });
1370
1372
 
1371
1373
  let seq = 0;
1372
1374
  let bytes = 0;
@@ -1378,7 +1380,7 @@ async function doHttpStream(send, id, method, path, reqHeaders) {
1378
1380
  if (done) break;
1379
1381
  if (!value || value.length === 0) continue;
1380
1382
  bytes += value.length;
1381
- send({ id, type: "http-chunk", seq: ++seq, body: Buffer.from(value).toString("base64"), bodyBase64: true });
1383
+ send({ id, type: "http-chunk", seq: ++seq, body: Buffer.from(value).toString("base64"), bodyBase64: true }, { id });
1382
1384
  }
1383
1385
  }
1384
1386
  } catch (e) {
@@ -1386,11 +1388,11 @@ async function doHttpStream(send, id, method, path, reqHeaders) {
1386
1388
  // 再写一次 head 会炸)。统一以 http-end 收尾,让 EventSource 按自己的节奏重连。
1387
1389
  const aborted = controller.signal.aborted === true;
1388
1390
  streamAborts.delete(id);
1389
- send({ id, type: "http-end", seq, ...(aborted ? { aborted: true } : { error: String(e?.message || e) }) });
1391
+ send({ id, type: "http-end", seq, ...(aborted ? { aborted: true } : { error: String(e?.message || e) }) }, { id });
1390
1392
  return { status, chunks: seq, bytes, aborted };
1391
1393
  }
1392
1394
  streamAborts.delete(id);
1393
- send({ id, type: "http-end", seq });
1395
+ send({ id, type: "http-end", seq }, { id });
1394
1396
  return { status, chunks: seq, bytes };
1395
1397
  }
1396
1398
 
@@ -132,15 +132,32 @@ export const SDK_MISSING_HINT =
132
132
  /** 长连接不支持代理(官方文档)→ 连接失败时给这条。 */
133
133
  export const PROXY_HINT = "飞书长连接不支持代理:如果公司网络必须走代理,这条通道在当前网络下连不上(可改用手机远程)。";
134
134
 
135
- /** 飞书官方文档 / 注册入口(配置页 8 步向导直接引用;不在此模块画 UI)。 */
135
+ /**
136
+ * 飞书官方文档 / 注册入口 + 配置向导(配置页直接渲染;本模块不画 UI)。
137
+ *
138
+ * 业主 2026-11-08 明确的产品口径:**接收人(open_id/chat_id)不让用户填** —— 用户本来也
139
+ * 不知道去哪拿,默认就是他/她本人。所以主路径只有两步(填 App ID/Secret + 发一句话自动绑定),
140
+ * 后台的 7 项配置折叠成「需要更细的步骤」。
141
+ */
136
142
  export const FEISHU_SETUP_GUIDE = Object.freeze({
137
143
  docs: Object.freeze({
144
+ createApp: "https://open.feishu.cn/app?lang=zh-CN",
145
+ cardInteractiveBot: "https://open.feishu.cn/document/uAjLw4CM/ukzMukzMukzM/feishu-cards/quick-start/develop-a-card-interactive-bot",
138
146
  longConnectionEvent: "https://open.feishu.cn/document/server-docs/event-subscription-guide/event-subscription-configure-/request-url-configuration-case",
139
147
  longConnectionCallback: "https://open.feishu.cn/document/uAjLw4CM/ukTMukTMukTM/event-subscription-guide/callback-subscription/configure-callback-request-address",
140
148
  sendMessage: "https://open.feishu.cn/document/server-docs/im-v1/message/create",
141
149
  receiveMessage: "https://open.feishu.cn/document/server-docs/im-v1/message/events/receive",
142
150
  registerApp: "https://open.feishu.cn/app"
143
151
  }),
152
+ /** 主路径(展示在第一层,只要两件事)。 */
153
+ quickPath: Object.freeze([
154
+ "在飞书开放平台创建一个企业自建应用,复制 **App ID / App Secret**(只填这两样,接收人不用填)",
155
+ "填进本面板 → 下载依赖 → 在飞书里给机器人**发一句话**:接收人自动绑定,之后通知/拍板都发给你"
156
+ ]),
157
+ quickPathHint: "应用能力/权限/事件与回调/发版这些后台设置仍需做一次(点开「需要更细的步骤」照着做)。",
158
+ detailsLabel: "需要更细的步骤",
159
+ /** 接收人自动绑定的口径(面板可展示成一句说明)。 */
160
+ bindingHint: "接收人不需要填:机器人收到你的第一条消息后自动记住这个会话;在群里 @ 它则记住那个群。",
144
161
  steps: Object.freeze([
145
162
  "打开飞书开放平台(open.feishu.cn/app)→ 创建**企业自建应用**(商店应用不支持长连接)",
146
163
  "「凭证与基础信息」→ 复制 **App ID / App Secret**(App ID 形如 cli_ 开头的一串字符)",
@@ -149,7 +166,7 @@ export const FEISHU_SETUP_GUIDE = Object.freeze({
149
166
  "「事件与回调 → 事件配置」→ 订阅方式选**使用长连接接收事件** → 添加「接收消息 v2.0」",
150
167
  "「事件与回调 → 回调配置」→ 订阅方式选**使用长连接接收回调** → 添加 card.action.trigger(拍板卡片用)",
151
168
  "「版本管理与发布」→ 创建版本并**发布**(不发布不生效);「可用范围」包含你自己/目标成员",
152
- "回到本面板:填 App ID / Secret → 「下载依赖」→「测试连接」→ 在飞书里给机器人**发一句话**完成绑定"
169
+ "回到本面板:填 App ID / Secret → 「下载依赖」→「测试连接」→ 在飞书里给机器人**发一句话**(接收人自动绑定,无需填写)"
153
170
  ]),
154
171
  // 最容易被忽略的顺序坑:保存事件配置时,必须已经有一个长连接在线。
155
172
  orderWarning: "顺序很重要:先在本机填好凭据让长连接在线,再去开发者后台保存「事件/回调」配置(否则保存会提示「应用未建立长连接」)。",
@@ -731,6 +748,10 @@ export function createFeishuBot({
731
748
  receiveIdType = "",
732
749
  allowFrom = [],
733
750
  cardApproval = true,
751
+ /** 配置里预填的绑定点(可选;兼容旧配置/手工排障)。正常流程由 chat.list 或首条消息自动绑定。 */
752
+ boundChatId = "",
753
+ /** 连接成功后用 chat.list 自动选群:每页条数。 */
754
+ chatListPageSize = 20,
734
755
  relayDir = "",
735
756
  env = process.env,
736
757
  logger,
@@ -755,6 +776,7 @@ export function createFeishuBot({
755
776
  const seenCards = new Map();
756
777
  let sender = null;
757
778
  let wsClient = null;
779
+ let sdkRef = null; // 懒加载后的 SDK(chat.list 自动选群用)
758
780
  let dispatcherHandlers = null;
759
781
  let pollTimer = null;
760
782
  let stopped = false;
@@ -768,6 +790,10 @@ export function createFeishuBot({
768
790
  lastConnectedAt: 0,
769
791
  lastMessageAt: 0,
770
792
  boundChatId: "",
793
+ boundChatType: "", // "chat_id"(群,来自 chat.list/群消息)| "open_id"(单聊,来自消息)
794
+ boundChatName: "", // 群名(面板展示"已自动绑定:<群名或私聊>")
795
+ boundSource: "", // "auto_group"(chat.list 自动选群)| "auto"(首条消息/预填)
796
+ chatCount: null, // 机器人所在群数(chat.list 结果;null=没查到/没权限)
771
797
  sdkVersion: "",
772
798
  ackCount: 0,
773
799
  decisionCount: 0
@@ -800,6 +826,7 @@ export function createFeishuBot({
800
826
  state.lastConnectedAt = Number(clock()) || Date.now();
801
827
  log.info("长连接已就绪");
802
828
  try { emitter.emit("ready", status()); } catch { /* 忽略 */ }
829
+ void autoBindFromChatList();
803
830
  } else if (!connected) {
804
831
  state.connected = false;
805
832
  state.authenticated = false;
@@ -830,6 +857,7 @@ export function createFeishuBot({
830
857
  state.lastConnectedAt = Number(clock()) || Date.now();
831
858
  try { emitter.emit("ready", status()); } catch { /* 忽略 */ }
832
859
  emitStatus();
860
+ void autoBindFromChatList();
833
861
  }
834
862
  }
835
863
 
@@ -910,7 +938,17 @@ export function createFeishuBot({
910
938
  void sender?.sendText?.(inbound.chatId, "你不在这个通道的白名单里,无法指挥这台电脑。", { receiveIdType: "chat_id" });
911
939
  return;
912
940
  }
913
- state.boundChatId = inbound.chatId;
941
+ /*
942
+ * 首次说话即绑定(与企微/钉钉一致):接收人不让用户填。
943
+ * · 群聊 → 记 chat_id(后续按 chat_id 推)
944
+ * · 单聊 → 记 sender.open_id(后续按 open_id 推,用户换群也不会发错地方)
945
+ * 绑定信息只放内存 + 状态文件(桥写),配置里不需要 receiveId。
946
+ */
947
+ const isGroup = inbound.chatType === "group";
948
+ state.boundChatId = isGroup ? inbound.chatId : (inbound.openId || inbound.chatId);
949
+ state.boundChatType = isGroup ? "chat_id" : "open_id"; // 直接就是后续发送用的 receive_id_type
950
+ state.boundChatName = ""; // 消息里没有群名;面板显示「私聊/群」即可
951
+ state.boundSource = "auto"; // 用户发消息即改绑(覆盖 chat.list 的 auto_group)
914
952
  state.lastMessageAt = Number(clock()) || Date.now();
915
953
  emitStatus();
916
954
  try { emitter.emit("message", inbound); } catch (e) { log.warn(`message 处理异常:${truncateText(e?.message || e, 100)}`); }
@@ -934,6 +972,133 @@ export function createFeishuBot({
934
972
  return { dispatcher, handlers };
935
973
  }
936
974
 
975
+ /**
976
+ * 接收人来源:
977
+ * · config —— 用户在高级场景里手工填了 receiveId(优先)
978
+ * · auto_group —— 连接成功后 chat.list 自动选中了机器人所在的群
979
+ * · auto —— 首条消息自动绑定(私聊 open_id / 群 chat_id),或配置预填
980
+ * · none —— 还没绑定(通知会跳过并记日志,不报错)
981
+ */
982
+ function receiveIdSource() {
983
+ if (String(receiveId || "").trim()) return "config";
984
+ if (!state.boundChatId) return "none";
985
+ return state.boundSource === "auto_group" ? "auto_group" : "auto";
986
+ }
987
+
988
+ /** 自动绑定后的推送类型(直接就是 receive_id_type)。 */
989
+ function boundTargetType() {
990
+ if (state.boundChatType === "chat_id") return "chat_id";
991
+ if (state.boundChatType === "open_id") return "open_id";
992
+ return "";
993
+ }
994
+
995
+ /**
996
+ * **连接成功后自动选群**(业主/Lead 实测:`client.im.v1.chat.list()` 能列出机器人所在群):
997
+ * · 有群 → 记为接收人(多个群取第一个,用户发消息会改绑)→ receiveIdSource=auto_group
998
+ * · 空 → 不绑定,等首条消息
999
+ * · 无权限/失败 → 只记日志,**不影响连接与"发消息绑定"**
1000
+ * 配置里填了 receiveId 时只记录群数,不覆盖(配置优先)。
1001
+ */
1002
+ async function autoBindFromChatList() {
1003
+ if (autoBindTried) return;
1004
+ autoBindTried = true;
1005
+ const cfgTo = String(receiveId || "").trim();
1006
+ let client = null;
1007
+ try {
1008
+ if (!sdkRef || typeof sdkRef.Client !== "function") {
1009
+ log.info("SDK 未提供 Client(跳过自动选群,等首条消息绑定)");
1010
+ return;
1011
+ }
1012
+ const opts = { appId, appSecret };
1013
+ if (normalizeDomain(domain) === "lark" && sdkRef?.Domain?.Lark) opts.domain = sdkRef.Domain.Lark;
1014
+ client = new sdkRef.Client(opts);
1015
+ } catch (e) {
1016
+ log.warn(`创建 Client 失败(跳过自动选群,不影响消息绑定):${truncateText(e?.message || e, 120)}`);
1017
+ return;
1018
+ }
1019
+ const listFn = client?.im?.v1?.chat?.list;
1020
+ if (typeof listFn !== "function") {
1021
+ log.info("SDK 没有 chat.list(跳过自动选群,等首条消息绑定)");
1022
+ return;
1023
+ }
1024
+ let resp = null;
1025
+ try {
1026
+ // 官方 1.76 的调用形态是 { params: {…} };兼容直接传参数 / 无参(Lead 实测无参也能列出群)
1027
+ try {
1028
+ resp = await listFn.call(client.im.v1.chat, { params: { page_size: chatListPageSize } });
1029
+ } catch (e1) {
1030
+ try {
1031
+ resp = await listFn.call(client.im.v1.chat, { page_size: chatListPageSize });
1032
+ } catch (e2) {
1033
+ resp = await listFn.call(client.im.v1.chat);
1034
+ }
1035
+ }
1036
+ } catch (e) {
1037
+ // 常见:应用没申请 im:chat:readonly → 只记日志(不影响连接/发消息/消息绑定)
1038
+ log.warn(`chat.list 失败(忽略,不影响消息绑定;如需自动选群请加 im:chat:readonly):${truncateText(e?.message || e, 120)}`);
1039
+ return;
1040
+ }
1041
+ const code = Number(resp?.code ?? resp?.data?.code);
1042
+ if (Number.isFinite(code) && code !== 0) {
1043
+ log.warn(`chat.list 返回错误码 ${code}(忽略;如需自动选群请加 im:chat:readonly);${feishuErrorHint(code, resp?.msg || "")}`);
1044
+ return;
1045
+ }
1046
+ const items = Array.isArray(resp?.data?.items) ? resp.data.items : (Array.isArray(resp?.items) ? resp.items : []);
1047
+ state.chatCount = items.length;
1048
+ // 显式指定(receiveId)或配置预填 boundChatId 都算"用户已经定了目标"→ 只记录群数,不覆盖
1049
+ const prefilled = String(boundChatId || "").trim();
1050
+ if (cfgTo || prefilled) {
1051
+ // 不覆盖用户指定的目标,但尽量补上群名(面板显示「已自动绑定:<群名>」)
1052
+ const wanted = cfgTo || prefilled;
1053
+ const hit = items.find((it) => String(it?.chat_id || it?.chatId || "") === wanted);
1054
+ if (hit && !state.boundChatName) state.boundChatName = String(hit.name || "");
1055
+ log.info(`接收人已指定(${cfgTo ? "receiveId" : "配置预填 boundChatId"},优先),chat.list 仅记录机器人所在群数=${items.length}`);
1056
+ emitStatus();
1057
+ return;
1058
+ }
1059
+ if (!items.length) {
1060
+ log.info("机器人还没进任何群:等用户私聊或拉群后自动绑定");
1061
+ emitStatus();
1062
+ return;
1063
+ }
1064
+ const first = items[0] || {};
1065
+ const chatId = String(first.chat_id || first.chatId || "");
1066
+ if (!chatId) {
1067
+ emitStatus();
1068
+ return;
1069
+ }
1070
+ state.boundChatId = chatId;
1071
+ state.boundChatType = "chat_id";
1072
+ state.boundChatName = String(first.name || "");
1073
+ state.boundSource = "auto_group";
1074
+ log.info(`已自动绑定到群 chat=${chatIdHash(chatId)} 群数=${items.length}${items.length > 1 ? "(多个群取第一个;用户发消息会改绑)" : ""}`);
1075
+ emitStatus();
1076
+ }
1077
+ let autoBindTried = false;
1078
+
1079
+ /**
1080
+ * 解析一次发送的 target 与 receive_id_type(产品口径:接收人默认自动绑定)。
1081
+ *
1082
+ * 优先级(任务书第 3 条):
1083
+ * ① 调用方显式传的 receiveIdType(内部回复用,最明确)
1084
+ * ② 配置里手工填的 receiveIdType(高级场景)
1085
+ * ③ 按 boundChatType 自动判断(群→chat_id / 单聊→open_id)
1086
+ * ④ 兜底 open_id(老行为)
1087
+ * target 优先级:显式 to > 配置 receiveId > 自动绑定的 boundChatId
1088
+ */
1089
+ function resolveSendTarget(explicitTo, explicitType) {
1090
+ const cfgTo = String(receiveId || "").trim();
1091
+ const to = String(explicitTo || "").trim() || cfgTo || state.boundChatId || "";
1092
+ let type = normalizeReceiveIdType(explicitType || "", "");
1093
+ if (!type && cfgTo) {
1094
+ // 只有"用户显式填了 receiveId"时,receiveIdType 才有意义(插件的默认 open_id 不能套到群上)
1095
+ type = normalizeReceiveIdType(receiveIdType, "") || DEFAULT_RECEIVE_ID_TYPE;
1096
+ }
1097
+ // 显式 to(内部回复,未传 type)沿用老行为 open_id;自动绑定则按 boundChatType 判断
1098
+ if (!type) type = explicitTo ? DEFAULT_RECEIVE_ID_TYPE : (boundTargetType() || DEFAULT_RECEIVE_ID_TYPE);
1099
+ return { to, type };
1100
+ }
1101
+
937
1102
  function status() {
938
1103
  return {
939
1104
  enabled: started && !stopped,
@@ -944,6 +1109,12 @@ export function createFeishuBot({
944
1109
  lastConnectedAt: state.lastConnectedAt,
945
1110
  lastMessageAt: state.lastMessageAt,
946
1111
  boundChatId: state.boundChatId,
1112
+ boundChatType: state.boundChatType,
1113
+ boundChatName: state.boundChatName,
1114
+ chatCount: state.chatCount,
1115
+ // 接收人来源:config / auto_group(chat.list 自动选群)/ auto(首条消息)/ none
1116
+ receiveIdSource: receiveIdSource(),
1117
+ receiveIdConfigured: Boolean(String(receiveId || "").trim()),
947
1118
  appId: maskAppId(appId),
948
1119
  domain: normalizeDomain(domain),
949
1120
  receiveIdType: normalizeReceiveIdType(receiveIdType, ""),
@@ -984,7 +1155,15 @@ export function createFeishuBot({
984
1155
  return { ok: false, reason: "sdk_missing", status: status() };
985
1156
  }
986
1157
  const sdk = loaded.sdk || {};
1158
+ sdkRef = sdk;
987
1159
  state.sdkVersion = String(loaded.status?.version || "");
1160
+ // 配置里预填的绑定点(可选):oc_ 开头视为群 chat_id,其余视为 open_id
1161
+ const seeded = String(boundChatId || "").trim();
1162
+ if (seeded && !state.boundChatId) {
1163
+ state.boundChatId = seeded;
1164
+ state.boundChatType = /^oc_/i.test(seeded) ? "chat_id" : "open_id";
1165
+ state.boundSource = "auto";
1166
+ }
988
1167
 
989
1168
  // ③ 发送器(REST)
990
1169
  sender = createFeishuSender({
@@ -1041,13 +1220,12 @@ export function createFeishuBot({
1041
1220
  return { ok: true };
1042
1221
  },
1043
1222
 
1044
- /** 发文本(自动按飞书通道格式化)。 */
1223
+ /** 发文本(自动按飞书通道格式化;接收人默认自动绑定)。 */
1045
1224
  async sendText(to, text, opts = {}) {
1046
- const target = String(to || receiveId || state.boundChatId || "");
1047
1225
  if (!sender) return { ok: false, reason: "发送器未初始化(连接尚未建立)" };
1048
- const r = await sender.sendText(target, text, {
1049
- receiveIdType: opts.receiveIdType || normalizeReceiveIdType(receiveIdType, state.boundChatId ? "chat_id" : DEFAULT_RECEIVE_ID_TYPE)
1050
- });
1226
+ const { to: target, type } = resolveSendTarget(to, opts.receiveIdType);
1227
+ if (!target) return { ok: false, reason: "还没绑定接收人:先在飞书里给机器人发一句话" };
1228
+ const r = await sender.sendText(target, text, { receiveIdType: type });
1051
1229
  if (r.ok) {
1052
1230
  state.lastMessageAt = Number(clock()) || Date.now();
1053
1231
  emitStatus();
@@ -1057,11 +1235,10 @@ export function createFeishuBot({
1057
1235
 
1058
1236
  /** 发审批卡片(返回值带 messageId,供回执后原地更新)。 */
1059
1237
  async sendCard(to, card, opts = {}) {
1060
- const target = String(to || receiveId || state.boundChatId || "");
1061
1238
  if (!sender) return { ok: false, reason: "发送器未初始化(连接尚未建立)" };
1062
- return sender.sendCard(target, card, {
1063
- receiveIdType: opts.receiveIdType || normalizeReceiveIdType(receiveIdType, state.boundChatId ? "chat_id" : DEFAULT_RECEIVE_ID_TYPE)
1064
- });
1239
+ const { to: target, type } = resolveSendTarget(to, opts.receiveIdType);
1240
+ if (!target) return { ok: false, reason: "还没绑定接收人:先在飞书里给机器人发一句话" };
1241
+ return sender.sendCard(target, card, { receiveIdType: type });
1065
1242
  },
1066
1243
 
1067
1244
  /** 更新卡片(best-effort)。 */
@@ -1254,6 +1431,12 @@ export function createFeishuBotController(options = {}) {
1254
1431
  lastConnectedAt: bs?.lastConnectedAt || 0,
1255
1432
  lastMessageAt: bs?.lastMessageAt || 0,
1256
1433
  boundChatId: (bs && bs.boundChatId) || "",
1434
+ boundChatType: (bs && bs.boundChatType) || "",
1435
+ boundChatName: (bs && bs.boundChatName) || "",
1436
+ chatCount: Number.isInteger(bs?.chatCount) ? bs.chatCount : null,
1437
+ // 面板据此显示「接收人:自动绑定(已/未)/ 手动配置」;receiveId 用户不用填
1438
+ receiveIdSource: (bs && bs.receiveIdSource) || (configNow?.receiveId ? "config" : "none"),
1439
+ receiveIdConfigured: Boolean(configNow?.receiveId),
1257
1440
  appId: bs?.appId || maskAppId(""),
1258
1441
  domain: bs?.domain || "feishu",
1259
1442
  receiveIdType: bs?.receiveIdType || "",
@@ -1342,7 +1525,7 @@ export function createFeishuBotController(options = {}) {
1342
1525
  if (!notifyEnabled || !bot) return;
1343
1526
  const target = targetOf();
1344
1527
  if (!target) {
1345
- log.info("有事件要推,但还没绑定接收方:先在飞书里给机器人发一句话,或在配置里填 receiveId");
1528
+ log.info("有事件要推,但还没绑定接收方:不用填接收人 —— 在飞书里给机器人发一句话,就会自动绑定到你这个会话");
1346
1529
  return;
1347
1530
  }
1348
1531
  if (!notifyRateOk()) {
@@ -1570,6 +1753,7 @@ export function createFeishuBotController(options = {}) {
1570
1753
  receiveId: cfg.receiveId,
1571
1754
  receiveIdType: cfg.receiveIdType,
1572
1755
  allowFrom: cfg.allowFrom,
1756
+ boundChatId: cfg.boundChatId,
1573
1757
  cardApproval: cardApprovalAllowed && cfg.cardApproval,
1574
1758
  relayDir,
1575
1759
  env,