@mininglamp-oss/cc-channel-octo 1.0.1

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 (87) hide show
  1. package/CHANGELOG.md +349 -0
  2. package/LICENSE +191 -0
  3. package/README.md +577 -0
  4. package/config.bot.example.json +15 -0
  5. package/config.example.json +33 -0
  6. package/dist/agent-bridge.d.ts +79 -0
  7. package/dist/agent-bridge.js +392 -0
  8. package/dist/agent-bridge.js.map +1 -0
  9. package/dist/commands.d.ts +57 -0
  10. package/dist/commands.js +121 -0
  11. package/dist/commands.js.map +1 -0
  12. package/dist/config.d.ts +278 -0
  13. package/dist/config.js +330 -0
  14. package/dist/config.js.map +1 -0
  15. package/dist/cron-evaluator.d.ts +53 -0
  16. package/dist/cron-evaluator.js +191 -0
  17. package/dist/cron-evaluator.js.map +1 -0
  18. package/dist/cron-fire-marker.d.ts +24 -0
  19. package/dist/cron-fire-marker.js +25 -0
  20. package/dist/cron-fire-marker.js.map +1 -0
  21. package/dist/cron-scheduler.d.ts +46 -0
  22. package/dist/cron-scheduler.js +114 -0
  23. package/dist/cron-scheduler.js.map +1 -0
  24. package/dist/cron-store.d.ts +62 -0
  25. package/dist/cron-store.js +63 -0
  26. package/dist/cron-store.js.map +1 -0
  27. package/dist/cron-tool.d.ts +44 -0
  28. package/dist/cron-tool.js +151 -0
  29. package/dist/cron-tool.js.map +1 -0
  30. package/dist/cwd-resolver.d.ts +72 -0
  31. package/dist/cwd-resolver.js +166 -0
  32. package/dist/cwd-resolver.js.map +1 -0
  33. package/dist/db-adapter.d.ts +21 -0
  34. package/dist/db-adapter.js +64 -0
  35. package/dist/db-adapter.js.map +1 -0
  36. package/dist/file-inline-wrap.d.ts +94 -0
  37. package/dist/file-inline-wrap.js +243 -0
  38. package/dist/file-inline-wrap.js.map +1 -0
  39. package/dist/gateway.d.ts +100 -0
  40. package/dist/gateway.js +420 -0
  41. package/dist/gateway.js.map +1 -0
  42. package/dist/group-config.d.ts +41 -0
  43. package/dist/group-config.js +104 -0
  44. package/dist/group-config.js.map +1 -0
  45. package/dist/group-context.d.ts +64 -0
  46. package/dist/group-context.js +396 -0
  47. package/dist/group-context.js.map +1 -0
  48. package/dist/inbound.d.ts +136 -0
  49. package/dist/inbound.js +667 -0
  50. package/dist/inbound.js.map +1 -0
  51. package/dist/index.d.ts +33 -0
  52. package/dist/index.js +922 -0
  53. package/dist/index.js.map +1 -0
  54. package/dist/media-inbound.d.ts +38 -0
  55. package/dist/media-inbound.js +131 -0
  56. package/dist/media-inbound.js.map +1 -0
  57. package/dist/mention-utils.d.ts +99 -0
  58. package/dist/mention-utils.js +185 -0
  59. package/dist/mention-utils.js.map +1 -0
  60. package/dist/octo/api.d.ts +148 -0
  61. package/dist/octo/api.js +320 -0
  62. package/dist/octo/api.js.map +1 -0
  63. package/dist/octo/socket.d.ts +102 -0
  64. package/dist/octo/socket.js +793 -0
  65. package/dist/octo/socket.js.map +1 -0
  66. package/dist/octo/types.d.ts +126 -0
  67. package/dist/octo/types.js +35 -0
  68. package/dist/octo/types.js.map +1 -0
  69. package/dist/prompt-safety.d.ts +78 -0
  70. package/dist/prompt-safety.js +148 -0
  71. package/dist/prompt-safety.js.map +1 -0
  72. package/dist/session-router.d.ts +127 -0
  73. package/dist/session-router.js +432 -0
  74. package/dist/session-router.js.map +1 -0
  75. package/dist/session-store.d.ts +89 -0
  76. package/dist/session-store.js +297 -0
  77. package/dist/session-store.js.map +1 -0
  78. package/dist/skill-linker.d.ts +31 -0
  79. package/dist/skill-linker.js +160 -0
  80. package/dist/skill-linker.js.map +1 -0
  81. package/dist/stream-relay.d.ts +42 -0
  82. package/dist/stream-relay.js +243 -0
  83. package/dist/stream-relay.js.map +1 -0
  84. package/dist/url-policy.d.ts +103 -0
  85. package/dist/url-policy.js +290 -0
  86. package/dist/url-policy.js.map +1 -0
  87. package/package.json +79 -0
