@agentunion/fastaun 0.2.18 → 0.2.20

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 (86) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/_packed_docs/CHANGELOG.md +23 -0
  3. package/_packed_docs/agent.md/SCHEMA.md +173 -0
  4. package/_packed_docs/agent.md/examples/codeagent-claudecode.md +61 -0
  5. package/_packed_docs/agent.md/examples/human-developer.md +60 -0
  6. package/_packed_docs/agent.md/examples/openclaw-lobster.md +52 -0
  7. package/_packed_docs/agent.md/examples/signed-openclaw-lobster.md +43 -0
  8. package/_packed_docs/protocol/00-/346/200/273/350/247/210/344/270/216/345/210/206/345/261/202.md +205 -0
  9. package/_packed_docs/protocol/00A-/350/256/276/350/256/241/345/216/237/345/210/231-/344/270/272Agent/350/200/214/347/224/237.md +197 -0
  10. package/_packed_docs/protocol/01-/350/272/253/344/273/275/344/270/216/345/207/255/350/257/201/345/215/217/350/256/256-auth.md +549 -0
  11. package/_packed_docs/protocol/02-/350/257/201/344/271/246/344/270/216/344/277/241/344/273/273/344/275/223/347/263/273.md +810 -0
  12. package/_packed_docs/protocol/03-Gateway-/350/277/236/346/216/245/346/250/241/345/274/217.md +262 -0
  13. package/_packed_docs/protocol/04-Peer-/345/255/220/345/215/217/350/256/256.md +180 -0
  14. package/_packed_docs/protocol/05-Relay-/345/255/220/345/215/217/350/256/256.md +164 -0
  15. package/_packed_docs/protocol/06-/346/234/215/345/212/241/345/215/217/350/256/256.md +1135 -0
  16. package/_packed_docs/protocol/07-/351/224/231/350/257/257/347/240/201/344/270/216/347/212/266/346/200/201/346/234/272.md +234 -0
  17. package/_packed_docs/protocol/08-AUN-E2EE-Group.md +900 -0
  18. package/_packed_docs/protocol/08-AUN-E2EE.md +413 -0
  19. package/_packed_docs/protocol/09-/345/256/211/345/205/250/350/200/203/350/231/221.md +316 -0
  20. package/_packed_docs/protocol/10-Group-/345/255/220/345/215/217/350/256/256.md +804 -0
  21. package/_packed_docs/protocol/11-Storage-/345/255/220/345/215/217/350/256/256.md +271 -0
  22. package/_packed_docs/protocol/12-Stream-/345/255/220/345/215/217/350/256/256.md +329 -0
  23. package/_packed_docs/protocol/13-Agent/350/241/214/344/270/272/350/247/204/350/214/203.md +141 -0
  24. package/_packed_docs/protocol/14-/344/272/244/344/272/222/346/234/272/345/210/266-/345/223/215/345/272/224/346/250/241/345/274/217/344/270/216/350/207/252/344/270/273/346/250/241/345/274/217.md +170 -0
  25. package/_packed_docs/protocol/README.md +71 -0
  26. package/_packed_docs/protocol/agent.md/SCHEMA.md +118 -0
  27. package/_packed_docs/protocol/agent.md/examples/codeagent-claudecode.md +61 -0
  28. package/_packed_docs/protocol/agent.md/examples/human-developer.md +60 -0
  29. package/_packed_docs/protocol/agent.md/examples/openclaw-lobster.md +52 -0
  30. package/_packed_docs/protocol/aun-docs-guide.md +49 -0
  31. package/_packed_docs/protocol/index.md +114 -0
  32. package/_packed_docs/protocol//350/215/211/346/241/210-agent.md/347/255/276/345/220/215/345/215/217/350/256/256.md +205 -0
  33. package/_packed_docs/protocol//350/215/211/346/241/210-/346/213/222/347/273/235/344/277/241/345/217/267/345/215/217/350/256/256.md +249 -0
  34. package/_packed_docs/protocol//351/231/204/345/275/225A-/346/234/257/350/257/255/350/241/250.md +337 -0
  35. package/_packed_docs/protocol//351/231/204/345/275/225B-/346/211/251/345/261/225/346/200/247/346/214/207/345/215/227.md +80 -0
  36. package/_packed_docs/protocol//351/231/204/345/275/225C-/347/247/201/351/222/245/347/256/241/347/220/206/344/270/216/350/272/253/344/273/275/346/201/242/345/244/215.md +704 -0
  37. package/_packed_docs/protocol//351/231/204/345/275/225D-Root_CA_/346/262/273/347/220/206/346/234/272/345/210/266.md +620 -0
  38. package/_packed_docs/protocol//351/231/204/345/275/225E-Root_CA_/345/207/206/345/205/245/346/265/201/347/250/213.md +605 -0
  39. package/_packed_docs/protocol//351/231/204/345/275/225F-Issuer_CA_/347/224/263/350/257/267/346/265/201/347/250/213.md +548 -0
  40. package/_packed_docs/protocol//351/231/204/345/275/225G-AID_/345/255/244/345/204/277/351/242/204/351/230/262/344/270/216/346/225/221/346/217/264/346/234/272/345/210/266.md +513 -0
  41. package/_packed_docs/protocol//351/231/204/345/275/225H-Identity/346/234/215/345/212/241/345/256/236/347/216/260/346/214/207/345/215/227.md +619 -0
  42. package/_packed_docs/protocol//351/231/204/345/275/225I-/350/267/250/345/237/237/346/266/210/346/201/257/350/267/257/347/224/261/345/256/236/347/216/260/346/214/207/345/215/227.md +492 -0
  43. package/_packed_docs/protocol//351/231/204/345/275/225J-/345/256/242/346/210/267/347/253/257/346/216/245/345/205/245/347/244/272/344/276/213.md +402 -0
  44. package/_packed_docs/protocol//351/231/204/345/275/225K-Agent_Web/345/217/221/347/216/260/345/215/217/350/256/256.md +130 -0
  45. package/_packed_docs/protocol//351/231/204/345/275/225L-E2EE/345/256/236/347/216/260/346/214/207/345/215/227.md +267 -0
  46. package/_packed_docs/protocol//351/231/204/345/275/225M-JWT/350/256/244/350/257/201/345/256/236/347/216/260/346/214/207/345/215/227.md +367 -0
  47. package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +223 -0
  48. package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +354 -0
  49. package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +172 -0
  50. package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +373 -0
  51. package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +611 -0
  52. package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +1152 -0
  53. package/_packed_docs/sdk/07-/351/224/231/350/257/257/345/244/204/347/220/206.md +150 -0
  54. package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +89 -0
  55. package/_packed_docs/sdk/09-custody-api-manual.md +445 -0
  56. package/_packed_docs/sdk/09-group-rpc-manual.md +1895 -0
  57. package/_packed_docs/sdk/09-message-rpc-manual.md +597 -0
  58. package/_packed_docs/sdk/09-meta-rpc-manual.md +142 -0
  59. package/_packed_docs/sdk/09-payload-reference.md +702 -0
  60. package/_packed_docs/sdk/09-storage-rpc-manual.md +408 -0
  61. package/_packed_docs/sdk/09-stream-rpc-manual.md +275 -0
  62. package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +72 -0
  63. package/_packed_docs/sdk/INDEX.md +131 -0
  64. package/_packed_docs/sdk/README.md +307 -0
  65. package/dist/auth.d.ts +8 -1
  66. package/dist/auth.js +83 -15
  67. package/dist/auth.js.map +1 -1
  68. package/dist/client.d.ts +46 -6
  69. package/dist/client.js +399 -122
  70. package/dist/client.js.map +1 -1
  71. package/dist/e2ee.js +21 -2
  72. package/dist/e2ee.js.map +1 -1
  73. package/dist/keystore/aid-db.d.ts +6 -0
  74. package/dist/keystore/aid-db.js +21 -0
  75. package/dist/keystore/aid-db.js.map +1 -1
  76. package/dist/keystore/file.d.ts +6 -0
  77. package/dist/keystore/file.js +8 -0
  78. package/dist/keystore/file.js.map +1 -1
  79. package/dist/keystore/index.d.ts +2 -0
  80. package/dist/namespaces/auth.d.ts +13 -4
  81. package/dist/namespaces/auth.js +88 -13
  82. package/dist/namespaces/auth.js.map +1 -1
  83. package/dist/transport.d.ts +10 -0
  84. package/dist/transport.js +24 -0
  85. package/dist/transport.js.map +1 -1
  86. package/package.json +4 -1
