@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,950 @@
1
+ /**
2
+ * 飞书长连接(pbbp2.Frame ↔ WebSocket)**零依赖**实现。
3
+ *
4
+ * 契约来源是量出来的,不是查文档:
5
+ * - 帧结构 `lark_oapi.ws.pb.pbbp2_pb2.Frame`(proto2):`SeqID(1)/LogID(2)` uint64、
6
+ * `service(3)/method(4)` int32、`headers(5)` repeated `Header{key=1,value=2}`(**两个子字段都是
7
+ * required**)、`payload_encoding(6)/payload_type(7)/LogIDNew(9)` optional string、
8
+ * `payload(8)` optional bytes。
9
+ * - 量具一(逐字节):`tools/parity/probe/pb_frames.py` 用**真 protobuf** 生成 fixture,
10
+ * `tools/parity/test/pbbp2.test.mjs` 双向断言(编码逐字节 / 解码后再编码回到原字节)。
11
+ * - 量具二(客户端):`tools/parity/probe/fake_ws_gateway.py` + `tools/parity/probe/ws_selftest.py`
12
+ * (独立实现的 Python 服务端,逐条校验 ping/合包/ACK)。
13
+ *
14
+ * proto2 的四个坑(每条都有量具钉住,别按直觉"优化"):
15
+ * 1. required **取 0 也必须上线**:Python `Frame()` 缺 required 时 `SerializeToString()` 抛
16
+ * `EncodeError: Message pbbp2.Frame is missing required fields: ...`
17
+ * ⇒ 本文件 `encodeFrame` 缺 required 同样抛。
18
+ * 2. `payload` **未设** vs **显式空 bytes** 是两种线上字节(后者是 `42 00`):JS 侧 `null`/`undefined`
19
+ * 表示未设、`Buffer.alloc(0)` 表示显式空。空 payload 塌成 null 会让 ACK 少两个字节。
20
+ * 3. `Header.key/value` 也是 **required**:显式空串照样写(`0a 00 12 00`);而 Python 里
21
+ * "`headers.add()` 之后什么都不设"会抛 EncodeError(实测)⇒ 本文件要求每个 header 两个字段都在。
22
+ * 4. `SeqID/LogID` 是 uint64:> 2^53 时 JS Number 会**静默丢精度** ⇒ 解码给 BigInt,
23
+ * 编码**拒绝**不安全的 Number(要求 BigInt 或十进制字符串)。
24
+ *
25
+ * 实测行为(2026-10-15,`Frame.DESCRIPTOR` + 逐字节探针,全部与 Python 对过):
26
+ * - 编码按**字段号升序**规范化(乱序输入解析后再序列化回到字段号顺序);
27
+ * - **未知字段会被保留**,追加在**所有已知字段之后**(按遇到顺序),wire type 0/1/2/5 都保留;
28
+ * - 非 repeated 字段重复出现 ⇒ **后者胜**(重复的 payload 只留最后一个);
29
+ * - `ParseFromString` 对"缺 required"是**宽容**的(不抛,`IsInitialized()==False`),只在
30
+ * `SerializeToString` 时抛 ⇒ 本文件 decode 同样宽容(缺的记进 `_missingRequired`),encode 才抛;
31
+ * - wire type 3/4(group)Python 抛 `DecodeError: Truncated message.` ⇒ 本文件抛;
32
+ * - 截断的 varint/长度前缀 Python 抛 `DecodeError` ⇒ 本文件抛。
33
+ *
34
+ * 已知差异(已登记,不许悄悄"补上"):
35
+ * - 未知字段的**相对位置**:Python 追加到末尾,本文件同样追加到末尾,但 Python 是
36
+ * "已知字段按字段号 + 未知字段按遇到顺序",本文件完全同构;两者对**同一条输入**输出一致(已测)。
37
+ * - 缺 required 的帧:Python 解析成功、序列化抛;本文件解码成功并记 `_missingRequired`,
38
+ * 编码时抛 —— 形状一致(都是"解析宽容、序列化严格"),但**错误文案不同**(跨运行时文案本就不对拍)。
39
+ */
40
+
41
+ // ACK 的 payload 走 `pyJsonDumps`(Python `json.dumps(ensure_ascii=False)` 的默认分隔符 `", "`/`": "`):
42
+ // 用 `JSON.stringify` 少两个空格就是逐字节分叉(`{"code":200}` vs `{"code": 200}`)。
43
+ import { pyJsonDumps } from './pyjson.js';
44
+
45
+ /** 帧方法(`lark_oapi.ws.enum.FrameType`) */
46
+ export const FRAME_TYPE_CONTROL = 0;
47
+ export const FRAME_TYPE_DATA = 1;
48
+
49
+ /** 头里的 `type` 取值(`lark_oapi.ws.enum.MessageType`) */
50
+ export const MESSAGE_TYPE_PING = 'ping';
51
+ export const MESSAGE_TYPE_PONG = 'pong';
52
+ export const MESSAGE_TYPE_EVENT = 'event';
53
+ export const MESSAGE_TYPE_CARD = 'card';
54
+
55
+ /** 头名(`lark_oapi.ws.const`) */
56
+ export const HEADER_TYPE = 'type';
57
+ export const HEADER_MESSAGE_ID = 'message_id';
58
+ export const HEADER_SUM = 'sum';
59
+ export const HEADER_SEQ = 'seq';
60
+ export const HEADER_TRACE_ID = 'trace_id';
61
+ export const HEADER_BIZ_RT = 'biz_rt';
62
+
63
+ /** 字段号(写死在 proto 里,改不了) */
64
+ const F_SEQ_ID = 1;
65
+ const F_LOG_ID = 2;
66
+ const F_SERVICE = 3;
67
+ const F_METHOD = 4;
68
+ const F_HEADERS = 5;
69
+ const F_PAYLOAD_ENCODING = 6;
70
+ const F_PAYLOAD_TYPE = 7;
71
+ const F_PAYLOAD = 8;
72
+ const F_LOG_ID_NEW = 9;
73
+
74
+ const WT_VARINT = 0;
75
+ const WT_FIXED64 = 1;
76
+ const WT_LEN = 2;
77
+ const WT_FIXED32 = 5;
78
+
79
+ const MAX_U64 = (1n << 64n) - 1n;
80
+ const MIN_I32 = -2147483648n;
81
+ const MAX_I32 = 2147483647n;
82
+ const MAX_SAFE_BIG = BigInt(Number.MAX_SAFE_INTEGER);
83
+
84
+ /** 编解码失败(对应 Python 的 `DecodeError` / `EncodeError`;文案跨运行时不保证一致) */
85
+ export class FeishuWsCodecError extends Error {
86
+ constructor(message) {
87
+ super(message);
88
+ this.name = 'FeishuWsCodecError';
89
+ }
90
+ }
91
+
92
+ // ------------------------------------------------------------------ varint / 原始写
93
+ /** 无符号 varint(入参必须是 0..2^64-1 的 BigInt) */
94
+ function varintBytes(value) {
95
+ const out = [];
96
+ let v = value;
97
+ for (;;) {
98
+ const b = Number(v & 0x7fn);
99
+ v >>= 7n;
100
+ if (v === 0n) { out.push(b); return Buffer.from(out); }
101
+ out.push(b | 0x80);
102
+ }
103
+ }
104
+
105
+ function tagBytes(field, wire) {
106
+ return varintBytes((BigInt(field) << 3n) | BigInt(wire));
107
+ }
108
+
109
+ function lenDelimBytes(field, payload) {
110
+ return Buffer.concat([tagBytes(field, WT_LEN), varintBytes(BigInt(payload.length)), payload]);
111
+ }
112
+
113
+ function utf8(s) {
114
+ return Buffer.from(s, 'utf8');
115
+ }
116
+
117
+ /** required 数值字段 → BigInt(Number 必须是安全整数,否则丢精度) */
118
+ function requiredBig(value, name, { min, max }) {
119
+ if (value === undefined || value === null) {
120
+ throw new FeishuWsCodecError(`missing required field: ${name}`);
121
+ }
122
+ let v;
123
+ if (typeof value === 'bigint') v = value;
124
+ else if (typeof value === 'number') {
125
+ if (!Number.isInteger(value)) {
126
+ throw new FeishuWsCodecError(`${name} 必须是整数,实际 ${value}`);
127
+ }
128
+ if (!Number.isSafeInteger(value)) {
129
+ // 正是计划里点名的那条红线:静默丢精度比抛错危险得多(雪花号量级会错号)
130
+ throw new FeishuWsCodecError(
131
+ `${name}=${value} 超过 2^53,JS Number 已丢精度 ⇒ 请传 BigInt 或十进制字符串`);
132
+ }
133
+ v = BigInt(value);
134
+ } else if (typeof value === 'string') {
135
+ if (!/^-?[0-9]+$/.test(value)) throw new FeishuWsCodecError(`${name} 不是十进制整数:${value}`);
136
+ v = BigInt(value);
137
+ } else {
138
+ throw new FeishuWsCodecError(`${name} 类型不接受:${typeof value}`);
139
+ }
140
+ if (v < min || v > max) throw new FeishuWsCodecError(`${name}=${v} 越界(${min}..${max})`);
141
+ return v;
142
+ }
143
+
144
+ /** optional string 字段:`undefined`/`null` = 未设;`''` = 显式空串(线上 `xx 00`) */
145
+ function optionalString(value, name) {
146
+ if (value === undefined || value === null) return null;
147
+ if (typeof value !== 'string') {
148
+ throw new FeishuWsCodecError(`${name} 必须是字符串,实际 ${typeof value}`);
149
+ }
150
+ return value;
151
+ }
152
+
153
+ function headerBytes(h, index) {
154
+ if (h === null || typeof h !== 'object') {
155
+ throw new FeishuWsCodecError(`headers[${index}] 必须是 {key,value} 对象`);
156
+ }
157
+ const key = h.key;
158
+ const value = h.value;
159
+ // 两个子字段都是 required ⇒ 缺一个就是 Python 的 EncodeError(实测:headers[0].key/headers[0].value)
160
+ if (typeof key !== 'string') {
161
+ throw new FeishuWsCodecError(`missing required field: headers[${index}].key`);
162
+ }
163
+ if (typeof value !== 'string') {
164
+ throw new FeishuWsCodecError(`missing required field: headers[${index}].value`);
165
+ }
166
+ return Buffer.concat([
167
+ lenDelimBytes(1, utf8(key)), // 空串照样写:0a 00
168
+ lenDelimBytes(2, utf8(value)), // 12 00
169
+ ]);
170
+ }
171
+
172
+ // ------------------------------------------------------------------ 编码
173
+ /**
174
+ * frame → 线上字节。
175
+ *
176
+ * 冻结接口(`tools/parity/test/pbbp2.test.mjs` 按此断言):
177
+ * `{ SeqID, LogID, service, method, headers: [{key,value}], payload: Buffer|null }`
178
+ * 可选:`payload_encoding` / `payload_type` / `LogIDNew`(string;`undefined`/`null` = 未设)
179
+ * 以及 `_unknown`(decode 保留的未知字段原始字节数组,追加在所有已知字段之后)。
180
+ */
181
+ export function encodeFrame(frame) {
182
+ if (frame === null || typeof frame !== 'object') {
183
+ throw new FeishuWsCodecError('encodeFrame 需要帧对象');
184
+ }
185
+ const seq = requiredBig(frame.SeqID, 'SeqID', { min: 0n, max: MAX_U64 });
186
+ const log = requiredBig(frame.LogID, 'LogID', { min: 0n, max: MAX_U64 });
187
+ const service = requiredBig(frame.service, 'service', { min: MIN_I32, max: MAX_I32 });
188
+ const method = requiredBig(frame.method, 'method', { min: MIN_I32, max: MAX_I32 });
189
+
190
+ // 一律按**字段号升序**写(Python 解析后重新序列化也是这个顺序,实测)
191
+ const parts = [];
192
+ parts.push(tagBytes(F_SEQ_ID, WT_VARINT), varintBytes(seq));
193
+ parts.push(tagBytes(F_LOG_ID, WT_VARINT), varintBytes(log));
194
+ // int32 负数:proto2 线上是**符号扩展到 64 位**后的 varint(-1 ⇒ 10 字节 ff…01)
195
+ parts.push(tagBytes(F_SERVICE, WT_VARINT), varintBytes(BigInt.asUintN(64, service)));
196
+ parts.push(tagBytes(F_METHOD, WT_VARINT), varintBytes(BigInt.asUintN(64, method)));
197
+
198
+ const headers = frame.headers === undefined || frame.headers === null ? [] : frame.headers;
199
+ if (!Array.isArray(headers)) throw new FeishuWsCodecError('headers 必须是数组');
200
+ headers.forEach((h, i) => parts.push(lenDelimBytes(F_HEADERS, headerBytes(h, i))));
201
+
202
+ const enc = optionalString(frame.payload_encoding, 'payload_encoding');
203
+ if (enc !== null) parts.push(lenDelimBytes(F_PAYLOAD_ENCODING, utf8(enc)));
204
+ const ptype = optionalString(frame.payload_type, 'payload_type');
205
+ if (ptype !== null) parts.push(lenDelimBytes(F_PAYLOAD_TYPE, utf8(ptype)));
206
+
207
+ const payload = frame.payload;
208
+ if (payload !== undefined && payload !== null) {
209
+ if (!Buffer.isBuffer(payload) && !(payload instanceof Uint8Array)) {
210
+ throw new FeishuWsCodecError(`payload 必须是 Buffer/Uint8Array,实际 ${typeof payload}`);
211
+ }
212
+ const buf = Buffer.isBuffer(payload) ? payload : Buffer.from(payload);
213
+ parts.push(lenDelimBytes(F_PAYLOAD, buf)); // 显式空 ⇒ 42 00
214
+ }
215
+ const logIdNew = optionalString(frame.LogIDNew, 'LogIDNew');
216
+ if (logIdNew !== null) parts.push(lenDelimBytes(F_LOG_ID_NEW, utf8(logIdNew)));
217
+
218
+ const missing = frame._missingRequired;
219
+ if (Array.isArray(missing) && missing.length > 0) {
220
+ throw new FeishuWsCodecError(
221
+ `missing required fields: ${missing.join(',')}(帧解析自缺失 required 的字节,不许回写)`);
222
+ }
223
+ const unknown = frame._unknown;
224
+ if (Array.isArray(unknown)) {
225
+ for (const raw of unknown) {
226
+ if (!Buffer.isBuffer(raw)) throw new FeishuWsCodecError('_unknown 只接受 Buffer');
227
+ parts.push(raw);
228
+ }
229
+ }
230
+ return Buffer.concat(parts);
231
+ }
232
+
233
+ // ------------------------------------------------------------------ 解码
234
+ function readVarint(bytes, start) {
235
+ let shift = 0n;
236
+ let val = 0n;
237
+ let i = start;
238
+ for (;;) {
239
+ if (i >= bytes.length) throw new FeishuWsCodecError(`truncated varint at offset ${start}`);
240
+ const b = bytes[i];
241
+ i += 1;
242
+ val |= BigInt(b & 0x7f) << shift;
243
+ if ((b & 0x80) === 0) return [val, i];
244
+ shift += 7n;
245
+ if (shift > 63n) throw new FeishuWsCodecError(`varint too long at offset ${start}`);
246
+ }
247
+ }
248
+
249
+ /** uint64 → Number 或 BigInt(> 2^53 才用 BigInt,普通帧仍是 Number,免得到处踩 1n !== 1) */
250
+ function u64(v) {
251
+ return v <= MAX_SAFE_BIG ? Number(v) : v;
252
+ }
253
+
254
+ /**
255
+ * 跳过一段未知字段并返回其**原始字节**(含 tag)。wire type 3/4(group)不支持。
256
+ * 注意 `tagStart` 是 tag 的起点(保留原始字节要用它),`afterTag` 才是值起点。
257
+ */
258
+ function rawField(bytes, tagStart, afterTag, wt) {
259
+ if (wt === WT_VARINT) {
260
+ const [, end] = readVarint(bytes, afterTag);
261
+ return [bytes.subarray(tagStart, end), end];
262
+ }
263
+ if (wt === WT_FIXED64 || wt === WT_FIXED32) {
264
+ const size = wt === WT_FIXED64 ? 8 : 4;
265
+ const end = afterTag + size;
266
+ if (end > bytes.length) throw new FeishuWsCodecError(`truncated fixed field at offset ${tagStart}`);
267
+ return [bytes.subarray(tagStart, end), end];
268
+ }
269
+ if (wt === WT_LEN) {
270
+ const [len, afterLen] = readVarint(bytes, afterTag);
271
+ const end = afterLen + Number(len);
272
+ if (end > bytes.length) {
273
+ throw new FeishuWsCodecError(`truncated length-delimited field at offset ${tagStart}`);
274
+ }
275
+ return [bytes.subarray(tagStart, end), end];
276
+ }
277
+ // group(3/4):Python 侧实测抛 DecodeError: Truncated message.
278
+ throw new FeishuWsCodecError(`unsupported wire type ${wt} at offset ${tagStart}`);
279
+ }
280
+
281
+ function decodeHeader(buf) {
282
+ let key = '';
283
+ let value = '';
284
+ let i = 0;
285
+ while (i < buf.length) {
286
+ const [tag, afterTag] = readVarint(buf, i);
287
+ const field = Number(tag >> 3n);
288
+ const wt = Number(tag & 7n);
289
+ if (field === 0) throw new FeishuWsCodecError('field number 0 in Header');
290
+ if (wt === WT_LEN && (field === 1 || field === 2)) {
291
+ const [len, afterLen] = readVarint(buf, afterTag);
292
+ const end = afterLen + Number(len);
293
+ if (end > buf.length) throw new FeishuWsCodecError('truncated Header string');
294
+ const s = buf.toString('utf8', afterLen, end);
295
+ if (field === 1) key = s; else value = s; // 重复出现 ⇒ 后者胜
296
+ i = end;
297
+ continue;
298
+ }
299
+ const [, end] = rawField(buf, i, afterTag, wt);
300
+ i = end;
301
+ }
302
+ return { key, value };
303
+ }
304
+
305
+ /**
306
+ * 线上字节 → frame。宽容(照 Python `ParseFromString`):缺 required 不抛,记进 `_missingRequired`。
307
+ * `payload`:未设 ⇒ `null`;显式空 ⇒ `Buffer.alloc(0)`。
308
+ * 未知字段按遇到顺序原样留在 `_unknown`(编码时追加在已知字段之后,与 Python 实测一致)。
309
+ */
310
+ export function decodeFrame(input) {
311
+ const bytes = Buffer.isBuffer(input) ? input : Buffer.from(input ?? Buffer.alloc(0));
312
+ let seq;
313
+ let log;
314
+ let service;
315
+ let method;
316
+ const headers = [];
317
+ let payloadEncoding = null;
318
+ let payloadType = null;
319
+ let payload = null;
320
+ let logIdNew = null;
321
+ const unknown = [];
322
+ const seen = new Set();
323
+
324
+ let i = 0;
325
+ while (i < bytes.length) {
326
+ const start = i;
327
+ const [tag, afterTag] = readVarint(bytes, i);
328
+ const field = Number(tag >> 3n);
329
+ const wt = Number(tag & 7n);
330
+ if (field === 0) throw new FeishuWsCodecError(`field number 0 at offset ${start}`);
331
+
332
+ let handled = false;
333
+ if (field === F_SEQ_ID || field === F_LOG_ID || field === F_SERVICE || field === F_METHOD) {
334
+ if (wt === WT_VARINT) {
335
+ const [raw, end] = readVarint(bytes, afterTag);
336
+ if (field === F_SEQ_ID) seq = u64(raw);
337
+ else if (field === F_LOG_ID) log = u64(raw);
338
+ // int32:低 32 位按有符号解释(负数是 10 字节符号扩展 varint)
339
+ else if (field === F_SERVICE) service = Number(BigInt.asIntN(32, raw));
340
+ else method = Number(BigInt.asIntN(32, raw));
341
+ seen.add(field);
342
+ i = end;
343
+ handled = true;
344
+ }
345
+ } else if (field === F_HEADERS && wt === WT_LEN) {
346
+ const [len, afterLen] = readVarint(bytes, afterTag);
347
+ const end = afterLen + Number(len);
348
+ if (end > bytes.length) throw new FeishuWsCodecError('truncated headers field');
349
+ headers.push(decodeHeader(bytes.subarray(afterLen, end)));
350
+ seen.add(field);
351
+ i = end;
352
+ handled = true;
353
+ } else if ((field === F_PAYLOAD_ENCODING || field === F_PAYLOAD_TYPE || field === F_LOG_ID_NEW)
354
+ && wt === WT_LEN) {
355
+ const [len, afterLen] = readVarint(bytes, afterTag);
356
+ const end = afterLen + Number(len);
357
+ if (end > bytes.length) throw new FeishuWsCodecError(`truncated field ${field}`);
358
+ const s = bytes.toString('utf8', afterLen, end);
359
+ if (field === F_PAYLOAD_ENCODING) payloadEncoding = s;
360
+ else if (field === F_PAYLOAD_TYPE) payloadType = s;
361
+ else logIdNew = s;
362
+ seen.add(field);
363
+ i = end;
364
+ handled = true;
365
+ } else if (field === F_PAYLOAD && wt === WT_LEN) {
366
+ const [len, afterLen] = readVarint(bytes, afterTag);
367
+ const end = afterLen + Number(len);
368
+ if (end > bytes.length) throw new FeishuWsCodecError('truncated payload');
369
+ payload = Buffer.from(bytes.subarray(afterLen, end)); // 显式空 ⇒ 长度 0 的 Buffer(不是 null)
370
+ seen.add(field);
371
+ i = end;
372
+ handled = true;
373
+ }
374
+
375
+ if (!handled) {
376
+ // wire type 与字段号不匹配、或压根不认识的字段号 ⇒ Python 也走"未知字段"分支
377
+ const [raw, end] = rawField(bytes, start, afterTag, wt);
378
+ unknown.push(Buffer.from(raw));
379
+ i = end;
380
+ }
381
+ }
382
+
383
+ const missing = [];
384
+ if (!seen.has(F_SEQ_ID)) missing.push('SeqID');
385
+ if (!seen.has(F_LOG_ID)) missing.push('LogID');
386
+ if (!seen.has(F_SERVICE)) missing.push('service');
387
+ if (!seen.has(F_METHOD)) missing.push('method');
388
+
389
+ return {
390
+ SeqID: seq === undefined ? 0 : seq,
391
+ LogID: log === undefined ? 0 : log,
392
+ service: service === undefined ? 0 : service,
393
+ method: method === undefined ? 0 : method,
394
+ headers,
395
+ payload_encoding: payloadEncoding,
396
+ payload_type: payloadType,
397
+ payload,
398
+ LogIDNew: logIdNew,
399
+ _unknown: unknown,
400
+ _missingRequired: missing,
401
+ };
402
+ }
403
+
404
+ // ------------------------------------------------------------------ 取值助手
405
+ /**
406
+ * 按头名取值 —— 语义照抄 SDK `client.py:38 _get_by_key`:
407
+ * **返回第一个命中**,一个都没有时**抛**(Python 侧 `HeaderNotFoundException`,
408
+ * 抛出后被 `_handle_message` 的 `except` 吃掉 ⇒ **不回 ACK、连接不断**)。
409
+ * 不要"顺手"改成返回空串:那会把"缺头"从"丢帧"变成"按空值处理",行为不同。
410
+ */
411
+ export function getHeader(frame, key) {
412
+ const headers = frame && Array.isArray(frame.headers) ? frame.headers : [];
413
+ for (const h of headers) {
414
+ if (h.key === key) return h.value;
415
+ }
416
+ throw new FeishuWsHeaderError(key);
417
+ }
418
+
419
+ /** 缺头(对应 Python `HeaderNotFoundException`) */
420
+ export class FeishuWsHeaderError extends Error {
421
+ constructor(key) {
422
+ super(`HeaderNotFoundException: ${key}`);
423
+ this.name = 'FeishuWsHeaderError';
424
+ this.key = key;
425
+ }
426
+ }
427
+
428
+ // ============================================================================================
429
+ // WS 客户端 —— 等价 `lark_oapi.ws.client.Client`(含 feishu.py `_CardAwareClient` 的卡片分派覆写)
430
+ //
431
+ // 契约出处(逐行读出来的,不是查文档):
432
+ // - 发现:`POST {domain}/callback/ws/endpoint`,头 `{locale: zh, User-Agent: oapi-sdk-python/v<ver>}`,
433
+ // 体 `{"AppID":…,"AppSecret":…}` ⇒ `{"code":0,"data":{"URL":…,"ClientConfig":{…}}}`(client.py:232-289)
434
+ // - 握手:`websockets.connect(conn_url, proxy=None)` —— **不带自定义头**;`device_id`/`service_id`
435
+ // 从 URL query 取(client.py:198-208)
436
+ // - 心跳:连上后**立刻**发第一帧 `CONTROL/ping`(`service=int(service_id)`,SeqID=LogID=0),
437
+ // 之后每 `PingInterval` 秒一次(client.py:181-191)
438
+ // - 控制帧:`type=ping` 忽略;`type=pong` 且 payload 非空 ⇒ 当 ClientConfig 更新连接参数(client.py:310-321)
439
+ // - 数据帧:先按序取 message_id/trace_id/sum/seq/type 五个头(**缺一个就丢帧且不回 ACK**,因为
440
+ // `_get_by_key` 抛 HeaderNotFoundException 被 `_handle_message` 吃掉);`sum>1` 走 5s 合包;
441
+ // `type=event` ⇒ 同步跑 handler、按耗时写 `biz_rt` 头、回 ACK;`type=card` ⇒ **SDK 直接 return**,
442
+ // 但产品子类把它接进 cardHandler 并**先 ACK**(biz_rt="0");其余 type ⇒ 什么都不做(client.py:323-359、
443
+ // feishu.py:1673-1713)
444
+ // - ACK = **把收到的原帧回写**:追加 `biz_rt` 头、payload 换成 `{"code": 200}`(handler 返回非 None 时
445
+ // 多一个 `"data"` = base64(marshal(结果));handler 抛异常则 `{"code": 500}` 且**不加** biz_rt 头)
446
+ // - 重连:首次抖动 `random()*ReconnectNonce`;`ReconnectCount>=0` 时最多试 N 次、间隔
447
+ // `ReconnectInterval`,否则一直试(client.py:361-408)
448
+ // - `ClientException`(code 非 0/1/1000040343、无凭据)**不重连**,直接上抛(start() 里 client.py:167-168)
449
+ //
450
+ // 与 Python 的**已知差异**(登记,不掩盖):
451
+ // 1. 失败握手的细分错误码拿不到:Python 从 `InvalidHandshake` 的响应头读
452
+ // `handshake-status`/`handshake-msg`/`handshake-autherrcode`,据此分 514/403/1000040350;
453
+ // Node 内建 WebSocket 的 error 事件不给任何响应头 ⇒ 只能抛通用错误。**产品可观测差异**
454
+ // = feishu.py `_ws_run` 兜底 print 出来的那一行文案。
455
+ // 2. SDK 内部 logger 的格式(`_fmt_log` + `[conn_id=…]`)不复刻:那是 `logging` 输出,不在
456
+ // stderr 的 `print` 对拍面上;可见行由 R2 的 glue 负责。
457
+ // 3. 发现请求的传输头(`Accept-Encoding`/`Accept`/`Connection`)由 `fetch`/`requests` 各自决定,
458
+ // 不逐字节对齐;被对齐的是方法、路径、`locale`、`User-Agent`、body 字节。
459
+ // 4. 追加能力 `close()`:Python SDK 没有干净的停止接口(feishu.py:1631 明说),产品侧不会调它,
460
+ // 只有量具用。
461
+ // ============================================================================================
462
+
463
+ /** 发现端点(`lark_oapi.ws.const.GEN_ENDPOINT_URI`) */
464
+ export const FS_WS_ENDPOINT_URI = '/callback/ws/endpoint';
465
+ /** 默认域名(`lark_oapi.core.const.FEISHU_DOMAIN`;SDK 的 ctor 参数,feishu.py 从不覆盖) */
466
+ export const FEISHU_DOMAIN = 'https://open.feishu.cn';
467
+ /** User-Agent:**刻意照抄** `oapi-sdk-python/v1.7.3`(`build_user_agent()` 实测值)。
468
+ * 理由是这条请求是与第三方网关的协议交互,UA 是线上字节的一部分;升级 lark-oapi 后要复核。 */
469
+ export const FS_WS_USER_AGENT = 'oapi-sdk-python/v1.7.3';
470
+ /** 网关 code(`lark_oapi.ws.const`) */
471
+ export const FS_OK = 0;
472
+ export const FS_SYSTEM_BUSY = 1;
473
+ export const FS_INTERNAL_ERROR = 1000040343;
474
+ export const FS_NO_CREDENTIAL = 1000040344;
475
+ /** 发现失败分级(对应 SDK 的 ServerException / ClientException) */
476
+ export class FeishuWsServerError extends Error {
477
+ constructor(code, msg) {
478
+ super(`server error (code=${code}): ${msg}`);
479
+ this.name = 'FeishuWsServerError';
480
+ this.code = code;
481
+ this.msg = msg;
482
+ }
483
+ }
484
+ export class FeishuWsClientError extends Error {
485
+ constructor(code, msg) {
486
+ super(`client error (code=${code}): ${msg}`);
487
+ this.name = 'FeishuWsClientError';
488
+ this.code = code;
489
+ this.msg = msg;
490
+ }
491
+ }
492
+
493
+ /** Python `int(str)` 的近似(失败要抛,不能像 parseInt 那样静默给 NaN) */
494
+ function pyInt(raw, what) {
495
+ const t = String(raw).trim();
496
+ if (!/^[+-]?[0-9](?:_?[0-9])*$/.test(t)) {
497
+ throw new FeishuWsCodecError(`invalid literal for int() with base 10: ${JSON.stringify(raw)}(${what})`);
498
+ }
499
+ return Number(t.replace(/_/g, ''));
500
+ }
501
+
502
+ /** 照 `requests` 的 `json=` 口径:`json.dumps(obj)` 的默认分隔符 `", "`/`": "` + `ensure_ascii=True` */
503
+ function jsonBodyEnsureAscii(obj) {
504
+ return pyJsonDumps(obj).replace(/[\u007f-\uffff]/g, (ch) => {
505
+ const cp = ch.codePointAt(0);
506
+ if (cp > 0xffff) {
507
+ const s = cp - 0x10000;
508
+ const hi = 0xd800 + (s >> 10);
509
+ const lo = 0xdc00 + (s & 0x3ff);
510
+ return `\\u${hi.toString(16).padStart(4, '0')}\\u${lo.toString(16).padStart(4, '0')}`;
511
+ }
512
+ return `\\u${cp.toString(16).padStart(4, '0')}`;
513
+ });
514
+ }
515
+
516
+ /** `lark_oapi.core.cache.ExpiringCache`(ttl 判定 + 30s 清扫) */
517
+ class ExpiringCache {
518
+ constructor(clearInterval = 30) {
519
+ this._m = new Map();
520
+ this._clearInterval = clearInterval;
521
+ this._timer = null;
522
+ }
523
+
524
+ get(key) {
525
+ const e = this._m.get(key);
526
+ if (e === undefined) return null;
527
+ if (e.expire < Date.now() / 1000) { this._m.delete(key); return null; }
528
+ return e.value;
529
+ }
530
+
531
+ set(key, value, ttl) {
532
+ this._m.set(key, { value, expire: Date.now() / 1000 + ttl });
533
+ if (this._timer === null) {
534
+ this._timer = setTimeout(() => { this._timer = null; this._clear(); }, this._clearInterval * 1000);
535
+ if (typeof this._timer.unref === 'function') this._timer.unref();
536
+ }
537
+ }
538
+
539
+ _clear() {
540
+ const now = Date.now() / 1000;
541
+ for (const [k, v] of this._m) if (v.expire < now) this._m.delete(k);
542
+ }
543
+
544
+ stop() {
545
+ if (this._timer !== null) clearTimeout(this._timer);
546
+ this._timer = null;
547
+ }
548
+ }
549
+
550
+ export class FeishuWsClient {
551
+ /**
552
+ * @param {object} options
553
+ * appId / appSecret 应用凭据(缺一 ⇒ 发现阶段直接抛 NO_CREDENTIAL)
554
+ * domain 发现域名(SDK ctor 参数,默认 FEISHU_DOMAIN;产品侧不覆盖)
555
+ * eventHandler(payload) `type=event` 的 handler,入参是**合包后的 payload 字节**;
556
+ * 返回值非 null 时会被 marshal+base64 塞进 ACK 的 data
557
+ * cardHandler(payload) `type=card` 的 handler,入参同上;**不等待**(照 feishu.py 的线程语义),
558
+ * 返回值丢弃
559
+ * autoReconnect 默认 true(照 SDK)
560
+ * log(level, msg) 诊断日志(默认丢弃;SDK 的 logging 格式不复刻)
561
+ * fetch / random / userAgent / pingTimer 量具用注入点
562
+ */
563
+ constructor(options = {}) {
564
+ this.appId = options.appId === undefined ? '' : String(options.appId);
565
+ this.appSecret = options.appSecret === undefined ? '' : String(options.appSecret);
566
+ this.domain = options.domain === undefined ? FEISHU_DOMAIN : options.domain;
567
+ this.eventHandler = options.eventHandler || null;
568
+ this.cardHandler = options.cardHandler || null;
569
+ this.autoReconnect = options.autoReconnect !== false;
570
+ this.log = typeof options.log === 'function' ? options.log : () => {};
571
+ this._fetch = options.fetch || ((...a) => globalThis.fetch(...a));
572
+ this._random = options.random || Math.random;
573
+ this._userAgent = options.userAgent === undefined ? FS_WS_USER_AGENT : options.userAgent;
574
+
575
+ // 本地缺省值 —— 每次握手都会被网关的 ClientConfig 覆盖(client.py:145-152)
576
+ this._reconnectNonce = 30;
577
+ this._reconnectCount = -1;
578
+ this._reconnectInterval = 120;
579
+ this._pingInterval = 120;
580
+
581
+ this._cache = new ExpiringCache(30);
582
+ this._conn = null;
583
+ this._connUrl = '';
584
+ this._connId = '';
585
+ this._serviceId = '';
586
+ this._queue = [];
587
+ this._waiters = [];
588
+ this._closed = false;
589
+ this._pingStopped = false;
590
+ this._finish = null;
591
+ this._donePromise = null;
592
+ /** 量具可观测面:连接次数 / 收帧数 / 发帧数 */
593
+ this.stats = { connects: 0, framesIn: 0, framesOut: 0, reconnects: 0 };
594
+ }
595
+
596
+ /** 等价 SDK `start()`:连接(失败按 ClientException 与否分流),继而心跳 + 收帧直到 close() */
597
+ async start() {
598
+ try {
599
+ await this._connect();
600
+ } catch (e) {
601
+ if (e instanceof FeishuWsClientError) {
602
+ this.log('error', `connect failed, err: ${e.message}`);
603
+ throw e; // ClientException:不重连,直接上抛(client.py:167-168)
604
+ }
605
+ this.log('error', `connect failed, err: ${e && e.message ? e.message : e}`);
606
+ await this._disconnect();
607
+ if (this.autoReconnect) await this._reconnect();
608
+ else throw e;
609
+ }
610
+ void this._pingLoop();
611
+ this._donePromise = new Promise((resolve) => { this._finish = resolve; });
612
+ return this._donePromise;
613
+ }
614
+
615
+ /** 量具用:Python SDK 没有干净的停止接口(feishu.py:1631 已说明) */
616
+ async close() {
617
+ this._closed = true;
618
+ this._pingStopped = true;
619
+ this._cache.stop();
620
+ await this._disconnect();
621
+ this._wakeAll();
622
+ if (this._finish !== null) { this._finish(); this._finish = null; }
623
+ }
624
+
625
+ // ------------------------------------------------------------------ 心跳 / 收帧
626
+ async _pingLoop() {
627
+ while (!this._pingStopped && !this._closed) {
628
+ try {
629
+ if (this._conn !== null) {
630
+ await this._writeMessage(encodeFrame({
631
+ SeqID: 0, LogID: 0, service: pyInt(this._serviceId, 'service_id'),
632
+ method: FRAME_TYPE_CONTROL, headers: [{ key: HEADER_TYPE, value: MESSAGE_TYPE_PING }],
633
+ payload: null,
634
+ }));
635
+ this.log('debug', 'ping success');
636
+ }
637
+ } catch (e) {
638
+ this.log('warn', `ping failed, err: ${e && e.message ? e.message : e}`);
639
+ }
640
+ // eslint-disable-next-line no-await-in-loop
641
+ await new Promise((r) => {
642
+ const t = setTimeout(r, this._pingInterval * 1000);
643
+ if (typeof t.unref === 'function') t.unref();
644
+ });
645
+ }
646
+ }
647
+
648
+ _wakeAll() {
649
+ const ws = this._waiters;
650
+ this._waiters = [];
651
+ for (const w of ws) w.resolve(null);
652
+ }
653
+
654
+ _pushMessage(buf) {
655
+ if (buf === null) { this._wakeAll(); return; }
656
+ const w = this._waiters.shift();
657
+ if (w !== undefined) w.resolve(buf);
658
+ else this._queue.push(buf);
659
+ }
660
+
661
+ _nextMessage() {
662
+ if (this._queue.length > 0) return Promise.resolve(this._queue.shift());
663
+ return new Promise((resolve) => { this._waiters.push({ resolve }); });
664
+ }
665
+
666
+ async _receiveMessageLoop() {
667
+ try {
668
+ for (;;) {
669
+ if (this._conn === null) throw new Error('connection is closed');
670
+ // eslint-disable-next-line no-await-in-loop
671
+ const msg = await this._nextMessage();
672
+ if (msg === null) throw new Error('connection is closed');
673
+ this.stats.framesIn += 1;
674
+ void this._handleMessage(msg); // 照 SDK:create_task,不 await
675
+ }
676
+ } catch (e) {
677
+ if (this._closed) return;
678
+ this.log('error', `receive message loop exit, err: ${e && e.message ? e.message : e}`);
679
+ await this._disconnect();
680
+ if (this.autoReconnect && !this._closed) { await this._reconnect(); return; }
681
+ // 照 SDK:收帧循环是 create_task 出去的,异常只进任务日志、不回到 start()(auto_reconnect=False
682
+ // 时 Python 是 "Task exception was never retrieved")⇒ 这里只留痕,**不许**上抛成未处理拒绝。
683
+ if (!this._closed) this.log('error', `receive loop 终止:${e && e.message ? e.message : e}`);
684
+ }
685
+ }
686
+
687
+ // ------------------------------------------------------------------ 帧分发
688
+ async _handleMessage(raw) {
689
+ try {
690
+ const frame = decodeFrame(raw);
691
+ if (frame.method === FRAME_TYPE_CONTROL) await this._handleControlFrame(frame);
692
+ else if (frame.method === FRAME_TYPE_DATA) await this._handleDataFrame(frame);
693
+ else throw new FeishuWsCodecError(`unknown FrameType ${frame.method}`);
694
+ } catch (e) {
695
+ this.log('error', `handle message failed, err: ${e && e.message ? e.message : e}`);
696
+ }
697
+ }
698
+
699
+ async _handleControlFrame(frame) {
700
+ const type = getHeader(frame, HEADER_TYPE);
701
+ if (type === MESSAGE_TYPE_PING) return;
702
+ if (type === MESSAGE_TYPE_PONG) {
703
+ if (!frame.payload || frame.payload.length === 0) return; // `if not frame.payload`
704
+ this._configure(JSON.parse(frame.payload.toString('utf8')));
705
+ return;
706
+ }
707
+ throw new FeishuWsCodecError(`unknown MessageType ${type}`); // Python `MessageType(type_)` 抛 ValueError
708
+ }
709
+
710
+ /**
711
+ * `type=event` 的 ACK 路径。
712
+ *
713
+ * ⚠️ **返回值到底进不进 `data`,是"分派层"的语义,不是本客户端的自由**(2026-10-15 与真 SDK
714
+ * 逐字节对拍实测,见 `tools/parity/probe/ws_oracle_diff.py`):
715
+ * `lark_oapi/event/dispatcher_handler.py:159-178` 只对**注册进 `_callback_processor_map`**
716
+ * 的类型(即 `register_p2_card_action_trigger` → `p2.card.action.trigger`)把
717
+ * `processor.do(data)` 的返回值 **return** 出去;走 `_processorMap` 的普通事件(如
718
+ * `im.message.receive_v1`)调用完 `processor.do(data)` **丢弃返回值** ⇒
719
+ * `_do_without_validation` 返回 None ⇒ ACK 只有 `{"code": 200}`、**没有 `data`**。
720
+ * 没注册的类型还会抛 `EventException("processor not found")` ⇒ ACK `{"code": 500}`。
721
+ * ⇒ R2 的 glue(`feishu_inbound.js`)必须照这个分派:普通消息事件 **return null**,
722
+ * `card.action.trigger` 才返回 toast 对象,未注册类型要**抛**。
723
+ * 本客户端只负责"handler 返回非 null 就塞进 data"这一层(等价 client.py:337-359)。
724
+ */
725
+ async _handleDataFrame(frame) {
726
+ // 头部取值顺序照 client.py:324-330(缺任何一个 ⇒ 抛 ⇒ 丢帧且不回 ACK)
727
+ const msgId = getHeader(frame, HEADER_MESSAGE_ID);
728
+ const traceId = getHeader(frame, HEADER_TRACE_ID);
729
+ const sum = pyInt(getHeader(frame, HEADER_SUM), 'sum');
730
+ const seq = pyInt(getHeader(frame, HEADER_SEQ), 'seq');
731
+ const type = getHeader(frame, HEADER_TYPE);
732
+
733
+ if (type === MESSAGE_TYPE_CARD) { await this._handleCardFrame(frame); return; }
734
+ if (type !== MESSAGE_TYPE_EVENT) return; // 其余 type:什么都不做(client.py:344-345)
735
+
736
+ let pl = frame.payload;
737
+ if (sum > 1) {
738
+ pl = this._combine(msgId, sum, seq, pl);
739
+ if (pl === null) return;
740
+ }
741
+ this.log('debug', `receive message, message_type: ${type}, message_id: ${msgId}, `
742
+ + `trace_id: ${traceId}, payload: ${pl === null || pl === undefined ? '' : pl.toString('utf8')}`);
743
+
744
+ let resp = { code: 200 };
745
+ try {
746
+ const start = Date.now();
747
+ const result = this.eventHandler === null ? null : await this.eventHandler(pl);
748
+ const end = Date.now();
749
+ frame.headers.push({ key: HEADER_BIZ_RT, value: String(end - start) });
750
+ if (result !== null && result !== undefined) {
751
+ resp.data = Buffer.from(pyJsonDumps(result), 'utf8').toString('base64');
752
+ }
753
+ } catch (e) {
754
+ this.log('error', `handle message failed, message_type: ${type}, message_id: ${msgId}, `
755
+ + `trace_id: ${traceId}, err: ${e && e.message ? e.message : e}`);
756
+ resp = { code: 500 };
757
+ }
758
+ frame.payload = Buffer.from(pyJsonDumps(resp), 'utf8');
759
+ await this._writeMessage(encodeFrame(frame));
760
+ }
761
+
762
+ /** 产品覆写(feishu.py:1685-1702):卡片帧**先 ACK**(biz_rt="0"),handler 另起一路不等它 */
763
+ async _handleCardFrame(frame) {
764
+ const msgId = getHeader(frame, HEADER_MESSAGE_ID);
765
+ const sum = pyInt(getHeader(frame, HEADER_SUM), 'sum');
766
+ const seq = pyInt(getHeader(frame, HEADER_SEQ), 'seq');
767
+ let pl = frame.payload;
768
+ if (sum > 1) {
769
+ pl = this._combine(msgId, sum, seq, pl);
770
+ if (pl === null) return;
771
+ }
772
+ if (this.cardHandler !== null) {
773
+ // 照 `threading.Thread(...).start()`:不 await;handler 自己吞异常(R2 的 glue 负责那行 print)
774
+ Promise.resolve()
775
+ .then(() => this.cardHandler(pl))
776
+ .catch((e) => this.log('error', `card handler error: ${e && e.message ? e.message : e}`));
777
+ }
778
+ frame.headers.push({ key: HEADER_BIZ_RT, value: '0' });
779
+ frame.payload = Buffer.from(pyJsonDumps({ code: 200 }), 'utf8');
780
+ await this._writeMessage(encodeFrame(frame));
781
+ }
782
+
783
+ /** 照 `Client._combine`(client.py:430-446):5s 缓存、缺片返回 None、负下标按 Python 语义回绕 */
784
+ _combine(msgId, sum, seq, bs) {
785
+ const val = this._cache.get(msgId);
786
+ if (val === null) {
787
+ const buf = new Array(sum).fill(null);
788
+ buf[this._seqIndex(seq, sum)] = bs;
789
+ this._cache.set(msgId, buf, 5);
790
+ return null;
791
+ }
792
+ val[this._seqIndex(seq, sum)] = bs;
793
+ const parts = [];
794
+ for (const v of val) {
795
+ if (!v || v.length === 0) { this._cache.set(msgId, val, 5); return null; }
796
+ parts.push(v);
797
+ }
798
+ return Buffer.concat(parts);
799
+ }
800
+
801
+ _seqIndex(seq, sum) {
802
+ const idx = seq < 0 ? sum + seq : seq;
803
+ if (idx < 0 || idx >= sum) throw new FeishuWsCodecError(`list index out of range(seq=${seq} sum=${sum})`);
804
+ return idx;
805
+ }
806
+
807
+ _configure(conf) {
808
+ const pick = (v) => (v === undefined ? null : v);
809
+ this._reconnectCount = pick(conf.ReconnectCount);
810
+ this._reconnectInterval = pick(conf.ReconnectInterval);
811
+ this._reconnectNonce = pick(conf.ReconnectNonce);
812
+ this._pingInterval = pick(conf.PingInterval);
813
+ }
814
+
815
+ // ------------------------------------------------------------------ 连接
816
+ async _connect() {
817
+ if (this._conn !== null) return;
818
+ const connUrl = await this._getConnUrl();
819
+ const u = new URL(connUrl);
820
+ // Python `parse_qs`(keep_blank_values=False):空串等于没有 ⇒ KeyError
821
+ const pick = (name) => {
822
+ const v = u.searchParams.get(name);
823
+ if (v === null || v === '') throw new Error(`missing query param ${name}`);
824
+ return v;
825
+ };
826
+ const connId = pick('device_id');
827
+ const serviceId = pick('service_id');
828
+
829
+ const ws = new WebSocket(connUrl);
830
+ ws.binaryType = 'arraybuffer';
831
+ await new Promise((resolve, reject) => {
832
+ ws.addEventListener('open', () => resolve(), { once: true });
833
+ // 差异 1(见文件头):Node 的 error 事件不带握手响应头 ⇒ 细分错误码拿不到
834
+ ws.addEventListener('error', () => reject(new Error('websocket connect failed')), { once: true });
835
+ });
836
+ this._conn = ws;
837
+ this._connUrl = connUrl;
838
+ this._connId = connId;
839
+ this._serviceId = serviceId;
840
+ this.stats.connects += 1;
841
+ ws.addEventListener('message', (ev) => {
842
+ const d = ev.data;
843
+ this._pushMessage(Buffer.isBuffer(d) ? d : Buffer.from(d));
844
+ });
845
+ ws.addEventListener('close', () => { this._pushMessage(null); });
846
+ ws.addEventListener('error', (ev) => this.log('error', `websocket error: ${ev && ev.message}`));
847
+ this.log('info', `connected to ${connUrl}`);
848
+ void this._receiveMessageLoop();
849
+ }
850
+
851
+ async _disconnect() {
852
+ const cur = this._conn;
853
+ this._conn = null;
854
+ this._connUrl = '';
855
+ this._connId = '';
856
+ this._serviceId = '';
857
+ this._queue = [];
858
+ this._wakeAll();
859
+ if (cur !== null) {
860
+ try { cur.close(); } catch { /* 已关 */ }
861
+ this.log('info', 'disconnected');
862
+ }
863
+ }
864
+
865
+ async _writeMessage(data) {
866
+ if (this._conn === null) throw new Error('connection is closed, write message failed');
867
+ this._conn.send(data);
868
+ this.stats.framesOut += 1;
869
+ }
870
+
871
+ async _reconnect() {
872
+ this.stats.reconnects += 1;
873
+ if (this._reconnectNonce !== null && this._reconnectNonce > 0) {
874
+ const nonce = this._random() * this._reconnectNonce;
875
+ await new Promise((r) => setTimeout(r, nonce * 1000));
876
+ }
877
+ if (this._reconnectCount !== null && this._reconnectCount >= 0) {
878
+ for (let i = 0; i < this._reconnectCount; i += 1) {
879
+ // eslint-disable-next-line no-await-in-loop
880
+ if (await this._tryConnect(i)) return;
881
+ // eslint-disable-next-line no-await-in-loop
882
+ await new Promise((r) => setTimeout(r, this._reconnectInterval * 1000));
883
+ }
884
+ throw new FeishuWsServerError(0, `unable to connect to the server after trying ${this._reconnectCount} times`);
885
+ }
886
+ for (let i = 0; ; i += 1) {
887
+ // eslint-disable-next-line no-await-in-loop
888
+ if (await this._tryConnect(i)) return;
889
+ // eslint-disable-next-line no-await-in-loop
890
+ await new Promise((r) => setTimeout(r, this._reconnectInterval * 1000));
891
+ }
892
+ }
893
+
894
+ async _tryConnect(cnt) {
895
+ this.log('info', `trying to reconnect for the ${cnt + 1} time`);
896
+ try {
897
+ await this._connect();
898
+ return true;
899
+ } catch (e) {
900
+ if (e instanceof FeishuWsClientError) throw e; // 照 SDK:ClientException 直接上抛
901
+ this.log('error', `connect failed, err: ${e && e.message ? e.message : e}`);
902
+ return false;
903
+ }
904
+ }
905
+
906
+ /** 发现端点(client.py:232-289);`domain` 是 SDK 自带的 ctor 参数,产品侧保持默认 */
907
+ async _getConnUrl() {
908
+ if (this.appId === '' || this.appSecret === '') {
909
+ throw new FeishuWsClientError(
910
+ FS_NO_CREDENTIAL,
911
+ 'app_id is required and either app_secret or client_assertion_provider is required');
912
+ }
913
+ const url = this.domain + FS_WS_ENDPOINT_URI;
914
+ const res = await this._fetch(url, {
915
+ method: 'POST',
916
+ headers: {
917
+ locale: 'zh',
918
+ 'User-Agent': this._userAgent,
919
+ 'Content-Type': 'application/json',
920
+ },
921
+ body: jsonBodyEnsureAscii({ AppID: this.appId, AppSecret: this.appSecret }),
922
+ });
923
+ const text = await res.text();
924
+ if (res.status !== 200) {
925
+ let msg = 'system busy';
926
+ try { const p = JSON.parse(text); if (p && p.msg) msg = p.msg; } catch { /* 保持缺省 */ }
927
+ throw new FeishuWsServerError(res.status, msg);
928
+ }
929
+ const resp = JSON.parse(text);
930
+ if (resp.code === FS_OK) { /* ok */ } else if (resp.code === FS_SYSTEM_BUSY) {
931
+ throw new FeishuWsServerError(resp.code, 'system busy');
932
+ } else if (resp.code === FS_INTERNAL_ERROR) {
933
+ throw new FeishuWsServerError(resp.code, resp.msg);
934
+ } else {
935
+ throw new FeishuWsClientError(resp.code, resp.msg);
936
+ }
937
+ const data = resp.data === undefined || resp.data === null ? {} : resp.data;
938
+ if (data.ClientConfig !== undefined && data.ClientConfig !== null) this._configure(data.ClientConfig);
939
+ return data.URL;
940
+ }
941
+
942
+ /** 当前连接参数(量具断言用) */
943
+ config() {
944
+ return {
945
+ reconnectCount: this._reconnectCount, reconnectInterval: this._reconnectInterval,
946
+ reconnectNonce: this._reconnectNonce, pingInterval: this._pingInterval,
947
+ connId: this._connId, serviceId: this._serviceId, connUrl: this._connUrl,
948
+ };
949
+ }
950
+ }