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