package/dist/client.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * - 群组 E2EE 全自动编排(建群/加人/踢人/退出)
12
12
  */
13
13
  import * as crypto from 'node:crypto';
14
+ import * as fs from 'node:fs';
14
15
  import * as http from 'node:http';
15
16
  import * as https from 'node:https';
16
17
  import { join } from 'node:path';
@@ -81,7 +82,7 @@ const DEFAULT_SESSION_OPTIONS = {
81
82
  },
82
83
  timeouts: {
83
84
  connect: 5.0,
84
- call: 10.0,
85
+ call: 35.0,
85
86
  http: 30.0,
86
87
  },
87
88
  };
@@ -93,6 +94,20 @@ const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
93
94
  const PENDING_DECRYPT_LIMIT = 100;
94
95
  const PUSHED_SEQS_LIMIT = 50_000;
95
96
  const PENDING_ORDERED_LIMIT = 50_000;
97
+ // 心跳间隔下/上限(秒)。0 = 关闭心跳;负值视为 0;其余值 clamp 到 [10, 600]。
98
+ // 服务端通过 hello.heartbeat_interval 与 meta.ping pong 中的同名字段下发。
99
+ const HEARTBEAT_MIN_INTERVAL_SECONDS = 10;
100
+ const HEARTBEAT_MAX_INTERVAL_SECONDS = 600;
101
+ function clampHeartbeatInterval(value) {
102
+ const n = Number(value);
103
+ if (!Number.isFinite(n) || n <= 0)
104
+ return 0;
105
+ if (n < HEARTBEAT_MIN_INTERVAL_SECONDS)
106
+ return HEARTBEAT_MIN_INTERVAL_SECONDS;
107
+ if (n > HEARTBEAT_MAX_INTERVAL_SECONDS)
108
+ return HEARTBEAT_MAX_INTERVAL_SECONDS;
109
+ return n;
110
+ }
96
111
  // P1-23: 非幂等方法使用更长超时(35s),避免 SDK 10s 超时 < gateway 30s 处理时间
97
112
  const NON_IDEMPOTENT_TIMEOUT_MS = 35_000;
