@gavinc-cn/touchstone-dsh 0.1.1 → 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 (90) hide show
  1. package/README.md +39 -11
  2. package/README.zh.md +25 -10
  3. package/backend/lib/access.js +44 -0
  4. package/backend/lib/agents.js +341 -0
  5. package/backend/lib/args.js +94 -0
  6. package/backend/lib/auth.js +195 -0
  7. package/backend/lib/board.js +6154 -0
  8. package/backend/lib/builtin-assets.js +1177 -0
  9. package/backend/lib/casescan.js +456 -0
  10. package/backend/lib/chat.js +2053 -0
  11. package/backend/lib/cookie.js +76 -0
  12. package/backend/lib/db.js +2260 -0
  13. package/backend/lib/dshdriver.js +1148 -0
  14. package/backend/lib/dshevents.js +851 -0
  15. package/backend/lib/export-cases.js +261 -0
  16. package/backend/lib/feishu.js +1738 -0
  17. package/backend/lib/feishu_cfg.js +228 -0
  18. package/backend/lib/feishu_conv.js +1484 -0
  19. package/backend/lib/feishu_device.js +428 -0
  20. package/backend/lib/feishu_inbound.js +1674 -0
  21. package/backend/lib/feishu_provision.js +855 -0
  22. package/backend/lib/feishu_rest.js +398 -0
  23. package/backend/lib/feishu_slash.js +568 -0
  24. package/backend/lib/feishu_ws.js +950 -0
  25. package/backend/lib/http.js +312 -0
  26. package/backend/lib/lib.js +973 -0
  27. package/backend/lib/loadcase.js +749 -0
  28. package/backend/lib/loadgen.js +1407 -0
  29. package/backend/lib/localbus.js +164 -0
  30. package/backend/lib/lock.js +405 -0
  31. package/backend/lib/proc.js +278 -0
  32. package/backend/lib/prompts.js +1021 -0
  33. package/backend/lib/pybase64.js +114 -0
  34. package/backend/lib/pyjson.js +214 -0
  35. package/backend/lib/query.js +125 -0
  36. package/backend/lib/rag.js +1310 -0
  37. package/backend/lib/runner.js +4520 -0
  38. package/backend/lib/servercaps.js +182 -0
  39. package/backend/lib/serverstate.js +237 -0
  40. package/backend/lib/sessparse.js +1176 -0
  41. package/backend/lib/sse.js +207 -0
  42. package/backend/lib/static.js +84 -0
  43. package/backend/lib/taskjson.js +302 -0
  44. package/backend/lib/waitq.js +2114 -0
  45. package/backend/lib/worktree.js +588 -0
  46. package/backend/lib/zstd-frames.js +218 -0
  47. package/backend/routes/admin.js +553 -0
  48. package/backend/routes/agents.js +149 -0
  49. package/backend/routes/assets.js +145 -0
  50. package/backend/routes/board.js +1220 -0
  51. package/backend/routes/feishu.js +269 -0
  52. package/backend/routes/feishu_app.js +215 -0
  53. package/backend/routes/feishu_hook.js +358 -0
  54. package/backend/routes/index.js +297 -0
  55. package/backend/routes/projects.js +1059 -0
  56. package/backend/routes/sessions.js +946 -0
  57. package/backend/routes/tasks.js +1530 -0
  58. package/backend/schema/fixtures/ancient.sql +189 -0
  59. package/backend/schema/fixtures/pre_alter.sql +177 -0
  60. package/backend/schema/migrations.json +154 -0
  61. package/backend/schema/schema.json +1451 -0
  62. package/backend/schema/schema.sql +216 -0
  63. package/backend/server.js +498 -0
  64. package/board.py +805 -72
  65. package/chat.py +590 -17
  66. package/db.py +356 -140
  67. package/dsh-plugin/cordis.patch.yml +7 -3
  68. package/dsh-plugin/install.sh +28 -11
  69. package/dsh-plugin/lib/agent-driver.js +710 -89
  70. package/dsh-plugin/lib/client.bundle.js +94 -9
  71. package/dsh-plugin/lib/index.js +529 -39
  72. package/dsh-plugin/lib/pack-closure.mjs +291 -0
  73. package/dsh-plugin/scripts/prepack-check.mjs +30 -2
  74. package/dsh-plugin/src/client.js +94 -9
  75. package/dshdriver.py +74 -5
  76. package/dshevents.py +65 -3
  77. package/feishu.py +1197 -34
  78. package/feishu_conv.py +920 -0
  79. package/lifecycle.py +116 -13
  80. package/package.json +6 -1
  81. package/requirements.txt +2 -1
  82. package/server.py +218 -26
  83. package/sessparse.py +150 -44
  84. package/touchstone.py +6 -3
  85. package/waitq.py +23 -0
  86. package/webui/dist-plugin/assets/index-ChP3sg6y.css +1 -0
  87. package/webui/dist-plugin/assets/index-UrMyeAXF.js +484 -0
  88. package/webui/dist-plugin/index.html +2 -2
  89. package/webui/dist-plugin/assets/index-B0WoP1Qy.css +0 -1
  90. package/webui/dist-plugin/assets/index-DtwKoi1T.js +0 -473
