botmux 3.18.7 → 3.18.8
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/dist/.runtime-build-id +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.d.ts +57 -0
- package/dist/adapters/backend/tmux-pipe-backend.d.ts.map +1 -1
- package/dist/adapters/backend/tmux-pipe-backend.js +200 -21
- package/dist/adapters/backend/tmux-pipe-backend.js.map +1 -1
- package/dist/adapters/cli/pi-turn-boundary-extension.d.ts +66 -0
- package/dist/adapters/cli/pi-turn-boundary-extension.d.ts.map +1 -0
- package/dist/adapters/cli/pi-turn-boundary-extension.js +106 -0
- package/dist/adapters/cli/pi-turn-boundary-extension.js.map +1 -0
- package/dist/adapters/cli/pi.d.ts +29 -0
- package/dist/adapters/cli/pi.d.ts.map +1 -1
- package/dist/adapters/cli/pi.js +61 -10
- package/dist/adapters/cli/pi.js.map +1 -1
- package/dist/bot-registry.d.ts +9 -0
- package/dist/bot-registry.d.ts.map +1 -1
- package/dist/bot-registry.js +5 -0
- package/dist/bot-registry.js.map +1 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +8 -1
- package/dist/cli.js.map +1 -1
- package/dist/core/dashboard-ipc-server.d.ts.map +1 -1
- package/dist/core/dashboard-ipc-server.js +24 -0
- package/dist/core/dashboard-ipc-server.js.map +1 -1
- package/dist/core/worker-pool.d.ts.map +1 -1
- package/dist/core/worker-pool.js +134 -17
- package/dist/core/worker-pool.js.map +1 -1
- package/dist/daemon.d.ts.map +1 -1
- package/dist/daemon.js +5 -3
- package/dist/daemon.js.map +1 -1
- package/dist/dashboard/bot-payload.d.ts +2 -0
- package/dist/dashboard/bot-payload.d.ts.map +1 -1
- package/dist/dashboard/bot-payload.js +2 -0
- package/dist/dashboard/bot-payload.js.map +1 -1
- package/dist/dashboard/groups-matrix-snapshot.d.ts +27 -0
- package/dist/dashboard/groups-matrix-snapshot.d.ts.map +1 -1
- package/dist/dashboard/groups-matrix-snapshot.js +16 -0
- package/dist/dashboard/groups-matrix-snapshot.js.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.d.ts +4 -0
- package/dist/dashboard/web/bot-defaults-page.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults-page.js +52 -9
- package/dist/dashboard/web/bot-defaults-page.js.map +1 -1
- package/dist/dashboard/web/bot-defaults.d.ts +29 -0
- package/dist/dashboard/web/bot-defaults.d.ts.map +1 -1
- package/dist/dashboard/web/bot-defaults.js +45 -1
- package/dist/dashboard/web/bot-defaults.js.map +1 -1
- package/dist/dashboard/web/bot-onboarding.d.ts.map +1 -1
- package/dist/dashboard/web/bot-onboarding.js +5 -0
- package/dist/dashboard/web/bot-onboarding.js.map +1 -1
- package/dist/dashboard/web/dashboard-components.d.ts +29 -0
- package/dist/dashboard/web/dashboard-components.d.ts.map +1 -1
- package/dist/dashboard/web/dashboard-components.js +96 -4
- package/dist/dashboard/web/dashboard-components.js.map +1 -1
- package/dist/dashboard/web/groups-api.d.ts +11 -0
- package/dist/dashboard/web/groups-api.d.ts.map +1 -1
- package/dist/dashboard/web/groups-api.js +51 -0
- package/dist/dashboard/web/groups-api.js.map +1 -1
- package/dist/dashboard/web/i18n.d.ts.map +1 -1
- package/dist/dashboard/web/i18n.js +10 -0
- package/dist/dashboard/web/i18n.js.map +1 -1
- package/dist/dashboard/web/overview.d.ts.map +1 -1
- package/dist/dashboard/web/overview.js +11 -2
- package/dist/dashboard/web/overview.js.map +1 -1
- package/dist/dashboard/web/ui.d.ts.map +1 -1
- package/dist/dashboard/web/ui.js +12 -2
- package/dist/dashboard/web/ui.js.map +1 -1
- package/dist/dashboard-web/app.js +1 -1
- package/dist/dashboard-web/chunks/{agent-workbench-dock-page-6LC422N6.js → agent-workbench-dock-page-FAL2YGY5.js} +1 -1
- package/dist/dashboard-web/chunks/{agent-workbench-page-RSAWY7O2.js → agent-workbench-page-G65IOPLY.js} +1 -1
- package/dist/dashboard-web/chunks/bot-defaults-page-3BNFNZEO.js +1 -0
- package/dist/dashboard-web/chunks/chunk-2XHWGUTB.js +10 -0
- package/dist/dashboard-web/chunks/{chunk-R3LJUCEO.js → chunk-3OJQKYM3.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-K232GT3M.js → chunk-IRFGJSPL.js} +5 -5
- package/dist/dashboard-web/chunks/{chunk-IA5KMD5O.js → chunk-IYVXEFKU.js} +1 -1
- package/dist/dashboard-web/chunks/{chunk-YJA3KY3J.js → chunk-MYLVEXYF.js} +1 -1
- package/dist/dashboard-web/chunks/chunk-QALRQEKD.js +1 -0
- package/dist/dashboard-web/chunks/{connectors-page-DJCTHSFB.js → connectors-page-K6VZNYUQ.js} +1 -1
- package/dist/dashboard-web/chunks/{customization-page-VQJAEDZY.js → customization-page-FRP6RFOI.js} +1 -1
- package/dist/dashboard-web/chunks/{feedback-page-O7SZRKNM.js → feedback-page-4X7N5J33.js} +1 -1
- package/dist/dashboard-web/chunks/{groups-page-7PONUYHE.js → groups-page-RFQVUO6K.js} +1 -1
- package/dist/dashboard-web/chunks/{insights-page-J6DAAJ6D.js → insights-page-XWH77GX7.js} +1 -1
- package/dist/dashboard-web/chunks/{monitor-room-F3QNEW7N.js → monitor-room-WFU3745R.js} +1 -1
- package/dist/dashboard-web/chunks/{monitoring-page-JHDD2VS2.js → monitoring-page-3NYPZEQT.js} +1 -1
- package/dist/dashboard-web/chunks/{office-page-MMTN4IKD.js → office-page-4I3X6PTF.js} +1 -1
- package/dist/dashboard-web/chunks/overview-page-OQWLVTKT.js +1 -0
- package/dist/dashboard-web/chunks/{roles-page-FNI4MGX6.js → roles-page-RKUGNQOW.js} +1 -1
- package/dist/dashboard-web/chunks/{schedules-page-6PUU63KS.js → schedules-page-SAJKYRZ6.js} +1 -1
- package/dist/dashboard-web/chunks/{sessions-page-O4N72EXT.js → sessions-page-IU6VNSX4.js} +1 -1
- package/dist/dashboard-web/chunks/{settings-page-G7B3STUF.js → settings-page-WTIPX7BQ.js} +1 -1
- package/dist/dashboard-web/chunks/{skills-page-CEWKQOIG.js → skills-page-MKG3HJIU.js} +1 -1
- package/dist/dashboard-web/chunks/{team-federation-page-NUP4VK4H.js → team-federation-page-7SMZJWPT.js} +1 -1
- package/dist/dashboard-web/chunks/{v3-page-M4XF6D5Z.js → v3-page-JMCFLY4Y.js} +1 -1
- package/dist/dashboard-web/chunks/{whiteboards-page-L755NZX6.js → whiteboards-page-5YTWY5EB.js} +1 -1
- package/dist/dashboard-web/style.css +65 -3
- package/dist/dashboard.js +50 -4
- package/dist/dashboard.js.map +1 -1
- package/dist/i18n/en.d.ts.map +1 -1
- package/dist/i18n/en.js +4 -1
- package/dist/i18n/en.js.map +1 -1
- package/dist/i18n/zh.d.ts.map +1 -1
- package/dist/i18n/zh.js +4 -1
- package/dist/i18n/zh.js.map +1 -1
- package/dist/im/lark/event-dispatcher.d.ts.map +1 -1
- package/dist/im/lark/event-dispatcher.js +141 -38
- package/dist/im/lark/event-dispatcher.js.map +1 -1
- package/dist/services/card-prefs-store.d.ts +2 -0
- package/dist/services/card-prefs-store.d.ts.map +1 -1
- package/dist/services/card-prefs-store.js +9 -1
- package/dist/services/card-prefs-store.js.map +1 -1
- package/dist/services/pi-transcript.d.ts +21 -1
- package/dist/services/pi-transcript.d.ts.map +1 -1
- package/dist/services/pi-transcript.js +188 -8
- package/dist/services/pi-transcript.js.map +1 -1
- package/dist/services/session-store.d.ts.map +1 -1
- package/dist/services/session-store.js +495 -11
- package/dist/services/session-store.js.map +1 -1
- package/dist/services/under-review-notify-store.d.ts +18 -0
- package/dist/services/under-review-notify-store.d.ts.map +1 -0
- package/dist/services/under-review-notify-store.js +67 -0
- package/dist/services/under-review-notify-store.js.map +1 -0
- package/dist/setup/open-platform-automation.d.ts +210 -2
- package/dist/setup/open-platform-automation.d.ts.map +1 -1
- package/dist/setup/open-platform-automation.js +538 -44
- package/dist/setup/open-platform-automation.js.map +1 -1
- package/dist/setup/open-platform-outcome.d.ts.map +1 -1
- package/dist/setup/open-platform-outcome.js +5 -0
- package/dist/setup/open-platform-outcome.js.map +1 -1
- package/dist/workflows/events/payloads.d.ts +6 -6
- package/dist/workflows/events/schema.d.ts +12 -12
- package/package.json +9 -7
- package/dist/dashboard-web/chunks/bot-defaults-page-67RAWCSQ.js +0 -1
- package/dist/dashboard-web/chunks/chunk-MMLTCXHP.js +0 -10
- package/dist/dashboard-web/chunks/chunk-NYY66DNC.js +0 -1
- package/dist/dashboard-web/chunks/overview-page-IQ2UJAJP.js +0 -1
|
@@ -422,6 +422,58 @@ export function buildPrivilegeUpdatePayload(appId, privileges) {
|
|
|
422
422
|
*
|
|
423
423
|
* 调用方决定失败怎么处理(两处都是非致命,但一处 warn 一处进 result.warning)。
|
|
424
424
|
*/
|
|
425
|
+
/**
|
|
426
|
+
* 只读探查「这个应用为什么可能卡审批」,给管理员 DM 里补一句**具体线索**。
|
|
427
|
+
*
|
|
428
|
+
* 为什么值得单独探一次:光说「可能是数据范围没配」是转述,说「实测这两条现在是
|
|
429
|
+
* 『全部/未配置』」才是证据 —— 人拿着它能直接去 console 对应位置改。审核锁只锁写,
|
|
430
|
+
* `privilege/all` 这类读接口照常可用(实测),所以这次探查在审核中也能成功。
|
|
431
|
+
*
|
|
432
|
+
* 全程 best-effort:任何一步失败就返回空串,绝不让「补充线索」这种附加信息把主流程
|
|
433
|
+
* (告诉管理员卡住了)搞坏。
|
|
434
|
+
*/
|
|
435
|
+
export async function inspectUnderReviewConfigHints(appId, brand) {
|
|
436
|
+
if (brand === 'lark')
|
|
437
|
+
return '';
|
|
438
|
+
try {
|
|
439
|
+
const prepared = await prepareFeishuWebSession({ disableQrLogin: true, disableBytedcliFallback: true });
|
|
440
|
+
if (!prepared.ok)
|
|
441
|
+
return '';
|
|
442
|
+
const clientResult = await createOpenPlatformApiClient(prepared.cookies);
|
|
443
|
+
if (!clientResult.ok)
|
|
444
|
+
return '';
|
|
445
|
+
const post = clientResult.client.postJson;
|
|
446
|
+
const parts = [];
|
|
447
|
+
// ① 数据范围:把「还没收敛」的必填条目点出来(这正是最常见的卡审批原因)
|
|
448
|
+
try {
|
|
449
|
+
const state = extractOpenPlatformPrivileges(await post(`/developers/v1/privilege/all/${appId}`, {}));
|
|
450
|
+
const unnarrowed = state.privileges.filter(p => p.isRequired && !isPrivilegeRangeNarrowed(p));
|
|
451
|
+
if (unnarrowed.length > 0) {
|
|
452
|
+
const listed = unnarrowed.map(p => `${p.bizName || p.bizId}/${p.name || p.resource}`).join('、');
|
|
453
|
+
parts.push(`实测该应用有 ${unnarrowed.length} 项必填「数据范围」尚未收敛(${listed})——大概率就是卡点`);
|
|
454
|
+
}
|
|
455
|
+
else {
|
|
456
|
+
parts.push('实测必填「数据范围」都已收敛,卡点可能是别的规则(看审批详情)');
|
|
457
|
+
}
|
|
458
|
+
}
|
|
459
|
+
catch { /* 读不到就不提数据范围 */ }
|
|
460
|
+
// ② 租户审批规则原文链接:比我们转述强 —— 万一卡的是别的规则,链接照样有用。
|
|
461
|
+
// body 传空 `{}` 即可:实测跨 3 台(有草稿 / 审核中 / 无待发布版本)对照过
|
|
462
|
+
// `{}` 与 `{versionId}`,两者返回**逐字相同**(都拿到 auditUrl + 452 字的
|
|
463
|
+
// auditSummary)⟹ 该端点返回的是**租户级**规则、与具体版本无关,不必透传 versionId。
|
|
464
|
+
try {
|
|
465
|
+
const rule = await post(`/developers/v1/config/audit_rule/${appId}`, {});
|
|
466
|
+
const auditUrl = pickString(asRecord(asRecord(rule).data), ['auditUrl']);
|
|
467
|
+
if (auditUrl)
|
|
468
|
+
parts.push(`企业审批规则原文:${auditUrl}`);
|
|
469
|
+
}
|
|
470
|
+
catch { /* 拿不到链接不影响其余线索 */ }
|
|
471
|
+
return parts.length > 0 ? `\n\n**线索**:${parts.join(';')}。` : '';
|
|
472
|
+
}
|
|
473
|
+
catch {
|
|
474
|
+
return '';
|
|
475
|
+
}
|
|
476
|
+
}
|
|
425
477
|
async function narrowRequiredPrivilegeRanges(api, appId) {
|
|
426
478
|
const state = extractOpenPlatformPrivileges(await api.postJson(`/developers/v1/privilege/all/${appId}`, {}));
|
|
427
479
|
const needFill = selectPrivilegesNeedingAppAvailability(state);
|
|
@@ -1128,6 +1180,54 @@ export async function automateOpenPlatformSetup(options) {
|
|
|
1128
1180
|
await postJson(`/developers/v1/event/switch/${options.appId}`, { clientId: options.appId, eventMode: 4 });
|
|
1129
1181
|
}
|
|
1130
1182
|
catch (err) {
|
|
1183
|
+
// 「应用正在审核中」是**等待即自愈**的状态,不是配置错误:审核期间开放平台把整个
|
|
1184
|
+
// 应用配置写锁了(scope/update / robot/switch / safe_setting/update / base_info 全
|
|
1185
|
+
// 拒,读接口照常),审批通过后自然恢复。给它一个独立 reason,让上层能说人话、
|
|
1186
|
+
// 并且不要每次重启都把整条链路重跑一遍(线上两台 bot 各已空转 8 次)。
|
|
1187
|
+
// 注意:这一步**不是本函数第一个写操作**(redirect 白名单、scope/update、
|
|
1188
|
+
// privilege/update 都在它前面,也都会被 10046 拒),而是第一个**不吞错**的写 ——
|
|
1189
|
+
// 前三个各自 try/catch 成 warning 继续走,只有这里会把错误抛到这个 catch。所以
|
|
1190
|
+
// 归因放在这里是可行的(它无条件执行),但别据此以为前面没有写操作:真要提前
|
|
1191
|
+
// 识别 10046,得去看那几个 warning 而不是指望这里最先命中。
|
|
1192
|
+
if (openPlatformUnderReview(err)) {
|
|
1193
|
+
// ⚠️ **不自动撤回待审版本**(曾经写过,已删)。原先的推理是「缺必需权限 + 审核中
|
|
1194
|
+
// = 权限永远补不上 ⟹ 撤回是唯一出路」,那默认了「审批是中性的排队机制」。实际
|
|
1195
|
+
// **触发审批说明有配置不合规**:模板建的应用出生带「数据范围=全部」,正是租户
|
|
1196
|
+
// 审批规则里「申请全员范围要额外说明理由、视情况加签至 CEO-2」那一档(见
|
|
1197
|
+
// {@link isPrivilegeRangeNarrowed} 的注释),而 narrowRequiredPrivilegeRanges
|
|
1198
|
+
// 只收窄**新**版本 ⟹ 卡住的必然是没收窄过的旧版。
|
|
1199
|
+
//
|
|
1200
|
+
// ⟹ 撤回重提交会被**同一条规则再拦一次**,等于用不可逆动作(丢掉审批队列位置,
|
|
1201
|
+
// 线上见过排 18 / 23 天且不属于本机 owner 的)驱动一个死循环。更一般地:**审批是
|
|
1202
|
+
// 规则驱动的闸,不是队列**,撤回不绕过规则、只丢位置 —— 所以即便存在「配置合规
|
|
1203
|
+
// 但仍进审批」的情形,自动撤回照样不解决问题。正确处置是**告诉人、让人修配置**。
|
|
1204
|
+
//
|
|
1205
|
+
// 取待审版本 id 只为给上层做「同一个待审版本别重复打扰」的节流 key。这次读是
|
|
1206
|
+
// 只读(审核锁下照常可读)、且只在失败路径发生,常态零开销。
|
|
1207
|
+
//
|
|
1208
|
+
// ⚠️ 读失败必须降级成「没有 versionId」,**绝不能把已经确定的 app_under_review
|
|
1209
|
+
// 覆盖成 network / api_error**:分类是主信号,versionId 只是节流用的上下文,
|
|
1210
|
+
// 上下文取不到不能反过来污染主信号。拿不到 id 时上层自然退化成「不节流」——
|
|
1211
|
+
// 那也正是我们要的:「撞了 10046 却找不到待审版本」意味着模型与实际不一致,
|
|
1212
|
+
// 每次都值得说一遍(万一是我们判据自己有 bug,节流会掐掉唯一的信号)。
|
|
1213
|
+
let inReviewVersionId;
|
|
1214
|
+
try {
|
|
1215
|
+
inReviewVersionId = findInReviewVersionId(await postJson(`/developers/v1/app_version/list/${options.appId}`, {}));
|
|
1216
|
+
}
|
|
1217
|
+
catch { /* 拿不到就不节流,见上 */ }
|
|
1218
|
+
return {
|
|
1219
|
+
ok: false,
|
|
1220
|
+
reason: 'app_under_review',
|
|
1221
|
+
message: '应用正在飞书审核中,开放平台暂时锁定了它的配置写入(权限申请、机器人能力、回调白名单都改不了)。'
|
|
1222
|
+
+ '**审批被触发通常意味着有配置不合规**(最常见:权限的「数据范围」没配,默认成「全部/全员」,'
|
|
1223
|
+
+ '撞上租户「非必要不申请全员数据」的加签规则)。需要人工处理:到开放平台看审批详情 → 修掉不合规项 '
|
|
1224
|
+
+ '→ 撤回该待审版本 → 重新提交。注意:撤回后直接重提**不会**自动通过,规则会再拦一次。',
|
|
1225
|
+
sessionFile,
|
|
1226
|
+
redirectConfigured,
|
|
1227
|
+
redirectWarning,
|
|
1228
|
+
inReviewVersionId,
|
|
1229
|
+
};
|
|
1230
|
+
}
|
|
1131
1231
|
return {
|
|
1132
1232
|
ok: false,
|
|
1133
1233
|
reason: 'api_error',
|
|
@@ -1287,8 +1387,26 @@ export async function automateOpenPlatformSetup(options) {
|
|
|
1287
1387
|
// 激活 ACK(见 bot-onboarding 的 hasExactManagedAutomationAck / versionId 读取),
|
|
1288
1388
|
// 不发版就拿不到 versionId,激活会判失败。
|
|
1289
1389
|
// 这两条都传 false / 未传时,才走无变更短路。
|
|
1390
|
+
//
|
|
1391
|
+
// ⚠️ 第三个例外:**存在未提交的草稿**。草稿会让后续 `app_version/create` 一律撞
|
|
1392
|
+
// `code=10043 版本已创建`(见下方 findUncommittedDraftVersionId 处的长注释),而
|
|
1393
|
+
// 「有草稿」与「本轮有没有配置变更」是两件独立的事:一个 scope 已齐、事件已订阅、
|
|
1394
|
+
// 数据范围已收窄的 bot,`mutated` 恒为 false,会在这里短路 return —— 下面那段
|
|
1395
|
+
// 「提交草稿」的代码**永远到不了**,草稿就一直卡着。这正是本次要修的死锁,所以
|
|
1396
|
+
// 有草稿时必须往下走,把它提交掉(提交草稿本身不新建版本、不烧版本号)。
|
|
1397
|
+
// 读一次版本列表:既用来判「有没有卡住的草稿」(决定能否走无变更短路),也直接
|
|
1398
|
+
// 复用给下面的发版逻辑,避免同一轮里重复请求。读失败不阻断——退回「按 mutated 判」
|
|
1399
|
+
// 的旧行为,最坏是少救一次草稿,不会因为一个读请求把整条自愈判死。
|
|
1400
|
+
let versionList;
|
|
1401
|
+
try {
|
|
1402
|
+
versionList = await postJson(`/developers/v1/app_version/list/${options.appId}`, {});
|
|
1403
|
+
}
|
|
1404
|
+
catch (err) {
|
|
1405
|
+
await options.onStatus?.(`读取版本列表失败(${safeErrorMessage(err)}),跳过草稿检查`);
|
|
1406
|
+
}
|
|
1407
|
+
const pendingDraftVersionId = versionList === undefined ? undefined : findUncommittedDraftVersionId(versionList);
|
|
1290
1408
|
const mustPublish = options.appJustCreated === true || options.requireVerifiedEvents === true;
|
|
1291
|
-
if (!mutated && !mustPublish) {
|
|
1409
|
+
if (!mutated && !mustPublish && !pendingDraftVersionId) {
|
|
1292
1410
|
return {
|
|
1293
1411
|
ok: true,
|
|
1294
1412
|
sessionFile,
|
|
@@ -1348,13 +1466,41 @@ export async function automateOpenPlatformSetup(options) {
|
|
|
1348
1466
|
redirectWarning,
|
|
1349
1467
|
};
|
|
1350
1468
|
}
|
|
1351
|
-
const
|
|
1352
|
-
|
|
1353
|
-
|
|
1354
|
-
|
|
1355
|
-
|
|
1356
|
-
|
|
1357
|
-
|
|
1469
|
+
const versions = versionList ?? await postJson(`/developers/v1/app_version/list/${options.appId}`, {});
|
|
1470
|
+
// 已存在的**未提交草稿**要先消化掉,不能直接 create:飞书不允许并存两个未提交
|
|
1471
|
+
// 版本,有草稿时 create 一律回 `code=10043 版本已创建,请刷新`。历史行为是撞上就
|
|
1472
|
+
// 整个自愈失败并给管理员发 DM,而下次重启又原样重跑——一个草稿把自愈**永久**卡死
|
|
1473
|
+
// (线上实测 3 台、一天各 5 次)。
|
|
1474
|
+
//
|
|
1475
|
+
// 修法是**提交那个草稿**而不是绕过它:草稿的 scope 集合就是应用当前已声明的集合
|
|
1476
|
+
// (实测逐项相等,181/181 零差集),正是我们想发的内容;`scope/update` 早在本函数
|
|
1477
|
+
// 上半段就已经把缺失权限加进清单了,草稿会带上它们。实测直接 commit 即
|
|
1478
|
+
// `versionStatus` 0→2(已上线),不新建版本、不烧版本号。
|
|
1479
|
+
//
|
|
1480
|
+
// 故意**不**做「取消草稿再重建」:那需要引入一个破坏性的删除端点(本仓库从未用过
|
|
1481
|
+
// 任何 delete/cancel 版本的端点),还会多烧一个版本号,收益为零。
|
|
1482
|
+
//
|
|
1483
|
+
// ⚠️ 已知取舍:复用草稿等于发布**草稿自己那份可见范围快照**,而不是上面刚从
|
|
1484
|
+
// `visible/online` 读出来的现值(`visibleSuggest` 只在新建版本时才用得上)。本次
|
|
1485
|
+
// 涉及的两台 bot 实测草稿与线上版本的可见范围逐字相同(同一个 open_id、无部门/
|
|
1486
|
+
// 黑名单),所以没有收窄发生;但一个**很久以前**留下的草稿理论上可能带着过时的
|
|
1487
|
+
// 可见范围。这仍然比现状好:现状是自愈**永久失败**、权限一项都到不了位。真出现
|
|
1488
|
+
// 过时草稿时,用户在开放平台能看到该版本的可见范围并自行修正。
|
|
1489
|
+
const draftVersionId = findUncommittedDraftVersionId(versions);
|
|
1490
|
+
let versionId;
|
|
1491
|
+
let versionReused = false;
|
|
1492
|
+
if (draftVersionId) {
|
|
1493
|
+
versionId = draftVersionId;
|
|
1494
|
+
versionReused = true;
|
|
1495
|
+
}
|
|
1496
|
+
else {
|
|
1497
|
+
const appVersion = nextAppVersion(versions);
|
|
1498
|
+
const versionPayload = buildAppVersionCreatePayload(appVersion);
|
|
1499
|
+
versionPayload.visibleSuggest = visibility.visibleSuggest;
|
|
1500
|
+
versionPayload.blackVisibleSuggest = visibility.blackVisibleSuggest;
|
|
1501
|
+
const created = await postJson(`/developers/v1/app_version/create/${options.appId}`, versionPayload);
|
|
1502
|
+
versionId = extractVersionId(created);
|
|
1503
|
+
}
|
|
1358
1504
|
if (options.requireVerifiedEvents && !versionId) {
|
|
1359
1505
|
return {
|
|
1360
1506
|
ok: false,
|
|
@@ -1369,8 +1515,44 @@ export async function automateOpenPlatformSetup(options) {
|
|
|
1369
1515
|
redirectWarning,
|
|
1370
1516
|
};
|
|
1371
1517
|
}
|
|
1518
|
+
let versionWarning;
|
|
1519
|
+
let approvalAutoPassed;
|
|
1520
|
+
let approvalHumanApprovers;
|
|
1372
1521
|
if (versionId) {
|
|
1522
|
+
// 提交前先问一句「这一版提交后会不会秒过」。判据见 {@link predictApprovalFlow}:
|
|
1523
|
+
// 全自动通过 + 零真人审批人 ⟹ 提交不打扰任何人、立即生效,尽管提。
|
|
1524
|
+
//
|
|
1525
|
+
// 反过来,有真人审批人时**仍然照常提交**——这是既有行为,不改:botmux 建 bot /
|
|
1526
|
+
// 补权限本来就需要发版才生效,替用户提审是本来的分工。这里查流程的作用是**把
|
|
1527
|
+
// 「秒过」与「要人审」分开告知**:秒过的一声不吭办完;要人审的明确说「已提交,
|
|
1528
|
+
// 需要 X 审批」,让用户知道在等谁、以及别再重复点。
|
|
1529
|
+
//
|
|
1530
|
+
// 判不出来(known:false,接口报错 / 没有可判定的关卡)时不猜:既不宣称秒过,
|
|
1531
|
+
// 也不宣称要人审,只在日志里留原因。
|
|
1532
|
+
const prediction = await fetchApprovalFlowPrediction(postJson, options.appId, versionId, visibility);
|
|
1533
|
+
if (prediction.known) {
|
|
1534
|
+
approvalAutoPassed = prediction.autoApproved;
|
|
1535
|
+
if (prediction.humanApprovers.length > 0)
|
|
1536
|
+
approvalHumanApprovers = prediction.humanApprovers;
|
|
1537
|
+
}
|
|
1538
|
+
else if (prediction.reason) {
|
|
1539
|
+
await options.onStatus?.(`审批流程预判不可用(${prediction.reason}),按常规提交`);
|
|
1540
|
+
}
|
|
1373
1541
|
await postJson(`/developers/v1/publish/commit/${options.appId}/${versionId}`, { clientId: options.appId });
|
|
1542
|
+
// commit 返回 code=0 ≠ 版本真的提交了:线上实测过 commit 回 `{code:0,isOk:true}`
|
|
1543
|
+
// 而版本仍停在草稿态,于是日志谎报「published」,留下的草稿正是卡死后续自愈的
|
|
1544
|
+
// 元凶。回读一次把「以为发了」和「真发了」分开——查得到就是查得到,别再拿返回码
|
|
1545
|
+
// 当结论。回读本身失败不改判结果(版本可能已经提交成功),只记 warning。
|
|
1546
|
+
try {
|
|
1547
|
+
const after = await postJson(`/developers/v1/app_version/list/${options.appId}`, {});
|
|
1548
|
+
if (!isVersionCommitted(after, versionId)) {
|
|
1549
|
+
versionWarning =
|
|
1550
|
+
`版本 ${versionId} 提交后回读仍是「未提交审核」草稿:请到开放平台「版本管理」手动点「申请发布」`;
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
catch (err) {
|
|
1554
|
+
versionWarning = `版本 ${versionId} 提交状态回读失败(无法确认是否已提交): ${safeErrorMessage(err)}`;
|
|
1555
|
+
}
|
|
1374
1556
|
}
|
|
1375
1557
|
return {
|
|
1376
1558
|
ok: true,
|
|
@@ -1388,6 +1570,10 @@ export async function automateOpenPlatformSetup(options) {
|
|
|
1388
1570
|
eventModeReady,
|
|
1389
1571
|
redirectConfigured,
|
|
1390
1572
|
redirectWarning,
|
|
1573
|
+
versionReused,
|
|
1574
|
+
versionWarning,
|
|
1575
|
+
approvalAutoPassed,
|
|
1576
|
+
approvalHumanApprovers,
|
|
1391
1577
|
...(options.requireVerifiedEvents
|
|
1392
1578
|
? {
|
|
1393
1579
|
eventMode: eventState?.eventMode,
|
|
@@ -1462,7 +1648,7 @@ export async function createOpenPlatformApiClient(cookies, opts = {}) {
|
|
|
1462
1648
|
message: '开放平台页面没有返回 window.csrfToken;Web session 可能已过期或未完成开放平台登录',
|
|
1463
1649
|
};
|
|
1464
1650
|
}
|
|
1465
|
-
const request = async (path, body, contentType) => {
|
|
1651
|
+
const request = async (path, body, contentType, opts = {}) => {
|
|
1466
1652
|
const url = `${apiOrigin}${path}`;
|
|
1467
1653
|
const response = await session.fetchRaw(fetcher, url, {
|
|
1468
1654
|
method: 'POST',
|
|
@@ -1474,7 +1660,7 @@ export async function createOpenPlatformApiClient(cookies, opts = {}) {
|
|
|
1474
1660
|
...(contentType ? { 'content-type': contentType } : {}),
|
|
1475
1661
|
},
|
|
1476
1662
|
body,
|
|
1477
|
-
});
|
|
1663
|
+
}, 10, opts);
|
|
1478
1664
|
let data;
|
|
1479
1665
|
try {
|
|
1480
1666
|
data = await response.json();
|
|
@@ -1491,8 +1677,9 @@ export async function createOpenPlatformApiClient(cookies, opts = {}) {
|
|
|
1491
1677
|
return data;
|
|
1492
1678
|
};
|
|
1493
1679
|
const postJson = async (path, body) => request(path, body === undefined ? undefined : JSON.stringify(body), body === undefined ? undefined : 'application/json');
|
|
1680
|
+
const postJsonIdempotent = async (path, body) => request(path, body === undefined ? undefined : JSON.stringify(body), body === undefined ? undefined : 'application/json', { idempotent: true });
|
|
1494
1681
|
const postForm = async (path, body) => request(path, body);
|
|
1495
|
-
return { ok: true, client: { apiOrigin, postJson, postForm }, identity };
|
|
1682
|
+
return { ok: true, client: { apiOrigin, postJson, postJsonIdempotent, postForm }, identity };
|
|
1496
1683
|
}
|
|
1497
1684
|
/**
|
|
1498
1685
|
* 只检查现有缓存,不展示二维码。Dashboard 打开添加表单时调用;返回的账号/企业
|
|
@@ -1657,8 +1844,8 @@ options) {
|
|
|
1657
1844
|
// robot/event switch 都是幂等设值,故对宿主机↔飞书的瞬态网络抖动小步重试:
|
|
1658
1845
|
// 一次 undici `fetch failed` 不该让「应用已建成但没启用能力」半途而废
|
|
1659
1846
|
// (那会把用户丢进手动读 Secret + CLI 续跑的恢复路径)。
|
|
1660
|
-
await
|
|
1661
|
-
await
|
|
1847
|
+
await client.postJsonIdempotent(`/developers/v1/robot/switch/${appId}`, { clientId: appId, enable: true });
|
|
1848
|
+
await client.postJsonIdempotent(`/developers/v1/event/switch/${appId}`, { clientId: appId, eventMode: 4 }); // WebSocket
|
|
1662
1849
|
// 模板建出来的应用,「权限可访问的数据范围」出生就是 `mode:'all'`(console 上
|
|
1663
1850
|
// 显示「全部」)——而这里紧接着就要发**第一个版本**。不先收窄,这一版就带着
|
|
1664
1851
|
// 「全部」进审批:正是租户规则里要补充理由、视情况加签至 CEO-2 的那一档。
|
|
@@ -1675,9 +1862,12 @@ options) {
|
|
|
1675
1862
|
// 让应用**上架启用**(tenantAppStatus 0→2)。这样返回的就是一个「已启用、可
|
|
1676
1863
|
// 收发消息」的应用——等价于旧 SDK registerApp 直接产出可用 PersonalAgent 的效果。
|
|
1677
1864
|
// 这一步 fail-closed:拿到 versionId 后 commit 失败、或 code=0 却没 versionId
|
|
1678
|
-
// (可能留下未发布草稿),都视为创建失败抛出(带 appId,由调用方兜底/提示)
|
|
1679
|
-
//
|
|
1680
|
-
//
|
|
1865
|
+
// (可能留下未发布草稿),都视为创建失败抛出(带 appId,由调用方兜底/提示)。
|
|
1866
|
+
//
|
|
1867
|
+
// 这里**不做**「回读版本状态 + 补一刀 commit」:线上那 3 台卡死 bot 的 1.0.0
|
|
1868
|
+
// (本函数发的版本)全部 `status=1 已上线`,即本步的 commit 是成功的;卡死源是随后
|
|
1869
|
+
// automateOpenPlatformSetup 发的 1.0.1 草稿(见那边的 findUncommittedDraftVersionId)。
|
|
1870
|
+
// 给没有证据坏的路径加重试,只会给建应用链路凭空引入一个 fail-closed 抛点。
|
|
1681
1871
|
// ⚠️ version/create、publish/commit 是非幂等写操作(传输失败即结果未知,
|
|
1682
1872
|
// 重放会重复建版/撞版本号),故绝不套 retryIdempotent… 包装,与 fetchRaw
|
|
1683
1873
|
// 只对 GET/HEAD 重试同源。
|
|
@@ -1690,7 +1880,7 @@ options) {
|
|
|
1690
1880
|
// 读 Secret 是纯只读 POST(getAppSecret 同款,不触碰 reset),幂等可重试:
|
|
1691
1881
|
// 应用已建成、已发布,唯独最后一步读 Secret 撞网络抖动而失败最可惜——
|
|
1692
1882
|
// 重试让它自愈,而不是把整条链路判死。
|
|
1693
|
-
const appSecret = await
|
|
1883
|
+
const appSecret = await fetchOpenPlatformAppSecret(client, appId, { idempotentRetry: true });
|
|
1694
1884
|
return { appId, appSecret };
|
|
1695
1885
|
}
|
|
1696
1886
|
catch (err) {
|
|
@@ -1833,8 +2023,12 @@ export async function listOpenPlatformApps(client, opts = {}) {
|
|
|
1833
2023
|
* POST /developers/v1/secret/:clientId,响应含 secret 字段)。
|
|
1834
2024
|
* 只读接口——绝不触碰 /v1/secret/reset/*(会轮换 secret、打断在跑的 bot)。
|
|
1835
2025
|
*/
|
|
1836
|
-
export async function fetchOpenPlatformAppSecret(client, clientId) {
|
|
1837
|
-
|
|
2026
|
+
export async function fetchOpenPlatformAppSecret(client, clientId, opts = {}) {
|
|
2027
|
+
// 纯只读 POST(不触碰 reset)⟹ 语义幂等。建应用链路显式开启重试:应用已建成
|
|
2028
|
+
// 已发布,唯独最后一步读 Secret 撞网络抖动最可惜。预算只在 fetchRaw 一层。
|
|
2029
|
+
const payload = opts.idempotentRetry
|
|
2030
|
+
? await client.postJsonIdempotent(`/developers/v1/secret/${clientId}`, {})
|
|
2031
|
+
: await client.postJson(`/developers/v1/secret/${clientId}`, {});
|
|
1838
2032
|
const record = asRecord(payload);
|
|
1839
2033
|
const secret = pickString(asRecord(record.data), ['secret']) ?? pickString(record, ['secret']);
|
|
1840
2034
|
if (!secret)
|
|
@@ -1980,6 +2174,73 @@ const TRANSIENT_NETWORK_ERROR_CODES = new Set([
|
|
|
1980
2174
|
'UND_ERR_SOCKET',
|
|
1981
2175
|
]);
|
|
1982
2176
|
const TRANSIENT_FETCH_RETRY_DELAYS_MS = [300, 900];
|
|
2177
|
+
/**
|
|
2178
|
+
* 「TLS 握手完成之前连接就断了」——Node 内置 `_tls_wrap.js` 的 `onConnectEnd`
|
|
2179
|
+
* 唯一产出这句话(`ConnResetException`,code=ECONNRESET)。它的关键性质是
|
|
2180
|
+
* **可证明零副作用**,因此连非幂等写请求都能安全重放。
|
|
2181
|
+
*
|
|
2182
|
+
* 证明链(Node + undici 的 dispatch 时序,**不是**「握手未完成 ⟹ 没有加密通道」
|
|
2183
|
+
* —— 那个说法对 TLS 1.3 不成立:ServerHello 之后的握手消息已加密,协议还定义了
|
|
2184
|
+
* 0-RTT early data。成立的是下面这条客户端实现约束):
|
|
2185
|
+
* • undici 的 connector 只在 TLSSocket 的 `secureConnect` 监听器里回调
|
|
2186
|
+
* (`socket.setNoDelay(true).once('secureConnect', … cb(null, this))`),
|
|
2187
|
+
* HTTP client 拿到这个 socket 之后才 dispatch/write 请求;
|
|
2188
|
+
* • `onConnectEnd` 只在建 socket 时 `prependListener('end', …)` 挂上,并在
|
|
2189
|
+
* `onConnectSecure` 里摘掉。注意源码顺序是**先** `emit('secureConnect')`
|
|
2190
|
+
* **再** `removeListener`,所以不能说成「先摘监听器再交给 undici」;成立的是:
|
|
2191
|
+
* 远端 FIN / `end` 只可能在后续 I/O 分派中被处理,而那时监听器已摘。因此这句
|
|
2192
|
+
* 错误产出时,`secureConnect` 监听器没有成功走完 ⟹ undici 还没拿到可 dispatch
|
|
2193
|
+
* 的连接 ⟹ 应用请求行 / 头 / body 未写出;
|
|
2194
|
+
* • Node 目前也没有让 fetch 走 TLS 0-RTT early data 的路径。
|
|
2195
|
+
* • 实测(本机 TCP 抓包型探针):服务端只收到 1583 字节且首字节 0x16
|
|
2196
|
+
* (TLS handshake record),文本里 **不含** 请求方法、路径与 body 字段;
|
|
2197
|
+
* 对照组「握手完成后才断」收到 248 字节应用数据,且错误换成
|
|
2198
|
+
* `UND_ERR_SOCKET / other side closed` —— 两类错误可靠可分。
|
|
2199
|
+
*
|
|
2200
|
+
* 代理场景下「零字节」限定为**目标应用的 HTTP 请求**:HTTP proxy 的 CONNECT 可能
|
|
2201
|
+
* 已经发给代理,但目标 POST 尚未通过隧道发出,故「无重复副作用」的结论仍成立。
|
|
2202
|
+
*
|
|
2203
|
+
* ⚠️ 仅此一句话享受该待遇。`ECONNRESET` 本身**不够**:连接建成、请求已送达后
|
|
2204
|
+
* 被 RST 同样是 ECONNRESET,那种情况服务端可能已处理,重放会重复提交。
|
|
2205
|
+
*
|
|
2206
|
+
* ⚠️ 该形态绑定 Node/undici。Bun 原生 fetch 对同一真实故障抛的是顶层 `TypeError`
|
|
2207
|
+
* (message `The socket connection was closed unexpectedly...`、code=ECONNRESET、
|
|
2208
|
+
* **无 cause**),不满足本判据 ⟹ 不命中、不重试(保持旧行为)。这是已知的跨运行时
|
|
2209
|
+
* 缺口而非安全问题;要覆盖 Bun 必须先为它的文案建立同等级的「只可能握手前」证明。
|
|
2210
|
+
*/
|
|
2211
|
+
const PRE_TLS_DISCONNECT_MESSAGE = 'Client network socket disconnected before secure TLS connection was established';
|
|
2212
|
+
/**
|
|
2213
|
+
* 传输层失败是否**可证明「请求未送达」**,即重放绝不会产生重复副作用。
|
|
2214
|
+
* 只认 {@link PRE_TLS_DISCONNECT_MESSAGE} 那一句(唯一可证明未送达的
|
|
2215
|
+
* ECONNRESET)。外层会被 undici 包成 `TypeError('fetch failed', { cause })`,
|
|
2216
|
+
* 故顺**单一 cause 链**找。
|
|
2217
|
+
*
|
|
2218
|
+
* ⚠️ 刻意**不支持 AggregateError**,这是有意收窄而非遗漏:
|
|
2219
|
+
* • 真实的 Node pre-TLS 断连本来就不是聚合体 —— `net.internalConnectMultiple`
|
|
2220
|
+
* 只在**所有** TCP connect 尝试失败时才构造 `NodeAggregateError`(成员一律
|
|
2221
|
+
* 来自 `createConnectionError(…, 'connect', …)`),而这句文案由
|
|
2222
|
+
* `_tls_wrap.onConnectEnd` 在某条腿 connect **成功之后**才可能产出,两者
|
|
2223
|
+
* 互斥;
|
|
2224
|
+
* • 一旦支持聚合体,就要对 `.errors` 与 `AggregateError` 同样合法的 `.cause`
|
|
2225
|
+
* 同时做全称量词检查,任一遗漏都是 fail-open(实测:
|
|
2226
|
+
* `new AggregateError([preTls], '', { cause: socketHangUp })` 会被放行)。
|
|
2227
|
+
* provably 的证明责任配不上这点收益。
|
|
2228
|
+
*
|
|
2229
|
+
* 同理,精确文案的节点若**自带 cause**,说明它不是我们证明过的那个
|
|
2230
|
+
* `ConnResetException`(Node 构造它时不挂 cause),一律 fail-closed。
|
|
2231
|
+
*/
|
|
2232
|
+
function isProvablyUnsentTransportError(err, depth = 0) {
|
|
2233
|
+
if (depth > 4 || !(err instanceof Error))
|
|
2234
|
+
return false;
|
|
2235
|
+
if (err.name === 'AbortError' || err.name === 'TimeoutError')
|
|
2236
|
+
return false;
|
|
2237
|
+
if (err instanceof AggregateError)
|
|
2238
|
+
return false;
|
|
2239
|
+
if (err.code === 'ECONNRESET' && err.message === PRE_TLS_DISCONNECT_MESSAGE) {
|
|
2240
|
+
return err.cause === undefined;
|
|
2241
|
+
}
|
|
2242
|
+
return isProvablyUnsentTransportError(err.cause, depth + 1);
|
|
2243
|
+
}
|
|
1983
2244
|
function isLikelyTransientNetworkError(err, depth = 0) {
|
|
1984
2245
|
if (depth > 4 || !(err instanceof Error))
|
|
1985
2246
|
return false;
|
|
@@ -1999,26 +2260,6 @@ function isLikelyTransientNetworkError(err, depth = 0) {
|
|
|
1999
2260
|
}
|
|
2000
2261
|
return isLikelyTransientNetworkError(err.cause, depth + 1);
|
|
2001
2262
|
}
|
|
2002
|
-
// 对「幂等」console 请求复用 fetchRaw 的瞬态退避:传输层抖动(fetch failed /
|
|
2003
|
-
// ECONNRESET 等)时小步重试,一次网络毛刺不再中断整条链路。API 层拒绝
|
|
2004
|
-
// (OpenPlatformApiError、HTTP 非 2xx、code!=0)无 code / cause,isLikely… 判 false,
|
|
2005
|
-
// 只会立刻抛出而不会被重放。
|
|
2006
|
-
// ⚠️ 只能包装「重复调用无副作用」的请求。app_version/create、publish/commit 这类
|
|
2007
|
-
// 「传输失败即结果未知、重放会重复提交/撞版本号」的非幂等写操作绝不能用本包装
|
|
2008
|
-
// (与 fetchRaw 只对 GET/HEAD 重试同源:见其上方注释)。
|
|
2009
|
-
async function retryIdempotentOnTransientNetworkError(fn) {
|
|
2010
|
-
for (let attempt = 0;; attempt += 1) {
|
|
2011
|
-
try {
|
|
2012
|
-
return await fn();
|
|
2013
|
-
}
|
|
2014
|
-
catch (err) {
|
|
2015
|
-
if (attempt >= TRANSIENT_FETCH_RETRY_DELAYS_MS.length || !isLikelyTransientNetworkError(err)) {
|
|
2016
|
-
throw err;
|
|
2017
|
-
}
|
|
2018
|
-
await sleep(TRANSIENT_FETCH_RETRY_DELAYS_MS[attempt]);
|
|
2019
|
-
}
|
|
2020
|
-
}
|
|
2021
|
-
}
|
|
2022
2263
|
class MutableCookieJar {
|
|
2023
2264
|
cookies;
|
|
2024
2265
|
constructor(cookies) {
|
|
@@ -2039,13 +2280,21 @@ class MutableCookieJar {
|
|
|
2039
2280
|
finalUrl: finalResponseUrl(response, url),
|
|
2040
2281
|
};
|
|
2041
2282
|
}
|
|
2042
|
-
async fetchRaw(fetcher, url, init = {}, maxHops = 10) {
|
|
2283
|
+
async fetchRaw(fetcher, url, init = {}, maxHops = 10, opts = {}) {
|
|
2043
2284
|
let current = url;
|
|
2044
2285
|
let referer;
|
|
2045
|
-
//
|
|
2046
|
-
//
|
|
2286
|
+
// 幂等的 GET/HEAD:任意瞬态网络错误都可小步退避重试。
|
|
2287
|
+
// POST 全是 console 写操作或登录流程,传输错误时服务端可能已 commit(结果
|
|
2288
|
+
// 未知),重试等于重复提交 —— 唯一例外是「TLS 握手完成前就断连」,那类错误
|
|
2289
|
+
// 可证明请求一个字节都没发出去(见 isProvablyUnsentTransportError),重放
|
|
2290
|
+
// 不可能产生重复副作用;不放过它的代价是一次网络毛刺就让用户的改名/改头像
|
|
2291
|
+
// 整轮失败。
|
|
2292
|
+
// `opts.idempotent` 让调用方声明「这个 POST 重复调用无副作用」(robot/event
|
|
2293
|
+
// switch 设值、只读拉 Secret),从而与 GET/HEAD 同权:认全部瞬态错误。
|
|
2294
|
+
// 关键是预算**只此一层**——历史上这三处在外层另包了一轮 retry,与内层相乘
|
|
2295
|
+
// 成 9 次(实测 4.8s 退避);预算集中在这里后总尝试恒为 3。
|
|
2047
2296
|
const method = (init.method ?? 'GET').toUpperCase();
|
|
2048
|
-
const retryable = method === 'GET' || method === 'HEAD';
|
|
2297
|
+
const retryable = opts.idempotent === true || method === 'GET' || method === 'HEAD';
|
|
2049
2298
|
for (let hop = 0; hop <= maxHops; hop += 1) {
|
|
2050
2299
|
const headers = new Headers(init.headers);
|
|
2051
2300
|
const cookieHeader = getCookieHeader(this.cookies, current);
|
|
@@ -2061,7 +2310,12 @@ class MutableCookieJar {
|
|
|
2061
2310
|
break;
|
|
2062
2311
|
}
|
|
2063
2312
|
catch (err) {
|
|
2064
|
-
|
|
2313
|
+
// 可重试 = 幂等方法遇任意瞬态错误,或任意方法遇「可证明未送达」的
|
|
2314
|
+
// pre-TLS 断连。后者对写操作也安全(零字节送达 ⟹ 无副作用)。
|
|
2315
|
+
const mayRetry = retryable
|
|
2316
|
+
? isLikelyTransientNetworkError(err)
|
|
2317
|
+
: isProvablyUnsentTransportError(err);
|
|
2318
|
+
if (attempt >= TRANSIENT_FETCH_RETRY_DELAYS_MS.length || !mayRetry) {
|
|
2065
2319
|
throw err;
|
|
2066
2320
|
}
|
|
2067
2321
|
await sleep(TRANSIENT_FETCH_RETRY_DELAYS_MS[attempt]);
|
|
@@ -2118,6 +2372,24 @@ function openPlatformOwnerAccessDenied(error) {
|
|
|
2118
2372
|
const payload = asRecord(error.payload);
|
|
2119
2373
|
return error.status === 403 && payload.code === 10003;
|
|
2120
2374
|
}
|
|
2375
|
+
/**
|
|
2376
|
+
* 飞书开放平台的「应用整体正在审核中」锁:`code=10046 审核中, 请刷新`。
|
|
2377
|
+
*
|
|
2378
|
+
* 审核期间应用配置被**整体写锁**——实测被拒的不只是发版,还包括
|
|
2379
|
+
* `scope/update`(申请权限本体)、`robot/switch`、`safe_setting/update`、`base_info`;
|
|
2380
|
+
* 读接口(`scope/all` / `privilege/all` / `app_version/list` / `visible/online` /
|
|
2381
|
+
* `event/` / `callback/`)全部照常可读。
|
|
2382
|
+
*
|
|
2383
|
+
* 所以这是一种**等待即可自愈**的状态,不是配置错误:审批通过后写操作自然恢复。历史
|
|
2384
|
+
* 行为是把它当普通 `api_error` 硬失败,于是每次 daemon 重启都把整条链路重跑一遍、
|
|
2385
|
+
* 再报一次「开放平台 API 错误」(线上两台别人的 bot 今天各撞 8 次),既没用又盖掉了
|
|
2386
|
+
* 真实原因。识别出来单独给个 reason,让调用方能说人话、并且**不必反复重试**。
|
|
2387
|
+
*/
|
|
2388
|
+
export function openPlatformUnderReview(error) {
|
|
2389
|
+
if (!(error instanceof OpenPlatformApiError))
|
|
2390
|
+
return false;
|
|
2391
|
+
return asRecord(error.payload).code === 10046;
|
|
2392
|
+
}
|
|
2121
2393
|
class FeishuWebSessionError extends Error {
|
|
2122
2394
|
reason;
|
|
2123
2395
|
constructor(message, reason) {
|
|
@@ -2263,6 +2535,228 @@ export function nextAppVersion(payload) {
|
|
|
2263
2535
|
});
|
|
2264
2536
|
return [max[0], max[1], max[2] + 1].join('.');
|
|
2265
2537
|
}
|
|
2538
|
+
/**
|
|
2539
|
+
* console `app_version/list` 里 `versionStatus` 的取值。**别用公开 API
|
|
2540
|
+
* (`application/v6/.../app_versions`) 的 `status` 语义去读它** ——两套枚举不一样,
|
|
2541
|
+
* 实测同一批版本的对照(左 console / 右公开 API):
|
|
2542
|
+
*
|
|
2543
|
+
* | console `versionStatus` | 公开 API `status` | 含义 |
|
|
2544
|
+
* |---|---|---|
|
|
2545
|
+
* | `0` | `4` | 未提交审核(草稿) |
|
|
2546
|
+
* | `1` | `3` | 审核中 |
|
|
2547
|
+
* | `2` | `1` | 已上线(当前线上版本) |
|
|
2548
|
+
* | `100` | `1` | 历史已上线版本 |
|
|
2549
|
+
*
|
|
2550
|
+
* 草稿定 `DRAFT`、审核中定 `IN_REVIEW`:两者语义**相反**(草稿要提交,审核中要撤回),
|
|
2551
|
+
* 各有一个消费者。判据一律写成 `=== 常量`,别写 `!== 某个` 这种反向式——那会把
|
|
2552
|
+
* `100`/`2`(历史/当前线上版本)也一起卷进来。
|
|
2553
|
+
*/
|
|
2554
|
+
export const CONSOLE_VERSION_STATUS_DRAFT = 0;
|
|
2555
|
+
export const CONSOLE_VERSION_STATUS_IN_REVIEW = 1;
|
|
2556
|
+
/**
|
|
2557
|
+
* 找出 `app_version/list` 里那个**未提交审核的草稿**(console 上「待提交」)。
|
|
2558
|
+
*
|
|
2559
|
+
* 为什么需要它:飞书**不允许并存两个未提交版本**——存在草稿时 `app_version/create`
|
|
2560
|
+
* 一律回 `code=10043 版本已创建,请刷新`。而 botmux 的权限自愈每次都想发一版,于是
|
|
2561
|
+
* 一个草稿就能把自愈**永久**卡死:每次 daemon 重启都重跑一遍必败的请求,还给管理员
|
|
2562
|
+
* 重发一遍「缺 N 项权限」的 DM(线上实测一天 5 次,其中一台还是别人的 bot)。
|
|
2563
|
+
*
|
|
2564
|
+
* ⚠️ **只认草稿(`versionStatus=0`),绝不碰审核中(`versionStatus=1`)。** 审核中
|
|
2565
|
+
* 的版本是别人真的提交上去、正在排队的东西,自动流程去动它等于把人家的审批干掉。
|
|
2566
|
+
* 线上就有两台处于审核中且**不属于本机 owner**,所以这个边界是硬的。
|
|
2567
|
+
*
|
|
2568
|
+
* 返回草稿的 `versionId`;没有草稿返回 undefined。
|
|
2569
|
+
*/
|
|
2570
|
+
export function findUncommittedDraftVersionId(payload) {
|
|
2571
|
+
const data = asRecord(asRecord(payload).data);
|
|
2572
|
+
const versions = Array.isArray(data.versions) ? data.versions : [];
|
|
2573
|
+
for (const item of versions) {
|
|
2574
|
+
const record = asRecord(item);
|
|
2575
|
+
if (record.versionStatus !== CONSOLE_VERSION_STATUS_DRAFT)
|
|
2576
|
+
continue;
|
|
2577
|
+
const versionId = pickString(record, ['versionId', 'version_id', 'id']);
|
|
2578
|
+
if (versionId)
|
|
2579
|
+
return versionId;
|
|
2580
|
+
}
|
|
2581
|
+
return undefined;
|
|
2582
|
+
}
|
|
2583
|
+
/**
|
|
2584
|
+
* 回读 `app_version/list`,确认某个 versionId **真的离开了草稿态**。
|
|
2585
|
+
*
|
|
2586
|
+
* `publish/commit` 返回 `code=0` **不等于**版本已提交:线上实测过一次
|
|
2587
|
+
* commit 回 `{code:0, data:{isOk:true}}`、版本却仍停在 `versionStatus=0`,于是日志
|
|
2588
|
+
* 高高兴兴写「version … published」,实际留下的正是卡死后续所有自愈的那个草稿。
|
|
2589
|
+
* 同文件里 `eventModeReady` 早就立了「switch 接口返回成功≠生效,必须回读」的规矩,
|
|
2590
|
+
* 发版这条链路照抄一遍。
|
|
2591
|
+
*
|
|
2592
|
+
* 返回 true = 该版本已不是草稿(已上线或已进审核)。
|
|
2593
|
+
*/
|
|
2594
|
+
export function isVersionCommitted(payload, versionId) {
|
|
2595
|
+
const data = asRecord(asRecord(payload).data);
|
|
2596
|
+
const versions = Array.isArray(data.versions) ? data.versions : [];
|
|
2597
|
+
for (const item of versions) {
|
|
2598
|
+
const record = asRecord(item);
|
|
2599
|
+
if (pickString(record, ['versionId', 'version_id', 'id']) !== versionId)
|
|
2600
|
+
continue;
|
|
2601
|
+
return record.versionStatus !== CONSOLE_VERSION_STATUS_DRAFT;
|
|
2602
|
+
}
|
|
2603
|
+
// 版本号在列表里找不到 → 无法证明它已提交,保守判 false(宁可多报一句 warning,
|
|
2604
|
+
// 也不要重复上一次「拿 code=0 当已发布」的错)。
|
|
2605
|
+
return false;
|
|
2606
|
+
}
|
|
2607
|
+
/**
|
|
2608
|
+
* 找出正在**审核中**的版本(console 上「审核中」)。
|
|
2609
|
+
*
|
|
2610
|
+
* 与 {@link findUncommittedDraftVersionId} 是**互补的两种卡死**:草稿(0)让
|
|
2611
|
+
* `app_version/create` 撞 `code=10043`;审核中(1)让开放平台把**整个应用配置写锁**
|
|
2612
|
+
* (`scope/update` / `robot/switch` / `safe_setting/update` 全回 `code=10046`),
|
|
2613
|
+
* 详见 {@link openPlatformUnderReview}。
|
|
2614
|
+
*
|
|
2615
|
+
* 审核中的版本**只能先撤回**才能改配置——console 自己也这么说("Unable to edit, as
|
|
2616
|
+
* the organization administrator is reviewing the app's version release request."),
|
|
2617
|
+
* 它的 Withdraw 按钮打的就是 {@link cancelPendingReviewVersion} 里那个端点。
|
|
2618
|
+
*/
|
|
2619
|
+
export function findInReviewVersionId(payload) {
|
|
2620
|
+
const data = asRecord(asRecord(payload).data);
|
|
2621
|
+
const versions = Array.isArray(data.versions) ? data.versions : [];
|
|
2622
|
+
for (const item of versions) {
|
|
2623
|
+
const record = asRecord(item);
|
|
2624
|
+
if (record.versionStatus !== CONSOLE_VERSION_STATUS_IN_REVIEW)
|
|
2625
|
+
continue;
|
|
2626
|
+
const versionId = pickString(record, ['versionId', 'version_id', 'id']);
|
|
2627
|
+
if (versionId)
|
|
2628
|
+
return versionId;
|
|
2629
|
+
}
|
|
2630
|
+
return undefined;
|
|
2631
|
+
}
|
|
2632
|
+
/**
|
|
2633
|
+
* 撤回一个正在审核中的版本,好让应用配置重新可写。
|
|
2634
|
+
*
|
|
2635
|
+
* 端点是从 console 实测抓来的(**不是猜的**):版本详情页的 Withdraw 按钮打
|
|
2636
|
+
* `POST /developers/v1/publish/cancel_commit/<appId>/<versionId>`,body `{}` ——
|
|
2637
|
+
* 与既有的 `publish/commit/<appId>/<versionId>` 完全对称。当时用页面内 hook 拦下了
|
|
2638
|
+
* 请求所以没有真撤掉别人的审批。
|
|
2639
|
+
*
|
|
2640
|
+
* 🔴 **当前没有任何自动调用方,这是刻意的。** 曾经在「缺必需权限 + 审核中」时自动撤,
|
|
2641
|
+
* 后来判定那是错的:**触发审批通常意味着有配置不合规**(最常见是数据范围默认「全部」,
|
|
2642
|
+
* 撞租户加签规则),撤回重提会被同一条规则再拦一次 ⟹ 等于用不可逆动作(丢掉审批队列
|
|
2643
|
+
* 位置,线上见过排 18 / 23 天的)驱动一个死循环。更一般地:**审批是规则驱动的闸,不是
|
|
2644
|
+
* 队列**,撤回不绕过规则、只丢位置。所以正确处置是告诉人、让人改配置后自己撤。
|
|
2645
|
+
*
|
|
2646
|
+
* 保留它是为了「检测」与将来可能的**人工辅助**入口(比如显式命令)。若要再接自动调用,
|
|
2647
|
+
* 先回答「撤回之后凭什么这次能过」—— 答不上就不该撤。
|
|
2648
|
+
*
|
|
2649
|
+
* 撤回后**回读确认**:`cancel_commit` 返回 code=0 不等于状态真的变了(同一个坑在
|
|
2650
|
+
* `publish/commit` 上已经栽过一次,见 {@link isVersionCommitted})。
|
|
2651
|
+
*/
|
|
2652
|
+
export async function cancelPendingReviewVersion(postJson, appId, versionId) {
|
|
2653
|
+
try {
|
|
2654
|
+
await postJson(`/developers/v1/publish/cancel_commit/${appId}/${versionId}`, {});
|
|
2655
|
+
}
|
|
2656
|
+
catch (err) {
|
|
2657
|
+
return { ok: false, message: `撤回审核中版本失败: ${safeErrorMessage(err)}` };
|
|
2658
|
+
}
|
|
2659
|
+
try {
|
|
2660
|
+
const after = await postJson(`/developers/v1/app_version/list/${appId}`, {});
|
|
2661
|
+
if (findInReviewVersionId(after) === versionId) {
|
|
2662
|
+
return { ok: false, message: `版本 ${versionId} 撤回后回读仍是「审核中」` };
|
|
2663
|
+
}
|
|
2664
|
+
return { ok: true };
|
|
2665
|
+
}
|
|
2666
|
+
catch (err) {
|
|
2667
|
+
// 撤回请求本身没报错,只是回读失败 ⟹ 状态未知。当作失败处理(保守),让上层
|
|
2668
|
+
// 落回「审核中」提示而不是继续往一个可能仍锁着的应用上写。
|
|
2669
|
+
return { ok: false, message: `撤回后状态回读失败(无法确认): ${safeErrorMessage(err)}` };
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
/** 审批流程里「不构成审批关卡」的节点名(中英文环境都出现过)。 */
|
|
2673
|
+
const APPROVAL_FLOW_NON_GATE_NODES = new Set(['发起', '结束', 'Initiate', 'End']);
|
|
2674
|
+
/** console 对「这一关不需要人审」的节点类型取值(中/英文环境)。 */
|
|
2675
|
+
const APPROVAL_FLOW_AUTO_NODE_TYPES = new Set(['自动通过', 'Auto approved']);
|
|
2676
|
+
/**
|
|
2677
|
+
* 解析 `approval_nodes/get` 的返回,判断这一版提交后是否会自动通过。
|
|
2678
|
+
*
|
|
2679
|
+
* ⚠️ **两个「名字像在回答这个问题、实际答的是另一个」的字段,都不能当判据**:
|
|
2680
|
+
*
|
|
2681
|
+
* | 字段 | 来源 | 实测 | 真实语义 |
|
|
2682
|
+
* |---|---|---|---|
|
|
2683
|
+
* | `canAutoApproval` | 同一个 `approval_nodes/get` 响应 | 秒过那台是 **false**、无待发布版本那台反而 **true** | 更像「理论上能否免审」,与本次会不会秒过**相反** |
|
|
2684
|
+
* | `ApprovalType` | `config/audit_rule` | 秒过/要人审的 **4 台全是 1**,只有无待发布版本那台是 0 | 更像「有没有审批流」 |
|
|
2685
|
+
*
|
|
2686
|
+
* 两个都跨 5~6 台实测过。判据只能落在 `applyNodes` 的节点类型上 —— 上面那张表是为了
|
|
2687
|
+
* 让后人别图省事去读那两个字段(名字比 `applyNodes` 好懂得多,正因如此才危险)。
|
|
2688
|
+
*
|
|
2689
|
+
* ⚠️ 解析路径是 `data.applyInstanceInfo.applyNodes`。**不是** `approvalNodes.nodes`
|
|
2690
|
+
* ——按那个路径读,跨 6 台 bot 全部返回空数组,「所有输入同一输出」正是判据失效的
|
|
2691
|
+
* 特征(当时打了原始 JSON 才找到真路径)。
|
|
2692
|
+
*
|
|
2693
|
+
* 抄送人(`nodeCcUser`)**不算**审批人:抄送只知会、不阻塞流程,把它算进去会让本可
|
|
2694
|
+
* 自动提交的版本被误判成需要人工。
|
|
2695
|
+
*/
|
|
2696
|
+
export function predictApprovalFlow(payload) {
|
|
2697
|
+
const nodes = asRecord(asRecord(asRecord(payload).data).applyInstanceInfo).applyNodes;
|
|
2698
|
+
if (!Array.isArray(nodes) || nodes.length === 0) {
|
|
2699
|
+
// 空数组的正常成因是「没有待发布版本,无流程可算」,不是故障;但既然算不出来,
|
|
2700
|
+
// 就必须让调用方走保守路径,不能默认成「可以自动提交」。
|
|
2701
|
+
return { known: false, autoApproved: false, humanApprovers: [], reason: '审批流程为空(可能没有待发布版本)' };
|
|
2702
|
+
}
|
|
2703
|
+
const gates = nodes
|
|
2704
|
+
.map(node => asRecord(node))
|
|
2705
|
+
.filter(node => {
|
|
2706
|
+
const name = pickString(node, ['nodeName']) ?? '';
|
|
2707
|
+
if (APPROVAL_FLOW_NON_GATE_NODES.has(name))
|
|
2708
|
+
return false;
|
|
2709
|
+
// 抄送节点:有 nodeCcUser 且没有 nodeUser
|
|
2710
|
+
const cc = Array.isArray(node.nodeCcUser) ? node.nodeCcUser : [];
|
|
2711
|
+
const users = Array.isArray(node.nodeUser) ? node.nodeUser : [];
|
|
2712
|
+
return !(cc.length > 0 && users.length === 0);
|
|
2713
|
+
});
|
|
2714
|
+
if (gates.length === 0) {
|
|
2715
|
+
return { known: false, autoApproved: false, humanApprovers: [], reason: '审批流程里没有可判定的关卡节点' };
|
|
2716
|
+
}
|
|
2717
|
+
const humanApprovers = [];
|
|
2718
|
+
for (const gate of gates) {
|
|
2719
|
+
for (const entry of (Array.isArray(gate.nodeUser) ? gate.nodeUser : [])) {
|
|
2720
|
+
const name = pickString(asRecord(asRecord(entry).approver), ['name', 'enName']);
|
|
2721
|
+
if (name)
|
|
2722
|
+
humanApprovers.push(name);
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
const allAuto = gates.every(gate => APPROVAL_FLOW_AUTO_NODE_TYPES.has(pickString(gate, ['nodeType']) ?? ''));
|
|
2726
|
+
return {
|
|
2727
|
+
known: true,
|
|
2728
|
+
autoApproved: allAuto && humanApprovers.length === 0,
|
|
2729
|
+
humanApprovers: uniqueStrings(humanApprovers),
|
|
2730
|
+
};
|
|
2731
|
+
}
|
|
2732
|
+
/**
|
|
2733
|
+
* 查这一版提交后是否会自动通过。
|
|
2734
|
+
*
|
|
2735
|
+
* body 必须**带全字段**:只传 `{}` 会被开放平台拒 `code=10001 请求错误,请刷新页面后重试`。
|
|
2736
|
+
* `visibleSuggest` 用线上现值原样填(与发版同源,见 {@link parseOnlineVisibility}),
|
|
2737
|
+
* 因为可见范围会影响审批规则(申请全员范围要加签)。
|
|
2738
|
+
*
|
|
2739
|
+
* 任何异常都返回 `known:false`,由调用方 fail-closed —— 判不出来时**不许**自动提交。
|
|
2740
|
+
*/
|
|
2741
|
+
export async function fetchApprovalFlowPrediction(postJson, appId, versionId, visibility) {
|
|
2742
|
+
try {
|
|
2743
|
+
const payload = await postJson(`/developers/v1/approval_nodes/get/${appId}`, {
|
|
2744
|
+
visibleSuggest: visibility.visibleSuggest,
|
|
2745
|
+
blackVisibleSuggest: visibility.blackVisibleSuggest,
|
|
2746
|
+
b2cShareSplitConfigSuggest: {
|
|
2747
|
+
b2cGroupChatShareEnable: false,
|
|
2748
|
+
b2cP2PChatShareEnable: false,
|
|
2749
|
+
b2cP2PChatNeedAudit: false,
|
|
2750
|
+
},
|
|
2751
|
+
versionId,
|
|
2752
|
+
notCalculateFlow: false,
|
|
2753
|
+
});
|
|
2754
|
+
return predictApprovalFlow(payload);
|
|
2755
|
+
}
|
|
2756
|
+
catch (err) {
|
|
2757
|
+
return { known: false, autoApproved: false, humanApprovers: [], reason: safeErrorMessage(err) };
|
|
2758
|
+
}
|
|
2759
|
+
}
|
|
2266
2760
|
/** 从 app_version/create 响应提取 versionId(多种响应形态兼容)。 */
|
|
2267
2761
|
export function extractVersionId(payload) {
|
|
2268
2762
|
const direct = pickString(asRecord(payload), ['versionId', 'version_id', 'id']);
|