@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/auth.d.ts +6 -0
- package/dist/auth.js +70 -4
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +14 -1
- package/dist/client.js +221 -32
- package/dist/client.js.map +1 -1
- package/dist/e2ee.js +21 -2
- package/dist/e2ee.js.map +1 -1
- package/dist/keystore/aid-db.d.ts +6 -0
- package/dist/keystore/aid-db.js +21 -0
- package/dist/keystore/aid-db.js.map +1 -1
- package/dist/keystore/file.d.ts +6 -0
- package/dist/keystore/file.js +8 -0
- package/dist/keystore/file.js.map +1 -1
- package/dist/keystore/index.d.ts +2 -0
- package/dist/namespaces/auth.d.ts +12 -4
- package/dist/namespaces/auth.js +68 -7
- package/dist/namespaces/auth.js.map +1 -1
- package/package.json +42 -42
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
|
-
|
|
748
|
-
|
|
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
|
|
786
|
+
// auto-ack:仅当没有解密失败时才 ack。失败时让服务端 cursor 留在原位等 retry。
|
|
766
787
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
767
|
-
const shouldAck =
|
|
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
|
-
|
|
939
|
-
|
|
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
|
-
//
|
|
1816
|
-
|
|
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
|
-
|
|
1836
|
-
|
|
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
|
-
|
|
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
|
-
|
|
5358
|
+
// 只有活跃 prekey 被消费时才触发上传。历史 prekey 被消费不触发,避免上传风暴。
|
|
5359
|
+
if (!this._activePrekeyId || prekeyId !== this._activePrekeyId)
|
|
5195
5360
|
return;
|
|
5196
|
-
//
|
|
5197
|
-
|
|
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
|
-
|
|
5297
|
-
|
|
5298
|
-
|
|
5299
|
-
|
|
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
|
-
|
|
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 = 服务端主动关闭
|