@gavinc-cn/touchstone-dsh 0.2.0 → 0.3.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.
Files changed (111) hide show
  1. package/LICENSE +0 -0
  2. package/README.md +23 -15
  3. package/README.zh.md +17 -12
  4. package/auth.py +0 -0
  5. package/backend/lib/access.js +44 -0
  6. package/backend/lib/agents.js +341 -0
  7. package/backend/lib/args.js +94 -0
  8. package/backend/lib/auth.js +195 -0
  9. package/backend/lib/board.js +6154 -0
  10. package/backend/lib/builtin-assets.js +1177 -0
  11. package/backend/lib/casescan.js +456 -0
  12. package/backend/lib/chat.js +2053 -0
  13. package/backend/lib/cookie.js +76 -0
  14. package/backend/lib/db.js +2260 -0
  15. package/backend/lib/dshdriver.js +1148 -0
  16. package/backend/lib/dshevents.js +851 -0
  17. package/backend/lib/export-cases.js +261 -0
  18. package/backend/lib/feishu.js +1738 -0
  19. package/backend/lib/feishu_cfg.js +228 -0
  20. package/backend/lib/feishu_conv.js +1484 -0
  21. package/backend/lib/feishu_device.js +428 -0
  22. package/backend/lib/feishu_inbound.js +1674 -0
  23. package/backend/lib/feishu_provision.js +855 -0
  24. package/backend/lib/feishu_rest.js +398 -0
  25. package/backend/lib/feishu_slash.js +568 -0
  26. package/backend/lib/feishu_ws.js +950 -0
  27. package/backend/lib/http.js +312 -0
  28. package/backend/lib/lib.js +973 -0
  29. package/backend/lib/loadcase.js +749 -0
  30. package/backend/lib/loadgen.js +1407 -0
  31. package/backend/lib/localbus.js +164 -0
  32. package/backend/lib/lock.js +405 -0
  33. package/backend/lib/proc.js +278 -0
  34. package/backend/lib/prompts.js +1021 -0
  35. package/backend/lib/pybase64.js +114 -0
  36. package/backend/lib/pyjson.js +214 -0
  37. package/backend/lib/query.js +125 -0
  38. package/backend/lib/rag.js +1310 -0
  39. package/backend/lib/runner.js +4520 -0
  40. package/backend/lib/servercaps.js +182 -0
  41. package/backend/lib/serverstate.js +237 -0
  42. package/backend/lib/sessparse.js +1176 -0
  43. package/backend/lib/sse.js +207 -0
  44. package/backend/lib/static.js +84 -0
  45. package/backend/lib/taskjson.js +302 -0
  46. package/backend/lib/waitq.js +2114 -0
  47. package/backend/lib/worktree.js +588 -0
  48. package/backend/lib/zstd-frames.js +218 -0
  49. package/backend/routes/admin.js +553 -0
  50. package/backend/routes/agents.js +149 -0
  51. package/backend/routes/assets.js +145 -0
  52. package/backend/routes/board.js +1220 -0
  53. package/backend/routes/feishu.js +269 -0
  54. package/backend/routes/feishu_app.js +215 -0
  55. package/backend/routes/feishu_hook.js +358 -0
  56. package/backend/routes/index.js +297 -0
  57. package/backend/routes/projects.js +1059 -0
  58. package/backend/routes/sessions.js +946 -0
  59. package/backend/routes/tasks.js +1530 -0
  60. package/backend/schema/fixtures/ancient.sql +189 -0
  61. package/backend/schema/fixtures/pre_alter.sql +177 -0
  62. package/backend/schema/migrations.json +154 -0
  63. package/backend/schema/schema.json +1451 -0
  64. package/backend/schema/schema.sql +216 -0
  65. package/backend/server.js +498 -0
  66. package/board.py +30 -1
  67. package/builtin_assets.py +0 -0
  68. package/builtin_prompts/free_style/flow.md +0 -0
  69. package/builtin_prompts/free_style/load_test.md +0 -0
  70. package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
  71. package/builtin_prompts/free_style/templates/case_template.md +0 -0
  72. package/builtin_prompts/free_style/templates/index_template.md +0 -0
  73. package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
  74. package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
  75. package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
  76. package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
  77. package/builtin_prompts/free_style/templates/status_template.md +0 -0
  78. package/db.py +294 -129
  79. package/dsh-plugin/cordis.patch.yml +0 -0
  80. package/dsh-plugin/install.sh +22 -8
  81. package/dsh-plugin/lib/client.bundle.js +0 -0
  82. package/dsh-plugin/lib/index.js +299 -29
  83. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  84. package/dsh-plugin/scripts/build.mjs +0 -0
  85. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  86. package/dsh-plugin/scripts/select-bundle.mjs +0 -0
  87. package/dsh-plugin/src/client.js +0 -0
  88. package/dshevents.py +0 -0
  89. package/export_cases.py +0 -0
  90. package/extensions/dsh-autocommit/asset.json +0 -0
  91. package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
  92. package/extensions/dsh-autocommit/pkg/package.json +0 -0
  93. package/feishu_conv.py +0 -0
  94. package/lib.py +0 -0
  95. package/lifecycle.py +116 -13
  96. package/loadcase.py +0 -0
  97. package/loadgen.py +0 -0
  98. package/localbus.py +0 -0
  99. package/migrate_p7b.py +0 -0
  100. package/package.json +6 -1
  101. package/platcompat.py +0 -0
  102. package/prompts.py +0 -0
  103. package/rag.py +0 -0
  104. package/requirements.txt +0 -0
  105. package/touchstone.py +0 -0
  106. package/waitq.py +0 -0
  107. package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
  108. package/webui/dist-plugin/assets/{index-JLwyZZxC.js → index-UrMyeAXF.js} +110 -110
  109. package/webui/dist-plugin/index.html +2 -2
  110. package/worktree.py +0 -0
  111. package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