@@ -0,0 +1,793 @@
1
+ // Forked from openclaw-channel-octo v1.0.13 (2026-06-04)
2
+ // Source: https://github.com/Mininglamp-OSS/openclaw-channel-octo
3
+ // Changes: Exported Encoder and Decoder classes for protocol testing.
4
+ import { EventEmitter } from "events";
5
+ import WebSocket from "ws";
6
+ import { generateKeyPair, sharedKey } from "curve25519-js";
7
+ import { Buffer } from "buffer";
8
+ import CryptoJS from "crypto-js";
9
+ import { Md5 } from "md5-typescript";
10
+ import { randomBytes } from "node:crypto";
11
+ const PROTO_VERSION = 4;
12
+ /**
13
+ * Maximum bytes allowed in the WebSocket inbound assembly buffer.
14
+ * D1/S6 (齐 P0-1): a malicious server can send partial packets indefinitely
15
+ * (e.g. an unending variable-length encoding) and OOM the bot. 1 MiB is
16
+ * >> any legitimate single packet — if we cross it, close + reconnect.
17
+ */
18
+ const MAX_TEMP_BUFFER_BYTES = 1 * 1024 * 1024;
19
+ /**
20
+ * Maximum bytes used to encode a single variable-length integer (MQTT spec).
21
+ * D1/S6: refuse > 4 continuation bytes — anything longer is malformed and
22
+ * keeps tempBuffer filling forever.
23
+ */
24
+ const MAX_VARLEN_BYTES = 4;
25
+ /**
26
+ * Per-message decrypt/parse failure cap. After this many failed attempts on the
27
+ * SAME messageID, ack-and-drop it so a single poison (corrupt / non-JSON)
28
+ * payload cannot wedge the stream via infinite server redelivery. A transient
29
+ * failure (< cap) is left un-acked so the server retries.
30
+ */
31
+ const MAX_DECRYPT_RETRIES = 3;
32
+ /** Cap on distinct messageIDs tracked for decrypt failures (memory bound). */
33
+ const MAX_DECRYPT_FAIL_ENTRIES = 1000;
34
+ // ─── Binary Encoder / Decoder ───────────────────────────────────────────────
35
+ export class Encoder {
36
+ w = [];
37
+ writeByte(b) { this.w.push(b & 0xff); }
38
+ writeBytes(b) { for (let i = 0; i < b.length; i++)
39
+ this.w[this.w.length] = b[i]; }
40
+ writeInt16(b) { this.w.push((b >> 8) & 0xff, b & 0xff); }
41
+ writeInt32(b) { this.w.push((b >> 24) & 0xff, (b >> 16) & 0xff, (b >> 8) & 0xff, b & 0xff); }
42
+ writeInt64(n) {
43
+ const hi = Number(n >> 32n);
44
+ const lo = Number(n & 0xffffffffn);
45
+ this.writeInt32(hi);
46
+ this.writeInt32(lo);
47
+ }
48
+ writeString(s) {
49
+ if (s && s.length > 0) {
50
+ const arr = stringToUint(s);
51
+ this.writeInt16(arr.length);
52
+ for (let i = 0; i < arr.length; i++)
53
+ this.w[this.w.length] = arr[i];
54
+ }
55
+ else {
56
+ this.writeInt16(0);
57
+ }
58
+ }
59
+ toUint8Array() { return new Uint8Array(this.w); }
60
+ }
61
+ export class Decoder {
62
+ data;
63
+ offset = 0;
64
+ constructor(data) {
65
+ this.data = data;
66
+ }
67
+ /**
68
+ * Bounds guard: every reader calls this before touching the buffer so a
69
+ * truncated/malformed packet throws a typed error instead of silently reading
70
+ * `undefined` (which coerces to 0/NaN and produces corrupt parses — wrong
71
+ * messageID/seq → ack mismatch, message loss/dup). The packet-decode caller
72
+ * wraps decode in try/catch, so a throw cleanly rejects the bad packet.
73
+ */
74
+ require(n) {
75
+ if (this.offset + n > this.data.length) {
76
+ throw new RangeError(`WKDecoder: out-of-bounds read (need ${n} byte(s) at offset ${this.offset}, have ${this.data.length})`);
77
+ }
78
+ }
79
+ readByte() { this.require(1); return this.data[this.offset++]; }
80
+ readInt16() {
81
+ this.require(2);
82
+ const v = (this.data[this.offset] << 8) | this.data[this.offset + 1];
83
+ this.offset += 2;
84
+ return v;
85
+ }
86
+ readInt32() {
87
+ this.require(4);
88
+ const v = (this.data[this.offset] << 24) |
89
+ (this.data[this.offset + 1] << 16) |
90
+ (this.data[this.offset + 2] << 8) |
91
+ this.data[this.offset + 3];
92
+ this.offset += 4;
93
+ return v >>> 0; // unsigned
94
+ }
95
+ readInt64String() {
96
+ // Read 8 bytes as a big-endian unsigned integer string
97
+ this.require(8);
98
+ let n = BigInt(0);
99
+ for (let i = 0; i < 8; i++) {
100
+ n = (n << 8n) | BigInt(this.data[this.offset + i]);
101
+ }
102
+ this.offset += 8;
103
+ return n.toString();
104
+ }
105
+ readInt64BigInt() {
106
+ this.require(8);
107
+ let n = BigInt(0);
108
+ for (let i = 0; i < 8; i++) {
109
+ n = (n << 8n) | BigInt(this.data[this.offset + i]);
110
+ }
111
+ this.offset += 8;
112
+ return n;
113
+ }
114
+ readString() {
115
+ const len = this.readInt16();
116
+ if (len <= 0)
117
+ return "";
118
+ // Guard the declared length against the remaining buffer so an oversized
119
+ // length field can't over-read (slice() would silently short-return and
120
+ // leave the offset past the end, corrupting every subsequent field).
121
+ this.require(len);
122
+ const slice = this.data.slice(this.offset, this.offset + len);
123
+ this.offset += len;
124
+ return uintToString(Array.from(slice));
125
+ }
126
+ readRemaining() {
127
+ const d = this.data.slice(this.offset);
128
+ this.offset = this.data.length;
129
+ return d;
130
+ }
131
+ readVariableLength() {
132
+ let multiplier = 0;
133
+ let rLength = 0;
134
+ while (multiplier < 27) {
135
+ const b = this.readByte();
136
+ rLength = rLength | ((b & 127) << multiplier);
137
+ if ((b & 128) === 0)
138
+ break;
139
+ multiplier += 7;
140
+ }
141
+ return rLength;
142
+ }
143
+ }
144
+ function stringToUint(str) {
145
+ return Array.from(new TextEncoder().encode(str));
146
+ }
147
+ function uintToString(array) {
148
+ return new TextDecoder().decode(new Uint8Array(array));
149
+ }
150
+ function encodeVariableLength(len) {
151
+ if (len === 0)
152
+ return [0];
153
+ const ret = [];
154
+ while (len > 0) {
155
+ let digit = len % 0x80;
156
+ len = Math.floor(len / 0x80);
157
+ if (len > 0)
158
+ digit |= 0x80;
159
+ ret.push(digit);
160
+ }
161
+ return ret;
162
+ }
163
+ // ─── AES-CBC Encryption Helpers ─────────────────────────────────────────────
164
+ function aesDecrypt(data, aesKey, aesIV) {
165
+ const str = Buffer.from(data).toString("binary");
166
+ const ciphertext = CryptoJS.enc.Base64.parse(str);
167
+ const decrypted = CryptoJS.AES.decrypt(CryptoJS.enc.Base64.stringify(ciphertext), CryptoJS.enc.Utf8.parse(aesKey), {
168
+ keySize: 128 / 8,
169
+ iv: CryptoJS.enc.Utf8.parse(aesIV),
170
+ mode: CryptoJS.mode.CBC,
171
+ padding: CryptoJS.pad.Pkcs7,
172
+ });
173
+ return Uint8Array.from(Buffer.from(decrypted.toString(CryptoJS.enc.Utf8)));
174
+ }
175
+ // ─── Packet Encoding / Decoding ─────────────────────────────────────────────
176
+ function encodeConnectPacket(opts) {
177
+ const body = new Encoder();
178
+ body.writeByte(opts.version);
179
+ body.writeByte(opts.deviceFlag);
180
+ body.writeString(opts.deviceID);
181
+ body.writeString(opts.uid);
182
+ body.writeString(opts.token);
183
+ body.writeInt64(BigInt(opts.clientTimestamp));
184
+ body.writeString(opts.clientKey);
185
+ const bodyBytes = Array.from(body.toUint8Array());
186
+ const frame = new Encoder();
187
+ // header: packetType << 4 | flags
188
+ frame.writeByte((1 /* PacketType.CONNECT */ << 4) | 0);
189
+ frame.writeBytes(encodeVariableLength(bodyBytes.length));
190
+ frame.writeBytes(bodyBytes);
191
+ return frame.toUint8Array();
192
+ }
193
+ function encodePingPacket() {
194
+ return new Uint8Array([(7 /* PacketType.PING */ << 4) | 0]);
195
+ }
196
+ function encodeRecvackPacket(messageID, messageSeq) {
197
+ const body = new Encoder();
198
+ body.writeInt64(BigInt(messageID));
199
+ body.writeInt32(messageSeq);
200
+ const bodyBytes = Array.from(body.toUint8Array());
201
+ const frame = new Encoder();
202
+ frame.writeByte((6 /* PacketType.RECVACK */ << 4) | 0);
203
+ frame.writeBytes(encodeVariableLength(bodyBytes.length));
204
+ frame.writeBytes(bodyBytes);
205
+ return frame.toUint8Array();
206
+ }
207
+ function parseSettingByte(v) {
208
+ return {
209
+ receiptEnabled: ((v >> 7) & 0x01) > 0,
210
+ topic: ((v >> 3) & 0x01) > 0,
211
+ streamOn: ((v >> 1) & 0x01) > 0,
212
+ };
213
+ }
214
+ /**
215
+ * WuKongIM WebSocket client for bot connections.
216
+ *
217
+ * Implements the WuKongIM binary protocol directly over WebSocket,
218
+ * with per-instance DH key exchange, AES encryption, heartbeat,
219
+ * reconnect, and RECVACK.
220
+ *
221
+ * Each WKSocket instance maintains its own independent connection,
222
+ * enabling multiple bot accounts to run simultaneously.
223
+ */
224
+ export class WKSocket extends EventEmitter {
225
+ opts;
226
+ ws = null;
227
+ connected = false;
228
+ needReconnect = true;
229
+ reconnectTimer = null;
230
+ heartTimer = null;
231
+ pingRetryCount = 0;
232
+ pingMaxRetry = 3;
233
+ reconnectAttempts = 0;
234
+ stableTimer = null;
235
+ lastConnectTime = 0;
236
+ rapidDisconnectCount = 0;
237
+ // Per-instance crypto state (set after CONNACK)
238
+ aesKey = "";
239
+ aesIV = "";
240
+ dhPrivateKey = null;
241
+ serverVersion = 0;
242
+ // Buffer for handling packet fragmentation (sticky packets)
243
+ tempBuffer = [];
244
+ // Per-message decrypt-failure counts (by messageID). After
245
+ // MAX_DECRYPT_RETRIES, a poison message is ack'd-and-dropped so the server
246
+ // stops redelivering it forever (a single corrupt/non-JSON payload must not
247
+ // wedge the stream). Bounded to avoid unbounded growth from many distinct ids.
248
+ decryptFailCounts = new Map();
249
+ constructor(opts) {
250
+ super();
251
+ this.opts = opts;
252
+ }
253
+ /** Connect to WuKongIM WebSocket */
254
+ connect() {
255
+ this.needReconnect = true;
256
+ this.doConnect();
257
+ }
258
+ /** Gracefully disconnect */
259
+ disconnect() {
260
+ this.needReconnect = false;
261
+ this.connected = false;
262
+ this.lastConnectTime = 0;
263
+ this.rapidDisconnectCount = 0;
264
+ this.stopHeart();
265
+ this.stopReconnectTimer();
266
+ this.clearStableTimer();
267
+ if (this.ws) {
268
+ try {
269
+ this.ws.removeAllListeners();
270
+ this.ws.close();
271
+ }
272
+ catch { /* ignore */ }
273
+ this.ws = null;
274
+ }
275
+ }
276
+ /** Disconnect and wait for the old WS to fully close before resolving. */
277
+ async disconnectAndWait(timeoutMs = 2000) {
278
+ this.needReconnect = false;
279
+ this.connected = false;
280
+ this.stopHeart();
281
+ this.stopReconnectTimer();
282
+ this.clearStableTimer();
283
+ const oldWs = this.ws;
284
+ this.ws = null;
285
+ this.lastConnectTime = 0;
286
+ this.rapidDisconnectCount = 0;
287
+ if (!oldWs)
288
+ return;
289
+ return new Promise((resolve) => {
290
+ let resolved = false;
291
+ const done = () => {
292
+ if (resolved)
293
+ return;
294
+ resolved = true;
295
+ oldWs.removeAllListeners();
296
+ resolve();
297
+ };
298
+ oldWs.on("close", done);
299
+ try {
300
+ oldWs.close();
301
+ }
302
+ catch { /* ignore */ }
303
+ setTimeout(() => {
304
+ if (!resolved) {
305
+ try {
306
+ oldWs.terminate?.();
307
+ }
308
+ catch { /* ignore */ }
309
+ done();
310
+ }
311
+ }, timeoutMs);
312
+ });
313
+ }
314
+ // ─── Internal Connection Logic ──────────────────────────────────────────
315
+ doConnect() {
316
+ this.clearStableTimer();
317
+ if (this.ws) {
318
+ try {
319
+ this.ws.close();
320
+ }
321
+ catch { /* ignore */ }
322
+ this.ws = null;
323
+ }
324
+ this.tempBuffer = [];
325
+ const ws = new WebSocket(this.opts.wsUrl);
326
+ ws.binaryType = "arraybuffer";
327
+ this.ws = ws;
328
+ ws.on("open", () => {
329
+ if (this.ws !== ws)
330
+ return; // stale guard
331
+ this.tempBuffer = [];
332
+ // Generate DH key pair
333
+ const seed = randomBytes(32);
334
+ const keyPair = generateKeyPair(seed);
335
+ this.dhPrivateKey = keyPair.private;
336
+ const pubKey = Buffer.from(keyPair.public).toString("base64");
337
+ const deviceID = generateDeviceID() + "W";
338
+ const packet = encodeConnectPacket({
339
+ version: PROTO_VERSION,
340
+ deviceFlag: 0, // 0 = app/bot
341
+ deviceID,
342
+ uid: this.opts.uid,
343
+ token: this.opts.token,
344
+ clientTimestamp: Date.now(),
345
+ clientKey: pubKey,
346
+ });
347
+ ws.send(packet);
348
+ });
349
+ ws.on("message", (data) => {
350
+ if (this.ws !== ws)
351
+ return; // stale guard
352
+ // Buffer is already a Uint8Array view; use it directly to avoid the
353
+ // 3-arg vs 1-arg footgun. `new Uint8Array(buffer)` without byteOffset/
354
+ // byteLength reads the WHOLE underlying ArrayBuffer, which for a Buffer
355
+ // that is a view (e.g. from a buffer pool) leaks adjacent memory into
356
+ // the frame parser.
357
+ const bytes = data instanceof ArrayBuffer ? new Uint8Array(data) : data;
358
+ this.handleRawData(bytes);
359
+ });
360
+ ws.on("close", () => {
361
+ // Ignore close events from stale WebSocket instances.
362
+ // When onError triggers disconnect()+connect(), the old WS close event
363
+ // fires asynchronously and must not trigger a phantom reconnect.
364
+ if (this.ws !== ws)
365
+ return;
366
+ if (this.connected) {
367
+ this.connected = false;
368
+ this.opts.onDisconnected?.();
369
+ }
370
+ this.stopHeart();
371
+ this.clearStableTimer();
372
+ // Track rapid disconnects: if connection lasted <5s, it's unstable
373
+ if (this.lastConnectTime > 0) {
374
+ const duration = Date.now() - this.lastConnectTime;
375
+ if (duration < 5000) {
376
+ this.rapidDisconnectCount++;
377
+ }
378
+ else {
379
+ this.rapidDisconnectCount = 0;
380
+ }
381
+ this.lastConnectTime = 0;
382
+ }
383
+ // If 3+ consecutive rapid disconnects, trigger onError for token refresh
384
+ if (this.rapidDisconnectCount >= 3) {
385
+ this.needReconnect = false;
386
+ this.rapidDisconnectCount = 0;
387
+ this.opts.onError?.(new Error("Connect failed: rapid disconnect detected"));
388
+ return;
389
+ }
390
+ if (this.needReconnect) {
391
+ this.scheduleReconnect();
392
+ }
393
+ });
394
+ ws.on("error", (err) => {
395
+ if (this.ws !== ws)
396
+ return; // stale guard
397
+ console.debug("[WKSocket] ws error:", err.message);
398
+ // The 'close' event will follow, which handles reconnect
399
+ });
400
+ }
401
+ scheduleReconnect() {
402
+ this.stopReconnectTimer();
403
+ const baseDelay = 3000;
404
+ const maxDelay = 60000;
405
+ const exponentialDelay = Math.min(baseDelay * Math.pow(2, this.reconnectAttempts), maxDelay);
406
+ // Add ±25% random jitter to prevent thundering herd
407
+ const jitter = exponentialDelay * (0.75 + Math.random() * 0.5);
408
+ const delay = Math.floor(jitter);
409
+ this.reconnectAttempts++;
410
+ this.reconnectTimer = setTimeout(() => {
411
+ if (this.needReconnect) {
412
+ this.doConnect();
413
+ }
414
+ }, delay);
415
+ }
416
+ stopReconnectTimer() {
417
+ if (this.reconnectTimer) {
418
+ clearTimeout(this.reconnectTimer);
419
+ this.reconnectTimer = null;
420
+ }
421
+ }
422
+ startStableTimer() {
423
+ this.clearStableTimer();
424
+ this.stableTimer = setTimeout(() => {
425
+ if (this.connected) {
426
+ this.reconnectAttempts = 0;
427
+ this.rapidDisconnectCount = 0;
428
+ }
429
+ }, 30_000);
430
+ }
431
+ clearStableTimer() {
432
+ if (this.stableTimer) {
433
+ clearTimeout(this.stableTimer);
434
+ this.stableTimer = null;
435
+ }
436
+ }
437
+ // ─── Heartbeat ──────────────────────────────────────────────────────────
438
+ restartHeart() {
439
+ this.stopHeart();
440
+ this.pingRetryCount = 0;
441
+ this.heartTimer = setInterval(() => {
442
+ this.pingRetryCount++;
443
+ if (this.pingRetryCount > this.pingMaxRetry) {
444
+ console.debug("[WKSocket] ping timeout, reconnecting...");
445
+ this.stopHeart();
446
+ this.clearStableTimer();
447
+ if (this.ws) {
448
+ try {
449
+ this.ws.close();
450
+ }
451
+ catch { /* ignore */ }
452
+ this.ws = null;
453
+ }
454
+ if (this.connected) {
455
+ this.connected = false;
456
+ this.opts.onDisconnected?.();
457
+ }
458
+ if (this.needReconnect) {
459
+ this.scheduleReconnect();
460
+ }
461
+ return;
462
+ }
463
+ this.sendRaw(encodePingPacket());
464
+ }, 60_000); // 60s heartbeat interval (matches SDK default)
465
+ }
466
+ stopHeart() {
467
+ if (this.heartTimer) {
468
+ clearInterval(this.heartTimer);
469
+ this.heartTimer = null;
470
+ }
471
+ }
472
+ // ─── Raw Data & Packet Framing ──────────────────────────────────────────
473
+ sendRaw(data) {
474
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
475
+ this.ws.send(data);
476
+ }
477
+ }
478
+ handleRawData(data) {
479
+ for (let i = 0; i < data.length; i++)
480
+ this.tempBuffer.push(data[i]);
481
+ // D1/S6 (齐 P0-1): cap tempBuffer at MAX_TEMP_BUFFER_BYTES to prevent
482
+ // unbounded growth from a malicious/buggy server that sends partial
483
+ // packets indefinitely (e.g. an unending variable-length encoding).
484
+ if (this.tempBuffer.length > MAX_TEMP_BUFFER_BYTES) {
485
+ console.error(`[WKSocket] tempBuffer exceeded ${MAX_TEMP_BUFFER_BYTES} bytes (got ${this.tempBuffer.length}) — dropping and reconnecting`);
486
+ this.tempBuffer = [];
487
+ if (this.ws) {
488
+ try {
489
+ this.ws.close();
490
+ }
491
+ catch { /* ignore */ }
492
+ }
493
+ return;
494
+ }
495
+ try {
496
+ // Parse complete packets using a moving cursor instead of re-slicing the
497
+ // whole buffer per packet. The old `tempBuffer = tempBuffer.slice(total)`
498
+ // per iteration was O(n²): a peer dribbling many tiny frames near the 1 MiB
499
+ // cap forced ~n full-array copies of an ~n-element array, stalling the event
500
+ // loop (shared across bots) without exceeding the byte cap. Now we advance
501
+ // an offset and trim the consumed prefix exactly once at the end.
502
+ let consumed = 0;
503
+ for (;;) {
504
+ const used = this.unpackOne(this.tempBuffer, consumed);
505
+ if (used === 0)
506
+ break; // incomplete packet — wait for more bytes
507
+ consumed += used;
508
+ }
509
+ if (consumed > 0) {
510
+ this.tempBuffer = this.tempBuffer.slice(consumed);
511
+ }
512
+ }
513
+ catch (err) {
514
+ console.debug("[WKSocket] decode error:", err);
515
+ // Reset buffer and reconnect
516
+ this.tempBuffer = [];
517
+ if (this.ws) {
518
+ try {
519
+ this.ws.close();
520
+ }
521
+ catch { /* ignore */ }
522
+ }
523
+ }
524
+ }
525
+ /**
526
+ * Parse ONE packet starting at `start` in `data`. Returns the number of bytes
527
+ * consumed, or 0 if the buffer does not yet hold a complete packet (caller
528
+ * should wait for more bytes). Reads via `start + offset` and slices only the
529
+ * single packet's bytes — never the whole buffer — so repeated calls over a
530
+ * large buffer stay O(n) total, not O(n²).
531
+ */
532
+ unpackOne(data, start) {
533
+ const available = data.length - start;
534
+ if (available <= 0)
535
+ return 0;
536
+ const header = data[start];
537
+ const packetType = header >> 4;
538
+ // PONG is a single byte
539
+ if (packetType === 8 /* PacketType.PONG */) {
540
+ this.onPong();
541
+ return 1;
542
+ }
543
+ // PING from server (shouldn't happen but handle gracefully)
544
+ if (packetType === 7 /* PacketType.PING */) {
545
+ return 1;
546
+ }
547
+ const fixedHeaderLength = 1;
548
+ let pos = start + fixedHeaderLength;
549
+ let remLength = 0;
550
+ let multiplier = 1;
551
+ let hasMore = false;
552
+ let remLengthFull = true;
553
+ do {
554
+ if (pos > data.length - 1) {
555
+ remLengthFull = false;
556
+ break;
557
+ }
558
+ // D1/S6 (齐 P0-1): cap at MAX_VARLEN_BYTES per MQTT spec. Without
559
+ // this, a stream of 0x80 bytes would never terminate and would let
560
+ // tempBuffer grow until handleRawData kills the connection — raise
561
+ // earlier and more explicitly here.
562
+ if (pos - (start + fixedHeaderLength) >= MAX_VARLEN_BYTES) {
563
+ throw new Error(`[WKSocket] variable-length encoding exceeded ${MAX_VARLEN_BYTES} bytes — malformed packet`);
564
+ }
565
+ const digit = data[pos++];
566
+ remLength += (digit & 127) * multiplier;
567
+ multiplier *= 128;
568
+ hasMore = (digit & 0x80) !== 0;
569
+ } while (hasMore);
570
+ if (!remLengthFull)
571
+ return 0; // Incomplete frame — need more bytes
572
+ const remLengthLength = pos - (start + fixedHeaderLength);
573
+ const totalLength = fixedHeaderLength + remLengthLength + remLength;
574
+ if (totalLength > available)
575
+ return 0; // Incomplete packet — need more bytes
576
+ // Extract exactly this one packet's bytes and dispatch.
577
+ const packetData = new Uint8Array(data.slice(start, start + totalLength));
578
+ this.onPacket(packetData);
579
+ return totalLength;
580
+ }
581
+ // ─── Packet Handling ────────────────────────────────────────────────────
582
+ onPong() {
583
+ this.pingRetryCount = 0;
584
+ }
585
+ onPacket(data) {
586
+ const firstByte = data[0];
587
+ const packetType = firstByte >> 4;
588
+ // Skip the header and variable-length bytes to get body
589
+ const dec = new Decoder(data);
590
+ dec.readByte(); // header byte
591
+ if (packetType !== 7 /* PacketType.PING */ && packetType !== 8 /* PacketType.PONG */) {
592
+ dec.readVariableLength(); // remaining length
593
+ }
594
+ // WuKongIM header byte layout: [packetType:4][flags:4]
595
+ // Bit 0 of flags has DIFFERENT semantics per packet type:
596
+ // CONNACK: bit 0 = hasServerVersion (server includes version byte in body)
597
+ // RECV: bit 0 = noPersist (message should not be persisted)
598
+ // Bit 1 of flags:
599
+ // RECV: bit 1 = reddot (show unread badge)
600
+ switch (packetType) {
601
+ case 2 /* PacketType.CONNACK */: {
602
+ const hasServerVersion = (firstByte & 0x01) > 0;
603
+ this.onConnack(dec, hasServerVersion);
604
+ break;
605
+ }
606
+ case 5 /* PacketType.RECV */: {
607
+ const _noPersist = (firstByte & 0x01) > 0;
608
+ const _reddot = ((firstByte >> 1) & 0x01) > 0;
609
+ this.onRecv(dec, _noPersist, _reddot);
610
+ break;
611
+ }
612
+ case 9 /* PacketType.DISCONNECT */:
613
+ this.onDisconnect(dec);
614
+ break;
615
+ case 4 /* PacketType.SENDACK */:
616
+ // We don't send messages via WS, ignore
617
+ break;
618
+ }
619
+ }
620
+ onConnack(dec, hasServerVersion) {
621
+ if (hasServerVersion) {
622
+ this.serverVersion = dec.readByte();
623
+ }
624
+ dec.readInt64BigInt(); // timeDiff (unused)
625
+ const reasonCode = dec.readByte();
626
+ const serverKey = dec.readString();
627
+ const salt = dec.readString();
628
+ if (this.serverVersion >= 4) {
629
+ dec.readInt64BigInt(); // nodeId (unused)
630
+ }
631
+ if (reasonCode === 1) {
632
+ // Success — derive AES key from DH shared secret.
633
+ // A malformed/short salt yields a wrong AES-CBC IV (CryptoJS zero-pads it),
634
+ // which makes EVERY subsequent payload decrypt fail — i.e. a bot that looks
635
+ // connected (heartbeat fine) but silently drops every message. Fail the
636
+ // handshake instead so we reconnect and re-derive, rather than entering
637
+ // that silent-drop state. (serverKey is validated the same way: a bad DH
638
+ // key throws in sharedKey(), caught by handleRawData's try/catch.)
639
+ // The IV needs 16 BYTES, so validate by byte length, not char length: a
640
+ // 16-char salt with multibyte UTF-8 chars is <16 OR >16 bytes and would
641
+ // yield a wrong IV (the same silent-decrypt-failure this guard prevents).
642
+ const saltByteLen = salt ? Buffer.byteLength(salt, "utf8") : 0;
643
+ if (saltByteLen < 16) {
644
+ this.connected = false;
645
+ console.error(`[WKSocket] CONNACK salt too short (got ${saltByteLen} bytes, need >=16) — ` +
646
+ `AES IV would be invalid and every message would silently fail to decrypt. ` +
647
+ `Failing the connection to force a fresh handshake.`);
648
+ if (this.ws) {
649
+ try {
650
+ this.ws.close();
651
+ }
652
+ catch { /* ignore */ }
653
+ }
654
+ // needReconnect stays true (default) so the close handler reconnects.
655
+ this.opts.onError?.(new Error("CONNACK salt too short"));
656
+ return;
657
+ }
658
+ const serverPubKey = Uint8Array.from(Buffer.from(serverKey, "base64"));
659
+ const secret = sharedKey(this.dhPrivateKey, serverPubKey);
660
+ const secretBase64 = Buffer.from(secret).toString("base64");
661
+ const aesKeyFull = Md5.init(secretBase64);
662
+ this.aesKey = aesKeyFull.substring(0, 16);
663
+ // Take the first 16 BYTES of the salt as the IV (CryptoJS Utf8.parse(aesIV)
664
+ // re-encodes to bytes, so a 16-byte ASCII-equivalent slice is required).
665
+ this.aesIV = Buffer.from(salt, "utf8").subarray(0, 16).toString("latin1");
666
+ this.connected = true;
667
+ this.lastConnectTime = Date.now();
668
+ this.restartHeart();
669
+ this.startStableTimer();
670
+ this.opts.onConnected?.();
671
+ }
672
+ else if (reasonCode === 0) {
673
+ // Kicked
674
+ this.connected = false;
675
+ this.needReconnect = false;
676
+ if (this.ws) {
677
+ try {
678
+ this.ws.close();
679
+ }
680
+ catch { }
681
+ this.ws = null;
682
+ }
683
+ this.opts.onError?.(new Error("Kicked by server"));
684
+ this.opts.onDisconnected?.();
685
+ }
686
+ else {
687
+ // Connect failed
688
+ this.connected = false;
689
+ this.needReconnect = false;
690
+ this.opts.onError?.(new Error(`Connect failed: reasonCode=${reasonCode}`));
691
+ }
692
+ }
693
+ // eslint-disable-next-line @typescript-eslint/no-unused-vars
694
+ onRecv(dec, _noPersist, _reddot) {
695
+ const settingByte = dec.readByte();
696
+ const setting = parseSettingByte(settingByte);
697
+ dec.readString(); // msgKey (unused)
698
+ const fromUID = dec.readString();
699
+ const channelID = dec.readString();
700
+ const channelType = dec.readByte();
701
+ if (this.serverVersion >= 3) {
702
+ dec.readInt32(); // expire (unused)
703
+ }
704
+ dec.readString(); // clientMsgNo (unused)
705
+ const messageID = dec.readInt64String();
706
+ const messageSeq = dec.readInt32();
707
+ const timestamp = dec.readInt32();
708
+ if (setting.topic) {
709
+ dec.readString(); // topic (unused)
710
+ }
711
+ const encryptedPayload = dec.readRemaining();
712
+ // Decrypt + parse BEFORE acking. RECVACK tells the server "delivered, don't
713
+ // resend" — if we ack first and decrypt then fails (transient key/IV issue),
714
+ // the message is lost forever with only a debug log. By acking only after a
715
+ // successful parse, a transient failure leaves the message un-acked so the
716
+ // server redelivers it. (A *permanent* decrypt failure means the AES key/IV
717
+ // from CONNACK is wrong — onConnack now fails the connection in that case,
718
+ // forcing a fresh handshake rather than a silent-drop or redelivery loop.)
719
+ let payloadObj;
720
+ try {
721
+ const decryptedBytes = aesDecrypt(encryptedPayload, this.aesKey, this.aesIV);
722
+ const payloadStr = uintToString(Array.from(decryptedBytes));
723
+ payloadObj = JSON.parse(payloadStr);
724
+ }
725
+ catch (err) {
726
+ // Count failures per messageID. Below the cap, leave it un-acked so the
727
+ // server redelivers (handles a transient hiccup). At the cap, ack-and-drop
728
+ // this one poison message so it can't wedge the stream forever.
729
+ const fails = (this.decryptFailCounts.get(messageID) ?? 0) + 1;
730
+ if (fails >= MAX_DECRYPT_RETRIES) {
731
+ console.error(`[WKSocket] payload decrypt/parse failed ${fails}x for message ${messageID} — ` +
732
+ `ack-and-drop (poison message) so it stops redelivering:`, err);
733
+ this.decryptFailCounts.delete(messageID);
734
+ this.sendRaw(encodeRecvackPacket(messageID, messageSeq));
735
+ return;
736
+ }
737
+ // Bound the map so a flood of distinct failing ids can't grow it forever.
738
+ if (this.decryptFailCounts.size >= MAX_DECRYPT_FAIL_ENTRIES) {
739
+ this.decryptFailCounts.clear();
740
+ }
741
+ this.decryptFailCounts.set(messageID, fails);
742
+ console.error(`[WKSocket] payload decrypt/parse error (attempt ${fails}/${MAX_DECRYPT_RETRIES}) — ` +
743
+ `NOT acking so the server can redeliver:`, err);
744
+ return;
745
+ }
746
+ // Parse succeeded — clear any prior failure count and ack.
747
+ this.decryptFailCounts.delete(messageID);
748
+ this.sendRaw(encodeRecvackPacket(messageID, messageSeq));
749
+ // Build MessagePayload (same shape as SDK's contentObj-based output)
750
+ const payload = {
751
+ type: payloadObj?.type ?? 0,
752
+ content: payloadObj?.content,
753
+ ...payloadObj,
754
+ };
755
+ const msg = {
756
+ message_id: messageID,
757
+ message_seq: messageSeq,
758
+ from_uid: fromUID,
759
+ channel_id: channelID,
760
+ channel_type: channelType,
761
+ timestamp,
762
+ payload,
763
+ streamOn: setting.streamOn,
764
+ };
765
+ this.opts.onMessage(msg);
766
+ }
767
+ onDisconnect(dec) {
768
+ dec.readByte(); // reasonCode (unused)
769
+ dec.readString(); // reason (unused)
770
+ this.connected = false;
771
+ this.needReconnect = false;
772
+ this.stopHeart();
773
+ this.clearStableTimer();
774
+ if (this.ws) {
775
+ try {
776
+ this.ws.close();
777
+ }
778
+ catch { }
779
+ this.ws = null;
780
+ }
781
+ this.opts.onError?.(new Error("Kicked by server"));
782
+ this.opts.onDisconnected?.();
783
+ }
784
+ }
785
+ // ─── Utilities ──────────────────────────────────────────────────────────────
786
+ function generateDeviceID() {
787
+ return "xxxxxxxxxxxx4xxxyxxxxxxxxxxxxxxx".replace(/[xy]/g, (c) => {
788
+ const r = (Math.random() * 16) | 0;
789
+ const v = c === "x" ? r : (r & 0x3) | 0x8;
790
+ return v.toString(16);
791
+ });
792
+ }
793
+ //# sourceMappingURL=socket.js.map