@agentunion/fastaun 0.2.18 → 0.2.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -344,6 +344,8 @@ export class AUNClient {
344
344
  _peerPrekeysCache = new Map();
345
345
  _prekeyReplenishInflight = new Set();
346
346
  _prekeyReplenished = new Set();
347
+ // 当前活跃 prekey:只有这个 prekey 被消费时才触发 replenish 上传
348
+ _activePrekeyId = '';
347
349
  /** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
348
350
  _seqTracker = new SeqTracker();
349
351
  _seqTrackerContext = null;
@@ -375,6 +377,8 @@ export class AUNClient {
375
377
  _reconnectActive = false;
376
378
  _reconnectAbort = null;
377
379
  _serverKicked = false;
380
+ /** 缓存最近一次 gateway.disconnect 信息(含服务端附带的 detail),用于后续 connection.state 透传 */
381
+ _lastDisconnectInfo = null;
378
382
  _logger;
379
383
  _clientLog;
380
384
  constructor(config, debug = false) {
@@ -744,12 +748,29 @@ export class AUNClient {
744
748
  const gid = (p.group_id ?? '');
745
749
  if (gid) {
746
750
  const ns = `group:${gid}`;
747
- if (rawMessages.length > 0) {
748
- this._seqTracker.onPullResult(ns, rawMessages);
751
+ // 区分解密成功 / 失败:失败的 payload 仍是 e2ee.group_encrypted。
752
+ const decryptedOnly = [];
753
+ let failedCount = 0;
754
+ const decryptedMessages = Array.isArray(r.messages) ? r.messages : [];
755
+ for (const m of decryptedMessages) {
756
+ if (!isJsonObject(m))
757
+ continue;
758
+ const payload = isJsonObject(m.payload) ? m.payload : {};
759
+ const ptype = payload.type;
760
+ if (ptype === 'e2ee.group_encrypted') {
761
+ failedCount++;
762
+ this._enqueuePendingDecrypt(gid, m);
763
+ }
764
+ else {
765
+ decryptedOnly.push(m);
766
+ }
767
+ }
768
+ if (decryptedOnly.length > 0) {
769
+ // 仅用解密成功的消息推进 contig;失败的等 retry 解密成功才推进。
770
+ this._seqTracker.onPullResult(ns, decryptedOnly);
749
771
  }
750
772
  // ⚠️ 逻辑边界 L4:group retention floor 通道 = cursor.current_seq
751
773
  // 群路径目前无独立 earliest_available_seq 字段;若未来引入 group retention,需新增字段并同步更新此处。
752
- // 与 S2 [1,seq-1] 历史 gap 配合使用,force_contiguous_seq 是跳过空洞的唯一手段。
753
774
  const cursor = isJsonObject(r.cursor) ? r.cursor : null;
754
775
  if (cursor) {
755
776
  const serverAck = Number(cursor.current_seq ?? 0);
@@ -762,9 +783,9 @@ export class AUNClient {
762
783
  }
763
784
  }
764
785
  this._saveSeqTrackerState();
765
- // auto-ack contiguous_seq
786
+ // auto-ack:仅当没有解密失败时才 ack。失败时让服务端 cursor 留在原位等 retry。
766
787
  const contig = this._seqTracker.getContiguousSeq(ns);
767
- const shouldAck = rawMessages.length > 0 || (cursor !== null && Number(cursor.current_seq ?? 0) > 0);
788
+ const shouldAck = failedCount === 0 && (decryptedOnly.length > 0 || (cursor !== null && Number(cursor.current_seq ?? 0) > 0));
768
789
  if (contig > 0 && shouldAck) {
769
790
  this._transport.call('group.ack_messages', {
770
791
  group_id: gid,
@@ -773,6 +794,10 @@ export class AUNClient {
773
794
  slot_id: this._slotId,
774
795
  }).catch((e) => { this._clientLog.debug(`group.pull auto-ack failed: group=${gid} ${formatCaughtError(e)}`); });
775
796
  }
797
+ // 有解密失败时调度 recovery 兜底定时
798
+ if (failedCount > 0) {
799
+ this._scheduleRecoveryTimeout(gid);
800
+ }
776
801
  }
777
802
  }
778
803
  if (method === 'group.thought.get' && isJsonObject(result)) {
@@ -935,8 +960,10 @@ export class AUNClient {
935
960
  const did = String(pk.device_id ?? '').trim();
936
961
  return did && did !== PREKEY_FALLBACK_DEVICE_ID;
937
962
  });
938
- const canUseMultiDevice = routablePrekeys.length > 0
939
- && (routablePrekeys.length > 1 || selfSyncCopies.length > 0);
963
+ // 只要有 routable prekey 就走 multi_device 路径(即使只有 1 个 recipient device + 0 self copies)。
964
+ // 这确保服务端为每个已注册设备存储副本,离线设备重连后能 pull 到。
965
+ // single 路径仅在完全没有 routable prekey 时使用(legacy 兼容)。
966
+ const canUseMultiDevice = routablePrekeys.length > 0;
940
967
  if (!canUseMultiDevice) {
941
968
  return await this._sendEncryptedSingle({
942
969
  toAid,
@@ -1726,6 +1753,22 @@ export class AUNClient {
1726
1753
  }
1727
1754
  // 拦截 P2P 传输的群组密钥分发/请求/响应消息
1728
1755
  if (await this._tryHandleGroupKeyMessage(msg)) {
1756
+ // group_key 控制消息也要推进 seq tracker + auto-ack,
1757
+ // 否则 fillP2pGap 会因为 contig 卡在此 seq 之前而重复拉取同样的历史消息。
1758
+ const seq = msg.seq;
1759
+ if (seq !== undefined && seq !== null && this._aid) {
1760
+ const ns = `p2p:${this._aid}`;
1761
+ this._seqTracker.onMessageSeq(ns, seq);
1762
+ this._saveSeqTrackerState();
1763
+ const contig = this._seqTracker.getContiguousSeq(ns);
1764
+ if (contig > 0) {
1765
+ this._transport.call('message.ack', {
1766
+ seq: contig,
1767
+ device_id: this._deviceId,
1768
+ slot_id: this._slotId,
1769
+ }).catch(() => { });
1770
+ }
1771
+ }
1729
1772
  return;
1730
1773
  }
1731
1774
  // P2P 空洞检测
@@ -1812,8 +1855,12 @@ export class AUNClient {
1812
1855
  }
1813
1856
  const decrypted = await this._decryptGroupMessage(msg);
1814
1857
  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) {
1858
+ // 解密失败时**不推进 seq tracker / 不 auto-ack**:让服务端 cursor 留在原位,
1859
+ // 等密钥恢复后 retry 解密成功才推进 + ack;recovery 真的失败时由
1860
+ // _retryPendingDecryptMsgs(forceAdvanceOnFail=true) 兜底强制推进。
1861
+ const payloadForCheck = isJsonObject(msg.payload) ? msg.payload : null;
1862
+ const isDecryptFail = payloadForCheck?.type === 'e2ee.group_encrypted' && !decrypted.e2ee;
1863
+ if (!isDecryptFail && groupId && seq !== undefined && seq !== null) {
1817
1864
  const ns = `group:${groupId}`;
1818
1865
  const needPull = this._seqTracker.onMessageSeq(ns, seq);
1819
1866
  if (needPull) {
@@ -1832,10 +1879,12 @@ export class AUNClient {
1832
1879
  this._saveSeqTrackerState();
1833
1880
  }
1834
1881
  // 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)
1882
+ if (isDecryptFail) {
1883
+ if (groupId) {
1838
1884
  this._enqueuePendingDecrypt(groupId, msg);
1885
+ // 触发 recovery 兜底定时(30s 后如果仍未解开,强制推进)
1886
+ this._scheduleRecoveryTimeout(groupId);
1887
+ }
1839
1888
  await this._publishAppEvent('group.message_undecryptable', {
1840
1889
  message_id: msg.message_id,
1841
1890
  group_id: groupId,
@@ -2787,11 +2836,31 @@ export class AUNClient {
2787
2836
  }
2788
2837
  let result;
2789
2838
  if (actualPayload.type === 'e2ee.group_key_distribution') {
2839
+ // 快速跳过已过期的历史 epoch 分发:本地已有更高 epoch 时不发任何 RPC,
2840
+ // 避免 fillP2pGap 拉到大量历史群密钥消息时触发 epoch 编排风暴。
2841
+ const distGroupId = String(actualPayload.group_id ?? '');
2842
+ const distEpoch = Number(actualPayload.epoch ?? 0);
2843
+ if (distGroupId && distEpoch > 0) {
2844
+ const localEpoch = this._groupE2ee.currentEpoch(distGroupId) ?? 0;
2845
+ if (localEpoch >= distEpoch) {
2846
+ this._clientLog.debug(`skip stale group_key_distribution: group=${distGroupId} msg_epoch=${distEpoch} local_epoch=${localEpoch}`);
2847
+ return true;
2848
+ }
2849
+ }
2790
2850
  if (!await this._verifyActiveGroupRotationDistribution(actualPayload)) {
2791
2851
  return true;
2792
2852
  }
2793
2853
  }
2794
2854
  else if (actualPayload.type === 'e2ee.group_key_response') {
2855
+ const respGroupId = String(actualPayload.group_id ?? '');
2856
+ const respEpoch = Number(actualPayload.epoch ?? 0);
2857
+ if (respGroupId && respEpoch > 0) {
2858
+ const localEpoch = this._groupE2ee.currentEpoch(respGroupId) ?? 0;
2859
+ if (localEpoch >= respEpoch) {
2860
+ this._clientLog.debug(`skip stale group_key_response: group=${respGroupId} msg_epoch=${respEpoch} local_epoch=${localEpoch}`);
2861
+ return true;
2862
+ }
2863
+ }
2795
2864
  if (!await this._verifyGroupKeyResponseEpoch(actualPayload)) {
2796
2865
  return true;
2797
2866
  }
@@ -3057,6 +3126,8 @@ export class AUNClient {
3057
3126
  async _uploadPrekey() {
3058
3127
  const prekeyMaterial = this._e2ee.generatePrekey();
3059
3128
  const result = await this._transport.call('message.e2ee.put_prekey', prekeyMaterial);
3129
+ // 上传成功后记录为活跃 prekey
3130
+ this._activePrekeyId = String(prekeyMaterial.prekey_id ?? '');
3060
3131
  return isJsonObject(result) ? { ...result } : { ok: true };
3061
3132
  }
3062
3133
  /**
@@ -3223,23 +3294,47 @@ export class AUNClient {
3223
3294
  queue.push(msg);
3224
3295
  this._pendingDecryptMsgs.set(ns, queue.slice(-PENDING_DECRYPT_LIMIT));
3225
3296
  }
3226
- async _retryPendingDecryptMsgs(groupId) {
3297
+ async _retryPendingDecryptMsgs(groupId, forceAdvanceOnFail = false) {
3227
3298
  const ns = `group:${groupId}`;
3228
3299
  const queue = this._pendingDecryptMsgs.get(ns);
3229
3300
  if (!queue || queue.length === 0)
3230
3301
  return;
3231
3302
  this._pendingDecryptMsgs.set(ns, []);
3232
3303
  const stillPending = [];
3304
+ let forceAdvancedAny = false;
3233
3305
  for (const msg of queue) {
3234
3306
  try {
3235
3307
  const decrypted = await this._decryptGroupMessage(msg);
3236
3308
  const payload = isJsonObject(msg.payload) ? msg.payload : null;
3237
3309
  if (payload?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
3238
- stillPending.push(msg);
3310
+ if (forceAdvanceOnFail) {
3311
+ // recovery 真的失败:强制推进 seq tracker + 发 undecryptable + ack
3312
+ this._clientLog.info(`group recovery give up: group=${groupId} seq=${String(msg.seq ?? '')} → force advance + publish undecryptable`);
3313
+ const seq = msg.seq;
3314
+ if (seq !== undefined && seq !== null) {
3315
+ this._seqTracker.onMessageSeq(ns, seq);
3316
+ this._saveSeqTrackerState();
3317
+ forceAdvancedAny = true;
3318
+ }
3319
+ await this._publishAppEvent('group.message_undecryptable', {
3320
+ message_id: msg.message_id,
3321
+ group_id: groupId,
3322
+ from: msg.from,
3323
+ seq,
3324
+ timestamp: msg.timestamp,
3325
+ _decrypt_error: 'epoch recovery failed',
3326
+ });
3327
+ }
3328
+ else {
3329
+ stillPending.push(msg);
3330
+ }
3239
3331
  continue;
3240
3332
  }
3241
3333
  const seq = msg.seq;
3242
3334
  if (seq !== undefined && seq !== null) {
3335
+ // 推进 seq tracker(之前 push/pull 失败时没推进)
3336
+ this._seqTracker.onMessageSeq(ns, seq);
3337
+ this._saveSeqTrackerState();
3243
3338
  await this._publishOrderedMessage('group.message_created', ns, seq, decrypted);
3244
3339
  }
3245
3340
  else {
@@ -3250,6 +3345,18 @@ export class AUNClient {
3250
3345
  stillPending.push(msg);
3251
3346
  }
3252
3347
  }
3348
+ // 强制推进有变更时,按 contig auto-ack
3349
+ if (forceAdvancedAny) {
3350
+ const contig = this._seqTracker.getContiguousSeq(ns);
3351
+ if (contig > 0) {
3352
+ this._transport.call('group.ack_messages', {
3353
+ group_id: groupId,
3354
+ msg_seq: contig,
3355
+ device_id: this._deviceId,
3356
+ slot_id: this._slotId,
3357
+ }).catch((e) => { this._clientLog.debug(`group recovery force-advance ack failed: group=${groupId} ${formatCaughtError(e)}`); });
3358
+ }
3359
+ }
3253
3360
  const queuedDuringRetry = this._pendingDecryptMsgs.get(ns) ?? [];
3254
3361
  const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-PENDING_DECRYPT_LIMIT);
3255
3362
  if (mergedPending.length)
@@ -3257,6 +3364,28 @@ export class AUNClient {
3257
3364
  else
3258
3365
  this._pendingDecryptMsgs.delete(ns);
3259
3366
  }
3367
+ /**
3368
+ * recovery 兜底定时:N 秒后如果 pending queue 仍有未解开消息,强制推进 cursor。
3369
+ * 同一 group 短时间内只调度一次。
3370
+ */
3371
+ _recoveryTimeoutScheduled = new Map();
3372
+ _scheduleRecoveryTimeout(groupId, timeoutMs = 30000) {
3373
+ if (!groupId)
3374
+ return;
3375
+ const now = Date.now();
3376
+ const last = this._recoveryTimeoutScheduled.get(groupId) ?? 0;
3377
+ if (last && (last + timeoutMs) > now)
3378
+ return;
3379
+ this._recoveryTimeoutScheduled.set(groupId, now);
3380
+ setTimeout(() => {
3381
+ const ns = `group:${groupId}`;
3382
+ const queue = this._pendingDecryptMsgs.get(ns);
3383
+ if (!queue || queue.length === 0)
3384
+ return;
3385
+ this._clientLog.info(`group recovery timeout: group=${groupId} → force advance`);
3386
+ this._retryPendingDecryptMsgs(groupId, true).catch((exc) => this._clientLog.warn(`group ${groupId} recovery timeout retry failed: ${formatCaughtError(exc)}`));
3387
+ }, timeoutMs).unref?.();
3388
+ }
3260
3389
  _scheduleRetryPendingDecryptMsgs(groupId) {
3261
3390
  if (!groupId || !this._pendingDecryptMsgs.has(`group:${groupId}`))
3262
3391
  return;
@@ -3963,6 +4092,11 @@ export class AUNClient {
3963
4092
  const groupId = String(payload.group_id ?? '').trim();
3964
4093
  if (!groupId)
3965
4094
  return false;
4095
+ // 历史群(不在当前 session 活跃列表):跳过 RPC 验证,只做本地 handle_incoming
4096
+ if (!this._groupSynced.has(groupId)) {
4097
+ this._clientLog.debug(`skip RPC verify for inactive group: group=${groupId} rotation=${rotationId}`);
4098
+ return true;
4099
+ }
3966
4100
  const epoch = Number(payload.epoch ?? 0);
3967
4101
  if (!Number.isFinite(epoch) || epoch <= 0)
3968
4102
  return false;
@@ -4796,6 +4930,9 @@ export class AUNClient {
4796
4930
  this._gatewayUrl = gatewayUrl;
4797
4931
  this._slotId = String(params.slot_id ?? '');
4798
4932
  this._connectDeliveryMode = { ...(params.delivery_mode ?? this._connectDeliveryMode) };
4933
+ const extraInfo = (params.extra_info && typeof params.extra_info === 'object' && !Array.isArray(params.extra_info))
4934
+ ? params.extra_info
4935
+ : undefined;
4799
4936
  const prevState = this._state;
4800
4937
  this._auth.setInstanceContext({ deviceId: this._deviceId, slotId: this._slotId });
4801
4938
  this._state = 'connecting';
@@ -4814,6 +4951,9 @@ export class AUNClient {
4814
4951
  deviceId: this._deviceId,
4815
4952
  slotId: this._slotId,
4816
4953
  deliveryMode: this._connectDeliveryMode,
4954
+ connectionKind: String(params.connection_kind ?? 'long'),
4955
+ shortTtlMs: Number(params.short_ttl_ms ?? 0),
4956
+ extraInfo,
4817
4957
  });
4818
4958
  if (isJsonObject(authContext)) {
4819
4959
  const auth = authContext;
@@ -4834,6 +4974,9 @@ export class AUNClient {
4834
4974
  deviceId: this._deviceId,
4835
4975
  slotId: this._slotId,
4836
4976
  deliveryMode: this._connectDeliveryMode,
4977
+ connectionKind: String(params.connection_kind ?? 'long'),
4978
+ shortTtlMs: Number(params.short_ttl_ms ?? 0),
4979
+ extraInfo,
4837
4980
  });
4838
4981
  this._syncIdentityAfterConnect(String(params.access_token));
4839
4982
  }
@@ -4854,6 +4997,11 @@ export class AUNClient {
4854
4997
  catch (exc) {
4855
4998
  this._clientLog.warn(`prekey upload failed: ${formatCaughtError(exc)}`);
4856
4999
  }
5000
+ // connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
5001
+ // 群消息按惰性触发,不在此处主动 pull
5002
+ void this._fillP2pGap().catch((exc) => {
5003
+ this._clientLog.warn(`schedule post-connect P2P gap fill failed: ${formatCaughtError(exc)}`);
5004
+ });
4857
5005
  this._clientLog.debug(`_connectOnce exit: elapsed=${Date.now() - tStart}ms gateway=${gatewayUrl}, aid=${this._aid ?? ''}`);
4858
5006
  }
4859
5007
  catch (err) {
@@ -4940,16 +5088,29 @@ export class AUNClient {
4940
5088
  if ('timeouts' in request && request.timeouts != null && !isJsonObject(request.timeouts)) {
4941
5089
  throw new ValidationError('timeouts must be a dict');
4942
5090
  }
5091
+ // 长短连接参数校验
5092
+ const connectionKind = String(request.connection_kind ?? 'long');
5093
+ if (connectionKind !== 'long' && connectionKind !== 'short') {
5094
+ throw new ValidationError(`connection_kind must be "long" or "short", got "${connectionKind}"`);
5095
+ }
5096
+ request.connection_kind = connectionKind;
5097
+ const shortTtlMs = Number(request.short_ttl_ms ?? 0);
5098
+ if (!Number.isFinite(shortTtlMs) || shortTtlMs < 0 || Math.floor(shortTtlMs) !== shortTtlMs) {
5099
+ throw new ValidationError('short_ttl_ms must be a non-negative integer');
5100
+ }
5101
+ request.short_ttl_ms = connectionKind === 'short' ? shortTtlMs : 0;
4943
5102
  return request;
4944
5103
  }
4945
5104
  /** 从参数构建会话选项 */
4946
5105
  _buildSessionOptions(params) {
5106
+ const connectionKind = String(params.connection_kind ?? 'long');
4947
5107
  const options = {
4948
- auto_reconnect: DEFAULT_SESSION_OPTIONS.auto_reconnect,
5108
+ auto_reconnect: connectionKind === 'short' ? false : DEFAULT_SESSION_OPTIONS.auto_reconnect,
4949
5109
  heartbeat_interval: DEFAULT_SESSION_OPTIONS.heartbeat_interval,
4950
5110
  token_refresh_before: DEFAULT_SESSION_OPTIONS.token_refresh_before,
4951
5111
  retry: { ...DEFAULT_SESSION_OPTIONS.retry },
4952
5112
  timeouts: { ...DEFAULT_SESSION_OPTIONS.timeouts },
5113
+ connection_kind: connectionKind,
4953
5114
  };
4954
5115
  if ('auto_reconnect' in params)
4955
5116
  options.auto_reconnect = Boolean(params.auto_reconnect);
@@ -4968,6 +5129,9 @@ export class AUNClient {
4968
5129
  // ── 内部:后台任务 ────────────────────────────────────────
4969
5130
  /** 启动所有后台任务 */
4970
5131
  _startBackgroundTasks() {
5132
+ // 短连接生命周期短,禁用心跳与 token 刷新(不接收推送、不需要长期会话维护)
5133
+ if (this._sessionOptions.connection_kind === 'short')
5134
+ return;
4971
5135
  this._startHeartbeatTask();
4972
5136
  this._startTokenRefreshTask();
4973
5137
  this._startGroupEpochTasks();
@@ -5191,23 +5355,18 @@ export class AUNClient {
5191
5355
  const prekeyId = this._extractConsumedPrekeyId(message);
5192
5356
  if (!prekeyId || this._state !== 'connected')
5193
5357
  return;
5194
- if (this._prekeyReplenished.has(prekeyId))
5358
+ // 只有活跃 prekey 被消费时才触发上传。历史 prekey 被消费不触发,避免上传风暴。
5359
+ if (!this._activePrekeyId || prekeyId !== this._activePrekeyId)
5195
5360
  return;
5196
- // 同一时刻只允许一个 put_prekey inflight
5197
- if (this._prekeyReplenishInflight.size > 0)
5198
- return;
5199
- this._prekeyReplenishInflight.add(prekeyId);
5361
+ // 清空活跃标记,防止重复触发(新上传完成后会设新的 active)
5362
+ this._activePrekeyId = '';
5200
5363
  void (async () => {
5201
5364
  try {
5202
5365
  await this._uploadPrekey();
5203
- this._prekeyReplenished.add(prekeyId);
5204
5366
  }
5205
5367
  catch (exc) {
5206
5368
  this._clientLog.warn(`replenish current prekey after consuming ${prekeyId} failed: ${formatCaughtError(exc)}`);
5207
5369
  }
5208
- finally {
5209
- this._prekeyReplenishInflight.delete(prekeyId);
5210
- }
5211
5370
  })();
5212
5371
  }
5213
5372
  /** 启动群组 epoch 相关后台任务 */
@@ -5291,13 +5450,34 @@ export class AUNClient {
5291
5450
  }
5292
5451
  // ── 内部:断线重连 ────────────────────────────────────────
5293
5452
  /** 不重连 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}`);
5453
+ static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011, 4012, 4013, 4014, 4015]);
5454
+ /** 处理服务端主动断开通知 event/gateway.disconnect
5455
+ *
5456
+ * 服务端可能附带结构化 detail 字段(如配额超限时含 aid/device_id/slot_id/quota_kind/evicted_by)。
5457
+ * 透传到应用层可订阅事件 'gateway.disconnect',方便业务定位被踢原因。
5458
+ */
5459
+ async _onGatewayDisconnect(data) {
5460
+ const payload = (data && typeof data === 'object') ? data : {};
5461
+ const code = payload.code;
5462
+ const reason = payload.reason ?? '';
5463
+ const detail = (payload.detail && typeof payload.detail === 'object' && !Array.isArray(payload.detail))
5464
+ ? payload.detail
5465
+ : {};
5466
+ this._clientLog.warn(`server initiated disconnect: code=${code}, reason=${reason}, detail=${JSON.stringify(detail)}`);
5300
5467
  this._serverKicked = true;
5468
+ // 缓存最近一次 disconnect 信息,让后续 connection.state(terminal_failed) 也能带 detail
5469
+ this._lastDisconnectInfo = { code, reason, detail };
5470
+ // 透传给应用层订阅者
5471
+ try {
5472
+ await this._dispatcher.publish('gateway.disconnect', {
5473
+ code,
5474
+ reason,
5475
+ detail,
5476
+ });
5477
+ }
5478
+ catch (exc) {
5479
+ this._clientLog.debug(`publish gateway.disconnect failed: ${exc instanceof Error ? exc.message : String(exc)}`);
5480
+ }
5301
5481
  }
5302
5482
  /** 传输层断线回调 */
5303
5483
  async _handleTransportDisconnect(error, closeCode) {
@@ -5319,9 +5499,18 @@ export class AUNClient {
5319
5499
  this._state = 'terminal_failed';
5320
5500
  const reason = this._serverKicked ? 'server kicked' : `close code ${closeCode}`;
5321
5501
  this._clientLog.warn(`suppressing auto-reconnect: ${reason}`);
5322
- await this._dispatcher.publish('connection.state', {
5502
+ const disconnectInfo = this._lastDisconnectInfo ?? {};
5503
+ const eventPayload = {
5323
5504
  state: this._state, error, reason,
5324
- });
5505
+ };
5506
+ // 把服务端附带的结构化 detail(如配额超限信息)也带给应用层
5507
+ if (disconnectInfo.detail && Object.keys(disconnectInfo.detail).length > 0) {
5508
+ eventPayload.detail = disconnectInfo.detail;
5509
+ }
5510
+ if (disconnectInfo.code !== undefined && disconnectInfo.code !== null) {
5511
+ eventPayload.code = disconnectInfo.code;
5512
+ }
5513
+ await this._dispatcher.publish('connection.state', eventPayload);
5325
5514
  return;
5326
5515
  }
5327
5516
  // 1000 = 正常关闭, 1006 = 网络异常断开(无 close frame),其他 code = 服务端主动关闭