@@ -0,0 +1,855 @@
1
+ /**
2
+ * 飞书「供给与自检」族(M7-R5;`feishu.py:2685-2760` + `:2715-2760` + `:2776-2934` 的对应物)。
3
+ *
4
+ * 本文件当前只落**第一个函数** `verifyCredentials`(凭据快检)——它是 R3 车道
5
+ * (`backend/lib/feishu.js` 的 `saveUserConfig`)的输入,故先冻结在这里,避免两条车道
6
+ * 互相等。R5 车道在本文件**追加** `provisionApply`/`provisionStatus`/`provisionStart`/
7
+ * `provisionCancel`/`provisionPublish`/`provisionSupported`/`doctor`;**不要改动本函数**。
8
+ *
9
+ * 逐点对齐 oracle(行号即 `feishu.py`):
10
+ * - `verifyCredentials`(:2685 `verify_credentials`) —— 两步:`_tenant_token` 验凭据、
11
+ * `GET /open-apis/bot/v3/info` 读机器人;**绝不抛出**,一律折成 `{ok, detail}`。
12
+ *
13
+ * 三条**实测契约**(照抄,不许「顺手修正」):
14
+ * ① 无凭据时**零请求**:`app_config` 为 None ⇒ 直接回
15
+ * `{"ok": false, "detail": "应用凭据未配置(App ID / App Secret)"}`;
16
+ * ② `/bot/v3/info` 的 bot 在响应**顶层**(无 `data` 包裹,与 `botOpenId` 同读法);
17
+ * `app_name` 假值回落 `"(未命名)"`(Python `or`),`activate_status == 2` 才算「已启用」;
18
+ * ③ 异常分两桶:`FeishuRestError` ⇒ `str(e)`(业务码文案,**跨运行时逐字相同**,因为它由
19
+ * 响应 JSON 拼出);其它异常 ⇒ `探测失败: {e!r}`(**跨运行时文案不同**,已登记为
20
+ * 已知分歧;假上游路径不会走到这里)。
21
+ *
22
+ * 零 npm 依赖(只用 `./feishu_cfg.js` 与 `./feishu_rest.js`)。
23
+ */
24
+ import { appConfig } from './feishu_cfg.js';
25
+ import { FeishuRestError, rest, tenantToken } from './feishu_rest.js';
26
+
27
+ /** Python `str(x)` 的可用子集(文案里逐字出现:`None` / `True` / `2`) */
28
+ function pyStr(value) {
29
+ if (value === null || value === undefined) return 'None';
30
+ if (typeof value === 'boolean') return value ? 'True' : 'False';
31
+ return String(value);
32
+ }
33
+
34
+ /** Python `x or 默认值`(`app_name` 的假值回落口径) */
35
+ function pyTruthy(value) {
36
+ if (value === null || value === undefined || value === false) return false;
37
+ if (typeof value === 'number') return value !== 0;
38
+ if (typeof value === 'string') return value.length > 0;
39
+ if (Array.isArray(value)) return value.length > 0;
40
+ if (value instanceof Map || value instanceof Set) return value.size > 0;
41
+ if (typeof value === 'object') return Object.keys(value).length > 0;
42
+ return true;
43
+ }
44
+
45
+ /** 异常的 repr(`f"{e!r}"` 的可用子集:`TypeError('fetch failed')` 形态) */
46
+ function errRepr(error) {
47
+ if (error instanceof Error) return `${error.name || 'Error'}('${error.message}')`;
48
+ return pyStr(error);
49
+ }
50
+
51
+ /**
52
+ * Python `str(e)` 的可用子集(worker 的 `f"扫码创建失败: {e}"`,feishu.py:3064)。
53
+ *
54
+ * 两种形态都要照抄,因为 oracle 的异常有两族:
55
+ * - `RegisterAppError(code, description)`(lark_oapi/scene/registration/errors.py)继承了
56
+ * `Exception.__init__(f"{code}: {description}")` **却带着两个 args** ⇒ `str(e)` 走
57
+ * `BaseException.__str__` 的「args 长度 ≠ 1」分支,得到**元组 repr**:
58
+ * `('access_denied', '用户拒绝')`(带引号、逗号后有空格);
59
+ * - 普通 `RuntimeError("...")` 只有 1 个 arg ⇒ `str(e)` 就是裸消息。
60
+ * 判据:`code` 与 `description` **同时**是字符串时按元组渲染。这条对得上 oracle 测试
61
+ * `assert "access_denied" in st["error"]`(tests/test_feishu_provision.py:388)。
62
+ */
63
+ function pyErrStr(error) {
64
+ if (error instanceof Error) {
65
+ if (typeof error.code === 'string' && typeof error.description === 'string') {
66
+ return `(${pyRepr(error.code)}, ${pyRepr(error.description)})`;
67
+ }
68
+ return error.message;
69
+ }
70
+ return pyStr(error);
71
+ }
72
+
73
+ /** Python `repr(str)` 的可用子集:单引号优先(与 CPython 同选择),串内有单引号则用双引号 */
74
+ function pyRepr(value) {
75
+ const text = String(value);
76
+ if (!text.includes("'")) return `'${text}'`;
77
+ if (!text.includes('"')) return `"${text}"`;
78
+ return `'${text.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
79
+ }
80
+
81
+ /**
82
+ * stderr 一行(`print(..., file=sys.stderr, flush=True)`)。
83
+ * 走 `console.error` 而不是 `process.stderr.write`:仓库既有单测就是打桩 `console.error`
84
+ * 来抓这几行(`feishu_inbound.js` 的 `defaultLog` 同款理由)。
85
+ */
86
+ function logStderr(line) {
87
+ console.error(line);
88
+ }
89
+
90
+ /**
91
+ * 凭据快检(保存凭据后的即时反馈):token 可取 → 机器人信息可读,两步。
92
+ * 对齐 `feishu.verify_credentials`(feishu.py:2685-2706)。**绝不抛出**。
93
+ * @param {number} userId
94
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
95
+ * @returns {Promise<{ok: boolean, detail: string}>}
96
+ */
97
+ export async function verifyCredentials(userId, options = {}) {
98
+ const cfg = appConfig(userId, options);
99
+ if (cfg === null || cfg === undefined) {
100
+ return { ok: false, detail: '应用凭据未配置(App ID / App Secret)' };
101
+ }
102
+ try {
103
+ await tenantToken(cfg); // 第一步:凭据正确性(失败码 10003/10014 等)
104
+ const out = await rest('GET', '/open-apis/bot/v3/info', { cfg });
105
+ // /bot/v3/info 的 bot 在响应**顶层**(无 data 包裹,同 botOpenId 的读法)
106
+ const bot = (out && out.bot) || {};
107
+ const name = pyTruthy(bot.app_name) ? bot.app_name : '(未命名)';
108
+ const act = bot.activate_status;
109
+ const tail = act === 2 ? '已启用' : `启用状态=${pyStr(act)}(可能未发布/未启用)`;
110
+ return { ok: true, detail: `凭据可用:机器人 ${name}(${tail})` };
111
+ } catch (error) {
112
+ if (error instanceof FeishuRestError) return { ok: false, detail: error.message };
113
+ // 兜底:网络/形态异常一律转文案,不抛到 HTTP 层
114
+ return { ok: false, detail: `探测失败: ${errRepr(error)}` };
115
+ }
116
+ }
117
+
118
+ // ══════════════════════════════════════════════════════════════════════════════
119
+ // M7-R5 追加段:扫码供给(Device Flow 状态机)/ 应用配置补齐 / 提交发布 / 配置自检
120
+ //
121
+ // 对应 oracle `feishu.py`:
122
+ // `:1262-1300` 清单真源与总闸、`:2665-2712` `verify_credentials`(上一段,已冻结)、
123
+ // `:2715-2760` `provision_apply`、`:2771-2930` `_doc_item`/`doctor`、
124
+ // `:2930-2965` `_PROVISION`/`_lark_min_ok`/`provision_supported`/`_provision_addons`、
125
+ // `:2985-3043` `provision_status`/`provision_start`/`provision_cancel`、
126
+ // `:3095-3140` `_next_version`/`provision_publish`。
127
+ // 逐点对齐(行号即 oracle):闸门顺序、键序、文案、真值口径(Python `or`/`bool()`/`str()`)一律照抄。
128
+ //
129
+ // ══ 未移植项与登记的环境差异(**不是实现偷懒,是可观测契约的一部分**)══════════════
130
+ // ① **Device Flow 已移植**(M9):协议客户端在 `backend/lib/feishu_device.js`(RFC 8628 +
131
+ // 飞书变体,零 npm 依赖,逐行对齐 `lark_oapi.scene.registration._SyncFlow`)。
132
+ // `provisionSupported()` 因此改为**真能力探测**(见该函数注释),`provisionStart` 的
133
+ // 第三/四道闸(进行中未超 TTL、已配置需 force)随之**可达**。
134
+ // ② **`supported` 的判据两侧不同源,但同语义**:oracle 判「本机 lark-oapi 版本 ≥ 1.5.5」
135
+ // (`_lark_min_ok`,只读包元数据不 import SDK),Node 判「本运行时能跑 RFC 8628 客户端」。
136
+ // 两者都是**运行时能力**的静态判定,不是业务状态。本机两侧都 true ⇒ 量具
137
+ // `http-run.mjs` 的 `NORMALIZE.supported` 仍把它掩成 `<env>`(掩码可撤,读数已等价)。
138
+ // ③ **入站长连接(`inbound_ws`)** 的线程态由 M7.5 接线(`feishu_cfg.js:setInboundThread`),
139
+ // 本段只读 `inboundStatus()`;`doctor` 因此只保证**无凭据档**两侧确定可比(八项全确定),
140
+ // 有凭据档的 `inbound_ws` 是时变项(已登记未验)。
141
+ // ④ `_LAST_EVENT_TS` 与 `_TOUCH_EVENT`:oracle 的 `_touch_event`(feishu.py:1290) 是入站
142
+ // handler 的打点口,M7.5 接线时调用;本段先落模块态,`doctor` 的 `inbound_event` 依赖它。
143
+ // ══════════════════════════════════════════════════════════════════════════════
144
+
145
+ // ══ 追加段的 import:**写在末尾**(文件开头那段 import 属「已冻结区」,一字不动)═════
146
+ // ESM 的 `import` 声明在模块顶层**任意位置**都合法(模块求值前提升),所以这里再写同源 import
147
+ // 既不碰冻结区、也不会产生第二份模块实例。
148
+ import { inboundStatus, userConfig } from './feishu_cfg.js';
149
+ import { restSendText } from './feishu_rest.js';
150
+ import { _needScope, slashStatus } from './feishu_slash.js';
151
+ import { getFeishuBindingByUser, setFeishuUserCfg } from './db.js';
152
+ import {
153
+ DEVICE_REQUIRED_CALLBACKS, DEVICE_REQUIRED_EVENTS, DEVICE_REQUIRED_SCOPES,
154
+ _provisionAddons, createCancelEvent, makeDeviceFlow,
155
+ } from './feishu_device.js';
156
+ import { _TOKEN_CACHE } from './feishu_rest.js';
157
+ import { startInboundFor as inboundStartFor } from './feishu_inbound.js';
158
+ // `provisionSupported()` 的能力判据之一(与 `feishu_device.js` 里做 addons 编码的是同一个
159
+ // 内置模块;「本运行时能否跑 RFC 8628 客户端」= fetch + gzip 两件)。
160
+ import { gzipSync } from 'node:zlib';
161
+ /** 配置自动化总闸的 env 名(`feishu.py:1262`) */
162
+ export const PROVISION_ENV = 'TS_FEISHU_PROVISION';
163
+ /** `register_app` 的 lark-oapi 下限(`feishu.py:2932`) */
164
+ export const _REGISTER_MIN_VERSION = Object.freeze([1, 5, 5]);
165
+ /** 扫码流程状态存活上限(秒),过期视为 idle(`feishu.py:1274`) */
166
+ export const PROVISION_TTL = 600;
167
+ /** doctor 判「事件订阅真生效」的新鲜窗口(秒)(`feishu.py:1272`) */
168
+ export const EVENT_FRESH_S = 300;
169
+ /** `send_probe` 每用户频控(秒)(`feishu.py:1273`) */
170
+ export const SEND_PROBE_MIN_S = 60;
171
+
172
+ /**
173
+ * 平台所需权限清单(**唯一真源**,`feishu.py:1263-1269`):doctor 的 `slash_scope`、
174
+ * `register_app` 的 addons 预置、`application/v7` config 的补齐三处共用同一份。
175
+ * 每项 `[scope_name, token_type, 中文说明]`(第三列是给「人」看的,进不了协议)。
176
+ *
177
+ * M9 起 addons 的生成在 `feishu_device.js`(`DEVICE_REQUIRED_*`),本表必须与它**同集**
178
+ * ——否则「创建应用时声明的权限」与「事后 apply 补的权限」会漂移,而这正是本项要防的坑。
179
+ * 故文件末尾有一处一致性断言(启动即炸,不留到线上)。
180
+ */
181
+ export const REQUIRED_SCOPES = Object.freeze([
182
+ Object.freeze(['im:message.p2p_msg:readonly', 'tenant', '接收单聊消息']),
183
+ Object.freeze(['im:message.group_at_msg:readonly', 'tenant', '接收群聊 @机器人 消息']),
184
+ Object.freeze(['im:message:send_as_bot', 'tenant', '以机器人身份发消息/卡片']),
185
+ Object.freeze(['application:app_slash_command:read', 'tenant', '读取斜杠指令']),
186
+ Object.freeze(['application:app_slash_command:write', 'tenant', '注册斜杠指令']),
187
+ ]);
188
+ /** 事件清单(`feishu.py:1270`):`[event, 中文说明]` */
189
+ export const REQUIRED_EVENTS = Object.freeze([Object.freeze(['im.message.receive_v1', '接收消息'])]);
190
+ /** 回调清单(`feishu.py:1271`):`[callback, 中文说明]` */
191
+ export const REQUIRED_CALLBACKS = Object.freeze([Object.freeze(['card.action.trigger', '卡片按钮回调'])]);
192
+
193
+ /** 应用配置/发布端点(官方 application/v7;能力开关在 ability 子路径)(`feishu.py:1277-1281`) */
194
+ export const _APP_ABILITY_API = '/open-apis/application/v7/applications/%s/ability';
195
+ export const _APP_CONFIG_API = '/open-apis/application/v7/applications/%s/config';
196
+ export const _APP_PUBLISH_API = '/open-apis/application/v7/applications/%s/publish';
197
+ export const _APP_VERSIONS_API = '/open-apis/application/v6/applications/%s/app_versions';
198
+ /** 补齐应用配置所需的权限(冷启动点:扫码新建的应用默认没有它)(`feishu.py:1282`) */
199
+ export const _PATCH_SCOPE = 'application:application:patch';
200
+ /** 缺 patch 权限时的精确开通引导(`feishu.py:1283-1284`,逐字照抄) */
201
+ export const _PATCH_SCOPE_HINT = `应用缺少「${_PATCH_SCOPE}」权限:请到开发者后台 → 权限管理添加该权限`
202
+ + '并创建版本发布;或把该权限对应用设为免审后再试';
203
+
204
+ /** `user_id -> {state,url,error,app_id,steps,started_at,cancel}`(`feishu.py:2930`,进程内存态) */
205
+ export const _PROVISION = new Map();
206
+ /** `user_id -> 最近一次收到飞书事件的时刻`(`feishu.py:1286`;`_touch_event` 打点,M7.5 接线) */
207
+ export const _LAST_EVENT_TS = new Map();
208
+ /** `user_id -> 上次发「测试消息」的时刻`(`feishu.py:2768`,send_probe 频控) */
209
+ export const _LAST_PROBE_TS = new Map();
210
+
211
+ /** 「读状态 → 写状态」串行化(`feishu.py:2931` 的 `_PROVISION_LOCK`):并发点两下只建一条流程 */
212
+ const _PROVISION_LOCK = { tail: Promise.resolve() };
213
+ function withProvisionLock(fn) {
214
+ const run = _PROVISION_LOCK.tail.then(fn, fn);
215
+ _PROVISION_LOCK.tail = run.then(() => undefined, () => undefined);
216
+ return run;
217
+ }
218
+
219
+ /** 该进程的 env 取用口:`options.env` 优先(单测/路由传),否则 `process.env`(`os.environ` 口径) */
220
+ function envOf(options) {
221
+ return (options && options.env) || process.env;
222
+ }
223
+
224
+ /**
225
+ * 配置自动化写操作总闸(`feishu.provision_enabled`(feishu.py:1296-1299)):
226
+ * `(os.environ.get(K) or "1") != "0"` ⇒ **缺键与空串都是开**,只有字面 `"0"` 关。
227
+ * 只读的 status/doctor 不受它影响(关掉后仍可体检)。
228
+ */
229
+ export function provisionEnabled(env = process.env) {
230
+ return ((env && env[PROVISION_ENV]) || '1') !== '0';
231
+ }
232
+
233
+ /**
234
+ * 本机是否具备「扫码一键建应用」能力(`feishu.provision_supported`(feishu.py:2967-2969))。
235
+ *
236
+ * **判据的对应关系**(两侧同语义、不同源):
237
+ * - oracle 判 `_lark_min_ok()`(feishu.py:2935-2954):`importlib.metadata.version("lark-oapi")`
238
+ * 的版本元组 ≥ `(1,5,5)` —— 即「**本运行时有没有 register_app 这个能力**」,
239
+ * 且刻意**只读包元数据、不 import SDK**(import `lark_oapi.ws.client` 会在模块级
240
+ * `asyncio.get_event_loop()`,非主线程首次 import 有拿不到事件循环的风险)。
241
+ * - Node 侧对应的能力 = 「本运行时能否跑 RFC 8628 客户端」:能发 HTTPS 表单 POST
242
+ * (`globalThis.fetch`)**且**能把 addons 压成 gzip(`node:zlib` 的 `gzipSync`)。
243
+ * 两者都是**运行时能力的静态判定**,不是业务状态(与凭据、总闸、用户配置都无关);
244
+ * 与 oracle 一样**不做任何 I/O**(不探网、不读库、不读 env)。
245
+ *
246
+ * 本机(Node 24)两项齐备 ⇒ 回 `true`,与 oracle(lark-oapi 1.7.3)取值一致。
247
+ * 「能力齐备但真去扫码会失败」属下一层失败面,由 `provisionStart` 与 worker 如实报错。
248
+ */
249
+ export function provisionSupported() {
250
+ return typeof globalThis.fetch === 'function' && typeof gzipSync === 'function';
251
+ }
252
+
253
+ /**
254
+ * `_register_app_impl`(feishu.py:2957-2964) 的对应物:**唯一直接碰 Device Flow 的地方**,
255
+ * 也是测试打桩点(oracle 测试用 `monkeypatch.setattr(feishu, "_register_app_impl", stub)`)。
256
+ *
257
+ * 与 oracle 的差异(登记):oracle 在这里**再查一次** `_lark_min_ok()`,不达标就抛
258
+ * `RuntimeError("lark-oapi 版本过低或未安装(register_app 需 ≥1.5.5)")`。Node 侧
259
+ * `provisionStart` 已用同语义的 `provisionSupported()` 提前拦下,且两处判据必然同值,
260
+ * 这里不再重复一道(重复只会多一个不可达分支)。
261
+ *
262
+ * @param {(info: {url: string, expire_in: number}) => void} onQrCode
263
+ * @param {(info: {status: string, interval?: number}) => void} onStatusChange
264
+ * @param {{is_set?: () => boolean, isSet?: () => boolean, cancelled?: boolean}} cancelEvent
265
+ * @param {object} addons `_provisionAddons()` 的结果
266
+ * @param {{now?: () => number, sleep?: (s: number) => Promise<void>, defaults?: object}} [proc]
267
+ * 时钟替身(**Node 侧多出来的参数**,oracle 无):与 `provisionStart` 的 `options` 同性质
268
+ * ——只影响测试,不改变 oracle 可观测契约。真扫码路径恒不传 ⇒ 走真时钟。
269
+ * @returns {Promise<{client_id: string, client_secret: string, user_info?: object}>}
270
+ */
271
+ export function _registerAppImpl(onQrCode, onStatusChange, cancelEvent, addons, proc) {
272
+ return makeDeviceFlow(proc, {
273
+ addons, cancelEvent, onQrCode, onStatusChange, source: _SOURCE_OVERRIDE,
274
+ }).run();
275
+ }
276
+
277
+ /**
278
+ * 测试缝(对齐 `dsh-plugin/lib/index.js` 的 `_setNodeRuntimeProbeForTest`):
279
+ * **生产路径恒 `null`** ⇒ `_registerAppImpl` 走真 Device Flow。
280
+ * 给缝的理由:真扫码必须有人在飞书里点一下(唯一不可自动化的一步);没有缝的话
281
+ * `provisionStart` 第 ③④ 道闸与 worker 就只能在真机上验。
282
+ */
283
+ let registerAppImplForTest = null;
284
+ export function _setRegisterAppImplForTest(fn) {
285
+ registerAppImplForTest = fn;
286
+ }
287
+
288
+ /**
289
+ * `source` 的可覆盖入口(oracle `register_app(source=...)`):缺省 `null` ⇒ 裸 `"python-sdk"`。
290
+ * **登记为未验项**:`python-sdk` 是照抄 oracle 的取值(`_register_app_impl` 不传 source),
291
+ * 服务端是否校验该字面量未知;擅自改成别的值只会多一个失败面,故保持原值 + 留这个口子。
292
+ */
293
+ let _SOURCE_OVERRIDE = null;
294
+ export function _setSourceForTest(value) {
295
+ _SOURCE_OVERRIDE = value === undefined ? null : value;
296
+ }
297
+
298
+ /**
299
+ * 后台主体(`feishu._provision_worker`(feishu.py:3045-3090)):跑 Device Flow →
300
+ * 成功即写凭据 → 起长连接 → 自动补齐应用配置,全过程结果落 `_PROVISION` 供前端轮询。
301
+ *
302
+ * **绝不外抛**:Node 没有守护线程,本函数是 fire-and-forget 的 async,任何漏出的
303
+ * rejection 都会变成 unhandled rejection(进程级告警甚至退出)——所以整个函数体包在
304
+ * try/catch 里,连落库与 `startInboundFor` 那两步也不例外。凭据不入日志。
305
+ *
306
+ * 落库失败(oracle 里 `db.set_feishu_user_cfg` 抛异常会沿线程冒到顶层)在这里折成
307
+ * `failed` + 同文案:Node 侧没有「线程栈打印」这层兜底,静默吞掉才是真正的不一致。
308
+ *
309
+ * @param {number} userId
310
+ * @param {{set?: Function}} cancel
311
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options] 进程缝,透传给
312
+ * 依赖。oracle 无此层参数,但 Node 侧 `dbPath`/`env` 是既有惯例且**只影响测试**,
313
+ * 不改变 oracle 可观测契约。
314
+ */
315
+ async function _provisionWorker(userId, cancel, options = {}) {
316
+ const st = _PROVISION.get(userId);
317
+ if (st === undefined || st === null) return; // 状态被清掉(oracle 此处会 KeyError)
318
+
319
+ /**
320
+ * 「我是否仍是该用户当前那条流程」。
321
+ *
322
+ * oracle 里 worker 直接闭包持有 `_PROVISION[user_id]` 那个 dict,**永远**写它;只要
323
+ * `provision_start` 没被同用户重入就等价。Node 侧多出一个真实竞态:worker 是
324
+ * fire-and-forget 的 async,可能出现「旧流程还没收尾,用户已重新发起(TTL 过期后
325
+ * `provision_start` 会覆盖 `_PROVISION[user_id]`)」——此时旧 worker 若继续往
326
+ * **自己的** st 上写,还看不出问题;但它若写 `_PROVISION.get(userId)`(比如兜底 catch)
327
+ * 就会把**新**流程打成 failed。
328
+ *
329
+ * 收口办法:所有对 `_PROVISION` 的写入都先过这道闸 —— 只有「`_PROVISION.get(userId).cancel`
330
+ * 就是我手上这个 cancel」才允许写。旧 worker 的顺序完全保留(写自己的 st 或走 stderr),
331
+ * 只是不再污染新流程。这不改变 oracle 的任何可观测输出(同一时刻只有一条流程时恒为真)。
332
+ */
333
+ const isCurrentFlow = () => {
334
+ const cur = _PROVISION.get(userId);
335
+ return cur !== undefined && cur !== null && cur.cancel === cancel;
336
+ };
337
+
338
+ // 回调:`on_qr(url)` / `on_status(info)`(feishu.py:3051-3057)。
339
+ // ⚠ **口径是与 oracle 逐字节一致的「原样存」**:`lark_oapi` 调的是
340
+ // `self._on_qr_code({"url": qr_url, "expire_in": expire_in})`(scene/registration:298),
341
+ // 而 oracle 的 `on_qr(url)` 里就是 `st["url"] = url or ""` —— 存进去的是那个 **dict**,
342
+ // 于是 `provision_status()["url"]` 是 `{"url": …, "expire_in": …}`,
343
+ // HTTP 响应里 `url` 是**对象**而非字符串(2026-10-15 M9-R2 用真 oracle 实测钉死,
344
+ // 见 `tests/test_feishu_provision_url_shape.py`)。
345
+ // 首版这里写的是 `(info && info.url) || ''`(取 `.url` ⇒ 字符串)——**那是无意中偏离了
346
+ // oracle**,比对拍用例 `m9_feishu_device_start` 抓出来(`url` 类型 object vs string)。
347
+ // 修法:**恢复 oracle 口径**(本文件是移植件,不许自作主张改可观测输出),
348
+ // 面板那边由**共用前端**做双形态容错(`provisionQrUrl`)——这样两版后端的面板都能用。
349
+ // 这里**不需要** `isCurrentFlow` 闸:两个回调写的是闭包自己的 `st`(旧流程的状态对象),
350
+ // 新流程有它自己的对象,互不干扰 —— 实测把闸去掉没有任何判据变红(见报告「变异 9」)。
351
+ // 真正的越界点是「旧流程拿凭据去落库」——见下面那道闸。
352
+ const onQr = (info) => {
353
+ st.url = pyTruthy(info) ? info : ''; // oracle `url or ""`(空 dict / None / "" ⇒ "")
354
+ };
355
+ const onStatus = (info) => {
356
+ // 域切换(飞书/Lark)时链接作废,等 SDK 给新链接(旧 URL 已失效,不展示误导)
357
+ if (info !== null && typeof info === 'object' && info.status === 'domain_switched') {
358
+ st.url = '';
359
+ }
360
+ };
361
+
362
+ let out = null;
363
+ try {
364
+ const impl = registerAppImplForTest === null ? _registerAppImpl : registerAppImplForTest;
365
+ out = await impl(onQr, onStatus, cancel, _provisionAddons());
366
+ } catch (error) {
367
+ if (st.state !== 'cancelled') {
368
+ st.state = 'failed';
369
+ st.error = `扫码创建失败: ${pyErrStr(error)}`;
370
+ }
371
+ return;
372
+ }
373
+ if (st.state === 'cancelled') return;
374
+
375
+ const appId = (out || {}).client_id || '';
376
+ const secret = (out || {}).client_secret || '';
377
+ if (!(appId && secret)) {
378
+ st.state = 'failed';
379
+ st.error = '扫码流程未返回应用凭据(可能超时或未确认),可重新发起';
380
+ return;
381
+ }
382
+ if (!isCurrentFlow()) return; // 旧流程:凭据不再落库(新流程会自己拿一份)
383
+
384
+ // 合并写库:保留既有 enabled / webhook / base_url 等字段,只换应用凭据
385
+ try {
386
+ const cfg = { ...(userConfig(userId, options) || {}) };
387
+ cfg.app_id = appId;
388
+ cfg.app_secret = secret;
389
+ setFeishuUserCfg(userId, cfg, options);
390
+ _TOKEN_CACHE.delete(appId); // 新凭据:清同 app_id 的旧 token 缓存(feishu.py:3079)
391
+ st.app_id = appId;
392
+ } catch (error) {
393
+ st.state = 'failed';
394
+ st.error = `扫码创建失败: ${pyErrStr(error)}`;
395
+ return;
396
+ }
397
+
398
+ try {
399
+ inboundStartFor(userId, options); // 起长连接(失败只留痕,状态里如实呈现)
400
+ } catch (error) {
401
+ logStderr(`feishu provision: 用户 ${userId} 长连接启动异常: ${errRepr(error)}`);
402
+ }
403
+
404
+ const applied = await provisionApply(userId, options);
405
+ st.steps = applied.steps || [];
406
+ st.state = 'success';
407
+ st.error = applied.ok ? ''
408
+ : `应用已创建,但配置补齐未完成:${applied.error}`;
409
+ }
410
+
411
+ /**
412
+ * 起 worker(`threading.Thread(target=_provision_worker, ...).start()`,feishu.py:3030-3031)。
413
+ *
414
+ * **fire-and-forget**:Node 没有守护线程,返回的 promise 必须自带 catch,
415
+ * 否则 worker 内部任何漏网异常都会变成 unhandled rejection。
416
+ * 同步返回(不 await)——oracle 的 `Thread.start()` 也是立刻返回。
417
+ */
418
+ function startProvisionWorker(userId, cancel, options) {
419
+ const run = _provisionWorker(userId, cancel, options);
420
+ run.catch((error) => {
421
+ // 理论上到不了这里(worker 内部全 catch);真到了也绝不许冒泡成 unhandled rejection。
422
+ // 只在「我还是当前那条流程」时改状态,否则只留痕(见 `_provisionWorker.isCurrentFlow`)。
423
+ const cur = _PROVISION.get(userId);
424
+ if (cur !== undefined && cur !== null && cur.cancel === cancel && cur.state === 'waiting') {
425
+ cur.state = 'failed';
426
+ cur.error = `扫码创建失败: ${pyErrStr(error)}`;
427
+ } else {
428
+ logStderr(`feishu provision: 用户 ${userId} worker 异常: ${errRepr(error)}`);
429
+ }
430
+ });
431
+ }
432
+
433
+ /**
434
+ * 应用配置补齐(`feishu.provision_apply`(feishu.py:2715-2760)):把平台所需的
435
+ * **机器人能力 + 权限 + 长连接订阅 + 事件 + 回调**一次补齐到用户自己的应用。
436
+ *
437
+ * 两步 ① `PATCH .../ability`(`{bot:{enable:true}}`)② `PATCH .../config`
438
+ * (scope.add_scopes / event.subscription_type=websocket + add_events / callback.add_callbacks)。
439
+ * **失败即停**:第 ① 步失败就只回一条 step(已跑的步骤如实保留);缺 scope 时 `error`
440
+ * 换成精确开通引导 `_PATCH_SCOPE_HINT`(`_apply_error`),其余原样透出 `str(e)`。
441
+ * 返回键序恒为 `ok / need_patch_scope / error / steps`(Python dict 字面量序)。
442
+ * @param {number} userId
443
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
444
+ */
445
+ export async function provisionApply(userId, options = {}) {
446
+ if (!provisionEnabled(envOf(options))) {
447
+ return { ok: false, need_patch_scope: false, steps: [],
448
+ error: `配置自动化已关闭(${PROVISION_ENV}=0)` };
449
+ }
450
+ const cfg = appConfig(userId, options);
451
+ if (cfg === null || cfg === undefined) {
452
+ return { ok: false, need_patch_scope: false, steps: [],
453
+ error: '应用凭据未配置(App ID / App Secret),请先保存凭据' };
454
+ }
455
+ const appId = cfg.app_id;
456
+ const steps = [];
457
+ // ① 机器人能力(错误码 210041 即「未开启机器人能力」,先开它)
458
+ try {
459
+ await rest('PATCH', sprintf(_APP_ABILITY_API, appId),
460
+ { jsonBody: { bot: { enable: true } }, cfg });
461
+ steps.push({ key: 'ability', label: '开启机器人能力', ok: true, detail: '已开启' });
462
+ } catch (error) {
463
+ if (!(error instanceof FeishuRestError)) throw error; // oracle 只吞 FeishuRestError
464
+ steps.push({ key: 'ability', label: '开启机器人能力', ok: false, detail: error.message });
465
+ return { ok: false, need_patch_scope: _needScope(error),
466
+ error: applyError(error), steps };
467
+ }
468
+ // ② 权限 + 订阅方式(长连接)+ 事件 + 回调(一次 PATCH,减少请求与半生效窗口)
469
+ const body = {
470
+ scope: { add_scopes: REQUIRED_SCOPES.map(([name, tokenType]) =>
471
+ ({ scope_name: name, token_type: tokenType })) },
472
+ event: { subscription_type: 'websocket', add_events: REQUIRED_EVENTS.map(([e]) => e) },
473
+ callback: { callback_type: 'websocket', add_callbacks: REQUIRED_CALLBACKS.map(([c]) => c) },
474
+ };
475
+ try {
476
+ await rest('PATCH', sprintf(_APP_CONFIG_API, appId), { jsonBody: body, cfg });
477
+ steps.push({ key: 'config', label: '权限 / 长连接订阅 / 事件 / 回调', ok: true,
478
+ detail: '已写入(需发布新版本后生效)' });
479
+ } catch (error) {
480
+ if (!(error instanceof FeishuRestError)) throw error;
481
+ steps.push({ key: 'config', label: '权限 / 长连接订阅 / 事件 / 回调', ok: false,
482
+ detail: error.message });
483
+ return { ok: false, need_patch_scope: _needScope(error), error: applyError(error), steps };
484
+ }
485
+ return { ok: true, need_patch_scope: false, error: '', steps };
486
+ }
487
+
488
+ /** Python `%s` 单参格式化的可用子集(端点模板都是单个 `%s`) */
489
+ function sprintf(template, value) {
490
+ return template.replace('%s', String(value));
491
+ }
492
+
493
+ /** `feishu._apply_error`(feishu.py:2708-2712):缺 patch 权限给精确引导,其余原样透出 */
494
+ function applyError(error) {
495
+ if (_needScope(error)) return _PATCH_SCOPE_HINT;
496
+ return error.message;
497
+ }
498
+
499
+ /**
500
+ * doctor 单项(`feishu._doc_item`(feishu.py:2771-2773)):键序 key/label/state/detail/hint,
501
+ * `state ∈ ok|fail|warn|skip`,`hint` 是中文修复指引(缺省空串)。
502
+ */
503
+ export function _docItem(key, label, state, detail, hint = '') {
504
+ return { key, label, state, detail, hint };
505
+ }
506
+
507
+ /**
508
+ * 逐项配置体检(`feishu.doctor`(feishu.py:2776-2925)):八项 + summary 计数,**绝不抛出**。
509
+ *
510
+ * 项目顺序与文案逐字照抄 oracle:creds / token / bot / inbound_ws / inbound_event /
511
+ * slash_scope / send_scope / webhook;summary 键序 ok/fail/warn/skip。
512
+ * **无凭据档是唯一两侧确定可比的档**(其余七项都可能因 `inbound_ws` 线程态/网络返回时变——
513
+ * 已登记未验)。`send_probe=true` 只有在「凭据可用 + 已绑定」时才可能走到真发消息(60s 频控)。
514
+ * @param {number} userId
515
+ * @param {boolean} [sendProbe]
516
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
517
+ */
518
+ export async function doctor(userId, sendProbe = false, options = {}) {
519
+ const cfg = { ...(userConfig(userId, options) || {}) };
520
+ const hasCreds = pyTruthy(cfg.app_id) && pyTruthy(cfg.app_secret);
521
+ const items = [_docItem(
522
+ 'creds', '应用凭据(App ID / App Secret)',
523
+ hasCreds ? 'ok' : 'fail',
524
+ hasCreds ? '已配置' : '未配置',
525
+ hasCreds ? '' : '在「飞书机器人配置」填入 App ID 与 App Secret 并保存')];
526
+
527
+ let tokenOk = false;
528
+ if (!hasCreds) {
529
+ items.push(_docItem('token', '凭据有效性(tenant_access_token)', 'skip', '凭据未配置,跳过', ''));
530
+ } else {
531
+ try {
532
+ await tenantToken(cfg);
533
+ tokenOk = true;
534
+ items.push(_docItem('token', '凭据有效性(tenant_access_token)', 'ok',
535
+ 'token 获取成功', ''));
536
+ } catch (error) {
537
+ if (error instanceof FeishuRestError) {
538
+ items.push(_docItem('token', '凭据有效性(tenant_access_token)', 'fail', error.message,
539
+ '核对取值:开发者后台 → 凭证与基础信息 的 App ID / App Secret'));
540
+ } else {
541
+ items.push(_docItem('token', '凭据有效性(tenant_access_token)', 'fail',
542
+ `探测失败: ${errRepr(error)}`, '检查站点到 open.feishu.cn 的网络'));
543
+ }
544
+ }
545
+ }
546
+
547
+ if (!tokenOk) {
548
+ items.push(_docItem('bot', '机器人能力与启用状态', 'skip', '凭据未通过,跳过', ''));
549
+ } else {
550
+ try {
551
+ const out = await rest('GET', '/open-apis/bot/v3/info', { cfg });
552
+ const bot = out.bot || {};
553
+ const act = bot.activate_status;
554
+ const name = pyTruthy(bot.app_name) ? bot.app_name : '(未命名)';
555
+ const ok = act === 2;
556
+ items.push(_docItem('bot', '机器人能力与启用状态', ok ? 'ok' : 'warn',
557
+ `${name}(activate_status=${pyStr(act)})`,
558
+ ok ? '' : '应用能力 → 添加「机器人」,并在版本管理与发布里发布后生效'));
559
+ } catch (error) {
560
+ if (error instanceof FeishuRestError) {
561
+ items.push(_docItem('bot', '机器人能力与启用状态', 'fail', error.message,
562
+ '应用能力 → 添加应用能力 → 机器人,然后创建版本发布'));
563
+ } else {
564
+ items.push(_docItem('bot', '机器人能力与启用状态', 'fail',
565
+ `探测失败: ${errRepr(error)}`, '稍后重试'));
566
+ }
567
+ }
568
+ }
569
+
570
+ const st = inboundStatus(userId, options);
571
+ if (!hasCreds) {
572
+ items.push(_docItem('inbound_ws', '入站长连接(长连接)', 'skip', '凭据未配置,跳过', ''));
573
+ } else {
574
+ items.push(_docItem('inbound_ws', '入站长连接(长连接)', pyTruthy(st.thread) ? 'ok' : 'fail',
575
+ pyTruthy(st.thread) ? '连接线程运行中' : '未运行',
576
+ pyTruthy(st.thread) ? ''
577
+ : '凭据保存后自动连接;仍不启动看站点日志,并确认事件订阅方式为「长连接」'));
578
+ }
579
+
580
+ const lastEv = _LAST_EVENT_TS.get(userId);
581
+ if (!hasCreds) {
582
+ items.push(_docItem('inbound_event', '事件订阅(真实收到过消息)', 'skip',
583
+ '凭据未配置,跳过', ''));
584
+ } else if (lastEv && (Date.now() / 1000 - lastEv) <= EVENT_FRESH_S) {
585
+ items.push(_docItem('inbound_event', '事件订阅(真实收到过消息)', 'ok',
586
+ `${Math.trunc(Date.now() / 1000 - lastEv)} 秒前收到过事件`, ''));
587
+ } else {
588
+ items.push(_docItem('inbound_event', '事件订阅(真实收到过消息)', 'warn',
589
+ lastEv ? '最近一次事件已超过 5 分钟' : '尚未收到过事件',
590
+ '给机器人**发一条消息**验证:开发者后台 → 事件与回调 → 订阅方式选「长连接」'
591
+ + '并添加 im.message.receive_v1(按钮交互还需回调 card.action.trigger)'));
592
+ }
593
+
594
+ if (!tokenOk) {
595
+ items.push(_docItem('slash_scope', '斜杠指令权限', 'skip', '凭据未通过,跳过', ''));
596
+ } else {
597
+ let sl = null;
598
+ try {
599
+ sl = await slashStatus(userId, options);
600
+ } catch (error) {
601
+ // slash_status 只吞 FeishuRestError;响应形态意外(如 data 非 dict)会漏出其他异常
602
+ // —— doctor 承诺绝不外抛,这里降级为该项 fail。
603
+ items.push(_docItem('slash_scope', '斜杠指令权限', 'fail', `探测失败: ${errRepr(error)}`,
604
+ '稍后重试;仍失败可在「快捷指令」卡手动「注册 / 同步」看具体报错'));
605
+ sl = null;
606
+ }
607
+ if (sl !== null) {
608
+ if (pyTruthy(sl.need_scope)) {
609
+ items.push(_docItem('slash_scope', '斜杠指令权限', 'fail',
610
+ pyTruthy(sl.error) ? sl.error : '缺权限',
611
+ '权限管理 添加 application:app_slash_command:read 与 write,'
612
+ + '并创建版本发布;开通后回设置页点「注册 / 同步到飞书」'));
613
+ } else if (pyTruthy(sl.error)) {
614
+ items.push(_docItem('slash_scope', '斜杠指令权限', 'warn', String(sl.error),
615
+ '可在设置页「快捷指令」卡重试'));
616
+ } else {
617
+ items.push(_docItem('slash_scope', '斜杠指令权限', 'ok', '权限可用', ''));
618
+ }
619
+ }
620
+ }
621
+
622
+ let binding = null;
623
+ try {
624
+ binding = getFeishuBindingByUser(userId, options);
625
+ } catch {
626
+ binding = null;
627
+ }
628
+ if (!tokenOk || !binding) {
629
+ items.push(_docItem('send_scope', '发消息权限(可选探测)', 'skip',
630
+ binding ? '凭据未通过,跳过' : '未绑定飞书账号,跳过',
631
+ binding ? '' : '先在下方生成绑定码并在飞书里发「绑定 <码>」'));
632
+ } else if (!sendProbe) {
633
+ items.push(_docItem('send_scope', '发消息权限(可选探测)', 'skip',
634
+ '未探测(勾选「发送测试消息」后重跑自检)', ''));
635
+ } else if ((Date.now() / 1000 - (_LAST_PROBE_TS.get(userId) || 0)) < SEND_PROBE_MIN_S) {
636
+ items.push(_docItem('send_scope', '发消息权限(可选探测)', 'skip',
637
+ `${SEND_PROBE_MIN_S} 秒内已发过测试消息,稍后再试`, ''));
638
+ } else {
639
+ try {
640
+ await restSendText(binding.open_id,
641
+ '🔍 Touchstone 配置自检:收到这条消息说明「发消息」权限已开通。', cfg);
642
+ _LAST_PROBE_TS.set(userId, Date.now() / 1000);
643
+ items.push(_docItem('send_scope', '发消息权限(可选探测)', 'ok',
644
+ '测试消息已发送,去飞书查看', ''));
645
+ } catch (error) {
646
+ if (error instanceof FeishuRestError) {
647
+ items.push(_docItem('send_scope', '发消息权限(可选探测)', 'fail', error.message,
648
+ '权限管理 添加 im:message:send_as_bot 并创建版本发布'));
649
+ } else {
650
+ items.push(_docItem('send_scope', '发消息权限(可选探测)', 'fail',
651
+ `探测失败: ${errRepr(error)}`, '稍后重试'));
652
+ }
653
+ }
654
+ }
655
+
656
+ const hook = cfg.default_webhook || '';
657
+ items.push(_docItem('webhook', '默认推送 Webhook', pyTruthy(hook) ? 'ok' : 'warn',
658
+ pyTruthy(hook) ? '已配置' : '未配置(不影响机器人对话,只影响阻塞事件推送)',
659
+ pyTruthy(hook) ? '' : '群设置 → 群机器人 → 添加自定义机器人,把 Webhook 填到设置页'));
660
+
661
+ const summary = { ok: 0, fail: 0, warn: 0, skip: 0 };
662
+ for (const it of items) summary[it.state] = (summary[it.state] || 0) + 1;
663
+ return { items, summary };
664
+ }
665
+
666
+ /**
667
+ * 扫码流程现状(`feishu.provision_status`(feishu.py:2985-3004),设置页轮询口,只读)。
668
+ *
669
+ * 键序恒为 `supported/enabled/state/url/error/app_id/steps/age_s`;等待超 TTL 即对外按
670
+ * failed 呈现("确认链接已超时(10 分钟有效),可重新发起");
671
+ * `app_id` = 在跑流程的应用优先,否则回落**用户配置里的 app_id**(只填 app_id 也算「已接入」,
672
+ * 前端据此二次确认;真正的 force 闸门仍按 `app_config` 两件齐全判定)。
673
+ * @param {number} userId
674
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
675
+ */
676
+ export function provisionStatus(userId, options = {}) {
677
+ const st = _PROVISION.get(userId) || {};
678
+ // `st.get("state", "idle")`:**键在就取键值**(哪怕空串),只有键缺席才回落 idle
679
+ let state = st.state === undefined ? 'idle' : st.state;
680
+ let error = st.error === undefined ? '' : st.error;
681
+ const now = Date.now() / 1000;
682
+ if (state === 'waiting' && (now - (st.started_at || 0)) > PROVISION_TTL) {
683
+ state = 'failed';
684
+ error = error || '确认链接已超时(10 分钟有效),可重新发起';
685
+ }
686
+ const cfgAppId = (userConfig(userId, options) || {}).app_id;
687
+ const appId = pyTruthy(st.app_id) ? st.app_id : (pyTruthy(cfgAppId) ? cfgAppId : '');
688
+ return {
689
+ supported: provisionSupported(),
690
+ enabled: provisionEnabled(envOf(options)),
691
+ state,
692
+ url: st.url || '',
693
+ error,
694
+ app_id: appId,
695
+ steps: st.steps || [],
696
+ age_s: st.started_at ? Math.trunc(now - st.started_at) : 0,
697
+ };
698
+ }
699
+
700
+ /**
701
+ * 发起扫码建应用(`feishu.provision_start`(feishu.py:3006-3033))。返回键序 `ok/state/url/error`。
702
+ *
703
+ * **闸门顺序逐字照抄 oracle**:① 总闸(`TS_FEISHU_PROVISION=0`)② 运行时能力
704
+ * ③ 已有进行中流程且未超 TTL(返回既有流程现状)④ 已配置应用需 `force`
705
+ * ⑤ 落 `waiting` 状态 + 起 worker(`threading.Thread(...).start()`,:3030-3031)。
706
+ * M9 起 ② 不再恒拦 ⇒ **③④⑤ 全部可达**;四道闸的实现与文案一字未动。
707
+ *
708
+ * 第 ⑤ 步的 `cancel` 用 `createCancelEvent()`(`threading.Event` 的可用子集),
709
+ * **在锁内**建好后交给 worker —— 与 oracle 同为「先登记状态、再起线程」,
710
+ * 故 worker 一定能看到自己那条状态。
711
+ * @param {number} userId
712
+ * @param {boolean} [force] `bool(body.get("force"))` 口径(调用方已折算)
713
+ * @param {{db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
714
+ */
715
+ export async function provisionStart(userId, force = false, options = {}) {
716
+ if (!provisionEnabled(envOf(options))) {
717
+ return { ok: false, state: 'idle', url: '', error: `配置自动化已关闭(${PROVISION_ENV}=0)` };
718
+ }
719
+ if (!provisionSupported()) {
720
+ return { ok: false, state: 'idle', url: '',
721
+ error: '本机 lark-oapi 版本过低(register_app 需 ≥1.5.5),请升级依赖后重试' };
722
+ }
723
+ return withProvisionLock(async () => {
724
+ const cur = _PROVISION.get(userId) || {};
725
+ if (cur.state === 'waiting'
726
+ && (Date.now() / 1000 - (cur.started_at || 0)) <= PROVISION_TTL) {
727
+ return { ok: true, state: 'waiting', url: cur.url || '', error: '' };
728
+ }
729
+ const cfg = appConfig(userId, options);
730
+ if (cfg !== null && cfg !== undefined && !force) {
731
+ return { ok: false, state: 'idle', url: '',
732
+ error: `已配置应用 ${cfg.app_id};如需换应用请确认后重试` };
733
+ }
734
+ const cancel = createCancelEvent();
735
+ _PROVISION.set(userId, {
736
+ state: 'waiting', url: '', error: '', app_id: '', steps: [],
737
+ started_at: Date.now() / 1000, cancel,
738
+ });
739
+ startProvisionWorker(userId, cancel, options);
740
+ return { ok: true, state: 'waiting', url: '', error: '' };
741
+ });
742
+ }
743
+
744
+ /**
745
+ * 取消进行中的扫码流程(`feishu.provision_cancel`(feishu.py:3035-3043))。
746
+ * 键序 `ok/state/error`(**没有 url**);无进行中流程时 `state` 取当前态(缺省 idle)。
747
+ */
748
+ export function provisionCancel(userId) {
749
+ const st = _PROVISION.get(userId) || {};
750
+ if (st.state !== 'waiting') {
751
+ return { ok: false, state: st.state === undefined ? 'idle' : st.state,
752
+ error: '当前没有进行中的扫码流程' };
753
+ }
754
+ st.cancel.set();
755
+ st.state = 'cancelled';
756
+ return { ok: true, state: 'cancelled', error: '' };
757
+ }
758
+
759
+ /**
760
+ * 版本号递增(`feishu._next_version`(feishu.py:3095-3110)):取远端版本列表里的**最大语义版本**
761
+ * 补丁位 +1(无版本回 `1.0.0`)。解析失败的版本(非 `x.y.z`、非数字段)忽略;
762
+ * 比较用**整数元组**,避免 `"1.0.10" < "1.0.3"` 的字符串序陷阱。
763
+ * @param {Array<object>} items `data.items`(可空/含坏项)
764
+ * @returns {string}
765
+ */
766
+ export function _nextVersion(items) {
767
+ let best = null;
768
+ for (const it of items || []) {
769
+ const raw = pyStrip(pyStr((it || {}).version || ''));
770
+ const nums = [];
771
+ let bad = false;
772
+ for (const part of raw.split('.')) {
773
+ const num = pyIntOfPart(part);
774
+ if (num === null) { bad = true; break; } // `int(...)` 抛 ValueError ⇒ 该项忽略
775
+ nums.push(num);
776
+ }
777
+ if (bad) continue;
778
+ if (nums.length !== 3) continue; // 段数不对(`1.0`/`1.2.3.4`)⇒ 忽略
779
+ if (best === null || tupleGt(nums, best)) best = nums;
780
+ }
781
+ if (best === null) return '1.0.0';
782
+ return `${best[0]}.${best[1]}.${best[2] + 1}`;
783
+ }
784
+
785
+ /**
786
+ * Python `int(part)` 的可用子集:允许首尾空白与数字间的下划线(`int(" 1")` / `int("1_0")`
787
+ * 在 Python 里都合法),非法返回 `null`(对应 Python 的 `ValueError`)。
788
+ */
789
+ function pyIntOfPart(part) {
790
+ const text = String(part).trim();
791
+ if (!/^[+-]?\d(?:_?\d)*$/.test(text)) return null;
792
+ return Number.parseInt(text.replace(/_/g, ''), 10);
793
+ }
794
+
795
+ /** Python 元组按元素比较(整数序列;长度已由调用方保证相等) */
796
+ function tupleGt(a, b) {
797
+ for (let i = 0; i < a.length; i += 1) {
798
+ if (a[i] !== b[i]) return a[i] > b[i];
799
+ }
800
+ return false;
801
+ }
802
+
803
+ /**
804
+ * 提交发布自建应用(`feishu.provision_publish`(feishu.py:3114-3140))。
805
+ *
806
+ * 版本号缺省自动递增(先 `GET app_versions`,见 `_nextVersion`);显式版本走
807
+ * `str(version or "").strip()`。返回键序 `ok/version/version_id/need_patch_scope/error`。
808
+ * 发布后仍需**租户管理员审批**才线上生效(无 API,走前端引导)。
809
+ * @param {number} userId
810
+ * @param {{version?: string|null, remark?: string, changelog?: string,
811
+ * db?: object, dbPath?: string, env?: NodeJS.ProcessEnv}} [options]
812
+ */
813
+ export async function provisionPublish(userId, options = {}) {
814
+ if (!provisionEnabled(envOf(options))) {
815
+ return { ok: false, version: '', version_id: '', need_patch_scope: false,
816
+ error: `配置自动化已关闭(${PROVISION_ENV}=0)` };
817
+ }
818
+ const cfg = appConfig(userId, options);
819
+ if (cfg === null || cfg === undefined) {
820
+ return { ok: false, version: '', version_id: '', need_patch_scope: false,
821
+ error: '应用凭据未配置(App ID / App Secret),请先保存凭据' };
822
+ }
823
+ const appId = cfg.app_id;
824
+ let ver = pyStrip(pyStr(options.version || ''));
825
+ try {
826
+ if (!ver) {
827
+ const out = await rest('GET', sprintf(_APP_VERSIONS_API, appId), { cfg });
828
+ const items = ((out.data || {}).items) || [];
829
+ ver = _nextVersion(items);
830
+ }
831
+ const body = {
832
+ version: ver,
833
+ remark: options.remark || 'Touchstone 配置自动化',
834
+ changelog: options.changelog || '自动配置:机器人能力 / 权限 / 长连接事件订阅 / 卡片回调',
835
+ pc_default_ability: 'bot',
836
+ mobile_default_ability: 'bot',
837
+ };
838
+ const out = await rest('POST', sprintf(_APP_PUBLISH_API, appId), { jsonBody: body, cfg });
839
+ const data = out.data || {};
840
+ return { ok: true, version: data.version || ver,
841
+ version_id: pyStr(data.version_id || ''), need_patch_scope: false, error: '' };
842
+ } catch (error) {
843
+ if (error instanceof FeishuRestError) {
844
+ return { ok: false, version: ver, version_id: '', need_patch_scope: _needScope(error),
845
+ error: applyError(error) };
846
+ }
847
+ return { ok: false, version: ver, version_id: '', need_patch_scope: false,
848
+ error: `发布失败: ${errRepr(error)}` };
849
+ }
850
+ }
851
+
852
+ /** Python `str.strip()` 的可用子集(默认去首尾空白;`\x1c-\x1f` 等 Python 特有空白不涉及) */
853
+ function pyStrip(value) {
854
+ return String(value).replace(/^[\s\u0085]+|[\s\u0085]+$/g, '');
855
+ }