@gavinc-cn/touchstone-dsh 0.2.0 → 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +0 -0
- package/README.md +23 -15
- package/README.zh.md +17 -12
- package/auth.py +0 -0
- package/backend/lib/access.js +44 -0
- package/backend/lib/agents.js +341 -0
- package/backend/lib/args.js +94 -0
- package/backend/lib/auth.js +195 -0
- package/backend/lib/board.js +6227 -0
- package/backend/lib/builtin-assets.js +1177 -0
- package/backend/lib/casescan.js +456 -0
- package/backend/lib/chat.js +2090 -0
- package/backend/lib/cookie.js +76 -0
- package/backend/lib/db.js +2260 -0
- package/backend/lib/dshdriver.js +1148 -0
- package/backend/lib/dshevents.js +851 -0
- package/backend/lib/export-cases.js +261 -0
- package/backend/lib/feishu.js +1747 -0
- package/backend/lib/feishu_cfg.js +262 -0
- package/backend/lib/feishu_conv.js +1484 -0
- package/backend/lib/feishu_device.js +428 -0
- package/backend/lib/feishu_inbound.js +2077 -0
- package/backend/lib/feishu_provision.js +886 -0
- package/backend/lib/feishu_rest.js +398 -0
- package/backend/lib/feishu_slash.js +568 -0
- package/backend/lib/feishu_ws.js +950 -0
- package/backend/lib/http.js +312 -0
- package/backend/lib/lib.js +973 -0
- package/backend/lib/loadcase.js +749 -0
- package/backend/lib/loadgen.js +1407 -0
- package/backend/lib/localbus.js +164 -0
- package/backend/lib/lock.js +426 -0
- package/backend/lib/proc.js +361 -0
- package/backend/lib/prompts.js +1021 -0
- package/backend/lib/pybase64.js +114 -0
- package/backend/lib/pyjson.js +214 -0
- package/backend/lib/query.js +125 -0
- package/backend/lib/rag.js +1310 -0
- package/backend/lib/runner.js +4546 -0
- package/backend/lib/seed_hint.js +40 -0
- package/backend/lib/servercaps.js +182 -0
- package/backend/lib/serverstate.js +227 -0
- package/backend/lib/sessparse.js +1176 -0
- package/backend/lib/sse.js +207 -0
- package/backend/lib/static.js +84 -0
- package/backend/lib/taskjson.js +302 -0
- package/backend/lib/waitq.js +2114 -0
- package/backend/lib/worktree.js +588 -0
- package/backend/lib/zstd-frames.js +218 -0
- package/backend/routes/admin.js +553 -0
- package/backend/routes/agents.js +149 -0
- package/backend/routes/assets.js +145 -0
- package/backend/routes/board.js +1228 -0
- package/backend/routes/feishu.js +269 -0
- package/backend/routes/feishu_app.js +249 -0
- package/backend/routes/feishu_hook.js +358 -0
- package/backend/routes/index.js +298 -0
- package/backend/routes/projects.js +1138 -0
- package/backend/routes/sessions.js +1003 -0
- package/backend/routes/tasks.js +1530 -0
- package/backend/schema/fixtures/ancient.sql +189 -0
- package/backend/schema/fixtures/pre_alter.sql +177 -0
- package/backend/schema/migrations.json +154 -0
- package/backend/schema/schema.json +1451 -0
- package/backend/schema/schema.sql +216 -0
- package/backend/server.js +505 -0
- package/board.py +96 -13
- package/builtin_assets.py +0 -0
- package/builtin_prompts/free_style/flow.md +0 -0
- package/builtin_prompts/free_style/load_test.md +0 -0
- package/builtin_prompts/free_style/templates/bug_report_template.md +0 -0
- package/builtin_prompts/free_style/templates/case_template.md +0 -0
- package/builtin_prompts/free_style/templates/index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_charts_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_index_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_plan_template.md +0 -0
- package/builtin_prompts/free_style/templates/load_scenario_template.md +0 -0
- package/builtin_prompts/free_style/templates/status_template.md +0 -0
- package/chat.py +33 -0
- package/db.py +294 -129
- package/dsh-plugin/cordis.patch.yml +0 -0
- package/dsh-plugin/install.sh +22 -8
- package/dsh-plugin/lib/client.bundle.js +111 -2
- package/dsh-plugin/lib/index.js +340 -29
- package/dsh-plugin/lib/pack-closure.mjs +291 -0
- package/dsh-plugin/scripts/build.mjs +0 -0
- package/dsh-plugin/scripts/prepack-check.mjs +30 -2
- package/dsh-plugin/scripts/select-bundle.mjs +0 -0
- package/dsh-plugin/src/client.js +111 -2
- package/dshevents.py +0 -0
- package/export_cases.py +0 -0
- package/extensions/dsh-autocommit/asset.json +0 -0
- package/extensions/dsh-autocommit/pkg/lib/index.mjs +0 -0
- package/extensions/dsh-autocommit/pkg/package.json +0 -0
- package/feishu.py +417 -3
- package/feishu_conv.py +0 -0
- package/lib.py +0 -0
- package/lifecycle.py +116 -13
- package/loadcase.py +0 -0
- package/loadgen.py +0 -0
- package/localbus.py +0 -0
- package/migrate_p7b.py +0 -0
- package/package.json +6 -1
- package/platcompat.py +0 -0
- package/prompts.py +0 -0
- package/rag.py +0 -0
- package/requirements.txt +0 -0
- package/server.py +155 -11
- package/touchstone.py +0 -0
- package/waitq.py +0 -0
- package/webui/dist-plugin/assets/index-3QsYQi7y.css +1 -0
- package/webui/dist-plugin/assets/index-B8AMtW7y.js +486 -0
- package/webui/dist-plugin/index.html +2 -2
- package/worktree.py +0 -0
- package/webui/dist-plugin/assets/index-JLwyZZxC.js +0 -484
- package/webui/dist-plugin/assets/index-tJ0TlvQ3.css +0 -1
|
@@ -0,0 +1,428 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 飞书「扫码一键建应用」的 Device Flow 客户端(RFC 8628 + 飞书变体)——零 npm 依赖。
|
|
3
|
+
*
|
|
4
|
+
* 对应 oracle:`lark_oapi/scene/registration/__init__.py`(lark-oapi 1.7.3)的
|
|
5
|
+
* `_SyncFlow`,被 `feishu.py:2957 _register_app_impl` 调起、由 `:3045 _provision_worker`
|
|
6
|
+
* 在后台线程里跑。oracle 的 `register_app` **不是 SDK 魔法**:3 个表单 POST + 一个轮询循环,
|
|
7
|
+
* 无签名无加密(`addons` 只是 gzip + base64url)。所以这里能把整条协议逐行搬过来。
|
|
8
|
+
*
|
|
9
|
+
* ══ 逐点对齐(行号即 `scene/registration/__init__.py`)══════════════════════════════
|
|
10
|
+
* - `_ENDPOINT = "/oauth/v1/app/registration"`(:14)、`_SDK_NAME = "python-sdk"`(:15);
|
|
11
|
+
* - `run`(:273-325):init → 校验 `supported_auth_methods` 含 `client_secret` → begin
|
|
12
|
+
* → 拼确认链接(`_build_qr_url`:177-187) → 轮询直到 `deadline`;
|
|
13
|
+
* - 轮询判定顺序 `_handle_poll_response`(:197-232):**先成功 → 再域切换 → 再错误码**。
|
|
14
|
+
* 这个顺序是语义的一部分:一个同时带 `client_id` 和 `error` 的响应算**成功**;
|
|
15
|
+
* 一个同时带 `tenant_brand: "lark"` 和 `error: "access_denied"` 的响应算**切域**。
|
|
16
|
+
* - `slow_down` ⇒ `interval += 5`(:318-320),改的是**循环外的变量** ⇒ 对后续每轮持续生效;
|
|
17
|
+
* - `domain_switched` ⇒ `self._base_url = self._lark_url`(:211) 且只切一次(`domain_switched`
|
|
18
|
+
* 标志在 `_SyncFlow.run` 的循环外,:302/314-316);
|
|
19
|
+
* - 超时 ⇒ `AppExpiredError("expired_token", "polling timed out")`(:324);
|
|
20
|
+
* - 取消 ⇒ `_check_cancelled`(:269-271) 抛 `RegisterAppError("abort", "cancelled by caller")`。
|
|
21
|
+
*
|
|
22
|
+
* ══ 与 oracle 的**登记差异**(都不是偷懒,是运行时不同,且都可观测)══════════════════
|
|
23
|
+
* ① **网络层**:oracle 用 `requests.post`(同步阻塞),这里用 `globalThis.fetch`(异步)。
|
|
24
|
+
* 报文形态逐字节对齐:`urlencode(data)` 的表单体 + `application/x-www-form-urlencoded`。
|
|
25
|
+
* **不传 `signal`**:oracle 没有超时/中断机制(取消靠 `cancel_event` 在**轮次之间**生效),
|
|
26
|
+
* 加 abort 会多出一个 oracle 没有的失败面。单个 POST 挂死两侧都挂死,语义一致。
|
|
27
|
+
* ② **DEFLATE 码流不可逐字节**:`addons` 的 gzip 载荷用的是**运行时自带的 zlib**。
|
|
28
|
+
* Node v24 内置的是打过补丁的 `1.3.2.1-motley`,CPython 用的是 `1.3.2` ——
|
|
29
|
+
* 同一输入下 `deflate` 的 LZ77 解析不同 ⇒ 压缩字节不同(本机实测:305 字节明文,
|
|
30
|
+
* CPython 164B / Node 168B raw deflate)。能逐字节对齐的是**它外面和里面**:
|
|
31
|
+
* gzip 容器头(`1f8b08 00 00000000 02`,mtime=0 + XFL=2)、以及**解压后的明文**
|
|
32
|
+
* (与 oracle `_normalize_addons` + `json.dumps(ensure_ascii=False, separators=(",",":"))`
|
|
33
|
+
* 全等)。服务端吃的是明文 ⇒ 语义等价;但**不宣称码流逐字节相同**(已登记未验)。
|
|
34
|
+
* 若将来要求码流也逐字节,得自带一个 deflate 实现,不在本批范围。
|
|
35
|
+
* ③ `resp.json()` 的容错口径:oracle `requests.json()` 在**非 JSON 响应**上抛
|
|
36
|
+
* `json.JSONDecodeError`(不是 dict)⇒ 上层 `_provision_worker` 折成 `str(e)`;
|
|
37
|
+
* 这里折成空对象 ⇒ 走判定链后抛我们自己的 `RegisterAppError`。两端都**不静默成功**,
|
|
38
|
+
* 但异常类名不同(已登记;假上游路径不会走到)。
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
import { gzipSync } from 'node:zlib';
|
|
42
|
+
|
|
43
|
+
/** Device Flow 端点(registration/__init__.py:14) */
|
|
44
|
+
export const DEVICE_ENDPOINT = '/oauth/v1/app/registration';
|
|
45
|
+
/** `source` 的 SDK 名(:15);oracle 的 `_register_app_impl`(**不传** source) ⇒ 取裸 `"python-sdk"` */
|
|
46
|
+
export const SOURCE_SDK_NAME = 'python-sdk';
|
|
47
|
+
/** 飞书主域(`register_app(domain=...)` 缺省,:392) */
|
|
48
|
+
export const FEISHU_DOMAIN = 'https://accounts.feishu.cn';
|
|
49
|
+
/** Lark 主域(`register_app(lark_domain=...)` 缺省,:393);`tenant_brand == "lark"` 时切过去 */
|
|
50
|
+
export const LARK_DOMAIN = 'https://accounts.larksuite.com';
|
|
51
|
+
/** `begin_res.get("interval", 5)`(:294) */
|
|
52
|
+
export const DEFAULT_INTERVAL_S = 5;
|
|
53
|
+
/** `begin_res.get("expires_in", 600)`(:295) */
|
|
54
|
+
export const DEFAULT_EXPIRES_IN_S = 600;
|
|
55
|
+
/** `slow_down` 的步进(`interval += 5`,:319;RFC 8628 §3.5 的 MUST) */
|
|
56
|
+
export const SLOW_DOWN_STEP_S = 5;
|
|
57
|
+
/** oracle 要求 lark-oapi ≥ 1.5.5(`feishu.py:2932 _REGISTER_MIN_VERSION`),仅作文案/溯源用 */
|
|
58
|
+
export const LARK_MIN_VERSION = '1.5.5';
|
|
59
|
+
|
|
60
|
+
/** 平台所需权限清单——**唯一真源**(与 `feishu.py:1263-1269` 逐字相同,含 token 类型) */
|
|
61
|
+
export const DEVICE_REQUIRED_SCOPES = Object.freeze([
|
|
62
|
+
Object.freeze(['im:message.p2p_msg:readonly', 'tenant']),
|
|
63
|
+
Object.freeze(['im:message.group_at_msg:readonly', 'tenant']),
|
|
64
|
+
Object.freeze(['im:message:send_as_bot', 'tenant']),
|
|
65
|
+
Object.freeze(['application:app_slash_command:read', 'tenant']),
|
|
66
|
+
Object.freeze(['application:app_slash_command:write', 'tenant']),
|
|
67
|
+
]);
|
|
68
|
+
/** 事件清单(`feishu.py:1270`) */
|
|
69
|
+
export const DEVICE_REQUIRED_EVENTS = Object.freeze(['im.message.receive_v1']);
|
|
70
|
+
/** 回调清单(`feishu.py:1271`) */
|
|
71
|
+
export const DEVICE_REQUIRED_CALLBACKS = Object.freeze(['card.action.trigger']);
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* `feishu._provision_addons()`(feishu.py:2972-2982):**创建时**就把清单带上,
|
|
75
|
+
* 绕开 `application:application:patch` 冷启动;`preset=False` = 不要官方智能体模板。
|
|
76
|
+
* 返回的键序(preset/scopes/events/callbacks)即 JSON 里的键序,不许动。
|
|
77
|
+
*/
|
|
78
|
+
export function _provisionAddons() {
|
|
79
|
+
return {
|
|
80
|
+
preset: false,
|
|
81
|
+
scopes: {
|
|
82
|
+
tenant: DEVICE_REQUIRED_SCOPES.filter(([, t]) => t === 'tenant').map(([n]) => n),
|
|
83
|
+
},
|
|
84
|
+
events: { items: { tenant: [...DEVICE_REQUIRED_EVENTS] } },
|
|
85
|
+
callbacks: { items: [...DEVICE_REQUIRED_CALLBACKS] },
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* `_normalize_addons`(:41-105) 的可用子集 + `json.dumps(..., ensure_ascii=False,
|
|
91
|
+
* separators=(",", ":"))`(:109)。契约里的 addons 由本模块自己生成(结构固定),
|
|
92
|
+
* 故这里只做**键序固定**的紧凑序列化,不重写整套校验(不合法结构由上游 `_provisionAddons` 保证)。
|
|
93
|
+
* @param {object} [addons]
|
|
94
|
+
* @returns {string} UTF-8 明文 JSON
|
|
95
|
+
*/
|
|
96
|
+
export function _addonsPayload(addons) {
|
|
97
|
+
return JSON.stringify(addons === undefined ? _provisionAddons() : addons);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* `_encode_addons`(:108-112):JSON → gzip(mtime=0) → urlsafe base64 → 去掉尾部 `=`。
|
|
102
|
+
*
|
|
103
|
+
* `level: 9` 是照抄 `gzip.compress` 的缺省 `compresslevel=9`(gzip.py);`mtime: 0`
|
|
104
|
+
* 照抄调用点显式传的 `mtime=0` ⇒ 同一输入两次编码**逐字节相同**(否则 gzip 头会写墙钟时间)。
|
|
105
|
+
* 码流本身与 CPython 不同(见文件头②),但容器头与解压后明文可逐字节对拍。
|
|
106
|
+
* @param {object} [addons]
|
|
107
|
+
* @returns {string} base64url,无 `=` 补位
|
|
108
|
+
*/
|
|
109
|
+
export function _encodeAddons(addons) {
|
|
110
|
+
const compressed = gzipSync(Buffer.from(_addonsPayload(addons), 'utf8'), { mtime: 0, level: 9 });
|
|
111
|
+
return compressed.toString('base64url');
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Python `urllib.parse.quote_plus` 的**逐字节**等价物(`_build_qr_url` 里 `urlencode(params,
|
|
116
|
+
* doseq=True)` 用的就是它)。三个必须显式处理的点:
|
|
117
|
+
* 1. 安全集是 `ALWAYS_SAFE + " "`:`A-Za-z0-9_.-~` **和空格**;空格编成 `+`(不是 `%20`);
|
|
118
|
+
* 2. `encodeURIComponent` 的**不编码**集更大(多放行 `! ' ( ) *`)⇒ 必须补编;
|
|
119
|
+
* 3. 十六进制**大写**。
|
|
120
|
+
* @param {string} text
|
|
121
|
+
* @returns {string}
|
|
122
|
+
*/
|
|
123
|
+
export function _quotePlus(text) {
|
|
124
|
+
return encodeURIComponent(String(text))
|
|
125
|
+
.replace(/[!'()*]/g, (ch) => `%${ch.charCodeAt(0).toString(16).toUpperCase()}`)
|
|
126
|
+
.replace(/%20/g, '+');
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** `_quotePlus` 的逆:`unquote_plus` 形态——先 `+` 还原空格再 percent-decode */
|
|
130
|
+
function unquotePlus(text) {
|
|
131
|
+
const plus = String(text).replace(/\+/g, ' ');
|
|
132
|
+
try {
|
|
133
|
+
return decodeURIComponent(plus);
|
|
134
|
+
} catch {
|
|
135
|
+
return plus; // 非法转义:Python 亦不抛(`unquote` 保留原样)
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* `urlparse` 的可用子集(RFC 3986 的 `scheme://netloc/path;params?query#fragment`)。
|
|
141
|
+
* 只要求能**原样还原**本次要处理的 `verification_uri_complete`(HTTPS + query)。
|
|
142
|
+
*/
|
|
143
|
+
function splitUri(uri) {
|
|
144
|
+
const text = String(uri);
|
|
145
|
+
const m = /^(?:([A-Za-z][A-Za-z0-9+.-]*):)?(?:\/\/([^/?#]*))?([^?#]*)(?:\?([^#]*))?(?:#(.*))?$/
|
|
146
|
+
.exec(text);
|
|
147
|
+
if (m === null) return { prefix: '', query: '' };
|
|
148
|
+
const scheme = m[1] === undefined ? '' : `${m[1]}:`;
|
|
149
|
+
const netloc = m[2] === undefined ? '' : `//${m[2]}`;
|
|
150
|
+
const path = m[3] || '';
|
|
151
|
+
const query = m[4] === undefined ? '' : m[4];
|
|
152
|
+
const fragment = m[5] === undefined ? '' : `#${m[5]}`;
|
|
153
|
+
return { prefix: `${scheme}${netloc}${path}`, query, fragment };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* `parse_qs`(:179) 的可用子集。语义必须照抄的两点:
|
|
158
|
+
* 1. **空值被丢弃**(`keep_blank_values=False` 是缺省)⇒ `a=` / 裸 `a` 整个键都不出现;
|
|
159
|
+
* 2. 同名键按出现顺序聚成 list(`doseq=True` 再按序展开)⇒ 顺序**可还原**。
|
|
160
|
+
* @returns {Map<string, string[]>}
|
|
161
|
+
*/
|
|
162
|
+
function parseQuery(query) {
|
|
163
|
+
const out = new Map();
|
|
164
|
+
if (!query) return out;
|
|
165
|
+
for (const piece of String(query).split('&')) {
|
|
166
|
+
if (piece === '') continue;
|
|
167
|
+
const eq = piece.indexOf('=');
|
|
168
|
+
const rawKey = eq === -1 ? piece : piece.slice(0, eq);
|
|
169
|
+
const rawVal = eq === -1 ? '' : piece.slice(eq + 1);
|
|
170
|
+
if (rawVal === '') continue; // ① 丢空值
|
|
171
|
+
const key = unquotePlus(rawKey);
|
|
172
|
+
if (!out.has(key)) out.set(key, []);
|
|
173
|
+
out.get(key).push(unquotePlus(rawVal)); // ② 同名聚 list
|
|
174
|
+
}
|
|
175
|
+
return out;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* `_build_qr_url`(177-187):保留原 URI 的查询串,另加 `from=sdk` / `tp=sdk` /
|
|
180
|
+
* `source=...` / `addons=<编码清单>`,再按 `urlencode(params, doseq=True)` 重编码。
|
|
181
|
+
*
|
|
182
|
+
* 键序 = `params` 的 dict 插入序:**原查询串的键在前**(`parse_qs` 建的),
|
|
183
|
+
* 新键按 oracle 的赋值序追加(`from` → `tp` → `source` → `addons`;`app_preset` 未用)。
|
|
184
|
+
* @param {string} uri `begin_res["verification_uri_complete"]`
|
|
185
|
+
* @param {{addons?: object|null, source?: string|null}} [opts]
|
|
186
|
+
* `addons` 传 `null` ≡ oracle 的 `self._addons is None`(不带该键);
|
|
187
|
+
* `source` 是**可覆盖入口**(oracle 的 `register_app(source=...)`),缺省 `null`
|
|
188
|
+
* ⇒ 裸 `"python-sdk"`。oracle 的 `_register_app_impl` 正是缺省调用。
|
|
189
|
+
* @returns {string}
|
|
190
|
+
*/
|
|
191
|
+
export function _buildQrUrl(uri, opts = {}) {
|
|
192
|
+
const { prefix, query, fragment } = splitUri(uri);
|
|
193
|
+
const params = parseQuery(query);
|
|
194
|
+
params.set('from', ['sdk']);
|
|
195
|
+
params.set('tp', ['sdk']);
|
|
196
|
+
const source = opts.source === undefined ? null : opts.source;
|
|
197
|
+
params.set('source', [source ? `${SOURCE_SDK_NAME}/${source}` : SOURCE_SDK_NAME]);
|
|
198
|
+
const addons = opts.addons === undefined ? _provisionAddons() : opts.addons;
|
|
199
|
+
if (addons !== null) params.set('addons', [_encodeAddons(addons)]);
|
|
200
|
+
const parts = [];
|
|
201
|
+
for (const [key, values] of params) {
|
|
202
|
+
for (const value of values) parts.push(`${_quotePlus(key)}=${_quotePlus(value)}`);
|
|
203
|
+
}
|
|
204
|
+
return `${prefix}${parts.length ? `?${parts.join('&')}` : ''}${fragment}`;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** `data.get("supported_auth_methods") or []`(:278):非数组一律当空 */
|
|
208
|
+
export function supportedAuthMethods(body) {
|
|
209
|
+
const raw = body === null || typeof body !== 'object' ? undefined : body.supported_auth_methods;
|
|
210
|
+
return Array.isArray(raw) ? raw : [];
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** `errors.py`:`RegisterAppError(code, description)`,`str(e)` = `"{code}: {description}"` */
|
|
214
|
+
export class RegisterAppError extends Error {
|
|
215
|
+
constructor(code, description) {
|
|
216
|
+
super(`${code}: ${description}`);
|
|
217
|
+
this.name = 'RegisterAppError';
|
|
218
|
+
this.code = code;
|
|
219
|
+
this.description = description;
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** `AppAccessDeniedError(RegisterAppError)`(errors.py) */
|
|
224
|
+
export class AppAccessDeniedError extends RegisterAppError {
|
|
225
|
+
constructor(code, description) {
|
|
226
|
+
super(code, description);
|
|
227
|
+
this.name = 'AppAccessDeniedError';
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** `AppExpiredError(RegisterAppError)`(errors.py) */
|
|
232
|
+
export class AppExpiredError extends RegisterAppError {
|
|
233
|
+
constructor(code, description) {
|
|
234
|
+
super(code, description);
|
|
235
|
+
this.name = 'AppExpiredError';
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* `threading.Event` 的可用子集(只要 `is_set` / `set`;`_check_cancelled`(:270) 只读 `is_set`)。
|
|
241
|
+
* 别名一并给上,方便与其它移植文件里的线程态写法互认。
|
|
242
|
+
*/
|
|
243
|
+
export function createCancelEvent() {
|
|
244
|
+
let flag = false;
|
|
245
|
+
return {
|
|
246
|
+
set() { flag = true; },
|
|
247
|
+
clear() { flag = false; },
|
|
248
|
+
isSet() { return flag; },
|
|
249
|
+
is_set() { return flag; },
|
|
250
|
+
isSet_() { return flag; },
|
|
251
|
+
get cancelled() { return flag; },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/** 真时钟(ms → s,与 `time.monotonic()` 同为单位语义) */
|
|
256
|
+
function defaultNow() {
|
|
257
|
+
return Date.now() / 1000;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** 真睡觉 */
|
|
261
|
+
function defaultSleep(seconds) {
|
|
262
|
+
return new Promise((resolve) => { setTimeout(resolve, Math.max(0, seconds) * 1000); });
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** `time.sleep` 的可注入版本(单测用假时钟;生产走真 `setTimeout`) */
|
|
266
|
+
function sleepOf(proc, seconds) {
|
|
267
|
+
return (proc && proc.sleep ? proc.sleep : defaultSleep)(seconds);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* 一次 Device Flow 的可注入依赖:
|
|
272
|
+
* - `now` / `sleep`:时钟替身(单测用假时钟 ⇒ `expires_in` 兜底与 `slow_down` 间隔变成确定性读数);
|
|
273
|
+
* - `defaults`:覆盖 `begin` 响应的字段(仅测试用;协议本身不接受外部缺省)。
|
|
274
|
+
* `fetch` **不走这里**:oracle 直接调 `requests.post`,等价地这里直接调 `globalThis.fetch`
|
|
275
|
+
* 于**调用时**取(替身测试才能生效,也才能与 `tools/parity` 的 fetch 观测口一致)。
|
|
276
|
+
*/
|
|
277
|
+
function defaultProc() {
|
|
278
|
+
return { now: defaultNow, sleep: defaultSleep, defaults: null };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function asProc(proc) {
|
|
282
|
+
const base = defaultProc();
|
|
283
|
+
return {
|
|
284
|
+
now: proc && typeof proc.now === 'function' ? proc.now : base.now,
|
|
285
|
+
sleep: proc && typeof proc.sleep === 'function' ? proc.sleep : base.sleep,
|
|
286
|
+
defaults: proc && proc.defaults ? proc.defaults : null,
|
|
287
|
+
};
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/**
|
|
291
|
+
* 造一条 Device Flow(`_SyncFlow` + `register_app` 的等价物)。
|
|
292
|
+
* @param {{now?: () => number, sleep?: (s: number) => Promise<void>, defaults?: object}} [proc]
|
|
293
|
+
* @param {{addons?: object|null, source?: string|null, cancelEvent?: object|null,
|
|
294
|
+
* onQrCode?: Function|null, onStatusChange?: Function|null,
|
|
295
|
+
* domain?: string, larkDomain?: string}} [opts]
|
|
296
|
+
*/
|
|
297
|
+
export function makeDeviceFlow(proc, opts = {}) {
|
|
298
|
+
const p = asProc(proc);
|
|
299
|
+
const addons = opts.addons === undefined ? _provisionAddons() : opts.addons;
|
|
300
|
+
const source = opts.source === undefined ? null : opts.source;
|
|
301
|
+
const cancelEvent = opts.cancelEvent === undefined ? null : opts.cancelEvent;
|
|
302
|
+
const onQrCode = opts.onQrCode || null;
|
|
303
|
+
const onStatusChange = opts.onStatusChange || null;
|
|
304
|
+
const feishuDomain = opts.domain || FEISHU_DOMAIN;
|
|
305
|
+
const larkDomain = opts.larkDomain || LARK_DOMAIN;
|
|
306
|
+
const state = { baseUrl: feishuDomain };
|
|
307
|
+
|
|
308
|
+
/** `_check_cancelled`(:269-271) */
|
|
309
|
+
function checkCancelled() {
|
|
310
|
+
const set = cancelEvent !== null && cancelEvent !== undefined
|
|
311
|
+
&& (typeof cancelEvent.is_set === 'function' ? cancelEvent.is_set()
|
|
312
|
+
: typeof cancelEvent.isSet === 'function' ? cancelEvent.isSet()
|
|
313
|
+
: Boolean(cancelEvent.cancelled));
|
|
314
|
+
if (set) throw new RegisterAppError('abort', 'cancelled by caller');
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
/** `_notify_status`(:189-195):只有给了 interval 才带该键 */
|
|
318
|
+
function notifyStatus(status, interval) {
|
|
319
|
+
if (onStatusChange === null) return;
|
|
320
|
+
const info = { status };
|
|
321
|
+
if (interval !== undefined && interval !== null) info.interval = interval;
|
|
322
|
+
onStatusChange(info);
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
/** `_SyncFlow._post`(:261-267):表单 POST,`urlencode(data)` + 表单 Content-Type */
|
|
326
|
+
async function post(data) {
|
|
327
|
+
const response = await globalThis.fetch(`${state.baseUrl}${DEVICE_ENDPOINT}`, {
|
|
328
|
+
method: 'POST',
|
|
329
|
+
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
|
330
|
+
body: new URLSearchParams(data).toString(),
|
|
331
|
+
});
|
|
332
|
+
let parsed = null;
|
|
333
|
+
try {
|
|
334
|
+
parsed = await response.json();
|
|
335
|
+
} catch {
|
|
336
|
+
parsed = null; // 见文件头③
|
|
337
|
+
}
|
|
338
|
+
return parsed === null || typeof parsed !== 'object' ? {} : parsed;
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
/** `_handle_poll_response`(:197-232)。判定**顺序**是语义:成功 → 域切换 → 错误码 */
|
|
342
|
+
function handlePollResponse(data, domainSwitched) {
|
|
343
|
+
if (data.client_id && data.client_secret) {
|
|
344
|
+
const result = { client_id: data.client_id, client_secret: data.client_secret };
|
|
345
|
+
if (data.user_info) result.user_info = data.user_info; // `if data.get("user_info")`
|
|
346
|
+
return { action: 'success', result };
|
|
347
|
+
}
|
|
348
|
+
const userInfo = data.user_info || {};
|
|
349
|
+
if (userInfo.tenant_brand === 'lark' && !domainSwitched) { // 严格 `== "lark"`
|
|
350
|
+
state.baseUrl = larkDomain;
|
|
351
|
+
notifyStatus('domain_switched');
|
|
352
|
+
return { action: 'domain_switched', result: null };
|
|
353
|
+
}
|
|
354
|
+
const error = data.error === undefined || data.error === null ? '' : data.error;
|
|
355
|
+
const errorDesc = data.error_description === undefined || data.error_description === null
|
|
356
|
+
? '' : data.error_description;
|
|
357
|
+
if (error === 'authorization_pending') {
|
|
358
|
+
notifyStatus('polling');
|
|
359
|
+
return { action: 'pending', result: null };
|
|
360
|
+
}
|
|
361
|
+
if (error === 'slow_down') return { action: 'slow_down', result: null };
|
|
362
|
+
if (error === 'access_denied') throw new AppAccessDeniedError(error, errorDesc);
|
|
363
|
+
if (error === 'expired_token') throw new AppExpiredError(error, errorDesc);
|
|
364
|
+
throw new RegisterAppError(error, errorDesc);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
/** `_SyncFlow.run`(:273-325) */
|
|
368
|
+
async function run() {
|
|
369
|
+
checkCancelled(); // :274
|
|
370
|
+
|
|
371
|
+
// init(:277-283)
|
|
372
|
+
const initRes = await post({ action: 'init' });
|
|
373
|
+
const methods = supportedAuthMethods(initRes);
|
|
374
|
+
if (!methods.includes('client_secret')) {
|
|
375
|
+
throw new RegisterAppError('unsupported_auth_method',
|
|
376
|
+
'client_secret not in supported_auth_methods');
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// begin(:286-298)
|
|
380
|
+
const beginRaw = await post({
|
|
381
|
+
action: 'begin',
|
|
382
|
+
archetype: 'PersonalAgent',
|
|
383
|
+
auth_method: 'client_secret',
|
|
384
|
+
request_user_info: 'open_id',
|
|
385
|
+
});
|
|
386
|
+
const beginRes = p.defaults ? { ...p.defaults, ...beginRaw } : beginRaw;
|
|
387
|
+
const deviceCode = beginRes.device_code;
|
|
388
|
+
const interval0 = beginRes.interval === undefined ? DEFAULT_INTERVAL_S : beginRes.interval;
|
|
389
|
+
const expireIn = beginRes.expires_in === undefined ? DEFAULT_EXPIRES_IN_S : beginRes.expires_in;
|
|
390
|
+
const qrUrl = _buildQrUrl(beginRes.verification_uri_complete, { addons, source });
|
|
391
|
+
if (onQrCode !== null) onQrCode({ url: qrUrl, expire_in: expireIn });
|
|
392
|
+
|
|
393
|
+
// poll(:301-324)
|
|
394
|
+
let interval = interval0;
|
|
395
|
+
const deadline = p.now() + expireIn;
|
|
396
|
+
let domainSwitched = false;
|
|
397
|
+
while (p.now() < deadline) {
|
|
398
|
+
checkCancelled(); // :305
|
|
399
|
+
const pollRes = await post({ action: 'poll', device_code: deviceCode });
|
|
400
|
+
const { action, result } = handlePollResponse(pollRes, domainSwitched);
|
|
401
|
+
if (action === 'success') return result;
|
|
402
|
+
if (action === 'domain_switched') {
|
|
403
|
+
domainSwitched = true;
|
|
404
|
+
continue; // 切域当轮不吃 sleep(:314-316)
|
|
405
|
+
}
|
|
406
|
+
if (action === 'slow_down') {
|
|
407
|
+
interval += SLOW_DOWN_STEP_S; // :319(对后续每轮持续生效)
|
|
408
|
+
notifyStatus('slow_down', interval);
|
|
409
|
+
}
|
|
410
|
+
await sleepOf(p, interval); // :322
|
|
411
|
+
}
|
|
412
|
+
throw new AppExpiredError('expired_token', 'polling timed out'); // :324
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
return { run, state, checkCancelled, post, handlePollResponse, notifyStatus };
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/**
|
|
419
|
+
* `lark_oapi.register_app` 的等价入口(:387-411):建一条流程并跑到底。
|
|
420
|
+
* @param {{addons?: object|null, source?: string|null, cancelEvent?: object|null,
|
|
421
|
+
* onQrCode?: Function|null, onStatusChange?: Function|null,
|
|
422
|
+
* domain?: string, larkDomain?: string}} [opts]
|
|
423
|
+
* @param {{now?: Function, sleep?: Function, defaults?: object}} [proc]
|
|
424
|
+
* @returns {Promise<{client_id: string, client_secret: string, user_info?: object}>}
|
|
425
|
+
*/
|
|
426
|
+
export function registerApp(opts = {}, proc) {
|
|
427
|
+
return makeDeviceFlow(proc, opts).run();
|
|
428
|
+
}
|