98
113
  const NON_IDEMPOTENT_METHODS = new Set([
@@ -337,6 +352,7 @@ export class AUNClient {
337
352
  /** 当前实例上下文 */
338
353
  _deviceId;
339
354
  _slotId;
355
+ _connectedAt = 0;
340
356
  _connectDeliveryMode;
341
357
  _defaultConnectDeliveryMode;
342
358
  /** peer 证书缓存 */
@@ -344,13 +360,20 @@ export class AUNClient {
344
360
  _peerPrekeysCache = new Map();
345
361
  _prekeyReplenishInflight = new Set();
346
362
  _prekeyReplenished = new Set();
363
+ // 当前活跃 prekey:只有这个 prekey 被消费时才触发 replenish 上传
364
+ _activePrekeyId = '';
365
+ // 本地 agent.md 文件路径与对应 etag(quoted sha256 hex,与服务端 _agent_md_etag 一致)。
366
+ // 由 setLocalAgentMdPath() 设置;用于跟服务端 RPC 注入的 _meta.agent_md_etag 比对,
367
+ // 触发"本地未发布到服务端"或"服务端版本更新"的 UI 提示。
368
+ _localAgentMdPath = '';
369
+ _localAgentMdEtag = '';
370
+ // gateway 在 RPC envelope._meta.agent_md_etag 注入的服务端 etag;纯观察,无下游依赖。
371
+ _remoteAgentMdEtag = '';
347
372
  /** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
348
373
  _seqTracker = new SeqTracker();
349
374
  _seqTrackerContext = null;
350
375
  /** 惰性群同步:已同步过的 group_id 集合 */
351
376
  _groupSynced = new Set();
352
- /** 惰性 P2P 同步:是否已同步过 */
353
- _p2pSynced = false;
354
377
  /** 补洞去重:已完成/进行中的 key -> 开始时间戳,防止重复 pull 同一区间 */
355
378
  _gapFillDone = new Map();
356
379
  /** 已发布到应用层的 seq 集合(按命名空间),补洞路径 publish 前检查以避免重复分发 */
@@ -375,6 +398,8 @@ export class AUNClient {
375
398
  _reconnectActive = false;
376
399
  _reconnectAbort = null;
377
400
  _serverKicked = false;
401
+ /** 缓存最近一次 gateway.disconnect 信息(含服务端附带的 detail),用于后续 connection.state 透传 */
402
+ _lastDisconnectInfo = null;
378
403
  _logger;
379
404
  _clientLog;
380
405
  constructor(config, debug = false) {
@@ -428,6 +453,7 @@ export class AUNClient {
428
453
  verifySsl: this._configModel.verifySsl,
429
454
  logger: this._logger.for('aun_core.transport'),
430
455
  });
456
+ this._transport.setMetaObserver((meta) => this._observeRpcMeta(meta));
431
457
  this._e2ee = new E2EEManager({
432
458
  identityFn: () => this._identity ?? {},
433
459
  deviceIdFn: () => this._deviceId,
@@ -465,6 +491,65 @@ export class AUNClient {
465
491
  get aid() {
466
492
  return this._aid;
467
493
  }
494
+ /**
495
+ * 记录本地 agent.md 文件路径并一次性计算 etag(quoted sha256,与服务端一致)。
496
+ *
497
+ * - path 为空字符串:清除本地 path 与 etag。
498
+ * - 文件不存在 / 读取失败:清除 etag 并返回空串,不抛异常(应用可读 getLocalAgentMdEtag()
499
+ * 为空判断)。
500
+ * - 浏览器环境无文件系统:直接返回空串,记录 warn 日志。
501
+ * - 文件变更后需要重新调用 setLocalAgentMdPath() 触发重算(按设计:设置时一次性计算)。
502
+ *
503
+ * 返回当前 etag(quoted hex 或空串)。
504
+ */
505
+ setLocalAgentMdPath(path) {
506
+ const rawPath = String(path ?? '').trim();
507
+ if (!rawPath) {
508
+ this._localAgentMdPath = '';
509
+ this._localAgentMdEtag = '';
510
+ return '';
511
+ }
512
+ // 浏览器环境没有 fs,直接退回空串。Node 环境才尝试读文件。
513
+ const isNode = typeof process !== 'undefined' && !!process.versions?.node;
514
+ if (!isNode) {
515
+ this._clientLog.warn(`setLocalAgentMdPath skipped: not running in Node.js (path=${rawPath})`);
516
+ this._localAgentMdPath = rawPath;
517
+ this._localAgentMdEtag = '';
518
+ return '';
519
+ }
520
+ this._localAgentMdPath = rawPath;
521
+ try {
522
+ const data = fs.readFileSync(rawPath);
523
+ const digest = crypto.createHash('sha256').update(data).digest('hex');
524
+ this._localAgentMdEtag = `"${digest}"`;
525
+ }
526
+ catch (err) {
527
+ this._clientLog.warn(`setLocalAgentMdPath 读取失败 path=${rawPath} err=${err instanceof Error ? err.message : String(err)}`);
528
+ this._localAgentMdEtag = '';
529
+ }
530
+ return this._localAgentMdEtag;
531
+ }
532
+ /** 返回 setLocalAgentMdPath 计算的 etag;未设置或读取失败时返回空串。 */
533
+ getLocalAgentMdEtag() {
534
+ return this._localAgentMdEtag;
535
+ }
536
+ /**
537
+ * 返回 gateway 在最近一次 RPC envelope._meta 注入的服务端 agent.md etag。
538
+ *
539
+ * 未收到过则为空串;不阻塞调用,纯内存读。
540
+ */
541
+ getRemoteAgentMdEtag() {
542
+ return this._remoteAgentMdEtag;
543
+ }
544
+ /** transport 的 meta observer:吸收 gateway 注入的 _meta 字段。失败不影响业务。 */
545
+ _observeRpcMeta(meta) {
546
+ if (!meta || typeof meta !== 'object')
547
+ return;
548
+ const etag = String(meta.agent_md_etag ?? '').trim();
549
+ if (etag) {
550
+ this._remoteAgentMdEtag = etag;
551
+ }
552
+ }
468
553
  /** 连接状态 */
469
554
  get state() {
470
555
  return this._state;
@@ -515,7 +600,7 @@ export class AUNClient {
515
600
  this._sessionParams = normalized;
516
601
  this._sessionOptions = this._buildSessionOptions(normalized);
517
602
  const callTimeoutSec = this._sessionOptions.timeouts.call;
518
- this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 10_000);
603
+ this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 35_000);
519
604
  this._closing = false;
520
605
  this._clientLog.debug(`connect enter: gateway=${String(normalized.gateway ?? '')}, device_id=${this._deviceId}`);
521
606
  try {
@@ -662,6 +747,7 @@ export class AUNClient {
662
747
  return await this._sendEncrypted(p);
663
748
  }
664
749
  // encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
750
+ this._maybeAppendEchoTraceSend(p);
665
751
  }
666
752
  // 自动加密:group.send 默认加密(encrypt 默认 True)
667
753
  if (method === 'group.send') {
@@ -670,6 +756,7 @@ export class AUNClient {
670
756
  if (encrypt) {
671
757
  return await this._sendGroupEncrypted(p);
672
758
  }
759
+ this._maybeAppendEchoTraceSend(p);
673
760
  }
674
761
  if (method === 'group.thought.put') {
675
762
  const encrypt = p.encrypt ?? true;
@@ -744,12 +831,29 @@ export class AUNClient {
744
831
  const gid = (p.group_id ?? '');
745
832
  if (gid) {
746
833
  const ns = `group:${gid}`;
747
- if (rawMessages.length > 0) {
748
- this._seqTracker.onPullResult(ns, rawMessages);
834
+ // 区分解密成功 / 失败:失败的 payload 仍是 e2ee.group_encrypted。
835
+ const decryptedOnly = [];
836
+ let failedCount = 0;
837
+ const decryptedMessages = Array.isArray(r.messages) ? r.messages : [];
838
+ for (const m of decryptedMessages) {
839
+ if (!isJsonObject(m))
840
+ continue;
841
+ const payload = isJsonObject(m.payload) ? m.payload : {};
842
+ const ptype = payload.type;
843
+ if (ptype === 'e2ee.group_encrypted') {
844
+ failedCount++;
845
+ this._enqueuePendingDecrypt(gid, m);
846
+ }
847
+ else {
848
+ decryptedOnly.push(m);
849
+ }
850
+ }
851
+ if (decryptedOnly.length > 0) {
852
+ // 仅用解密成功的消息推进 contig;失败的等 retry 解密成功才推进。
853
+ this._seqTracker.onPullResult(ns, decryptedOnly);
749
854
  }
750
855
  // ⚠️ 逻辑边界 L4:group retention floor 通道 = cursor.current_seq
751
856
  // 群路径目前无独立 earliest_available_seq 字段;若未来引入 group retention,需新增字段并同步更新此处。
752
- // 与 S2 [1,seq-1] 历史 gap 配合使用,force_contiguous_seq 是跳过空洞的唯一手段。
753
857
  const cursor = isJsonObject(r.cursor) ? r.cursor : null;
754
858
  if (cursor) {
755
859
  const serverAck = Number(cursor.current_seq ?? 0);
@@ -762,9 +866,9 @@ export class AUNClient {
762
866
  }
763
867
  }
764
868
  this._saveSeqTrackerState();
765
- // auto-ack contiguous_seq
869
+ // auto-ack:仅当没有解密失败时才 ack。失败时让服务端 cursor 留在原位等 retry。
766
870
  const contig = this._seqTracker.getContiguousSeq(ns);
767
- const shouldAck = rawMessages.length > 0 || (cursor !== null && Number(cursor.current_seq ?? 0) > 0);
871
+ const shouldAck = failedCount === 0 && (decryptedOnly.length > 0 || (cursor !== null && Number(cursor.current_seq ?? 0) > 0));
768
872
  if (contig > 0 && shouldAck) {
769
873
  this._transport.call('group.ack_messages', {
770
874
  group_id: gid,
@@ -773,6 +877,10 @@ export class AUNClient {
773
877
  slot_id: this._slotId,
774
878
  }).catch((e) => { this._clientLog.debug(`group.pull auto-ack failed: group=${gid} ${formatCaughtError(e)}`); });
775
879
  }
880
+ // 有解密失败时调度 recovery 兜底定时
881
+ if (failedCount > 0) {
882
+ this._scheduleRecoveryTimeout(gid);
883
+ }
776
884
  }
777
885
  }
778
886
  if (method === 'group.thought.get' && isJsonObject(result)) {
@@ -913,10 +1021,8 @@ export class AUNClient {
913
1021
  const persistRequired = Boolean(params.persist_required || params.durable);
914
1022
  const protectedHeaders = this._protectedHeadersFromParams(params);
915
1023
  this._clientLog.debug(`_sendEncrypted enter: to=${toAid}, message_id=${messageId}`);
916
- // 惰性同步:首次发送 P2P 消息时先 pull 一次
917
- if (!this._p2pSynced) {
918
- await this._lazySyncP2p();
919
- }
1024
+ // 惰性 P2P 同步由 connect/reconnect 完成后的 _fillP2pGap 异步触发,
1025
+ // 不再在 send 路径上 await(与 C++ FillP2PGap 行为对齐,避免阻塞用户发送)。
920
1026
  // 内部发送逻辑,refreshPeerMaterial=true 时强制清缓存重新拉取对端材料
921
1027
  const sendAttempt = async (refreshPeerMaterial = false) => {
922
1028
  const recipientPrekeys = refreshPeerMaterial
@@ -929,24 +1035,13 @@ export class AUNClient {
929
1035
  timestamp,
930
1036
  protectedHeaders,
931
1037
  });
932
- // 多设备过滤:只保留有有效 device_id 的可路由 prekey
933
- // 占位符 PREKEY_FALLBACK_DEVICE_ID 表示服务端未分配真实设备 ID,不可用于多设备路由
1038
+ // 统一 multi-device 路径:必须有 routable prekey
934
1039
  const routablePrekeys = recipientPrekeys.filter(pk => {
935
1040
  const did = String(pk.device_id ?? '').trim();
936
1041
  return did && did !== PREKEY_FALLBACK_DEVICE_ID;
937
1042
  });
938
- const canUseMultiDevice = routablePrekeys.length > 0
939
- && (routablePrekeys.length > 1 || selfSyncCopies.length > 0);
940
- if (!canUseMultiDevice) {
941
- return await this._sendEncryptedSingle({
942
- toAid,
943
- payload,
944
- messageId,
945
- timestamp,
946
- prekey: routablePrekeys[0] ?? recipientPrekeys[0],
947
- persistRequired,
948
- protectedHeaders,
949
- });
1043
+ if (routablePrekeys.length === 0) {
1044
+ throw new Error(`no registered device prekeys for ${toAid}, cannot send encrypted message`);
950
1045
  }
951
1046
  const recipientCopies = await this._buildRecipientDeviceCopies({
952
1047
  toAid,
@@ -998,39 +1093,6 @@ export class AUNClient {
998
1093
  throw exc;
999
1094
  }
1000
1095
  }
1001
- async _sendEncryptedSingle(opts) {
1002
- this._clientLog.debug(`_sendEncryptedSingle enter: to=${opts.toAid}, message_id=${opts.messageId}, has_prekey=${!!opts.prekey}, persist_required=${!!opts.persistRequired}`);
1003
- let prekey = opts.prekey ?? null;
1004
- if (!prekey) {
1005
- this._clientLog.debug(`_sendEncryptedSingle fetching peer prekey: to=${opts.toAid}`);
1006
- prekey = await this._fetchPeerPrekey(opts.toAid);
1007
- }
1008
- const peerCertFingerprint = typeof prekey?.cert_fingerprint === 'string' ? prekey.cert_fingerprint : undefined;
1009
- const peerCertPem = await this._fetchPeerCert(opts.toAid, peerCertFingerprint);
1010
- const [envelope, encryptResult] = this._encryptCopyPayload({
1011
- logicalToAid: opts.toAid,
1012
- payload: opts.payload,
1013
- peerCertPem,
1014
- prekey,
1015
- messageId: opts.messageId,
1016
- timestamp: opts.timestamp,
1017
- protectedHeaders: opts.protectedHeaders,
1018
- });
1019
- this._ensureEncryptResult(opts.toAid, encryptResult);
1020
- this._clientLog.debug(`_sendEncryptedSingle envelope built: to=${opts.toAid}, message_id=${opts.messageId}, scheme=${String(envelope?.scheme ?? '')}`);
1021
- const sendParams = {
1022
- to: opts.toAid,
1023
- payload: envelope,
1024
- type: 'e2ee.encrypted',
1025
- encrypted: true,
1026
- message_id: opts.messageId,
1027
- timestamp: opts.timestamp,
1028
- };
1029
- if (opts.persistRequired) {
1030
- sendParams.persist_required = true;
1031
- }
1032
- return await this._transport.call('message.send', sendParams);
1033
- }
1034
1096
  async _buildRecipientDeviceCopies(opts) {
1035
1097
  this._clientLog.debug(`_buildRecipientDeviceCopies enter: to=${opts.toAid}, message_id=${opts.messageId}, prekey_count=${opts.prekeys.length}`);
1036
1098
  const recipientCopies = [];
@@ -1322,33 +1384,6 @@ export class AUNClient {
1322
1384
  this._clientLog.warn(`lazy sync group ${groupId} failed: ${formatCaughtError(exc)}`);
1323
1385
  }
1324
1386
  }
1325
- /** 惰性同步:首次激活 P2P 通道时 pull 最近消息,建立 seq 基线 */
1326
- async _lazySyncP2p() {
1327
- this._p2pSynced = true;
1328
- if (!this._aid)
1329
- return;
1330
- try {
1331
- const ns = `p2p:${this._aid}`;
1332
- const afterSeq = this._seqTracker.getContiguousSeq(ns);
1333
- const result = await this._transport.call('message.pull', {
1334
- after_seq: afterSeq,
1335
- limit: 200,
1336
- });
1337
- const messages = Array.isArray(result?.messages) ? result.messages : [];
1338
- for (const msg of messages) {
1339
- const seq = msg?.seq;
1340
- if (seq != null)
1341
- this._seqTracker.onMessageSeq(ns, Number(seq));
1342
- }
1343
- if (messages.length > 0) {
1344
- this._saveSeqTrackerState();
1345
- this._clientLog.info(`lazy sync P2P: pull ${messages.length} messages, after_seq=${afterSeq}`);
1346
- }
1347
- }
1348
- catch (exc) {
1349
- this._clientLog.warn(`lazy sync P2P failed: ${formatCaughtError(exc)}`);
1350
- }
1351
- }
1352
1387
  _isGroupEpochTooOldError(exc) {
1353
1388
  const text = String(exc).toLowerCase();
1354
1389
  return text.includes('e2ee epoch too old') || text.includes('epoch below sender membership floor');
@@ -1726,12 +1761,27 @@ export class AUNClient {
1726
1761
  }
1727
1762
  // 拦截 P2P 传输的群组密钥分发/请求/响应消息
1728
1763
  if (await this._tryHandleGroupKeyMessage(msg)) {
1764
+ // group_key 控制消息也要推进 seq tracker + auto-ack,
1765
+ // 否则 fillP2pGap 会因为 contig 卡在此 seq 之前而重复拉取同样的历史消息。
1766
+ const seq = msg.seq;
1767
+ if (seq !== undefined && seq !== null && this._aid) {
1768
+ const ns = `p2p:${this._aid}`;
1769
+ this._seqTracker.onMessageSeq(ns, seq);
1770
+ this._saveSeqTrackerState();
1771
+ const contig = this._seqTracker.getContiguousSeq(ns);
1772
+ if (contig > 0) {
1773
+ this._transport.call('message.ack', {
1774
+ seq: contig,
1775
+ device_id: this._deviceId,
1776
+ slot_id: this._slotId,
1777
+ }).catch(() => { });
1778
+ }
1779
+ }
1729
1780
  return;
1730
1781
  }
1731
1782
  // P2P 空洞检测
1732
1783
  const seq = msg.seq;
1733
1784
  if (seq !== undefined && seq !== null && this._aid) {
1734
- this._p2pSynced = true; // 收到推送即视为已激活
1735
1785
  const ns = `p2p:${this._aid}`;
1736
1786
  const needPull = this._seqTracker.onMessageSeq(ns, seq);
1737
1787
  if (needPull) {
@@ -1812,8 +1862,12 @@ export class AUNClient {
1812
1862
  }
1813
1863
  const decrypted = await this._decryptGroupMessage(msg);
1814
1864
  this._clientLog.debug(`group message decrypt done: group=${groupId}, from=${String(msg.from ?? '')}, seq=${String(seq ?? '')}, e2ee=${String(!!decrypted.e2ee)}`);
1815
- // 只有带 payload 的真实消息,在同步解密/恢复尝试结束后才推进游标。
1816
- if (groupId && seq !== undefined && seq !== null) {
1865
+ // 解密失败时**不推进 seq tracker / 不 auto-ack**:让服务端 cursor 留在原位,
1866
+ // 等密钥恢复后 retry 解密成功才推进 + ack;recovery 真的失败时由
1867
+ // _retryPendingDecryptMsgs(forceAdvanceOnFail=true) 兜底强制推进。
1868
+ const payloadForCheck = isJsonObject(msg.payload) ? msg.payload : null;
1869
+ const isDecryptFail = payloadForCheck?.type === 'e2ee.group_encrypted' && !decrypted.e2ee;
1870
+ if (!isDecryptFail && groupId && seq !== undefined && seq !== null) {
1817
1871
  const ns = `group:${groupId}`;
1818
1872
  const needPull = this._seqTracker.onMessageSeq(ns, seq);
1819
1873
  if (needPull) {
@@ -1832,10 +1886,12 @@ export class AUNClient {
1832
1886
  this._saveSeqTrackerState();
1833
1887
  }
1834
1888
  // R3: 解密失败 → 不 publish 密文给应用层,入 pending 队列 + 发 undecryptable 事件
1835
- const payloadForCheck = isJsonObject(msg.payload) ? msg.payload : null;
1836
- if (payloadForCheck?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
1837
- if (groupId)
1889
+ if (isDecryptFail) {
1890
+ if (groupId) {
1838
1891
  this._enqueuePendingDecrypt(groupId, msg);
1892
+ // 触发 recovery 兜底定时(30s 后如果仍未解开,强制推进)
1893
+ this._scheduleRecoveryTimeout(groupId);
1894
+ }
1839
1895
  await this._publishAppEvent('group.message_undecryptable', {
1840
1896
  message_id: msg.message_id,
1841
1897
  group_id: groupId,
@@ -2059,8 +2115,64 @@ export class AUNClient {
2059
2115
  return this._attachCurrentInstanceContext(payload);
2060
2116
  }
2061
2117
  async _publishAppEvent(event, payload) {
2118
+ if ((event === 'message.received' || event === 'group.message_created') && isJsonObject(payload)) {
2119
+ this._maybeAppendEchoTraceReceive(payload);
2120
+ }
2121
+ // 注入本地/远端 agent.md etag,让应用层判断版本一致性;失败不影响业务。
2122
+ if (isJsonObject(payload)) {
2123
+ try {
2124
+ const localEtag = this._localAgentMdEtag || '';
2125
+ const remoteEtag = this._remoteAgentMdEtag || '';
2126
+ if (localEtag || remoteEtag) {
2127
+ const obj = payload;
2128
+ if (!('_agent_md' in obj)) {
2129
+ obj._agent_md = {
2130
+ local_etag: localEtag,
2131
+ remote_etag: remoteEtag,
2132
+ };
2133
+ }
2134
+ }
2135
+ }
2136
+ catch (err) {
2137
+ this._clientLog.debug(`agent_md etag inject skipped: ${err instanceof Error ? err.message : String(err)}`);
2138
+ }
2139
+ }
2062
2140
  await this._dispatcher.publish(event, this._normalizePublishedMessagePayload(event, payload));
2063
2141
  }
2142
+ _echoTimestamp() {
2143
+ const now = new Date();
2144
+ const hh = String(now.getHours()).padStart(2, '0');
2145
+ const mm = String(now.getMinutes()).padStart(2, '0');
2146
+ const ss = String(now.getSeconds()).padStart(2, '0');
2147
+ const ms = String(now.getMilliseconds()).padStart(3, '0');
2148
+ return `${hh}:${mm}:${ss}.${ms}`;
2149
+ }
2150
+ _isEchoPayload(payload) {
2151
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
2152
+ return false;
2153
+ const text = payload.text;
2154
+ if (typeof text !== 'string' || text.length > 4096)
2155
+ return false;
2156
+ return text.split('\n', 1)[0].toLowerCase().includes('echo');
2157
+ }
2158
+ _maybeAppendEchoTraceSend(params) {
2159
+ const payload = params.payload;
2160
+ if (!this._isEchoPayload(payload))
2161
+ return;
2162
+ const uptime = this._connectedAt ? Math.floor((Date.now() - this._connectedAt) / 1000) : 0;
2163
+ const trace = `${this._echoTimestamp()} [AUN-SDK.send] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
2164
+ params.payload = { ...payload, text: payload.text + '\n' + trace };
2165
+ }
2166
+ _maybeAppendEchoTraceReceive(msg) {
2167
+ if (msg.encrypted)
2168
+ return;
2169
+ const payload = msg.payload;
2170
+ if (!this._isEchoPayload(payload))
2171
+ return;
2172
+ const uptime = this._connectedAt ? Math.floor((Date.now() - this._connectedAt) / 1000) : 0;
2173
+ const trace = `${this._echoTimestamp()} [AUN-SDK.receive] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
2174
+ msg.payload = { ...payload, text: payload.text + '\n' + trace };
2175
+ }
2064
2176
  _messageTargetsCurrentInstance(message) {
2065
2177
  if (!isJsonObject(message))
2066
2178
  return true;
@@ -2787,11 +2899,31 @@ export class AUNClient {
2787
2899
  }
2788
2900
  let result;
2789
2901
  if (actualPayload.type === 'e2ee.group_key_distribution') {
2902
+ // 快速跳过已过期的历史 epoch 分发:本地已有更高 epoch 时不发任何 RPC,
2903
+ // 避免 fillP2pGap 拉到大量历史群密钥消息时触发 epoch 编排风暴。
2904
+ const distGroupId = String(actualPayload.group_id ?? '');
2905
+ const distEpoch = Number(actualPayload.epoch ?? 0);
2906
+ if (distGroupId && distEpoch > 0) {
2907
+ const localEpoch = this._groupE2ee.currentEpoch(distGroupId) ?? 0;
2908
+ if (localEpoch >= distEpoch) {
2909
+ this._clientLog.debug(`skip stale group_key_distribution: group=${distGroupId} msg_epoch=${distEpoch} local_epoch=${localEpoch}`);
2910
+ return true;
2911
+ }
2912
+ }
2790
2913
  if (!await this._verifyActiveGroupRotationDistribution(actualPayload)) {
2791
2914
  return true;
2792
2915
  }
2793
2916
  }
2794
2917
  else if (actualPayload.type === 'e2ee.group_key_response') {
2918
+ const respGroupId = String(actualPayload.group_id ?? '');
2919
+ const respEpoch = Number(actualPayload.epoch ?? 0);
2920
+ if (respGroupId && respEpoch > 0) {
2921
+ const localEpoch = this._groupE2ee.currentEpoch(respGroupId) ?? 0;
2922
+ if (localEpoch >= respEpoch) {
2923
+ this._clientLog.debug(`skip stale group_key_response: group=${respGroupId} msg_epoch=${respEpoch} local_epoch=${localEpoch}`);
2924
+ return true;
2925
+ }
2926
+ }
2795
2927
  if (!await this._verifyGroupKeyResponseEpoch(actualPayload)) {
2796
2928
  return true;
2797
2929
  }
@@ -3057,6 +3189,8 @@ export class AUNClient {
3057
3189
  async _uploadPrekey() {
3058
3190
  const prekeyMaterial = this._e2ee.generatePrekey();
3059
3191
  const result = await this._transport.call('message.e2ee.put_prekey', prekeyMaterial);
3192
+ // 上传成功后记录为活跃 prekey
3193
+ this._activePrekeyId = String(prekeyMaterial.prekey_id ?? '');
3060
3194
  return isJsonObject(result) ? { ...result } : { ok: true };
3061
3195
  }
3062
3196
  /**
@@ -3223,23 +3357,47 @@ export class AUNClient {
3223
3357
  queue.push(msg);
3224
3358
  this._pendingDecryptMsgs.set(ns, queue.slice(-PENDING_DECRYPT_LIMIT));
3225
3359
  }
3226
- async _retryPendingDecryptMsgs(groupId) {
3360
+ async _retryPendingDecryptMsgs(groupId, forceAdvanceOnFail = false) {
3227
3361
  const ns = `group:${groupId}`;
3228
3362
  const queue = this._pendingDecryptMsgs.get(ns);
3229
3363
  if (!queue || queue.length === 0)
3230
3364
  return;
3231
3365
  this._pendingDecryptMsgs.set(ns, []);
3232
3366
  const stillPending = [];
3367
+ let forceAdvancedAny = false;
3233
3368
  for (const msg of queue) {
3234
3369
  try {
3235
3370
  const decrypted = await this._decryptGroupMessage(msg);
3236
3371
  const payload = isJsonObject(msg.payload) ? msg.payload : null;
3237
3372
  if (payload?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
3238
- stillPending.push(msg);
3373
+ if (forceAdvanceOnFail) {
3374
+ // recovery 真的失败:强制推进 seq tracker + 发 undecryptable + ack
3375
+ this._clientLog.info(`group recovery give up: group=${groupId} seq=${String(msg.seq ?? '')} → force advance + publish undecryptable`);
3376
+ const seq = msg.seq;
3377
+ if (seq !== undefined && seq !== null) {
3378
+ this._seqTracker.onMessageSeq(ns, seq);
3379
+ this._saveSeqTrackerState();
3380
+ forceAdvancedAny = true;
3381
+ }
3382
+ await this._publishAppEvent('group.message_undecryptable', {
3383
+ message_id: msg.message_id,
3384
+ group_id: groupId,
3385
+ from: msg.from,
3386
+ seq,
3387
+ timestamp: msg.timestamp,
3388
+ _decrypt_error: 'epoch recovery failed',
3389
+ });
3390
+ }
3391
+ else {
3392
+ stillPending.push(msg);
3393
+ }
3239
3394
  continue;
3240
3395
  }
3241
3396
  const seq = msg.seq;
3242
3397
  if (seq !== undefined && seq !== null) {
3398
+ // 推进 seq tracker(之前 push/pull 失败时没推进)
3399
+ this._seqTracker.onMessageSeq(ns, seq);
3400
+ this._saveSeqTrackerState();
3243
3401
  await this._publishOrderedMessage('group.message_created', ns, seq, decrypted);
3244
3402
  }
3245
3403
  else {
@@ -3250,6 +3408,18 @@ export class AUNClient {
3250
3408
  stillPending.push(msg);
3251
3409
  }
3252
3410
  }
3411
+ // 强制推进有变更时,按 contig auto-ack
3412
+ if (forceAdvancedAny) {
3413
+ const contig = this._seqTracker.getContiguousSeq(ns);
3414
+ if (contig > 0) {
3415
+ this._transport.call('group.ack_messages', {
3416
+ group_id: groupId,
3417
+ msg_seq: contig,
3418
+ device_id: this._deviceId,
3419
+ slot_id: this._slotId,
3420
+ }).catch((e) => { this._clientLog.debug(`group recovery force-advance ack failed: group=${groupId} ${formatCaughtError(e)}`); });
3421
+ }
3422
+ }
3253
3423
  const queuedDuringRetry = this._pendingDecryptMsgs.get(ns) ?? [];
3254
3424
  const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-PENDING_DECRYPT_LIMIT);
3255
3425
  if (mergedPending.length)
@@ -3257,6 +3427,28 @@ export class AUNClient {
3257
3427
  else
3258
3428
  this._pendingDecryptMsgs.delete(ns);
3259
3429
  }
3430
+ /**
3431
+ * recovery 兜底定时:N 秒后如果 pending queue 仍有未解开消息,强制推进 cursor。
3432
+ * 同一 group 短时间内只调度一次。
3433
+ */
3434
+ _recoveryTimeoutScheduled = new Map();
3435
+ _scheduleRecoveryTimeout(groupId, timeoutMs = 30000) {
3436
+ if (!groupId)
3437
+ return;
3438
+ const now = Date.now();
3439
+ const last = this._recoveryTimeoutScheduled.get(groupId) ?? 0;
3440
+ if (last && (last + timeoutMs) > now)
3441
+ return;
3442
+ this._recoveryTimeoutScheduled.set(groupId, now);
3443
+ setTimeout(() => {
3444
+ const ns = `group:${groupId}`;
3445
+ const queue = this._pendingDecryptMsgs.get(ns);
3446
+ if (!queue || queue.length === 0)
3447
+ return;
3448
+ this._clientLog.info(`group recovery timeout: group=${groupId} → force advance`);
3449
+ this._retryPendingDecryptMsgs(groupId, true).catch((exc) => this._clientLog.warn(`group ${groupId} recovery timeout retry failed: ${formatCaughtError(exc)}`));
3450
+ }, timeoutMs).unref?.();
3451
+ }
3260
3452
  _scheduleRetryPendingDecryptMsgs(groupId) {
3261
3453
  if (!groupId || !this._pendingDecryptMsgs.has(`group:${groupId}`))
3262
3454
  return;
@@ -3963,6 +4155,11 @@ export class AUNClient {
3963
4155
  const groupId = String(payload.group_id ?? '').trim();
3964
4156
  if (!groupId)
3965
4157
  return false;
4158
+ // 历史群(不在当前 session 活跃列表):跳过 RPC 验证,只做本地 handle_incoming
4159
+ if (!this._groupSynced.has(groupId)) {
4160
+ this._clientLog.debug(`skip RPC verify for inactive group: group=${groupId} rotation=${rotationId}`);
4161
+ return true;
4162
+ }
3966
4163
  const epoch = Number(payload.epoch ?? 0);
3967
4164
  if (!Number.isFinite(epoch) || epoch <= 0)
3968
4165
  return false;
@@ -4695,7 +4892,6 @@ export class AUNClient {
4695
4892
  this._pendingOrderedMsgs.clear();
4696
4893
  this._pendingDecryptMsgs.clear();
4697
4894
  this._groupSynced.clear();
4698
- this._p2pSynced = false;
4699
4895
  }
4700
4896
  _refreshSeqTrackerContext() {
4701
4897
  const nextContext = this._currentSeqTrackerContext();
@@ -4707,7 +4903,6 @@ export class AUNClient {
4707
4903
  this._pendingOrderedMsgs.clear();
4708
4904
  this._pendingDecryptMsgs.clear();
4709
4905
  this._groupSynced.clear();
4710
- this._p2pSynced = false;
4711
4906
  this._seqTrackerContext = nextContext;
4712
4907
  }
4713
4908
  /** 将 SeqTracker 状态保存到 keystore */
@@ -4796,6 +4991,9 @@ export class AUNClient {
4796
4991
  this._gatewayUrl = gatewayUrl;
4797
4992
  this._slotId = String(params.slot_id ?? '');
4798
4993
  this._connectDeliveryMode = { ...(params.delivery_mode ?? this._connectDeliveryMode) };
4994
+ const extraInfo = (params.extra_info && typeof params.extra_info === 'object' && !Array.isArray(params.extra_info))
4995
+ ? params.extra_info
4996
+ : undefined;
4799
4997
  const prevState = this._state;
4800
4998
  this._auth.setInstanceContext({ deviceId: this._deviceId, slotId: this._slotId });
4801
4999
  this._state = 'connecting';
@@ -4814,6 +5012,9 @@ export class AUNClient {
4814
5012
  deviceId: this._deviceId,
4815
5013
  slotId: this._slotId,
4816
5014
  deliveryMode: this._connectDeliveryMode,
5015
+ connectionKind: String(params.connection_kind ?? 'long'),
5016
+ shortTtlMs: Number(params.short_ttl_ms ?? 0),
5017
+ extraInfo,
4817
5018
  });
4818
5019
  if (isJsonObject(authContext)) {
4819
5020
  const auth = authContext;
@@ -4827,17 +5028,27 @@ export class AUNClient {
4827
5028
  this._sessionParams.access_token = String(auth.token ?? params.access_token ?? '');
4828
5029
  }
4829
5030
  }
5031
+ if (isJsonObject(auth.hello) && 'heartbeat_interval' in auth.hello) {
5032
+ this._applyServerHeartbeatInterval(auth.hello.heartbeat_interval, 'auth');
5033
+ }
4830
5034
  }
4831
5035
  }
4832
5036
  else {
4833
- await this._auth.initializeWithToken(this._transport, challenge, String(params.access_token), {
5037
+ const hello = await this._auth.initializeWithToken(this._transport, challenge, String(params.access_token), {
4834
5038
  deviceId: this._deviceId,
4835
5039
  slotId: this._slotId,
4836
5040
  deliveryMode: this._connectDeliveryMode,
5041
+ connectionKind: String(params.connection_kind ?? 'long'),
5042
+ shortTtlMs: Number(params.short_ttl_ms ?? 0),
5043
+ extraInfo,
4837
5044
  });
4838
5045
  this._syncIdentityAfterConnect(String(params.access_token));
5046
+ if (isJsonObject(hello) && 'heartbeat_interval' in hello) {
5047
+ this._applyServerHeartbeatInterval(hello.heartbeat_interval, 'auth');
5048
+ }
4839
5049
  }
4840
5050
  this._state = 'connected';
5051
+ this._connectedAt = Date.now();
4841
5052
  this._clientLog.debug(`auth complete, connection ready: aid=${this._aid ?? ''}, gateway=${gatewayUrl}`);
4842
5053
  await this._dispatcher.publish('connection.state', { state: this._state, gateway: gatewayUrl });
4843
5054
  // auth 阶段 aid 可能被 identity 覆盖(上方 this._aid = identity.aid);
@@ -4854,6 +5065,11 @@ export class AUNClient {
4854
5065
  catch (exc) {
4855
5066
  this._clientLog.warn(`prekey upload failed: ${formatCaughtError(exc)}`);
4856
5067
  }
5068
+ // connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
5069
+ // 群消息按惰性触发,不在此处主动 pull
5070
+ void this._fillP2pGap().catch((exc) => {
5071
+ this._clientLog.warn(`schedule post-connect P2P gap fill failed: ${formatCaughtError(exc)}`);
5072
+ });
4857
5073
  this._clientLog.debug(`_connectOnce exit: elapsed=${Date.now() - tStart}ms gateway=${gatewayUrl}, aid=${this._aid ?? ''}`);
4858
5074
  }
4859
5075
  catch (err) {
@@ -4940,16 +5156,29 @@ export class AUNClient {
4940
5156
  if ('timeouts' in request && request.timeouts != null && !isJsonObject(request.timeouts)) {
4941
5157
  throw new ValidationError('timeouts must be a dict');
4942
5158
  }
5159
+ // 长短连接参数校验
5160
+ const connectionKind = String(request.connection_kind ?? 'long');
5161
+ if (connectionKind !== 'long' && connectionKind !== 'short') {
5162
+ throw new ValidationError(`connection_kind must be "long" or "short", got "${connectionKind}"`);
5163
+ }
5164
+ request.connection_kind = connectionKind;
5165
+ const shortTtlMs = Number(request.short_ttl_ms ?? 0);
5166
+ if (!Number.isFinite(shortTtlMs) || shortTtlMs < 0 || Math.floor(shortTtlMs) !== shortTtlMs) {
5167
+ throw new ValidationError('short_ttl_ms must be a non-negative integer');
5168
+ }
5169
+ request.short_ttl_ms = connectionKind === 'short' ? shortTtlMs : 0;
4943
5170
  return request;
4944
5171
  }
4945
5172
  /** 从参数构建会话选项 */
4946
5173
  _buildSessionOptions(params) {
5174
+ const connectionKind = String(params.connection_kind ?? 'long');
4947
5175
  const options = {
4948
5176
  auto_reconnect: DEFAULT_SESSION_OPTIONS.auto_reconnect,
4949
5177
  heartbeat_interval: DEFAULT_SESSION_OPTIONS.heartbeat_interval,
4950
5178
  token_refresh_before: DEFAULT_SESSION_OPTIONS.token_refresh_before,
4951
5179
  retry: { ...DEFAULT_SESSION_OPTIONS.retry },
4952
5180
  timeouts: { ...DEFAULT_SESSION_OPTIONS.timeouts },
5181
+ connection_kind: connectionKind,
4953
5182
  };
4954
5183
  if ('auto_reconnect' in params)
4955
5184
  options.auto_reconnect = Boolean(params.auto_reconnect);
@@ -4968,8 +5197,12 @@ export class AUNClient {
4968
5197
  // ── 内部:后台任务 ────────────────────────────────────────
4969
5198
  /** 启动所有后台任务 */
4970
5199
  _startBackgroundTasks() {
4971
- this._startHeartbeatTask();
4972
- this._startTokenRefreshTask();
5200
+ // 短连接不启动 heartbeat 与 token 刷新(生命周期短,不需要长期会话维护);
5201
+ // auto_reconnect 仍允许,由 _sessionOptions.auto_reconnect 决定
5202
+ if (this._sessionOptions.connection_kind !== 'short') {
5203
+ this._startHeartbeatTask();
5204
+ this._startTokenRefreshTask();
5205
+ }
4973
5206
  this._startGroupEpochTasks();
4974
5207
  }
4975
5208
  /** 停止所有后台任务 */
@@ -5007,10 +5240,9 @@ export class AUNClient {
5007
5240
  _startHeartbeatTask() {
5008
5241
  if (this._heartbeatTimer !== null)
5009
5242
  return;
5010
- const rawIntervalSeconds = Number(this._sessionOptions.heartbeat_interval ?? DEFAULT_SESSION_OPTIONS.heartbeat_interval);
5011
- if (!Number.isFinite(rawIntervalSeconds) || rawIntervalSeconds <= 0)
5243
+ const interval = clampHeartbeatInterval(this._sessionOptions.heartbeat_interval ?? DEFAULT_SESSION_OPTIONS.heartbeat_interval);
5244
+ if (interval <= 0)
5012
5245
  return;
5013
- const interval = Math.max(rawIntervalSeconds, 30) * 1000;
5014
5246
  // M25: 把连续失败阈值从 3 次收窄到 2 次。既能容忍一次网络抖动/GC 暂停,
5015
5247
  // 又把半开连接的检测延迟从 3 个心跳周期降到 2 个。
5016
5248
  // 真正的 socket 死亡由 ws.on('close') 立即触发 _handleTransportDisconnect,
@@ -5020,8 +5252,12 @@ export class AUNClient {
5020
5252
  this._heartbeatTimer = setInterval(() => {
5021
5253
  if (this._closing || this._state !== 'connected')
5022
5254
  return;
5023
- this._transport.call('meta.ping', {}).then(() => {
5255
+ this._transport.call('meta.ping', {}).then((pong) => {
5024
5256
  consecutiveFailures = 0;
5257
+ // 服务端可在 pong 中下发新的 heartbeat_interval(秒,0=关闭)
5258
+ if (isJsonObject(pong) && 'heartbeat_interval' in pong) {
5259
+ this._applyServerHeartbeatInterval(pong.heartbeat_interval, 'pong');
5260
+ }
5025
5261
  }).catch((exc) => {
5026
5262
  consecutiveFailures++;
5027
5263
  this._clientLog.warn(`heartbeat failed (${consecutiveFailures}/${maxFailures}): ${formatCaughtError(exc)}`);
@@ -5031,12 +5267,28 @@ export class AUNClient {
5031
5267
  this._handleTransportDisconnect(exc instanceof Error ? exc : new Error(String(exc)));
5032
5268
  }
5033
5269
  });
5034
- }, interval);
5270
+ }, interval * 1000);
5035
5271
  // 允许 Node.js 进程在只剩定时器时退出
5036
5272
  if (this._heartbeatTimer && typeof this._heartbeatTimer === 'object' && 'unref' in this._heartbeatTimer) {
5037
5273
  this._heartbeatTimer.unref();
5038
5274
  }
5039
5275
  }
5276
+ /** 服务端通过 hello/pong 下发 heartbeat_interval;clamp 后写入 session_options 并按需重启心跳。 */
5277
+ _applyServerHeartbeatInterval(raw, source) {
5278
+ const newInterval = clampHeartbeatInterval(raw);
5279
+ const oldInterval = clampHeartbeatInterval(this._sessionOptions.heartbeat_interval);
5280
+ if (newInterval === oldInterval)
5281
+ return;
5282
+ this._sessionOptions.heartbeat_interval = newInterval;
5283
+ this._clientLog.debug(`heartbeat_interval updated by ${source}: ${oldInterval} -> ${newInterval}`);
5284
+ if (this._heartbeatTimer !== null) {
5285
+ clearInterval(this._heartbeatTimer);
5286
+ this._heartbeatTimer = null;
5287
+ }
5288
+ if (newInterval > 0 && this._state === 'connected' && !this._closing) {
5289
+ this._startHeartbeatTask();
5290
+ }
5291
+ }
5040
5292
  /** 启动 token 刷新任务 */
5041
5293
  _startTokenRefreshTask() {
5042
5294
  if (this._tokenRefreshTimer !== null)
@@ -5191,23 +5443,18 @@ export class AUNClient {
5191
5443
  const prekeyId = this._extractConsumedPrekeyId(message);
5192
5444
  if (!prekeyId || this._state !== 'connected')
5193
5445
  return;
5194
- if (this._prekeyReplenished.has(prekeyId))
5195
- return;
5196
- // 同一时刻只允许一个 put_prekey inflight
5197
- if (this._prekeyReplenishInflight.size > 0)
5446
+ // 只有活跃 prekey 被消费时才触发上传。历史 prekey 被消费不触发,避免上传风暴。
5447
+ if (!this._activePrekeyId || prekeyId !== this._activePrekeyId)
5198
5448
  return;
5199
- this._prekeyReplenishInflight.add(prekeyId);
5449
+ // 清空活跃标记,防止重复触发(新上传完成后会设新的 active)
5450
+ this._activePrekeyId = '';
5200
5451
  void (async () => {
5201
5452
  try {
5202
5453
  await this._uploadPrekey();
5203
- this._prekeyReplenished.add(prekeyId);
5204
5454
  }
5205
5455
  catch (exc) {
5206
5456
  this._clientLog.warn(`replenish current prekey after consuming ${prekeyId} failed: ${formatCaughtError(exc)}`);
5207
5457
  }
5208
- finally {
5209
- this._prekeyReplenishInflight.delete(prekeyId);
5210
- }
5211
5458
  })();
5212
5459
  }
5213
5460
  /** 启动群组 epoch 相关后台任务 */
@@ -5291,13 +5538,34 @@ export class AUNClient {
5291
5538
  }
5292
5539
  // ── 内部:断线重连 ────────────────────────────────────────
5293
5540
  /** 不重连 close code 集合:认证失败/权限错误/被踢等,重连无意义 */
5294
- static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011]);
5295
- /** 处理服务端主动断开通知 event/gateway.disconnect */
5296
- _onGatewayDisconnect(data) {
5297
- const code = data?.code;
5298
- const reason = data?.reason ?? '';
5299
- this._clientLog.warn(`server initiated disconnect: code=${code}, reason=${reason}`);
5541
+ static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011, 4012, 4013, 4014, 4015]);
5542
+ /** 处理服务端主动断开通知 event/gateway.disconnect
5543
+ *
5544
+ * 服务端可能附带结构化 detail 字段(如配额超限时含 aid/device_id/slot_id/quota_kind/evicted_by)。
5545
+ * 透传到应用层可订阅事件 'gateway.disconnect',方便业务定位被踢原因。
5546
+ */
5547
+ async _onGatewayDisconnect(data) {
5548
+ const payload = (data && typeof data === 'object') ? data : {};
5549
+ const code = payload.code;
5550
+ const reason = payload.reason ?? '';
5551
+ const detail = (payload.detail && typeof payload.detail === 'object' && !Array.isArray(payload.detail))
5552
+ ? payload.detail
5553
+ : {};
5554
+ this._clientLog.warn(`server initiated disconnect: code=${code}, reason=${reason}, detail=${JSON.stringify(detail)}`);
5300
5555
  this._serverKicked = true;
5556
+ // 缓存最近一次 disconnect 信息,让后续 connection.state(terminal_failed) 也能带 detail
5557
+ this._lastDisconnectInfo = { code, reason, detail };
5558
+ // 透传给应用层订阅者
5559
+ try {
5560
+ await this._dispatcher.publish('gateway.disconnect', {
5561
+ code,
5562
+ reason,
5563
+ detail,
5564
+ });
5565
+ }
5566
+ catch (exc) {
5567
+ this._clientLog.debug(`publish gateway.disconnect failed: ${exc instanceof Error ? exc.message : String(exc)}`);
5568
+ }
5301
5569
  }
5302
5570
  /** 传输层断线回调 */
5303
5571
  async _handleTransportDisconnect(error, closeCode) {
@@ -5319,9 +5587,18 @@ export class AUNClient {
5319
5587
  this._state = 'terminal_failed';
5320
5588
  const reason = this._serverKicked ? 'server kicked' : `close code ${closeCode}`;
5321
5589
  this._clientLog.warn(`suppressing auto-reconnect: ${reason}`);
5322
- await this._dispatcher.publish('connection.state', {
5590
+ const disconnectInfo = this._lastDisconnectInfo ?? {};
5591
+ const eventPayload = {
5323
5592
  state: this._state, error, reason,
5324
- });
5593
+ };
5594
+ // 把服务端附带的结构化 detail(如配额超限信息)也带给应用层
5595
+ if (disconnectInfo.detail && Object.keys(disconnectInfo.detail).length > 0) {
5596
+ eventPayload.detail = disconnectInfo.detail;
5597
+ }
5598
+ if (disconnectInfo.code !== undefined && disconnectInfo.code !== null) {
5599
+ eventPayload.code = disconnectInfo.code;
5600
+ }
5601
+ await this._dispatcher.publish('connection.state', eventPayload);
5325
5602
  return;
5326
5603
  }
5327
5604
  // 1000 = 正常关闭, 1006 = 网络异常断开(无 close frame),其他 code = 服务端主动关闭