@agentunion/fastaun-browser 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.
- package/CHANGELOG.md +26 -0
- package/_packed_docs/CHANGELOG.md +26 -0
- package/_packed_docs/agent.md/SCHEMA.md +173 -0
- package/_packed_docs/agent.md/examples/codeagent-claudecode.md +61 -0
- package/_packed_docs/agent.md/examples/human-developer.md +60 -0
- package/_packed_docs/agent.md/examples/openclaw-lobster.md +52 -0
- package/_packed_docs/agent.md/examples/signed-openclaw-lobster.md +43 -0
- package/_packed_docs/protocol/00-/346/200/273/350/247/210/344/270/216/345/210/206/345/261/202.md +205 -0
- 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
- 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
- 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
- package/_packed_docs/protocol/03-Gateway-/350/277/236/346/216/245/346/250/241/345/274/217.md +262 -0
- package/_packed_docs/protocol/04-Peer-/345/255/220/345/215/217/350/256/256.md +180 -0
- package/_packed_docs/protocol/05-Relay-/345/255/220/345/215/217/350/256/256.md +164 -0
- package/_packed_docs/protocol/06-/346/234/215/345/212/241/345/215/217/350/256/256.md +1135 -0
- 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
- package/_packed_docs/protocol/08-AUN-E2EE-Group.md +900 -0
- package/_packed_docs/protocol/08-AUN-E2EE.md +413 -0
- package/_packed_docs/protocol/09-/345/256/211/345/205/250/350/200/203/350/231/221.md +316 -0
- package/_packed_docs/protocol/10-Group-/345/255/220/345/215/217/350/256/256.md +804 -0
- package/_packed_docs/protocol/11-Storage-/345/255/220/345/215/217/350/256/256.md +271 -0
- package/_packed_docs/protocol/12-Stream-/345/255/220/345/215/217/350/256/256.md +329 -0
- package/_packed_docs/protocol/13-Agent/350/241/214/344/270/272/350/247/204/350/214/203.md +141 -0
- 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
- package/_packed_docs/protocol/README.md +71 -0
- package/_packed_docs/protocol/agent.md/SCHEMA.md +118 -0
- package/_packed_docs/protocol/agent.md/examples/codeagent-claudecode.md +61 -0
- package/_packed_docs/protocol/agent.md/examples/human-developer.md +60 -0
- package/_packed_docs/protocol/agent.md/examples/openclaw-lobster.md +52 -0
- package/_packed_docs/protocol/aun-docs-guide.md +49 -0
- package/_packed_docs/protocol/index.md +114 -0
- 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
- 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
- package/_packed_docs/protocol//351/231/204/345/275/225A-/346/234/257/350/257/255/350/241/250.md +337 -0
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- 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
- package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +223 -0
- package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +354 -0
- package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +172 -0
- package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +373 -0
- package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +611 -0
- package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +1152 -0
- package/_packed_docs/sdk/07-/351/224/231/350/257/257/345/244/204/347/220/206.md +150 -0
- package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +89 -0
- package/_packed_docs/sdk/09-custody-api-manual.md +445 -0
- package/_packed_docs/sdk/09-group-rpc-manual.md +1895 -0
- package/_packed_docs/sdk/09-message-rpc-manual.md +597 -0
- package/_packed_docs/sdk/09-meta-rpc-manual.md +142 -0
- package/_packed_docs/sdk/09-payload-reference.md +702 -0
- package/_packed_docs/sdk/09-storage-rpc-manual.md +408 -0
- package/_packed_docs/sdk/09-stream-rpc-manual.md +275 -0
- package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +72 -0
- package/_packed_docs/sdk/INDEX.md +131 -0
- package/_packed_docs/sdk/README.md +307 -0
- package/dist/auth.d.ts +11 -1
- package/dist/auth.d.ts.map +1 -1
- package/dist/auth.js +92 -17
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +51 -9
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +394 -122
- package/dist/client.js.map +1 -1
- package/dist/e2ee.d.ts.map +1 -1
- package/dist/e2ee.js +20 -0
- package/dist/e2ee.js.map +1 -1
- package/dist/keystore/index.d.ts +11 -0
- package/dist/keystore/index.d.ts.map +1 -1
- package/dist/keystore/indexeddb.d.ts +35 -0
- package/dist/keystore/indexeddb.d.ts.map +1 -1
- package/dist/keystore/indexeddb.js +91 -0
- package/dist/keystore/indexeddb.js.map +1 -1
- package/dist/namespaces/auth.d.ts +10 -3
- package/dist/namespaces/auth.d.ts.map +1 -1
- package/dist/namespaces/auth.js +94 -15
- package/dist/namespaces/auth.js.map +1 -1
- package/dist/transport.d.ts +9 -1
- package/dist/transport.d.ts.map +1 -1
- package/dist/transport.js +24 -0
- package/dist/transport.js.map +1 -1
- package/package.json +4 -1
package/dist/client.js
CHANGED
|
@@ -87,7 +87,7 @@ const DEFAULT_SESSION_OPTIONS = {
|
|
|
87
87
|
},
|
|
88
88
|
timeouts: {
|
|
89
89
|
connect: 5.0,
|
|
90
|
-
call:
|
|
90
|
+
call: 35.0,
|
|
91
91
|
http: 30.0,
|
|
92
92
|
},
|
|
93
93
|
};
|
|
@@ -99,6 +99,20 @@ const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
|
|
|
99
99
|
const PENDING_DECRYPT_LIMIT = 100;
|
|
100
100
|
const PUSHED_SEQS_LIMIT = 50_000;
|
|
101
101
|
const PENDING_ORDERED_LIMIT = 50_000;
|
|
102
|
+
// 心跳间隔下/上限(秒)。0 = 关闭心跳;负值视为 0;其余值 clamp 到 [10, 600]。
|
|
103
|
+
// 服务端通过 hello.heartbeat_interval 与 meta.ping pong 中的同名字段下发。
|
|
104
|
+
const HEARTBEAT_MIN_INTERVAL_SECONDS = 10;
|
|
105
|
+
const HEARTBEAT_MAX_INTERVAL_SECONDS = 600;
|
|
106
|
+
function clampHeartbeatInterval(value) {
|
|
107
|
+
const n = Number(value);
|
|
108
|
+
if (!Number.isFinite(n) || n <= 0)
|
|
109
|
+
return 0;
|
|
110
|
+
if (n < HEARTBEAT_MIN_INTERVAL_SECONDS)
|
|
111
|
+
return HEARTBEAT_MIN_INTERVAL_SECONDS;
|
|
112
|
+
if (n > HEARTBEAT_MAX_INTERVAL_SECONDS)
|
|
113
|
+
return HEARTBEAT_MAX_INTERVAL_SECONDS;
|
|
114
|
+
return n;
|
|
115
|
+
}
|
|
102
116
|
// P1-23: 非幂等方法使用更长超时(35s),避免 SDK 10s 超时 < gateway 30s 处理时间
|
|
103
117
|
const NON_IDEMPOTENT_TIMEOUT = 35;
|
|
104
118
|
const NON_IDEMPOTENT_METHODS = new Set([
|
|
@@ -321,6 +335,7 @@ export class AUNClient {
|
|
|
321
335
|
_gatewayUrl = null;
|
|
322
336
|
_deviceId;
|
|
323
337
|
_slotId;
|
|
338
|
+
_connectedAt = 0;
|
|
324
339
|
_connectDeliveryMode;
|
|
325
340
|
_defaultConnectDeliveryMode;
|
|
326
341
|
_closing = false;
|
|
@@ -343,6 +358,8 @@ export class AUNClient {
|
|
|
343
358
|
_certCache = new Map();
|
|
344
359
|
_prekeyReplenishInflight = new Set();
|
|
345
360
|
_prekeyReplenished = new Set();
|
|
361
|
+
// 当前活跃 prekey:只有这个 prekey 被消费时才触发 replenish 上传
|
|
362
|
+
_activePrekeyId = '';
|
|
346
363
|
_peerPrekeysCache = new Map();
|
|
347
364
|
// 后台任务 handle(浏览器 setInterval/setTimeout)
|
|
348
365
|
_heartbeatTimer = null;
|
|
@@ -352,6 +369,17 @@ export class AUNClient {
|
|
|
352
369
|
_groupEpochCleanupTimer = null;
|
|
353
370
|
_groupEpochRotateTimer = null;
|
|
354
371
|
_cacheCleanupTimer = null;
|
|
372
|
+
/**
|
|
373
|
+
* 本地 agent.md 内容对应的 etag(quoted sha256 hex,与服务端 _agent_md_etag 一致)。
|
|
374
|
+
*
|
|
375
|
+
* 浏览器无法直接读本地文件,因此 API 不接收 path,而是接收 markdown 字符串:
|
|
376
|
+
* 应用层(如 `<input type=file>`)读出文本后调用 setLocalAgentMdContent() 设置。
|
|
377
|
+
* 用于跟服务端 RPC 注入的 _meta.agent_md_etag 比对,触发"本地未发布到服务端"或
|
|
378
|
+
* "服务端版本更新"的 UI 提示。
|
|
379
|
+
*/
|
|
380
|
+
_localAgentMdEtag = '';
|
|
381
|
+
/** gateway 在 RPC envelope._meta.agent_md_etag 注入的服务端 etag;纯观察,无下游依赖。 */
|
|
382
|
+
_remoteAgentMdEtag = '';
|
|
355
383
|
/** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
|
|
356
384
|
_seqTracker = new SeqTracker();
|
|
357
385
|
_seqTrackerContext = null;
|
|
@@ -370,14 +398,17 @@ export class AUNClient {
|
|
|
370
398
|
_groupEpochRotationRetryTimers = new Map();
|
|
371
399
|
/** Lazy group sync:首次发送群消息前自动拉取历史 */
|
|
372
400
|
_groupSynced = new Set();
|
|
373
|
-
/** Lazy P2P sync:首次发送 P2P 消息前自动拉取历史 */
|
|
374
|
-
_p2pSynced = false;
|
|
375
401
|
/** gap fill 来源标记:true 表示当前正在补洞(pull 触发),false 表示非补洞 */
|
|
376
402
|
_gapFillActive = false;
|
|
377
403
|
// 重连相关
|
|
378
404
|
_reconnectActive = false;
|
|
379
405
|
_reconnectAbort = null;
|
|
380
406
|
_serverKicked = false;
|
|
407
|
+
/**
|
|
408
|
+
* 缓存最近一次服务端 gateway.disconnect 信息(含 code/reason/detail),
|
|
409
|
+
* 让后续 connection.state(terminal_failed) 也能携带 detail(如配额超限信息)。
|
|
410
|
+
*/
|
|
411
|
+
_lastDisconnectInfo = null;
|
|
381
412
|
// Logger(per-client 单例 + 各模块子 logger)
|
|
382
413
|
_logger;
|
|
383
414
|
_clientLog;
|
|
@@ -430,6 +461,7 @@ export class AUNClient {
|
|
|
430
461
|
timeout: DEFAULT_SESSION_OPTIONS.timeouts.call,
|
|
431
462
|
onDisconnect: (error, closeCode) => this._handleTransportDisconnect(error, closeCode),
|
|
432
463
|
});
|
|
464
|
+
this._transport.setMetaObserver((meta) => this._observeRpcMeta(meta));
|
|
433
465
|
this._e2ee = new E2EEManager({
|
|
434
466
|
identityFn: () => this._identity ?? {},
|
|
435
467
|
deviceIdFn: () => this._deviceId,
|
|
@@ -492,14 +524,66 @@ export class AUNClient {
|
|
|
492
524
|
});
|
|
493
525
|
}
|
|
494
526
|
// 服务端主动断开通知:记录日志并标记不重连
|
|
495
|
-
this._dispatcher.subscribe('_raw.gateway.disconnect', (data) => {
|
|
496
|
-
this._onGatewayDisconnect(data);
|
|
527
|
+
this._dispatcher.subscribe('_raw.gateway.disconnect', async (data) => {
|
|
528
|
+
await this._onGatewayDisconnect(data);
|
|
497
529
|
});
|
|
498
530
|
}
|
|
499
531
|
// ── 属性 ──────────────────────────────────────────
|
|
500
532
|
get aid() {
|
|
501
533
|
return this._aid;
|
|
502
534
|
}
|
|
535
|
+
/**
|
|
536
|
+
* 设置本地 agent.md 内容并一次性计算 etag(quoted sha256,与服务端一致)。
|
|
537
|
+
*
|
|
538
|
+
* 浏览器环境无法直接读本地文件,应用应通过 `<input type=file>` 等方式读出 markdown
|
|
539
|
+
* 文本后调用本方法。content 为空字符串时清除本地 etag。失败不抛异常(debug 日志记录),
|
|
540
|
+
* 应用可读 getLocalAgentMdEtag() 返回值或本方法返回值判断。
|
|
541
|
+
*
|
|
542
|
+
* 返回当前 etag(quoted hex 或空串)。
|
|
543
|
+
*/
|
|
544
|
+
async setLocalAgentMdContent(content) {
|
|
545
|
+
const text = String(content ?? '');
|
|
546
|
+
if (text.length === 0) {
|
|
547
|
+
this._localAgentMdEtag = '';
|
|
548
|
+
return '';
|
|
549
|
+
}
|
|
550
|
+
try {
|
|
551
|
+
const data = new TextEncoder().encode(text);
|
|
552
|
+
const buf = await crypto.subtle.digest('SHA-256', data);
|
|
553
|
+
const bytes = new Uint8Array(buf);
|
|
554
|
+
let hex = '';
|
|
555
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
556
|
+
hex += bytes[i].toString(16).padStart(2, '0');
|
|
557
|
+
}
|
|
558
|
+
this._localAgentMdEtag = `"${hex}"`;
|
|
559
|
+
}
|
|
560
|
+
catch (exc) {
|
|
561
|
+
this._clientLog.warn(`setLocalAgentMdContent sha256 failed: ${String(exc)}`);
|
|
562
|
+
this._localAgentMdEtag = '';
|
|
563
|
+
}
|
|
564
|
+
return this._localAgentMdEtag;
|
|
565
|
+
}
|
|
566
|
+
/** 返回 setLocalAgentMdContent 计算的 etag;未设置或计算失败时返回空串。 */
|
|
567
|
+
getLocalAgentMdEtag() {
|
|
568
|
+
return this._localAgentMdEtag;
|
|
569
|
+
}
|
|
570
|
+
/**
|
|
571
|
+
* 返回 gateway 在最近一次 RPC envelope._meta 注入的服务端 agent.md etag。
|
|
572
|
+
*
|
|
573
|
+
* 未收到过则为空串;不阻塞调用,纯内存读。
|
|
574
|
+
*/
|
|
575
|
+
getRemoteAgentMdEtag() {
|
|
576
|
+
return this._remoteAgentMdEtag;
|
|
577
|
+
}
|
|
578
|
+
/** transport 的 meta observer:吸收 gateway 注入的 _meta 字段。失败不影响业务。 */
|
|
579
|
+
_observeRpcMeta(meta) {
|
|
580
|
+
if (!isJsonObject(meta))
|
|
581
|
+
return;
|
|
582
|
+
const etag = String(meta.agent_md_etag ?? '').trim();
|
|
583
|
+
if (etag) {
|
|
584
|
+
this._remoteAgentMdEtag = etag;
|
|
585
|
+
}
|
|
586
|
+
}
|
|
503
587
|
get state() {
|
|
504
588
|
return this._state;
|
|
505
589
|
}
|
|
@@ -714,6 +798,7 @@ export class AUNClient {
|
|
|
714
798
|
return this._sendEncrypted(p);
|
|
715
799
|
}
|
|
716
800
|
// encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
|
|
801
|
+
this._maybeAppendEchoTraceSend(p);
|
|
717
802
|
}
|
|
718
803
|
// 自动加密:group.send 默认加密(encrypt 默认 true)
|
|
719
804
|
if (method === 'group.send') {
|
|
@@ -722,6 +807,7 @@ export class AUNClient {
|
|
|
722
807
|
if (encrypt) {
|
|
723
808
|
return this._sendGroupEncrypted(p);
|
|
724
809
|
}
|
|
810
|
+
this._maybeAppendEchoTraceSend(p);
|
|
725
811
|
}
|
|
726
812
|
if (method === 'group.thought.put') {
|
|
727
813
|
const encrypt = p.encrypt !== undefined ? p.encrypt : true;
|
|
@@ -788,7 +874,6 @@ export class AUNClient {
|
|
|
788
874
|
if (method === 'group.pull' && isJsonObject(result)) {
|
|
789
875
|
const r = result;
|
|
790
876
|
const messages = r.messages;
|
|
791
|
-
// 先保存原始消息(解密前),用于喂 SeqTracker(与 P2P message.pull 路径对齐)
|
|
792
877
|
const rawMessages = (Array.isArray(messages) ? messages : []).filter(isJsonObject);
|
|
793
878
|
if (rawMessages.length) {
|
|
794
879
|
r.messages = await this._decryptGroupMessages(rawMessages);
|
|
@@ -796,13 +881,28 @@ export class AUNClient {
|
|
|
796
881
|
const gid = (p.group_id ?? '');
|
|
797
882
|
if (gid) {
|
|
798
883
|
const ns = `group:${gid}`;
|
|
799
|
-
//
|
|
800
|
-
|
|
801
|
-
|
|
884
|
+
// 区分解密成功 / 失败:失败的 payload 仍是 e2ee.group_encrypted。
|
|
885
|
+
const decryptedOnly = [];
|
|
886
|
+
let failedCount = 0;
|
|
887
|
+
const decryptedMessages = Array.isArray(r.messages) ? r.messages : [];
|
|
888
|
+
for (const m of decryptedMessages) {
|
|
889
|
+
if (!isJsonObject(m))
|
|
890
|
+
continue;
|
|
891
|
+
const payload = isJsonObject(m.payload) ? m.payload : {};
|
|
892
|
+
const ptype = payload.type;
|
|
893
|
+
if (ptype === 'e2ee.group_encrypted') {
|
|
894
|
+
failedCount++;
|
|
895
|
+
this._enqueuePendingDecrypt(gid, m);
|
|
896
|
+
}
|
|
897
|
+
else {
|
|
898
|
+
decryptedOnly.push(m);
|
|
899
|
+
}
|
|
900
|
+
}
|
|
901
|
+
if (decryptedOnly.length) {
|
|
902
|
+
// 仅用解密成功的消息推进 contig;失败的等 retry 解密成功才推进。
|
|
903
|
+
this._seqTracker.onPullResult(ns, decryptedOnly);
|
|
802
904
|
}
|
|
803
905
|
// ⚠️ 逻辑边界 L4:group retention floor 通道 = cursor.current_seq
|
|
804
|
-
// 群路径目前无独立 earliest_available_seq 字段;若未来引入 group retention,需新增字段并同步更新此处。
|
|
805
|
-
// 与 S2 [1,seq-1] 历史 gap 配合使用,force_contiguous_seq 是跳过空洞的唯一手段。
|
|
806
906
|
const cursor = isJsonObject(r.cursor) ? r.cursor : null;
|
|
807
907
|
if (cursor) {
|
|
808
908
|
const serverAck = Number(cursor.current_seq ?? 0);
|
|
@@ -815,9 +915,9 @@ export class AUNClient {
|
|
|
815
915
|
}
|
|
816
916
|
}
|
|
817
917
|
this._saveSeqTrackerState();
|
|
818
|
-
// auto-ack
|
|
918
|
+
// auto-ack:仅当没有解密失败时才 ack。失败时让服务端 cursor 留在原位等 retry。
|
|
819
919
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
820
|
-
const shouldAck =
|
|
920
|
+
const shouldAck = failedCount === 0 && (decryptedOnly.length > 0 || (cursor !== null && Number(cursor.current_seq ?? 0) > 0));
|
|
821
921
|
if (contig > 0 && shouldAck) {
|
|
822
922
|
this._transport.call('group.ack_messages', {
|
|
823
923
|
group_id: gid,
|
|
@@ -826,6 +926,10 @@ export class AUNClient {
|
|
|
826
926
|
slot_id: this._slotId,
|
|
827
927
|
}).catch((e) => { this._clientLog.warn('group.pull auto-ack failed: group=' + gid, e); });
|
|
828
928
|
}
|
|
929
|
+
// 有解密失败时调度 recovery 兜底定时
|
|
930
|
+
if (failedCount > 0) {
|
|
931
|
+
this._scheduleRecoveryTimeout(gid);
|
|
932
|
+
}
|
|
829
933
|
}
|
|
830
934
|
}
|
|
831
935
|
if (method === 'group.thought.get' && isJsonObject(result)) {
|
|
@@ -920,12 +1024,26 @@ export class AUNClient {
|
|
|
920
1024
|
}
|
|
921
1025
|
// 拦截 P2P 传输的群组密钥分发/请求/响应消息
|
|
922
1026
|
if (await this._tryHandleGroupKeyMessage(msg)) {
|
|
1027
|
+
// group_key 控制消息也要推进 seq tracker + auto-ack,
|
|
1028
|
+
// 否则 fillP2pGap 会因为 contig 卡在此 seq 之前而重复拉取同样的历史消息。
|
|
1029
|
+
const seq = msg.seq;
|
|
1030
|
+
if (seq !== undefined && seq !== null && this._aid) {
|
|
1031
|
+
const ns = `p2p:${this._aid}`;
|
|
1032
|
+
this._seqTracker.onMessageSeq(ns, seq);
|
|
1033
|
+
this._saveSeqTrackerState();
|
|
1034
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1035
|
+
if (contig > 0) {
|
|
1036
|
+
this._transport.call('message.ack', {
|
|
1037
|
+
seq: contig,
|
|
1038
|
+
device_id: this._deviceId,
|
|
1039
|
+
slot_id: this._slotId,
|
|
1040
|
+
}).catch(() => { });
|
|
1041
|
+
}
|
|
1042
|
+
}
|
|
923
1043
|
return;
|
|
924
1044
|
}
|
|
925
1045
|
// P2P 空洞检测
|
|
926
1046
|
const seq = msg.seq;
|
|
927
|
-
// 推送路径收到 P2P 消息 → 标记已同步,后续发送无需再 lazySyncP2p
|
|
928
|
-
this._p2pSynced = true;
|
|
929
1047
|
if (seq !== undefined && seq !== null && this._aid) {
|
|
930
1048
|
const ns = `p2p:${this._aid}`;
|
|
931
1049
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
@@ -1004,8 +1122,12 @@ export class AUNClient {
|
|
|
1004
1122
|
return;
|
|
1005
1123
|
}
|
|
1006
1124
|
const decrypted = await this._decryptGroupMessage(msg);
|
|
1007
|
-
//
|
|
1008
|
-
|
|
1125
|
+
// 解密失败时**不推进 seq tracker / 不 auto-ack**:让服务端 cursor 留在原位,
|
|
1126
|
+
// 等密钥恢复后 retry 解密成功才推进 + ack;recovery 真的失败时由
|
|
1127
|
+
// _retryPendingDecryptMsgs(forceAdvanceOnFail=true) 兜底强制推进。
|
|
1128
|
+
const payloadForCheck = isJsonObject(msg.payload) ? msg.payload : null;
|
|
1129
|
+
const isDecryptFail = payloadForCheck?.type === 'e2ee.group_encrypted' && !decrypted.e2ee;
|
|
1130
|
+
if (!isDecryptFail && groupId && seq !== undefined && seq !== null) {
|
|
1009
1131
|
const ns = `group:${groupId}`;
|
|
1010
1132
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1011
1133
|
if (needPull) {
|
|
@@ -1023,10 +1145,12 @@ export class AUNClient {
|
|
|
1023
1145
|
this._saveSeqTrackerState();
|
|
1024
1146
|
}
|
|
1025
1147
|
// R3: 解密失败 → 不 publish 密文给应用层,入 pending 队列 + 发 undecryptable 事件
|
|
1026
|
-
|
|
1027
|
-
|
|
1028
|
-
if (groupId)
|
|
1148
|
+
if (isDecryptFail) {
|
|
1149
|
+
if (groupId) {
|
|
1029
1150
|
this._enqueuePendingDecrypt(groupId, msg);
|
|
1151
|
+
// 触发 recovery 兜底定时(30s 后如果仍未解开,强制推进)
|
|
1152
|
+
this._scheduleRecoveryTimeout(groupId);
|
|
1153
|
+
}
|
|
1030
1154
|
await this._publishAppEvent('group.message_undecryptable', {
|
|
1031
1155
|
message_id: msg.message_id ?? null,
|
|
1032
1156
|
group_id: groupId,
|
|
@@ -1349,8 +1473,61 @@ export class AUNClient {
|
|
|
1349
1473
|
return this._attachCurrentInstanceContext(payload);
|
|
1350
1474
|
}
|
|
1351
1475
|
async _publishAppEvent(event, payload) {
|
|
1476
|
+
if ((event === 'message.received' || event === 'group.message_created') && isJsonObject(payload)) {
|
|
1477
|
+
this._maybeAppendEchoTraceReceive(payload);
|
|
1478
|
+
}
|
|
1479
|
+
// 注入本地/远端 agent.md etag,让应用层判断版本一致性;失败不影响业务。
|
|
1480
|
+
if (isJsonObject(payload)) {
|
|
1481
|
+
try {
|
|
1482
|
+
const localEtag = this._localAgentMdEtag || '';
|
|
1483
|
+
const remoteEtag = this._remoteAgentMdEtag || '';
|
|
1484
|
+
if ((localEtag || remoteEtag) && payload._agent_md === undefined) {
|
|
1485
|
+
payload._agent_md = {
|
|
1486
|
+
local_etag: localEtag,
|
|
1487
|
+
remote_etag: remoteEtag,
|
|
1488
|
+
};
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
catch (exc) {
|
|
1492
|
+
this._clientLog.debug(`agent_md etag inject skipped: ${String(exc)}`);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1352
1495
|
await this._dispatcher.publish(event, this._normalizePublishedMessagePayload(event, payload));
|
|
1353
1496
|
}
|
|
1497
|
+
_echoTimestamp() {
|
|
1498
|
+
const now = new Date();
|
|
1499
|
+
const hh = String(now.getHours()).padStart(2, '0');
|
|
1500
|
+
const mm = String(now.getMinutes()).padStart(2, '0');
|
|
1501
|
+
const ss = String(now.getSeconds()).padStart(2, '0');
|
|
1502
|
+
const ms = String(now.getMilliseconds()).padStart(3, '0');
|
|
1503
|
+
return `${hh}:${mm}:${ss}.${ms}`;
|
|
1504
|
+
}
|
|
1505
|
+
_isEchoPayload(payload) {
|
|
1506
|
+
if (!payload || typeof payload !== 'object' || Array.isArray(payload))
|
|
1507
|
+
return false;
|
|
1508
|
+
const text = payload.text;
|
|
1509
|
+
if (typeof text !== 'string' || text.length > 4096)
|
|
1510
|
+
return false;
|
|
1511
|
+
return text.split('\n', 1)[0].toLowerCase().includes('echo');
|
|
1512
|
+
}
|
|
1513
|
+
_maybeAppendEchoTraceSend(params) {
|
|
1514
|
+
const payload = params.payload;
|
|
1515
|
+
if (!this._isEchoPayload(payload))
|
|
1516
|
+
return;
|
|
1517
|
+
const uptime = this._connectedAt ? Math.floor((Date.now() - this._connectedAt) / 1000) : 0;
|
|
1518
|
+
const trace = `${this._echoTimestamp()} [AUN-SDK.send] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
|
|
1519
|
+
params.payload = { ...payload, text: payload.text + '\n' + trace };
|
|
1520
|
+
}
|
|
1521
|
+
_maybeAppendEchoTraceReceive(msg) {
|
|
1522
|
+
if (msg.encrypted)
|
|
1523
|
+
return;
|
|
1524
|
+
const payload = msg.payload;
|
|
1525
|
+
if (!this._isEchoPayload(payload))
|
|
1526
|
+
return;
|
|
1527
|
+
const uptime = this._connectedAt ? Math.floor((Date.now() - this._connectedAt) / 1000) : 0;
|
|
1528
|
+
const trace = `${this._echoTimestamp()} [AUN-SDK.receive] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
|
|
1529
|
+
msg.payload = { ...payload, text: payload.text + '\n' + trace };
|
|
1530
|
+
}
|
|
1354
1531
|
_messageTargetsCurrentInstance(message) {
|
|
1355
1532
|
if (!isJsonObject(message))
|
|
1356
1533
|
return true;
|
|
@@ -1864,10 +2041,8 @@ export class AUNClient {
|
|
|
1864
2041
|
}
|
|
1865
2042
|
const persistRequired = Boolean(params.persist_required || params.durable);
|
|
1866
2043
|
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
1867
|
-
//
|
|
1868
|
-
|
|
1869
|
-
await this._lazySyncP2p();
|
|
1870
|
-
}
|
|
2044
|
+
// 惰性 P2P 同步由 connect/reconnect 完成后的 _fillP2pGap 异步触发,
|
|
2045
|
+
// 不再在 send 路径上 await(与 C++ FillP2PGap 行为对齐,避免阻塞用户发送)。
|
|
1871
2046
|
// 内部发送逻辑,refreshPeerMaterial=true 时强制清缓存重新拉取对端材料
|
|
1872
2047
|
const sendAttempt = async (refreshPeerMaterial = false) => {
|
|
1873
2048
|
const recipientPrekeys = refreshPeerMaterial
|
|
@@ -1876,20 +2051,13 @@ export class AUNClient {
|
|
|
1876
2051
|
const selfSyncCopies = await this._buildSelfSyncCopies({
|
|
1877
2052
|
logicalToAid: toAid, payload, messageId, timestamp, protectedHeaders,
|
|
1878
2053
|
});
|
|
1879
|
-
//
|
|
1880
|
-
// 占位符 PREKEY_FALLBACK_DEVICE_ID 表示服务端未分配真实设备 ID,不可用于多设备路由
|
|
2054
|
+
// 统一 multi-device 路径:必须有 routable prekey
|
|
1881
2055
|
const routablePrekeys = recipientPrekeys.filter(pk => {
|
|
1882
2056
|
const did = String(pk.device_id ?? '').trim();
|
|
1883
2057
|
return did && did !== PREKEY_FALLBACK_DEVICE_ID;
|
|
1884
2058
|
});
|
|
1885
|
-
|
|
1886
|
-
|
|
1887
|
-
if (!canUseMultiDevice) {
|
|
1888
|
-
return await this._sendEncryptedSingle({
|
|
1889
|
-
toAid, payload, messageId, timestamp,
|
|
1890
|
-
prekey: routablePrekeys[0] ?? recipientPrekeys[0],
|
|
1891
|
-
persistRequired, protectedHeaders,
|
|
1892
|
-
});
|
|
2059
|
+
if (routablePrekeys.length === 0) {
|
|
2060
|
+
throw new Error(`no registered device prekeys for ${toAid}, cannot send encrypted message`);
|
|
1893
2061
|
}
|
|
1894
2062
|
const recipientCopies = await this._buildRecipientDeviceCopies({
|
|
1895
2063
|
toAid, payload, messageId, timestamp,
|
|
@@ -1932,63 +2100,6 @@ export class AUNClient {
|
|
|
1932
2100
|
throw err;
|
|
1933
2101
|
}
|
|
1934
2102
|
}
|
|
1935
|
-
/**
|
|
1936
|
-
* 首次发送 P2P 消息前懒拉取历史消息,同步 seqTracker 避免空洞。
|
|
1937
|
-
* 只在本连接周期内执行一次。
|
|
1938
|
-
*/
|
|
1939
|
-
async _lazySyncP2p() {
|
|
1940
|
-
this._p2pSynced = true;
|
|
1941
|
-
if (!this._aid)
|
|
1942
|
-
return;
|
|
1943
|
-
const ns = `p2p:${this._aid}`;
|
|
1944
|
-
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1945
|
-
try {
|
|
1946
|
-
const result = await this._transport.call('message.pull', {
|
|
1947
|
-
after_seq: afterSeq,
|
|
1948
|
-
limit: 200,
|
|
1949
|
-
});
|
|
1950
|
-
if (isJsonObject(result)) {
|
|
1951
|
-
const messages = result.messages;
|
|
1952
|
-
if (Array.isArray(messages) && messages.length > 0) {
|
|
1953
|
-
this._seqTracker.onPullResult(ns, messages.filter(isJsonObject));
|
|
1954
|
-
this._saveSeqTrackerState();
|
|
1955
|
-
}
|
|
1956
|
-
}
|
|
1957
|
-
}
|
|
1958
|
-
catch (exc) {
|
|
1959
|
-
this._clientLog.warn(`lazySyncP2p failed:${String(exc)}`);
|
|
1960
|
-
}
|
|
1961
|
-
}
|
|
1962
|
-
async _sendEncryptedSingle(opts) {
|
|
1963
|
-
let prekey = opts.prekey;
|
|
1964
|
-
if (prekey === undefined) {
|
|
1965
|
-
prekey = await this._fetchPeerPrekey(opts.toAid);
|
|
1966
|
-
}
|
|
1967
|
-
const peerCertFingerprint = typeof prekey?.cert_fingerprint === 'string' ? prekey.cert_fingerprint : undefined;
|
|
1968
|
-
const peerCertPem = await this._fetchPeerCert(opts.toAid, peerCertFingerprint);
|
|
1969
|
-
const [envelope, encryptResult] = await this._encryptCopyPayload({
|
|
1970
|
-
logicalToAid: opts.toAid,
|
|
1971
|
-
payload: opts.payload,
|
|
1972
|
-
peerCertPem,
|
|
1973
|
-
prekey,
|
|
1974
|
-
messageId: opts.messageId,
|
|
1975
|
-
timestamp: opts.timestamp,
|
|
1976
|
-
protectedHeaders: opts.protectedHeaders,
|
|
1977
|
-
});
|
|
1978
|
-
await this._ensureEncryptResult(opts.toAid, encryptResult);
|
|
1979
|
-
const sendParams = {
|
|
1980
|
-
to: opts.toAid,
|
|
1981
|
-
payload: envelope,
|
|
1982
|
-
type: 'e2ee.encrypted',
|
|
1983
|
-
encrypted: true,
|
|
1984
|
-
message_id: opts.messageId,
|
|
1985
|
-
timestamp: opts.timestamp,
|
|
1986
|
-
};
|
|
1987
|
-
if (opts.persistRequired) {
|
|
1988
|
-
sendParams.persist_required = true;
|
|
1989
|
-
}
|
|
1990
|
-
return this._transport.call('message.send', sendParams);
|
|
1991
|
-
}
|
|
1992
2103
|
async _buildRecipientDeviceCopies(opts) {
|
|
1993
2104
|
const recipientCopies = [];
|
|
1994
2105
|
const certCache = new Map();
|
|
@@ -2731,23 +2842,47 @@ export class AUNClient {
|
|
|
2731
2842
|
queue.push(msg);
|
|
2732
2843
|
this._pendingDecryptMsgs.set(ns, queue.slice(-PENDING_DECRYPT_LIMIT));
|
|
2733
2844
|
}
|
|
2734
|
-
async _retryPendingDecryptMsgs(groupId) {
|
|
2845
|
+
async _retryPendingDecryptMsgs(groupId, forceAdvanceOnFail = false) {
|
|
2735
2846
|
const ns = `group:${groupId}`;
|
|
2736
2847
|
const queue = this._pendingDecryptMsgs.get(ns);
|
|
2737
2848
|
if (!queue || queue.length === 0)
|
|
2738
2849
|
return;
|
|
2739
2850
|
this._pendingDecryptMsgs.set(ns, []);
|
|
2740
2851
|
const stillPending = [];
|
|
2852
|
+
let forceAdvancedAny = false;
|
|
2741
2853
|
for (const msg of queue) {
|
|
2742
2854
|
try {
|
|
2743
2855
|
const decrypted = await this._decryptGroupMessage(msg);
|
|
2744
2856
|
const payload = isJsonObject(msg.payload) ? msg.payload : null;
|
|
2745
2857
|
if (payload?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
|
|
2746
|
-
|
|
2858
|
+
if (forceAdvanceOnFail) {
|
|
2859
|
+
// recovery 真的失败:强制推进 + 发 undecryptable
|
|
2860
|
+
this._clientLog.info(`group recovery give up: group=${groupId} seq=${String(msg.seq ?? '')} → force advance + publish undecryptable`);
|
|
2861
|
+
const seq = msg.seq;
|
|
2862
|
+
if (seq !== undefined && seq !== null) {
|
|
2863
|
+
this._seqTracker.onMessageSeq(ns, seq);
|
|
2864
|
+
this._saveSeqTrackerState();
|
|
2865
|
+
forceAdvancedAny = true;
|
|
2866
|
+
}
|
|
2867
|
+
await this._publishAppEvent('group.message_undecryptable', {
|
|
2868
|
+
message_id: msg.message_id,
|
|
2869
|
+
group_id: groupId,
|
|
2870
|
+
from: msg.from,
|
|
2871
|
+
seq,
|
|
2872
|
+
timestamp: msg.timestamp,
|
|
2873
|
+
_decrypt_error: 'epoch recovery failed',
|
|
2874
|
+
});
|
|
2875
|
+
}
|
|
2876
|
+
else {
|
|
2877
|
+
stillPending.push(msg);
|
|
2878
|
+
}
|
|
2747
2879
|
continue;
|
|
2748
2880
|
}
|
|
2749
2881
|
const seq = msg.seq;
|
|
2750
2882
|
if (seq !== undefined && seq !== null) {
|
|
2883
|
+
// 推进 seq tracker(之前 push/pull 失败时没推进)
|
|
2884
|
+
this._seqTracker.onMessageSeq(ns, seq);
|
|
2885
|
+
this._saveSeqTrackerState();
|
|
2751
2886
|
await this._publishOrderedMessage('group.message_created', ns, seq, decrypted);
|
|
2752
2887
|
}
|
|
2753
2888
|
else {
|
|
@@ -2758,6 +2893,17 @@ export class AUNClient {
|
|
|
2758
2893
|
stillPending.push(msg);
|
|
2759
2894
|
}
|
|
2760
2895
|
}
|
|
2896
|
+
if (forceAdvancedAny) {
|
|
2897
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2898
|
+
if (contig > 0) {
|
|
2899
|
+
this._transport.call('group.ack_messages', {
|
|
2900
|
+
group_id: groupId,
|
|
2901
|
+
msg_seq: contig,
|
|
2902
|
+
device_id: this._deviceId,
|
|
2903
|
+
slot_id: this._slotId,
|
|
2904
|
+
}).catch((e) => { this._clientLog.warn('group recovery force-advance ack failed: group=' + groupId, e); });
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2761
2907
|
const queuedDuringRetry = this._pendingDecryptMsgs.get(ns) ?? [];
|
|
2762
2908
|
const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-PENDING_DECRYPT_LIMIT);
|
|
2763
2909
|
if (mergedPending.length)
|
|
@@ -2765,6 +2911,25 @@ export class AUNClient {
|
|
|
2765
2911
|
else
|
|
2766
2912
|
this._pendingDecryptMsgs.delete(ns);
|
|
2767
2913
|
}
|
|
2914
|
+
// recovery 兜底定时去重:每个 group 在 30s 内最多调度一次"超时强制推进"任务
|
|
2915
|
+
_recoveryTimeoutScheduled = new Map();
|
|
2916
|
+
_scheduleRecoveryTimeout(groupId, timeoutMs = 30000) {
|
|
2917
|
+
if (!groupId)
|
|
2918
|
+
return;
|
|
2919
|
+
const now = Date.now();
|
|
2920
|
+
const last = this._recoveryTimeoutScheduled.get(groupId) ?? 0;
|
|
2921
|
+
if (last && (last + timeoutMs) > now)
|
|
2922
|
+
return;
|
|
2923
|
+
this._recoveryTimeoutScheduled.set(groupId, now);
|
|
2924
|
+
setTimeout(() => {
|
|
2925
|
+
const ns = `group:${groupId}`;
|
|
2926
|
+
const queue = this._pendingDecryptMsgs.get(ns);
|
|
2927
|
+
if (!queue || queue.length === 0)
|
|
2928
|
+
return;
|
|
2929
|
+
this._clientLog.info(`group recovery timeout: group=${groupId} → force advance`);
|
|
2930
|
+
this._safeAsync(this._retryPendingDecryptMsgs(groupId, true));
|
|
2931
|
+
}, timeoutMs);
|
|
2932
|
+
}
|
|
2768
2933
|
_scheduleRetryPendingDecryptMsgs(groupId) {
|
|
2769
2934
|
if (!groupId || !this._pendingDecryptMsgs.has(`group:${groupId}`))
|
|
2770
2935
|
return;
|
|
@@ -3395,11 +3560,31 @@ export class AUNClient {
|
|
|
3395
3560
|
let result = null;
|
|
3396
3561
|
try {
|
|
3397
3562
|
if (actualPayload.type === 'e2ee.group_key_distribution') {
|
|
3563
|
+
// 快速跳过已过期的历史 epoch 分发:本地已有更高 epoch 时不发任何 RPC,
|
|
3564
|
+
// 避免 fillP2pGap 拉到大量历史群密钥消息时触发 epoch 编排风暴。
|
|
3565
|
+
const distGroupId = String(actualPayload.group_id ?? '');
|
|
3566
|
+
const distEpoch = Number(actualPayload.epoch ?? 0);
|
|
3567
|
+
if (distGroupId && distEpoch > 0) {
|
|
3568
|
+
const localEpoch = await this._groupE2ee.currentEpoch(distGroupId) ?? 0;
|
|
3569
|
+
if (localEpoch >= distEpoch) {
|
|
3570
|
+
this._clientLog.debug(`skip stale group_key_distribution: group=${distGroupId} msg_epoch=${distEpoch} local_epoch=${localEpoch}`);
|
|
3571
|
+
return true;
|
|
3572
|
+
}
|
|
3573
|
+
}
|
|
3398
3574
|
if (!await this._verifyActiveGroupRotationDistribution(actualPayload)) {
|
|
3399
3575
|
return true;
|
|
3400
3576
|
}
|
|
3401
3577
|
}
|
|
3402
3578
|
else if (actualPayload.type === 'e2ee.group_key_response') {
|
|
3579
|
+
const respGroupId = String(actualPayload.group_id ?? '');
|
|
3580
|
+
const respEpoch = Number(actualPayload.epoch ?? 0);
|
|
3581
|
+
if (respGroupId && respEpoch > 0) {
|
|
3582
|
+
const localEpoch = await this._groupE2ee.currentEpoch(respGroupId) ?? 0;
|
|
3583
|
+
if (localEpoch >= respEpoch) {
|
|
3584
|
+
this._clientLog.debug(`skip stale group_key_response: group=${respGroupId} msg_epoch=${respEpoch} local_epoch=${localEpoch}`);
|
|
3585
|
+
return true;
|
|
3586
|
+
}
|
|
3587
|
+
}
|
|
3403
3588
|
if (!await this._verifyGroupKeyResponseEpoch(actualPayload)) {
|
|
3404
3589
|
return true;
|
|
3405
3590
|
}
|
|
@@ -3681,6 +3866,8 @@ export class AUNClient {
|
|
|
3681
3866
|
async _uploadPrekey() {
|
|
3682
3867
|
const prekeyMaterial = await this._e2ee.generatePrekey();
|
|
3683
3868
|
const result = await this._transport.call('message.e2ee.put_prekey', prekeyMaterial);
|
|
3869
|
+
// 上传成功后记录为活跃 prekey
|
|
3870
|
+
this._activePrekeyId = String(prekeyMaterial.prekey_id ?? '');
|
|
3684
3871
|
return isJsonObject(result) ? { ...result } : { ok: true };
|
|
3685
3872
|
}
|
|
3686
3873
|
/** 确保发送方证书在本地可用且未过期 */
|
|
@@ -3895,6 +4082,11 @@ export class AUNClient {
|
|
|
3895
4082
|
const groupId = String(payload.group_id ?? '').trim();
|
|
3896
4083
|
if (!groupId)
|
|
3897
4084
|
return false;
|
|
4085
|
+
// 历史群(不在当前 session 活跃列表):跳过 RPC 验证,只做本地 handle_incoming
|
|
4086
|
+
if (!this._groupSynced.has(groupId)) {
|
|
4087
|
+
this._clientLog.debug(`skip RPC verify for inactive group: group=${groupId} rotation=${rotationId}`);
|
|
4088
|
+
return true;
|
|
4089
|
+
}
|
|
3898
4090
|
const epoch = Number(payload.epoch ?? 0);
|
|
3899
4091
|
if (!Number.isFinite(epoch) || epoch <= 0)
|
|
3900
4092
|
return false;
|
|
@@ -4679,6 +4871,9 @@ export class AUNClient {
|
|
|
4679
4871
|
deviceId: this._deviceId,
|
|
4680
4872
|
slotId: this._slotId,
|
|
4681
4873
|
deliveryMode: this._connectDeliveryMode,
|
|
4874
|
+
connectionKind: String(params.connection_kind ?? 'long'),
|
|
4875
|
+
shortTtlMs: Number(params.short_ttl_ms ?? 0),
|
|
4876
|
+
extraInfo: params.extra_info,
|
|
4682
4877
|
});
|
|
4683
4878
|
if (isJsonObject(authContext)) {
|
|
4684
4879
|
const auth = authContext;
|
|
@@ -4690,15 +4885,24 @@ export class AUNClient {
|
|
|
4690
4885
|
this._sessionParams.access_token = String(auth.token ?? params.access_token ?? '');
|
|
4691
4886
|
}
|
|
4692
4887
|
}
|
|
4888
|
+
if (isJsonObject(auth.hello) && 'heartbeat_interval' in auth.hello) {
|
|
4889
|
+
this._applyServerHeartbeatInterval(auth.hello.heartbeat_interval, 'auth');
|
|
4890
|
+
}
|
|
4693
4891
|
}
|
|
4694
4892
|
}
|
|
4695
4893
|
else {
|
|
4696
|
-
await this._auth.initializeWithToken(this._transport, challenge, String(params.access_token), {
|
|
4894
|
+
const hello = await this._auth.initializeWithToken(this._transport, challenge, String(params.access_token), {
|
|
4697
4895
|
deviceId: this._deviceId,
|
|
4698
4896
|
slotId: this._slotId,
|
|
4699
4897
|
deliveryMode: this._connectDeliveryMode,
|
|
4898
|
+
connectionKind: String(params.connection_kind ?? 'long'),
|
|
4899
|
+
shortTtlMs: Number(params.short_ttl_ms ?? 0),
|
|
4900
|
+
extraInfo: params.extra_info,
|
|
4700
4901
|
});
|
|
4701
4902
|
await this._syncIdentityAfterConnect(String(params.access_token));
|
|
4903
|
+
if (isJsonObject(hello) && 'heartbeat_interval' in hello) {
|
|
4904
|
+
this._applyServerHeartbeatInterval(hello.heartbeat_interval, 'auth');
|
|
4905
|
+
}
|
|
4702
4906
|
}
|
|
4703
4907
|
}
|
|
4704
4908
|
catch (err) {
|
|
@@ -4711,11 +4915,11 @@ export class AUNClient {
|
|
|
4711
4915
|
throw err;
|
|
4712
4916
|
}
|
|
4713
4917
|
this._state = 'connected';
|
|
4918
|
+
this._connectedAt = Date.now();
|
|
4714
4919
|
await this._dispatcher.publish('connection.state', {
|
|
4715
4920
|
state: this._state,
|
|
4716
4921
|
gateway: gatewayUrl,
|
|
4717
4922
|
});
|
|
4718
|
-
// auth 阶段 aid 可能被 identity 覆盖;若 context 发生变化,重新 refresh + restore。
|
|
4719
4923
|
if (this._seqTrackerContext !== this._currentSeqTrackerContext()) {
|
|
4720
4924
|
this._refreshSeqTrackerContext();
|
|
4721
4925
|
await this._restoreSeqTrackerState();
|
|
@@ -4728,6 +4932,9 @@ export class AUNClient {
|
|
|
4728
4932
|
catch (exc) {
|
|
4729
4933
|
this._clientLog.warn(`prekey upload failed:${String(exc)}`);
|
|
4730
4934
|
}
|
|
4935
|
+
// connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
|
|
4936
|
+
// 群消息按惰性触发,不在此处主动 pull
|
|
4937
|
+
this._safeAsync(this._fillP2pGap());
|
|
4731
4938
|
this._clientLog.debug(`_connectOnce exit: elapsed=${Date.now() - tStart}ms aid=${this._aid ?? '-'}`);
|
|
4732
4939
|
}
|
|
4733
4940
|
catch (err) {
|
|
@@ -4813,15 +5020,38 @@ export class AUNClient {
|
|
|
4813
5020
|
if (request.timeouts !== undefined && !isJsonObject(request.timeouts)) {
|
|
4814
5021
|
throw new ValidationError('timeouts must be an object');
|
|
4815
5022
|
}
|
|
5023
|
+
// 长短连接选项:默认 long,向后兼容
|
|
5024
|
+
const kindRaw = request.connection_kind;
|
|
5025
|
+
if (kindRaw == null) {
|
|
5026
|
+
request.connection_kind = 'long';
|
|
5027
|
+
}
|
|
5028
|
+
else {
|
|
5029
|
+
request.connection_kind = String(kindRaw).trim().toLowerCase();
|
|
5030
|
+
}
|
|
5031
|
+
if (request.connection_kind !== 'long' && request.connection_kind !== 'short') {
|
|
5032
|
+
throw new ValidationError("connection_kind must be 'long' or 'short'");
|
|
5033
|
+
}
|
|
5034
|
+
try {
|
|
5035
|
+
request.short_ttl_ms = Math.max(0, Math.floor(Number(request.short_ttl_ms) || 0));
|
|
5036
|
+
}
|
|
5037
|
+
catch {
|
|
5038
|
+
throw new ValidationError('short_ttl_ms must be a non-negative integer');
|
|
5039
|
+
}
|
|
5040
|
+
if (request.connection_kind !== 'short') {
|
|
5041
|
+
request.short_ttl_ms = 0;
|
|
5042
|
+
}
|
|
4816
5043
|
return request;
|
|
4817
5044
|
}
|
|
4818
5045
|
_buildSessionOptions(params) {
|
|
5046
|
+
const connectionKind = String(params.connection_kind ?? 'long');
|
|
4819
5047
|
const options = {
|
|
4820
5048
|
auto_reconnect: DEFAULT_SESSION_OPTIONS.auto_reconnect,
|
|
4821
5049
|
heartbeat_interval: DEFAULT_SESSION_OPTIONS.heartbeat_interval,
|
|
4822
5050
|
token_refresh_before: DEFAULT_SESSION_OPTIONS.token_refresh_before,
|
|
4823
5051
|
retry: { ...DEFAULT_SESSION_OPTIONS.retry },
|
|
4824
5052
|
timeouts: { ...DEFAULT_SESSION_OPTIONS.timeouts },
|
|
5053
|
+
connection_kind: connectionKind,
|
|
5054
|
+
short_ttl_ms: Number(params.short_ttl_ms ?? 0),
|
|
4825
5055
|
};
|
|
4826
5056
|
if ('auto_reconnect' in params) {
|
|
4827
5057
|
options.auto_reconnect = Boolean(params.auto_reconnect);
|
|
@@ -4842,8 +5072,12 @@ export class AUNClient {
|
|
|
4842
5072
|
}
|
|
4843
5073
|
// ── 内部:后台任务 ────────────────────────────────
|
|
4844
5074
|
_startBackgroundTasks() {
|
|
4845
|
-
|
|
4846
|
-
|
|
5075
|
+
// 短连接不启动 heartbeat 与 token 刷新(生命周期短,不需要长期会话维护);
|
|
5076
|
+
// auto_reconnect 仍允许,由 _sessionOptions.auto_reconnect 决定
|
|
5077
|
+
if (this._sessionOptions?.connection_kind !== 'short') {
|
|
5078
|
+
this._startHeartbeat();
|
|
5079
|
+
this._startTokenRefresh();
|
|
5080
|
+
}
|
|
4847
5081
|
this._startPrekeyRefresh();
|
|
4848
5082
|
this._startGroupEpochTasks();
|
|
4849
5083
|
}
|
|
@@ -4881,10 +5115,9 @@ export class AUNClient {
|
|
|
4881
5115
|
_startHeartbeat() {
|
|
4882
5116
|
if (this._heartbeatTimer !== null)
|
|
4883
5117
|
return;
|
|
4884
|
-
const
|
|
4885
|
-
if (
|
|
5118
|
+
const interval = clampHeartbeatInterval(this._sessionOptions.heartbeat_interval ?? DEFAULT_SESSION_OPTIONS.heartbeat_interval);
|
|
5119
|
+
if (interval <= 0)
|
|
4886
5120
|
return;
|
|
4887
|
-
const interval = Math.max(rawIntervalSeconds, 30) * 1000;
|
|
4888
5121
|
// M25: 把连续失败阈值从 3 次收窄到 2 次。既能容忍一次网络抖动/GC 暂停,
|
|
4889
5122
|
// 又把半开连接的检测延迟从 3 个心跳周期降到 2 个,避免 RPC 长时间挂起。
|
|
4890
5123
|
// 真正的 socket 死亡由 ws.on('close') 立即触发 _handleTransportDisconnect,
|
|
@@ -4895,8 +5128,12 @@ export class AUNClient {
|
|
|
4895
5128
|
if (this._state !== 'connected' || this._closing)
|
|
4896
5129
|
return;
|
|
4897
5130
|
try {
|
|
4898
|
-
await this._transport.call('meta.ping', {});
|
|
5131
|
+
const pong = await this._transport.call('meta.ping', {});
|
|
4899
5132
|
consecutiveFailures = 0;
|
|
5133
|
+
// 服务端可在 pong 中下发新的 heartbeat_interval(秒,0=关闭)
|
|
5134
|
+
if (isJsonObject(pong) && 'heartbeat_interval' in pong) {
|
|
5135
|
+
this._applyServerHeartbeatInterval(pong.heartbeat_interval, 'pong');
|
|
5136
|
+
}
|
|
4900
5137
|
}
|
|
4901
5138
|
catch (exc) {
|
|
4902
5139
|
consecutiveFailures++;
|
|
@@ -4907,7 +5144,24 @@ export class AUNClient {
|
|
|
4907
5144
|
this._handleTransportDisconnect(exc instanceof Error ? exc : new Error(String(exc)));
|
|
4908
5145
|
}
|
|
4909
5146
|
}
|
|
4910
|
-
}, interval);
|
|
5147
|
+
}, interval * 1000);
|
|
5148
|
+
}
|
|
5149
|
+
/** 服务端通过 hello/pong 下发 heartbeat_interval;clamp 后写入 session_options 并按需重启心跳。 */
|
|
5150
|
+
_applyServerHeartbeatInterval(raw, source) {
|
|
5151
|
+
const newInterval = clampHeartbeatInterval(raw);
|
|
5152
|
+
const oldInterval = clampHeartbeatInterval(this._sessionOptions.heartbeat_interval);
|
|
5153
|
+
if (newInterval === oldInterval)
|
|
5154
|
+
return;
|
|
5155
|
+
this._sessionOptions.heartbeat_interval = newInterval;
|
|
5156
|
+
this._clientLog.debug(`heartbeat_interval updated by ${source}: ${oldInterval} -> ${newInterval}`);
|
|
5157
|
+
// 重启定时器以应用新间隔(关闭/启动通过定时器有/无区分)
|
|
5158
|
+
if (this._heartbeatTimer !== null) {
|
|
5159
|
+
clearInterval(this._heartbeatTimer);
|
|
5160
|
+
this._heartbeatTimer = null;
|
|
5161
|
+
}
|
|
5162
|
+
if (newInterval > 0 && this._state === 'connected' && !this._closing) {
|
|
5163
|
+
this._startHeartbeat();
|
|
5164
|
+
}
|
|
4911
5165
|
}
|
|
4912
5166
|
/** Token 刷新定时器 */
|
|
4913
5167
|
_startTokenRefresh() {
|
|
@@ -5107,22 +5361,17 @@ export class AUNClient {
|
|
|
5107
5361
|
const prekeyId = this._extractConsumedPrekeyId(message);
|
|
5108
5362
|
if (!prekeyId || this._state !== 'connected')
|
|
5109
5363
|
return;
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
// 同一时刻只允许一个 put_prekey inflight
|
|
5113
|
-
if (this._prekeyReplenishInflight.size > 0)
|
|
5364
|
+
// 只有活跃 prekey 被消费时才触发上传。历史 prekey 被消费不触发,避免上传风暴。
|
|
5365
|
+
if (!this._activePrekeyId || prekeyId !== this._activePrekeyId)
|
|
5114
5366
|
return;
|
|
5115
|
-
|
|
5367
|
+
// 清空活跃标记,防止重复触发(新上传完成后会设新的 active)
|
|
5368
|
+
this._activePrekeyId = '';
|
|
5116
5369
|
this._safeAsync((async () => {
|
|
5117
5370
|
try {
|
|
5118
5371
|
await this._uploadPrekey();
|
|
5119
|
-
this._prekeyReplenished.add(prekeyId);
|
|
5120
5372
|
}
|
|
5121
5373
|
catch (exc) {
|
|
5122
|
-
this._clientLog.warn(`
|
|
5123
|
-
}
|
|
5124
|
-
finally {
|
|
5125
|
-
this._prekeyReplenishInflight.delete(prekeyId);
|
|
5374
|
+
this._clientLog.warn(`replenish current prekey after consuming ${prekeyId} failed: ${String(exc)}`);
|
|
5126
5375
|
}
|
|
5127
5376
|
})());
|
|
5128
5377
|
}
|
|
@@ -5191,13 +5440,28 @@ export class AUNClient {
|
|
|
5191
5440
|
}
|
|
5192
5441
|
// ── 内部:断线重连 ────────────────────────────────
|
|
5193
5442
|
/** 不重连 close code 集合:认证失败/权限错误/被踢等,重连无意义 */
|
|
5194
|
-
static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011]);
|
|
5195
|
-
/** 处理服务端主动断开通知 event/gateway.disconnect
|
|
5196
|
-
|
|
5197
|
-
|
|
5198
|
-
|
|
5199
|
-
|
|
5443
|
+
static _NO_RECONNECT_CODES = new Set([4001, 4003, 4008, 4009, 4010, 4011, 4012, 4013, 4014, 4015]);
|
|
5444
|
+
/** 处理服务端主动断开通知 event/gateway.disconnect
|
|
5445
|
+
*
|
|
5446
|
+
* 服务端可能附带结构化 detail 字段(如配额超限时含 aid/device_id/slot_id/quota_kind/evicted_by)。
|
|
5447
|
+
* 透传到应用层可订阅事件 'gateway.disconnect',方便业务定位被踢原因。
|
|
5448
|
+
*/
|
|
5449
|
+
async _onGatewayDisconnect(data) {
|
|
5450
|
+
const obj = (data && typeof data === 'object') ? data : {};
|
|
5451
|
+
const code = obj.code;
|
|
5452
|
+
const reason = obj.reason ?? '';
|
|
5453
|
+
const detail = (obj.detail && typeof obj.detail === 'object') ? obj.detail : {};
|
|
5454
|
+
this._clientLog.warn(`server initiated disconnect: code=${code}, reason=${reason}, detail=${JSON.stringify(detail)}`);
|
|
5200
5455
|
this._serverKicked = true;
|
|
5456
|
+
// 缓存最近一次 disconnect 信息,让后续 connection.state(terminal_failed) 也能带 detail
|
|
5457
|
+
this._lastDisconnectInfo = { code, reason, detail };
|
|
5458
|
+
// 透传给应用层订阅者
|
|
5459
|
+
try {
|
|
5460
|
+
await this._dispatcher.publish('gateway.disconnect', { code, reason, detail });
|
|
5461
|
+
}
|
|
5462
|
+
catch (exc) {
|
|
5463
|
+
this._clientLog.debug(`publish gateway.disconnect failed: ${exc?.message ?? exc}`);
|
|
5464
|
+
}
|
|
5201
5465
|
}
|
|
5202
5466
|
async _handleTransportDisconnect(error, closeCode) {
|
|
5203
5467
|
if (this._closing || this._state === 'closed')
|
|
@@ -5218,9 +5482,19 @@ export class AUNClient {
|
|
|
5218
5482
|
this._state = 'terminal_failed';
|
|
5219
5483
|
const reason = this._serverKicked ? 'server kicked' : `close code ${closeCode}`;
|
|
5220
5484
|
this._clientLog.warn(`suppress auto-reconnect: ${reason}`);
|
|
5221
|
-
|
|
5485
|
+
const disconnectInfo = this._lastDisconnectInfo ?? {};
|
|
5486
|
+
const eventPayload = {
|
|
5222
5487
|
state: this._state, error, reason,
|
|
5223
|
-
}
|
|
5488
|
+
};
|
|
5489
|
+
// 把服务端附带的结构化 detail(如配额超限信息)也带给应用层
|
|
5490
|
+
const detail = disconnectInfo.detail;
|
|
5491
|
+
if (detail && typeof detail === 'object' && Object.keys(detail).length > 0) {
|
|
5492
|
+
eventPayload.detail = detail;
|
|
5493
|
+
}
|
|
5494
|
+
if (disconnectInfo.code !== undefined && disconnectInfo.code !== null) {
|
|
5495
|
+
eventPayload.code = disconnectInfo.code;
|
|
5496
|
+
}
|
|
5497
|
+
await this._dispatcher.publish('connection.state', eventPayload);
|
|
5224
5498
|
return;
|
|
5225
5499
|
}
|
|
5226
5500
|
// 1000 = 正常关闭, 1006 = 网络异常断开(无 close frame),其他 code = 服务端主动关闭
|
|
@@ -5526,7 +5800,6 @@ export class AUNClient {
|
|
|
5526
5800
|
this._pendingOrderedMsgs.clear();
|
|
5527
5801
|
this._pendingDecryptMsgs.clear();
|
|
5528
5802
|
this._groupSynced.clear();
|
|
5529
|
-
this._p2pSynced = false;
|
|
5530
5803
|
}
|
|
5531
5804
|
_refreshSeqTrackerContext() {
|
|
5532
5805
|
const nextContext = this._currentSeqTrackerContext();
|
|
@@ -5538,7 +5811,6 @@ export class AUNClient {
|
|
|
5538
5811
|
this._pendingOrderedMsgs.clear();
|
|
5539
5812
|
this._pendingDecryptMsgs.clear();
|
|
5540
5813
|
this._groupSynced.clear();
|
|
5541
|
-
this._p2pSynced = false;
|
|
5542
5814
|
this._seqTrackerContext = nextContext;
|
|
5543
5815
|
}
|
|
5544
5816
|
/** 将 SeqTracker 状态保存到 keystore */
|