@@ -0,0 +1,398 @@
1
+ /**
2
+ * 飞书 REST 客户端(M7-R4;`feishu.py:1039-1183` 的对应物)。
3
+ *
4
+ * 逐点对齐 oracle(行号即 `feishu.py`):
5
+ * - `FeishuRestError`(:1039) —— 带 `code` 字段(飞书业务码;拿不到业务码时为 `None`/`null`);
6
+ * - `_TOKEN_CACHE`(:1049) / `tenantToken`(:1072 `_tenant_token`) —— 按 app_id 分桶缓存,
7
+ * 过期前 5 分钟刷新(`expire = 取值时刻 + expire - 300`,**取值时刻在请求之前**);
8
+ * - `_BOT_OPEN_ID`(:1050) / `botOpenId`(:1176 `bot_open_id`);
9
+ * - `rest`(:1095 `_rest`) —— 统一鉴权、路径规范化、非 JSON/非 dict 重试 3 次;
10
+ * - `restSendText`/`restSendCard`/`restReply`/`restPatchText`(:1140-1173)。
11
+ *
12
+ * 四条**实测契约**(照抄,不许「顺手修正」):
13
+ * ① `_rest` **只看 `body.code`,不看 HTTP 状态码** ⇒ HTTP 500 + 合法 JSON `{"code":0}`
14
+ * 一样当成功返回(oracle 实测)。同理 HTTP 200 + `code!=0` 抛错。**代码里没有
15
+ * `resp.ok` / `resp.status` 判定**,`resp.status` 只出现在非 JSON 留痕文案里。
16
+ * ② 路径规范化(:1105-1107):`path = "/" + path.lstrip("/")`,再判 `startswith("/open-apis")`
17
+ * ⇒ 调用方带不带前缀都恰好一层(历史上多拼一层打到 `/open-apis/open-apis/...`,
18
+ * 网关返纯文本 404,异常被吞成「机器人毫无反应」)。
19
+ * ③ 非 JSON / 非 dict 响应 = 网关粘连/截断,往 stderr 留痕后**重试**,最多 3 次;
20
+ * 3 次后抛 `FeishuRestError`,文案 `REST {method} {path} {err_hint}(已重试 3 次)`。
21
+ * `code != 0` 立即抛 `REST {path} code={code} {msg}`(**不重试**)。
22
+ * ④ token 请求体走 `pyJsonDumps`(Python `requests` 的 `json=` 序列化 = CPython 默认
23
+ * 分隔符 `", "` / `": "`)⇒ 与 oracle 逐字节一致;超时 10s(:1083 硬编码)。
24
+ *
25
+ * 与 Python 的形态差异(有意,逐条登记):
26
+ * - 全部函数 async(Node 无同步 HTTP);返回 Promise。
27
+ * - `requests` → `globalThis.fetch`(调用时才取,便于测试替身/对拍缝)。**副作用**:
28
+ * 真机请求头与会话形态与 requests 不同(undici 会自己加 `accept`/`accept-encoding`/
29
+ * `user-agent: undici`,requests 加 `User-Agent: python-requests/…`/`Accept-Encoding`)——
30
+ * 属对拍计划 §四 R2 已登记的跨运行时差异,靠归一表处理,不在本文件里伪装。
31
+ * - 超时用 `AbortSignal.timeout(秒*1000)`。异常文案的 `{e}` 逐字不同(`token 请求失败: {e}`、
32
+ * `REST {method} {path} 失败: {e}` 的**外壳逐字相同**,内层报错文本是运行时自带的:
33
+ * `fetch failed` vs `[Errno 111] Connection refused`)——同样登记为已知分歧。
34
+ * - 响应体**读取**失败(`resp.text()` reject):Python 侧 `r.json()` 内部的 OSError 会**穿透**
35
+ * `_rest`(不被包装),这里归进 `REST {method} {path} 失败: {e}` 桶(形状同连接失败,
36
+ * 不谎称是「非 JSON 响应」)。已知分歧,未验。
37
+ * - 查询串自实现 `pyQuotePlus`(= `urllib.parse.quote_plus`,UTF-8 字节级、空格 `+`、
38
+ * `~` 不转义);**不用** `URLSearchParams`(它把 `~` 编成 `%7E`,与 oracle 分叉)。
39
+ * - `str()` 口径:`None` → `'None'`、`True/False` → `'True'/'False'`(文案里逐字出现)。
40
+ *
41
+ * 零 npm 依赖(只用 `./pyjson.js`)。
42
+ */
43
+ import { pyJsonDumps } from './pyjson.js';
44
+
45
+ /** 飞书开放平台 origin(`feishu.py:1081/1108` 硬编码;**无 env 覆盖点**,别加) */
46
+ export const FEISHU_HOST = 'https://open.feishu.cn';
47
+
48
+ /**
49
+ * 开发者后台应用页 origin(`feishu.py:1511-1512` 的 `https://open.feishu.cn/app/{app_id}`)。
50
+ * 与 `FEISHU_HOST` 是**同一字节串**:Python 没有这个常量(两处硬编码),这里收口成一个名字,
51
+ * 给斜杠权限卡与后续 R5 供给族共用。若判断错:只影响 `FEISHU_DOMAIN` 这个名字的取值口径。
52
+ */
53
+ export const FEISHU_DOMAIN = 'https://open.feishu.cn';
54
+
55
+ /** tenant_access_token 请求超时(秒;`feishu.py:1083` 硬编码 10) */
56
+ const TOKEN_TIMEOUT_S = 10;
57
+
58
+ /** `_rest` 网络异常外的重试次数(`feishu.py:1110` 的 `for attempt in (1, 2, 3)`) */
59
+ const REST_ATTEMPTS = 3;
60
+
61
+ /**
62
+ * 飞书 REST 调用失败(token 获取 / 业务 code 非 0 / 网络异常 / 连续非 JSON 响应)。
63
+ * `code` = 飞书业务码(99991640 缺权限、40000000 业务约束、40000031 图标非法……),
64
+ * 网络异常/非 JSON 这类拿不到业务码的情形为 `null`(对齐 `feishu.py:1039-1046`)。
65
+ */
66
+ export class FeishuRestError extends Error {
67
+ /**
68
+ * @param {string} message
69
+ * @param {any} [code] 飞书业务码;缺省 `null`(Python `None`)
70
+ */
71
+ constructor(message, code = null) {
72
+ super(message);
73
+ this.name = 'FeishuRestError';
74
+ this.code = code;
75
+ }
76
+ }
77
+
78
+ /**
79
+ * `app_id -> {token, expire}`(epoch 秒)。对齐 `feishu._TOKEN_CACHE`(feishu.py:1049):
80
+ * **模块级可变 dict**,导出是为了让测试/对拍能 `clear()` 做隔离(Python 侧同样可 poke)。
81
+ * @type {Map<string, {token: string, expire: number}>}
82
+ */
83
+ export const _TOKEN_CACHE = new Map();
84
+
85
+ /**
86
+ * `app_id -> 机器人自身 open_id`。对齐 `feishu._BOT_OPEN_ID`(feishu.py:1050),同上可清。
87
+ * @type {Map<string, string|null>}
88
+ */
89
+ export const _BOT_OPEN_ID = new Map();
90
+
91
+ /** Python `dict.get(k, d)`:键存在但值为 None 时返回 None(不是 d) */
92
+ function dictGet(dict, key, dflt = undefined) {
93
+ return Object.hasOwn(dict, key) ? dict[key] : dflt;
94
+ }
95
+
96
+ /** Python 真值口径(`""`/`0`/`None`/`False`/空容器为假) */
97
+ function pyTruthy(value) {
98
+ if (Array.isArray(value)) return value.length > 0;
99
+ if (value !== null && typeof value === 'object') return Object.keys(value).length > 0;
100
+ return Boolean(value);
101
+ }
102
+
103
+ /** Python `str(x)` 的可用子集(错误文案/查询串里逐字出现;浮点偏差见 pyjson.js 文件头) */
104
+ function pyStr(value) {
105
+ if (value === null || value === undefined) return 'None';
106
+ if (value === true) return 'True';
107
+ if (value === false) return 'False';
108
+ return String(value);
109
+ }
110
+
111
+ /** Python `x == 0`:`False == 0`、`0.0 == 0` 都为真(`!==` 会把 `false` 误判成非 0) */
112
+ function pyEqZero(value) {
113
+ if (typeof value === 'boolean') return value === false;
114
+ if (typeof value === 'bigint') return value === 0n;
115
+ return value === 0;
116
+ }
117
+
118
+ /** `urllib.parse.quote_plus`(UTF-8 字节级;空格 `+`;`A-Za-z0-9_.-~` 原样) */
119
+ function pyQuotePlus(value) {
120
+ let out = '';
121
+ for (const byte of Buffer.from(pyStr(value), 'utf8')) {
122
+ if ((byte >= 0x41 && byte <= 0x5a) || (byte >= 0x61 && byte <= 0x7a)
123
+ || (byte >= 0x30 && byte <= 0x39) || byte === 0x5f || byte === 0x2e
124
+ || byte === 0x2d || byte === 0x7e) {
125
+ out += String.fromCharCode(byte);
126
+ } else if (byte === 0x20) {
127
+ out += '+';
128
+ } else {
129
+ out += '%' + byte.toString(16).toUpperCase().padStart(2, '0');
130
+ }
131
+ }
132
+ return out;
133
+ }
134
+
135
+ /** `requests` 的 `params=` 编码(dict → `urlencode`;顺序即插入序) */
136
+ function encodeParams(params) {
137
+ if (params === null || params === undefined) return '';
138
+ const parts = [];
139
+ for (const key of Object.keys(params)) {
140
+ parts.push(`${pyQuotePlus(key)}=${pyQuotePlus(params[key])}`);
141
+ }
142
+ return parts.join('&');
143
+ }
144
+
145
+ /** Python `repr(bytes)` 的可用子集(非 JSON 留痕用:`b'...'`,非可打印/非 ASCII 走 `\xNN`) */
146
+ function pyBytesRepr(text, limit = 200) {
147
+ const buf = Buffer.from(text, 'utf8').subarray(0, limit);
148
+ let out = "b'";
149
+ for (const byte of buf) {
150
+ if (byte === 0x5c) out += '\\\\';
151
+ else if (byte === 0x27) out += "\\'";
152
+ else if (byte === 0x09) out += '\\t';
153
+ else if (byte === 0x0a) out += '\\n';
154
+ else if (byte === 0x0d) out += '\\r';
155
+ else if (byte >= 0x20 && byte < 0x7f) out += String.fromCharCode(byte);
156
+ else out += '\\x' + byte.toString(16).padStart(2, '0');
157
+ }
158
+ return out + "'";
159
+ }
160
+
161
+ /** Python `repr(str)` 的可用子集(非 dict 留痕用:单引号串;超 200 **字符**截断,同 oracle) */
162
+ function pyStrRepr(text, limit = 200) {
163
+ const chars = Array.from(text).slice(0, limit);
164
+ let out = "'";
165
+ for (const ch of chars) {
166
+ if (ch === '\\') out += '\\\\';
167
+ else if (ch === "'") out += "\\'";
168
+ else if (ch === '\t') out += '\\t';
169
+ else if (ch === '\n') out += '\\n';
170
+ else if (ch === '\r') out += '\\r';
171
+ else if (ch.codePointAt(0) < 0x20) out += '\\x' + ch.codePointAt(0).toString(16).padStart(2, '0');
172
+ else out += ch;
173
+ }
174
+ return out + "'";
175
+ }
176
+
177
+ /** 线程安全的 stderr 留痕(Python `print(..., file=sys.stderr, flush=True)`) */
178
+ function stderrLine(line) {
179
+ process.stderr.write(`${line}\n`);
180
+ }
181
+
182
+ /** 异常 → 文案(Python `f"{e}"` = `str(e)`;`Error` 取 `.message`,其余 `String()`) */
183
+ function errText(error) {
184
+ if (error instanceof Error) return error.message;
185
+ return pyStr(error);
186
+ }
187
+
188
+ /** 请求 init(Python `requests.request(..., headers={...}, json=..., timeout=...)` 的对位) */
189
+ function requestInit({ method, jsonBody, token, timeout }) {
190
+ const init = {
191
+ method,
192
+ headers: {
193
+ Authorization: `Bearer ${token}`,
194
+ 'Content-Type': 'application/json',
195
+ },
196
+ };
197
+ // Python `json=None` ⇒ requests 不序列化请求体(≠ `json={}`)
198
+ if (jsonBody !== null && jsonBody !== undefined) init.body = pyJsonDumps(jsonBody);
199
+ if (timeout !== null && timeout !== undefined) init.signal = AbortSignal.timeout(timeout * 1000);
200
+ return init;
201
+ }
202
+
203
+ /**
204
+ * tenant_access_token(按 app_id 分桶缓存,过期前 5 分钟刷新)。
205
+ * 对齐 `feishu._tenant_token`(feishu.py:1072-1092)。
206
+ *
207
+ * 失败口径:网络/解析失败 ⇒ `FeishuRestError('token 请求失败: {e}')`(code `null`);
208
+ * 业务码非 0 ⇒ `FeishuRestError('token 获取失败 code={code} {msg}', code)`(**不写缓存**)。
209
+ * @param {{app_id?: string, app_secret?: string}} cfg
210
+ * @returns {Promise<string>}
211
+ */
212
+ export async function tenantToken(cfg) {
213
+ const appId = (cfg && cfg.app_id) || '';
214
+ const now = Date.now() / 1000; // Python `time.time()`(取值在请求之前)
215
+ const cached = _TOKEN_CACHE.get(appId) || {};
216
+ if (pyTruthy(cached.token) && now < dictGet(cached, 'expire', 0)) return cached.token;
217
+ let out;
218
+ try {
219
+ const resp = await globalThis.fetch(
220
+ `${FEISHU_HOST}/open-apis/auth/v3/tenant_access_token/internal`,
221
+ {
222
+ method: 'POST',
223
+ headers: { 'Content-Type': 'application/json' },
224
+ body: pyJsonDumps({
225
+ app_id: appId,
226
+ app_secret: dictGet(cfg || {}, 'app_secret', ''),
227
+ }),
228
+ signal: AbortSignal.timeout(TOKEN_TIMEOUT_S * 1000),
229
+ },
230
+ );
231
+ out = await resp.json();
232
+ } catch (error) {
233
+ throw new FeishuRestError(`token 请求失败: ${errText(error)}`);
234
+ }
235
+ if (!pyEqZero(dictGet(out, 'code'))) {
236
+ throw new FeishuRestError(
237
+ `token 获取失败 code=${pyStr(dictGet(out, 'code'))} ${pyStr(dictGet(out, 'msg', ''))}`,
238
+ dictGet(out, 'code', null),
239
+ );
240
+ }
241
+ if (!Object.hasOwn(out, 'tenant_access_token')) {
242
+ // Python 这里是 `out["tenant_access_token"]` ⇒ KeyError(不是 FeishuRestError)
243
+ throw new Error("KeyError: 'tenant_access_token'");
244
+ }
245
+ // 键存在但为 null 时 Python 抛 TypeError(`float + None`);这里 null 折 0。
246
+ // 不可达分支(真响应恒带 int),登记为已知分歧。
247
+ _TOKEN_CACHE.set(appId, {
248
+ token: out.tenant_access_token,
249
+ expire: now + dictGet(out, 'expire', 7200) - 300,
250
+ });
251
+ return out.tenant_access_token;
252
+ }
253
+
254
+ /**
255
+ * 飞书 REST 统一调用(按用户应用 cfg 鉴权)。对齐 `feishu._rest`(feishu.py:1095-1137)。
256
+ *
257
+ * 路径规范化:调用方带/不带 `/open-apis` 前缀均恰好拼出一层。业务码非 0 立即抛;
258
+ * 非 JSON/非 dict 响应留痕原始字节后重试,最多 3 次。
259
+ * @param {string} method HTTP 方法(Python 侧直传 requests)
260
+ * @param {string} path 形如 `/open-apis/im/v1/messages` 或 `im/v1/messages`
261
+ * @param {{params?: object, jsonBody?: any, cfg?: object, timeout?: number|null}} [options]
262
+ * `timeout` 秒;`null` = 不限时(Python `None`),缺省 10。
263
+ * @returns {Promise<object>} 解析后的响应 dict(**不看 HTTP 状态码**,见文件头 ①)
264
+ * @throws {FeishuRestError} cfg 缺失 / 网络异常 / 非 JSON 连续 3 次 / 业务码非 0
265
+ */
266
+ export async function rest(method, path, { params, jsonBody, cfg, timeout = 10 } = {}) {
267
+ if (cfg === null || cfg === undefined) {
268
+ throw new FeishuRestError('应用未配置(app_id/app_secret 缺失)');
269
+ }
270
+ let norm = `/${String(path).replace(/^\/+/, '')}`;
271
+ if (!norm.startsWith('/open-apis')) norm = `/open-apis${norm}`;
272
+ const query = encodeParams(params);
273
+ const url = `${FEISHU_HOST}${norm}${query ? `?${query}` : ''}`;
274
+ let errHint = '';
275
+ for (let attempt = 1; attempt <= REST_ATTEMPTS; attempt += 1) {
276
+ // token 在 try 之外取:Python 侧 `_tenant_token` 抛的 FeishuRestError 不属
277
+ // `except (OSError, ValueError)` ⇒ 穿透 `_rest`,这里同样不包装。
278
+ const token = await tenantToken(cfg);
279
+ let resp;
280
+ try {
281
+ resp = await globalThis.fetch(url, requestInit({ method, jsonBody, token, timeout }));
282
+ } catch (error) {
283
+ throw new FeishuRestError(`REST ${method} ${norm} 失败: ${errText(error)}`);
284
+ }
285
+ let raw;
286
+ try {
287
+ raw = await resp.text();
288
+ } catch (error) {
289
+ throw new FeishuRestError(`REST ${method} ${norm} 失败: ${errText(error)}`);
290
+ }
291
+ let out;
292
+ try {
293
+ out = JSON.parse(raw);
294
+ } catch {
295
+ stderrLine(`feishu rest: 非 JSON 响应(${attempt}/${REST_ATTEMPTS}) ${method} ${norm} `
296
+ + `status=${resp.status} raw=${pyBytesRepr(raw)}`);
297
+ errHint = `响应非 JSON status=${resp.status}`;
298
+ continue;
299
+ }
300
+ if (out === null || typeof out !== 'object' || Array.isArray(out)) {
301
+ stderrLine(`feishu rest: 非 dict 响应(${attempt}/${REST_ATTEMPTS}) ${method} ${norm} `
302
+ + `raw=${pyStrRepr(raw)}`);
303
+ errHint = '响应非对象';
304
+ continue;
305
+ }
306
+ if (!pyEqZero(dictGet(out, 'code'))) {
307
+ throw new FeishuRestError(
308
+ `REST ${norm} code=${pyStr(dictGet(out, 'code'))} ${pyStr(dictGet(out, 'msg', ''))}`,
309
+ dictGet(out, 'code', null),
310
+ );
311
+ }
312
+ return out;
313
+ }
314
+ throw new FeishuRestError(`REST ${method} ${norm} ${errHint}(已重试 ${REST_ATTEMPTS} 次)`);
315
+ }
316
+
317
+ /**
318
+ * 主动发单聊文本。对齐 `feishu.rest_send_text`(feishu.py:1140-1146)。
319
+ * @param {string} openId 接收方在该应用下的 open_id
320
+ * @param {string} text
321
+ * @param {object} cfg 用户应用凭据
322
+ * @returns {Promise<void>} Python 侧隐式 `None`
323
+ */
324
+ export async function restSendText(openId, text, cfg) {
325
+ await rest('POST', '/open-apis/im/v1/messages', {
326
+ params: { receive_id_type: 'open_id' },
327
+ jsonBody: {
328
+ receive_id: openId,
329
+ msg_type: 'text',
330
+ content: pyJsonDumps({ text }),
331
+ },
332
+ cfg,
333
+ });
334
+ }
335
+
336
+ /**
337
+ * 主动发单聊交互卡片(schema 2.0 dict)。对齐 `feishu.rest_send_card`(feishu.py:1149-1157):
338
+ * `content` 直接传卡片 JSON 串;`{"data": …}` 包裹会被 230099 拒绝(2026-09-27 spike 实测)。
339
+ * @param {string} openId
340
+ * @param {object} card
341
+ * @param {object} cfg
342
+ * @returns {Promise<void>}
343
+ */
344
+ export async function restSendCard(openId, card, cfg) {
345
+ await rest('POST', '/open-apis/im/v1/messages', {
346
+ params: { receive_id_type: 'open_id' },
347
+ jsonBody: {
348
+ receive_id: openId,
349
+ msg_type: 'interactive',
350
+ content: pyJsonDumps(card),
351
+ },
352
+ cfg,
353
+ });
354
+ }
355
+
356
+ /**
357
+ * 回复触发消息(带线程上下文)。对齐 `feishu.rest_reply`(feishu.py:1160-1165)。
358
+ * @param {string} messageId
359
+ * @param {string} text
360
+ * @param {object} cfg
361
+ * @returns {Promise<void>}
362
+ */
363
+ export async function restReply(messageId, text, cfg) {
364
+ await rest('POST', `/open-apis/im/v1/messages/${messageId}/reply`, {
365
+ jsonBody: { msg_type: 'text', content: pyJsonDumps({ text }) },
366
+ cfg,
367
+ });
368
+ }
369
+
370
+ /**
371
+ * 原地补丁 bot 已发消息(「占位-补丁」回执用)。对齐 `feishu.rest_patch_text`(feishu.py:1168-1173):
372
+ * 方法是 **PUT**(假上游路由表 PATCH/PUT 都收,见 `tools/parity/probe/fake_feishu.mjs`)。
373
+ * @param {string} messageId
374
+ * @param {string} text
375
+ * @param {object} cfg
376
+ * @returns {Promise<void>}
377
+ */
378
+ export async function restPatchText(messageId, text, cfg) {
379
+ await rest('PUT', `/open-apis/im/v1/messages/${messageId}`, {
380
+ jsonBody: { msg_type: 'text', content: pyJsonDumps({ text }) },
381
+ cfg,
382
+ });
383
+ }
384
+
385
+ /**
386
+ * 机器人自身 open_id(`/bot/v3/info`,按 app_id 缓存一次)。对齐 `feishu.bot_open_id`(feishu.py:1176-1183)。
387
+ * 取不到时缓存 `null`(Python `None`)并原样返回。
388
+ * @param {object} cfg
389
+ * @returns {Promise<string|null>}
390
+ */
391
+ export async function botOpenId(cfg) {
392
+ const appId = (cfg && cfg.app_id) || '';
393
+ if (pyTruthy(_BOT_OPEN_ID.get(appId))) return _BOT_OPEN_ID.get(appId);
394
+ const out = await rest('GET', '/open-apis/bot/v3/info', { cfg });
395
+ const bot = out.bot || {};
396
+ _BOT_OPEN_ID.set(appId, dictGet(bot, 'open_id', null));
397
+ return _BOT_OPEN_ID.get(appId);
398
+ }