@agentunion/fastaun 0.3.3 → 0.3.4
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 +21 -0
- package/_packed_docs/CHANGELOG.md +21 -0
- package/_packed_docs/INDEX.md +81 -0
- package/_packed_docs/KITE_DOCS_GUIDE.md +55 -0
- package/_packed_docs/agent.md//350/277/234/347/250/213agent.md/347/274/223/345/255/230/344/270/216etag/351/200/217/344/274/240/346/226/271/346/241/210.md +328 -0
- package/_packed_docs/cli/AUN-CLI/350/256/276/350/256/241/346/226/207/346/241/243.md +686 -0
- package/_packed_docs/design//350/267/250/350/257/255/350/250/200/345/256/271/345/231/250E2E/346/265/213/350/257/225/346/226/271/346/241/210.md +665 -0
- package/_packed_docs/protocol//351/231/204/345/275/225N-/345/210/206/345/270/203/345/274/217Trace/345/215/217/350/256/256.md +257 -0
- package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +5 -5
- package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +1 -1
- package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +2 -2
- package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +454 -429
- package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +1410 -1398
- package/_packed_docs/sdk/07-/351/224/231/350/257/257/345/244/204/347/220/206.md +19 -1
- package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +20 -5
- package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +8 -8
- package/_packed_docs/sdk/E2EE_V2/346/266/210/346/201/257/351/200/232/344/277/241/346/227/266/345/272/217/345/233/276.md +171 -0
- package/_packed_docs/sdk/INDEX.md +22 -22
- package/_packed_docs/sdk/README.md +3 -3
- package/dist/auth.d.ts +41 -8
- package/dist/auth.js +380 -101
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +60 -19
- package/dist/client.js +1049 -443
- package/dist/client.js.map +1 -1
- package/dist/errors.d.ts +4 -0
- package/dist/errors.js +7 -0
- package/dist/errors.js.map +1 -1
- package/dist/events.d.ts +9 -0
- package/dist/events.js +42 -12
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/keystore/file.d.ts +20 -0
- package/dist/keystore/file.js +91 -1
- package/dist/keystore/file.js.map +1 -1
- package/dist/namespaces/auth.d.ts +33 -4
- package/dist/namespaces/auth.js +170 -65
- package/dist/namespaces/auth.js.map +1 -1
- package/dist/secret-store/file-store.d.ts +21 -2
- package/dist/secret-store/file-store.js +166 -11
- package/dist/secret-store/file-store.js.map +1 -1
- package/dist/tools/cross-sdk-agent.js +2 -2
- package/dist/tools/cross-sdk-agent.js.map +1 -1
- package/dist/transport.d.ts +8 -1
- package/dist/transport.js +151 -32
- package/dist/transport.js.map +1 -1
- package/dist/v2/e2ee/decrypt.js +1 -1
- package/dist/v2/e2ee/decrypt.js.map +1 -1
- package/dist/v2/e2ee/encrypt-p2p.js +3 -2
- package/dist/v2/e2ee/encrypt-p2p.js.map +1 -1
- package/dist/v2/session/session.d.ts +1 -0
- package/dist/v2/session/session.js +7 -1
- package/dist/v2/session/session.js.map +1 -1
- package/package.json +46 -46
package/dist/client.js
CHANGED
|
@@ -36,6 +36,9 @@ import { encryptP2PMessage, encryptGroupMessage, decryptMessage, } from './v2/e2
|
|
|
36
36
|
import { ecdsaVerifyRaw } from './v2/crypto/ecdsa.js';
|
|
37
37
|
import { computeStateCommitment } from './v2/state/index.js';
|
|
38
38
|
import { isJsonObject, } from './types.js';
|
|
39
|
+
function isPromiseLike(value) {
|
|
40
|
+
return Boolean(value && typeof value.then === 'function');
|
|
41
|
+
}
|
|
39
42
|
/**
|
|
40
43
|
* 递归排序键的 JSON 序列化(Canonical JSON for AUN)
|
|
41
44
|
* 等价于 Python json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=False)
|
|
@@ -197,6 +200,59 @@ const SIGNED_METHODS = new Set([
|
|
|
197
200
|
]);
|
|
198
201
|
/** peer 证书缓存 TTL(1 小时) */
|
|
199
202
|
const PEER_CERT_CACHE_TTL = 3600;
|
|
203
|
+
function normalizeV2WrapPolicy(raw) {
|
|
204
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
205
|
+
return { explicit: false, version: '', protocol: '', scope: 'device' };
|
|
206
|
+
}
|
|
207
|
+
const obj = raw;
|
|
208
|
+
let protocol = String(obj.protocol ?? '').trim().toUpperCase();
|
|
209
|
+
if (protocol !== '1DH' && protocol !== '3DH')
|
|
210
|
+
protocol = '';
|
|
211
|
+
let scope = String(obj.scope ?? '').trim().toLowerCase();
|
|
212
|
+
if (scope !== 'aid' && scope !== 'device') {
|
|
213
|
+
scope = obj.per_aid_wrap === true ? 'aid' : 'device';
|
|
214
|
+
}
|
|
215
|
+
if (scope === 'aid')
|
|
216
|
+
protocol = '1DH';
|
|
217
|
+
return {
|
|
218
|
+
explicit: true,
|
|
219
|
+
version: String(obj.version ?? ''),
|
|
220
|
+
protocol,
|
|
221
|
+
scope: scope,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function v2WrapCapabilities() {
|
|
225
|
+
return {
|
|
226
|
+
version: 'v2.1',
|
|
227
|
+
protocols: ['1DH', '3DH'],
|
|
228
|
+
scopes: ['aid', 'device'],
|
|
229
|
+
per_aid_wrap: true,
|
|
230
|
+
per_device_wrap: true,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function applyV2WrapPolicyToTargets(targets, policy) {
|
|
234
|
+
if (!policy.explicit)
|
|
235
|
+
return targets;
|
|
236
|
+
const out = [];
|
|
237
|
+
const seen = new Set();
|
|
238
|
+
for (const target of targets) {
|
|
239
|
+
const row = { ...target };
|
|
240
|
+
if (policy.protocol === '1DH') {
|
|
241
|
+
row.keySource = 'aid_master';
|
|
242
|
+
row.spkPkDer = undefined;
|
|
243
|
+
row.spkId = '';
|
|
244
|
+
}
|
|
245
|
+
if (policy.scope === 'aid') {
|
|
246
|
+
const key = `${row.aid}\x1f${row.role}`;
|
|
247
|
+
if (seen.has(key))
|
|
248
|
+
continue;
|
|
249
|
+
seen.add(key);
|
|
250
|
+
row.deviceId = '';
|
|
251
|
+
}
|
|
252
|
+
out.push(row);
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
200
256
|
function _v2LeftPad32(b) {
|
|
201
257
|
if (b.length === 32)
|
|
202
258
|
return b;
|
|
@@ -364,15 +420,14 @@ export class AUNClient {
|
|
|
364
420
|
_defaultConnectDeliveryMode;
|
|
365
421
|
/** peer 证书缓存 */
|
|
366
422
|
_certCache = new Map();
|
|
367
|
-
//
|
|
423
|
+
// AIDs 目录:{agentMdPath}/{aid}/agentmd.json 保存元数据,{agentMdPath}/{aid}/agent.md 保存正文。
|
|
368
424
|
_agentMdPath = '';
|
|
369
425
|
_localAgentMdPath = '';
|
|
370
426
|
_localAgentMdEtag = '';
|
|
371
427
|
// gateway 在 RPC envelope._meta.agent_md_etag 注入的服务端 etag;纯观察,无下游依赖。
|
|
372
428
|
_remoteAgentMdEtag = '';
|
|
373
429
|
_agentMdCache = new Map();
|
|
374
|
-
_agentMdFetchInflight = new
|
|
375
|
-
_agentMdLastListRebuilt = false;
|
|
430
|
+
_agentMdFetchInflight = new Map();
|
|
376
431
|
/** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
|
|
377
432
|
_seqTracker = new SeqTracker();
|
|
378
433
|
_seqTrackerContext = null;
|
|
@@ -380,6 +435,11 @@ export class AUNClient {
|
|
|
380
435
|
_groupSynced = new Set();
|
|
381
436
|
/** 补洞去重:已完成/进行中的 key -> 开始时间戳,防止重复 pull 同一区间 */
|
|
382
437
|
_gapFillDone = new Map();
|
|
438
|
+
/** pull gate:按消费单元串行化 public pull / gap fill / push auto-pull。 */
|
|
439
|
+
_pullGates = new Map();
|
|
440
|
+
_pullResponseKeys = new Map();
|
|
441
|
+
/** 当前异步调用栈是否属于通知触发的后台 RPC。 */
|
|
442
|
+
_backgroundRpcDepth = 0;
|
|
383
443
|
/** 已发布到应用层的 seq 集合(按命名空间),补洞路径 publish 前检查以避免重复分发 */
|
|
384
444
|
_pushedSeqs = new Map();
|
|
385
445
|
/** 已解密但因 seq 空洞暂缓发布的应用层消息(按 namespace -> seq) */
|
|
@@ -409,10 +469,9 @@ export class AUNClient {
|
|
|
409
469
|
/** 最近一次已成功提交的 membership_snapshot;相同快照直接跳过。 */
|
|
410
470
|
_v2AutoProposeLastSnapshot = new Map();
|
|
411
471
|
_v2LazyProposeTriggered = new Map();
|
|
412
|
-
_v2PullInflight = false;
|
|
413
|
-
_v2PullPending = false;
|
|
414
472
|
static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
|
|
415
473
|
static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
|
|
474
|
+
static PULL_GATE_STALE_MS = 3000;
|
|
416
475
|
static V2_SIG_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
417
476
|
static V2_SIG_CACHE_MAX = 16_384;
|
|
418
477
|
_reconnectActive = false;
|
|
@@ -426,7 +485,7 @@ export class AUNClient {
|
|
|
426
485
|
const rawConfig = { ...(config ?? {}) };
|
|
427
486
|
this._configModel = configFromMap(rawConfig);
|
|
428
487
|
const initAid = String(rawConfig.aid ?? '').trim() || null;
|
|
429
|
-
this._agentMdPath = path.join(this._configModel.aunPath, '
|
|
488
|
+
this._agentMdPath = path.join(this._configModel.aunPath, 'AIDs');
|
|
430
489
|
this.config = {
|
|
431
490
|
aun_path: this._configModel.aunPath,
|
|
432
491
|
root_ca_path: this._configModel.rootCaPath,
|
|
@@ -456,6 +515,19 @@ export class AUNClient {
|
|
|
456
515
|
secretStoreLogger: this._logger.for('aun_core.secret-store'),
|
|
457
516
|
});
|
|
458
517
|
this._keystore = keystore;
|
|
518
|
+
// 启动时被动清理 registerAid 留下的孤儿临时目录(>10 分钟)
|
|
519
|
+
try {
|
|
520
|
+
const cleanup = keystore.cleanupPendingDirs;
|
|
521
|
+
if (typeof cleanup === 'function') {
|
|
522
|
+
const removed = cleanup.call(keystore, 600_000);
|
|
523
|
+
if (removed > 0) {
|
|
524
|
+
this._clientLog.info(`_pending cleanup removed=${removed}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
catch (err) {
|
|
529
|
+
this._clientLog.warn(`_pending cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
530
|
+
}
|
|
459
531
|
this._slotId = '';
|
|
460
532
|
this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
|
|
461
533
|
this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
|
|
@@ -547,6 +619,23 @@ export class AUNClient {
|
|
|
547
619
|
if (!target) {
|
|
548
620
|
throw new ValidationError('fetchAgentMd requires aid (or local AID)');
|
|
549
621
|
}
|
|
622
|
+
return await this._startAgentMdFetchTask(target);
|
|
623
|
+
}
|
|
624
|
+
async _startAgentMdFetchTask(target) {
|
|
625
|
+
const existing = this._agentMdFetchInflight.get(target);
|
|
626
|
+
if (existing) {
|
|
627
|
+
return await existing;
|
|
628
|
+
}
|
|
629
|
+
const task = this._fetchAgentMdOnce(target);
|
|
630
|
+
this._agentMdFetchInflight.set(target, task);
|
|
631
|
+
task.finally(() => {
|
|
632
|
+
if (this._agentMdFetchInflight.get(target) === task) {
|
|
633
|
+
this._agentMdFetchInflight.delete(target);
|
|
634
|
+
}
|
|
635
|
+
}).catch(() => undefined);
|
|
636
|
+
return await task;
|
|
637
|
+
}
|
|
638
|
+
async _fetchAgentMdOnce(target) {
|
|
550
639
|
const content = await this.auth.downloadAgentMd(target);
|
|
551
640
|
const signature = await this.auth.verifyAgentMd(content, { aid: target });
|
|
552
641
|
const isSelf = target === (this._aid ?? '');
|
|
@@ -585,11 +674,11 @@ export class AUNClient {
|
|
|
585
674
|
};
|
|
586
675
|
}
|
|
587
676
|
/**
|
|
588
|
-
* 设置 agent.md 本地存储根目录;为空时恢复默认 {aun_path}/
|
|
677
|
+
* 设置 agent.md 本地存储根目录;为空时恢复默认 {aun_path}/AIDs。
|
|
589
678
|
*/
|
|
590
679
|
setAgentMdPath(root) {
|
|
591
680
|
const raw = String(root ?? '').trim();
|
|
592
|
-
const next = raw || path.join(this._configModel.aunPath, '
|
|
681
|
+
const next = raw || path.join(this._configModel.aunPath, 'AIDs');
|
|
593
682
|
fs.mkdirSync(next, { recursive: true });
|
|
594
683
|
this._agentMdPath = next;
|
|
595
684
|
this._agentMdCache.clear();
|
|
@@ -662,15 +751,15 @@ export class AUNClient {
|
|
|
662
751
|
return target;
|
|
663
752
|
}
|
|
664
753
|
_agentMdRoot() {
|
|
665
|
-
const root = this._agentMdPath || path.join(this._configModel.aunPath, '
|
|
754
|
+
const root = this._agentMdPath || path.join(this._configModel.aunPath, 'AIDs');
|
|
666
755
|
fs.mkdirSync(root, { recursive: true });
|
|
667
756
|
return root;
|
|
668
757
|
}
|
|
669
758
|
_agentMdFilePath(aid) {
|
|
670
759
|
return path.join(this._agentMdRoot(), this._agentMdSafeAid(aid), 'agent.md');
|
|
671
760
|
}
|
|
672
|
-
|
|
673
|
-
return path.join(this._agentMdRoot(), '
|
|
761
|
+
_agentMdMetaPath(aid) {
|
|
762
|
+
return path.join(this._agentMdRoot(), this._agentMdSafeAid(aid), 'agentmd.json');
|
|
674
763
|
}
|
|
675
764
|
_atomicWriteText(filePath, content) {
|
|
676
765
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -712,8 +801,9 @@ export class AUNClient {
|
|
|
712
801
|
_sleepSync(ms) {
|
|
713
802
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
714
803
|
}
|
|
715
|
-
|
|
716
|
-
const lockPath = path.join(this.
|
|
804
|
+
_withAgentMdRecordLock(aid, fn) {
|
|
805
|
+
const lockPath = path.join(path.dirname(this._agentMdMetaPath(aid)), 'agentmd.json.lock');
|
|
806
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
717
807
|
const deadline = Date.now() + 5000;
|
|
718
808
|
let fd = null;
|
|
719
809
|
while (fd === null) {
|
|
@@ -749,93 +839,39 @@ export class AUNClient {
|
|
|
749
839
|
catch { /* ignore */ }
|
|
750
840
|
}
|
|
751
841
|
}
|
|
752
|
-
|
|
753
|
-
const
|
|
754
|
-
for (const
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
}
|
|
758
|
-
_normalizeAgentMdList(data) {
|
|
759
|
-
const records = {};
|
|
760
|
-
let iterable = [];
|
|
761
|
-
if (isJsonObject(data)) {
|
|
762
|
-
const rawRecords = data.records;
|
|
763
|
-
if (Array.isArray(rawRecords))
|
|
764
|
-
iterable = rawRecords;
|
|
765
|
-
else if (isJsonObject(rawRecords)) {
|
|
766
|
-
iterable = Object.values(rawRecords);
|
|
767
|
-
}
|
|
768
|
-
}
|
|
769
|
-
else if (Array.isArray(data)) {
|
|
770
|
-
iterable = data;
|
|
771
|
-
}
|
|
772
|
-
for (const item of iterable) {
|
|
773
|
-
if (!isJsonObject(item))
|
|
774
|
-
continue;
|
|
775
|
-
const aid = String(item.aid ?? '').trim();
|
|
776
|
-
if (!aid)
|
|
777
|
-
continue;
|
|
778
|
-
const record = {};
|
|
779
|
-
for (const [key, value] of Object.entries(item)) {
|
|
780
|
-
if (key !== 'content')
|
|
781
|
-
record[key] = value;
|
|
782
|
-
}
|
|
783
|
-
record.aid = aid;
|
|
784
|
-
for (const key of ['fetched_at', 'observed_at', 'checked_at', 'updated_at']) {
|
|
785
|
-
record[key] = Number(record[key] ?? 0) || 0;
|
|
786
|
-
}
|
|
787
|
-
records[aid] = record;
|
|
842
|
+
_writeAgentMdRecordUnlocked(aid, record) {
|
|
843
|
+
const payload = {};
|
|
844
|
+
for (const [key, value] of Object.entries(record)) {
|
|
845
|
+
if (key !== 'content' && value !== undefined && value !== null)
|
|
846
|
+
payload[key] = value;
|
|
788
847
|
}
|
|
789
|
-
|
|
848
|
+
payload.aid = this._agentMdSafeAid(aid);
|
|
849
|
+
this._atomicWriteText(this._agentMdMetaPath(aid), `${JSON.stringify(payload, null, 2)}\n`);
|
|
790
850
|
}
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
const
|
|
795
|
-
for (const
|
|
796
|
-
if (
|
|
797
|
-
|
|
798
|
-
const aid = entry.name;
|
|
799
|
-
const filePath = path.join(root, aid, 'agent.md');
|
|
800
|
-
if (!fs.existsSync(filePath))
|
|
801
|
-
continue;
|
|
802
|
-
try {
|
|
803
|
-
const content = fs.readFileSync(filePath, 'utf-8');
|
|
804
|
-
let fetchedAt = now;
|
|
805
|
-
try {
|
|
806
|
-
fetchedAt = Math.trunc(fs.statSync(filePath).mtimeMs);
|
|
807
|
-
}
|
|
808
|
-
catch { /* ignore */ }
|
|
809
|
-
records[aid] = {
|
|
810
|
-
aid,
|
|
811
|
-
local_etag: this._agentMdContentEtag(content),
|
|
812
|
-
fetched_at: fetchedAt,
|
|
813
|
-
updated_at: now,
|
|
814
|
-
};
|
|
815
|
-
}
|
|
816
|
-
catch (err) {
|
|
817
|
-
this._clientLog.warn(`agent.md rebuild skipped unreadable file aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
818
|
-
}
|
|
851
|
+
_normalizeAgentMdRecord(aid, data) {
|
|
852
|
+
if (!isJsonObject(data))
|
|
853
|
+
return {};
|
|
854
|
+
const record = {};
|
|
855
|
+
for (const [key, value] of Object.entries(data)) {
|
|
856
|
+
if (key !== 'content')
|
|
857
|
+
record[key] = value;
|
|
819
858
|
}
|
|
820
|
-
this.
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
}
|
|
824
|
-
_readAgentMdListUnlocked() {
|
|
825
|
-
const filePath = this._agentMdListPath();
|
|
826
|
-
if (!fs.existsSync(filePath)) {
|
|
827
|
-
this._agentMdLastListRebuilt = true;
|
|
828
|
-
return this._rebuildAgentMdListUnlocked();
|
|
859
|
+
record.aid = this._agentMdSafeAid(String(record.aid ?? aid));
|
|
860
|
+
for (const key of ['fetched_at', 'observed_at', 'checked_at', 'updated_at']) {
|
|
861
|
+
record[key] = Number(record[key] ?? 0) || 0;
|
|
829
862
|
}
|
|
863
|
+
return record;
|
|
864
|
+
}
|
|
865
|
+
_readAgentMdRecordUnlocked(aid) {
|
|
866
|
+
const filePath = this._agentMdMetaPath(aid);
|
|
867
|
+
if (!fs.existsSync(filePath))
|
|
868
|
+
return {};
|
|
830
869
|
try {
|
|
831
|
-
|
|
832
|
-
this._agentMdLastListRebuilt = false;
|
|
833
|
-
return this._normalizeAgentMdList(parsed);
|
|
870
|
+
return this._normalizeAgentMdRecord(aid, JSON.parse(fs.readFileSync(filePath, 'utf-8')));
|
|
834
871
|
}
|
|
835
872
|
catch (err) {
|
|
836
|
-
this._clientLog.warn(`agent.md
|
|
837
|
-
|
|
838
|
-
return this._rebuildAgentMdListUnlocked();
|
|
873
|
+
this._clientLog.warn(`agent.md metadata damaged, ignoring: aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
874
|
+
return {};
|
|
839
875
|
}
|
|
840
876
|
}
|
|
841
877
|
_readAgentMdContent(aid) {
|
|
@@ -861,21 +897,25 @@ export class AUNClient {
|
|
|
861
897
|
if (!target)
|
|
862
898
|
return null;
|
|
863
899
|
try {
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
const loaded = { ...record, aid: target };
|
|
900
|
+
const loaded = this._withAgentMdRecordLock(target, () => {
|
|
901
|
+
const record = this._readAgentMdRecordUnlocked(target);
|
|
902
|
+
const next = Object.keys(record).length > 0 ? { ...record, aid: target } : { aid: target };
|
|
868
903
|
try {
|
|
869
904
|
const content = this._readAgentMdContent(target);
|
|
870
|
-
|
|
871
|
-
|
|
905
|
+
next.content = content;
|
|
906
|
+
next.local_etag = this._agentMdContentEtag(content);
|
|
872
907
|
}
|
|
873
908
|
catch (err) {
|
|
874
|
-
this.
|
|
909
|
+
if (fs.existsSync(this._agentMdMetaPath(target))) {
|
|
910
|
+
this._clientLog.warn(`agent.md content read failed: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
911
|
+
}
|
|
875
912
|
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
913
|
+
return next;
|
|
914
|
+
});
|
|
915
|
+
if (Object.keys(loaded).length <= 1)
|
|
916
|
+
return null;
|
|
917
|
+
this._agentMdCache.set(target, { ...loaded });
|
|
918
|
+
return { ...loaded };
|
|
879
919
|
}
|
|
880
920
|
catch (err) {
|
|
881
921
|
this._clientLog.debug(`agent.md cache load skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -890,25 +930,23 @@ export class AUNClient {
|
|
|
890
930
|
const inputFields = { ...fields };
|
|
891
931
|
const hasContent = Object.prototype.hasOwnProperty.call(inputFields, 'content') && inputFields.content !== undefined && inputFields.content !== null;
|
|
892
932
|
let savedTo = '';
|
|
893
|
-
|
|
894
|
-
|
|
895
|
-
|
|
896
|
-
|
|
897
|
-
inputFields.local_etag
|
|
898
|
-
|
|
899
|
-
inputFields.fetched_at
|
|
900
|
-
|
|
901
|
-
|
|
902
|
-
|
|
903
|
-
const
|
|
904
|
-
const next = { ...(records[target] ?? {}), aid: target };
|
|
933
|
+
const record = this._withAgentMdRecordLock(target, () => {
|
|
934
|
+
if (hasContent) {
|
|
935
|
+
const content = String(inputFields.content ?? '');
|
|
936
|
+
savedTo = this._writeAgentMdContent(target, content);
|
|
937
|
+
if (!inputFields.local_etag)
|
|
938
|
+
inputFields.local_etag = this._agentMdContentEtag(content);
|
|
939
|
+
if (!inputFields.fetched_at)
|
|
940
|
+
inputFields.fetched_at = Date.now();
|
|
941
|
+
}
|
|
942
|
+
delete inputFields.content;
|
|
943
|
+
const next = { ...this._readAgentMdRecordUnlocked(target), aid: target };
|
|
905
944
|
for (const [key, value] of Object.entries(inputFields)) {
|
|
906
945
|
if (value !== undefined && value !== null)
|
|
907
946
|
next[key] = value;
|
|
908
947
|
}
|
|
909
948
|
next.updated_at = Date.now();
|
|
910
|
-
|
|
911
|
-
this._writeAgentMdListUnlocked(records);
|
|
949
|
+
this._writeAgentMdRecordUnlocked(target, next);
|
|
912
950
|
return next;
|
|
913
951
|
});
|
|
914
952
|
const loaded = { ...record };
|
|
@@ -967,15 +1005,12 @@ export class AUNClient {
|
|
|
967
1005
|
return;
|
|
968
1006
|
if (this._agentMdFetchInflight.has(target))
|
|
969
1007
|
return;
|
|
970
|
-
this._agentMdFetchInflight.add(target);
|
|
971
1008
|
void this.fetchAgentMd(target).catch((err) => {
|
|
972
1009
|
this._saveAgentMdRecord(target, {
|
|
973
1010
|
last_error: err instanceof Error ? err.message : String(err),
|
|
974
1011
|
remote_status: 'found',
|
|
975
1012
|
});
|
|
976
1013
|
this._clientLog.debug(`agent.md auto fetch failed: aid=${target} source=${source || '-'} err=${err instanceof Error ? err.message : String(err)}`);
|
|
977
|
-
}).finally(() => {
|
|
978
|
-
this._agentMdFetchInflight.delete(target);
|
|
979
1014
|
});
|
|
980
1015
|
}
|
|
981
1016
|
_observeAgentMdMeta(aid, etag = '', lastModified = '', source = '') {
|
|
@@ -1026,7 +1061,7 @@ export class AUNClient {
|
|
|
1026
1061
|
}
|
|
1027
1062
|
this._observeAgentMdMeta(senderAid, String(sender.etag ?? '').trim(), String(sender.last_modified ?? sender.lastModified ?? '').trim(), 'envelope');
|
|
1028
1063
|
}
|
|
1029
|
-
async checkAgentMd(aid, maxUnsyncedDays =
|
|
1064
|
+
async checkAgentMd(aid, maxUnsyncedDays = 1) {
|
|
1030
1065
|
const target = String(aid ?? this._aid ?? '').trim();
|
|
1031
1066
|
if (!target)
|
|
1032
1067
|
throw new ValidationError('checkAgentMd requires aid (or local AID)');
|
|
@@ -1035,7 +1070,9 @@ export class AUNClient {
|
|
|
1035
1070
|
const localFound = !!(Object.keys(before).length > 0 && (String(before.content ?? '') || localEtag));
|
|
1036
1071
|
const remoteEtagCached = String(before.remote_etag ?? '').trim();
|
|
1037
1072
|
const lastModifiedCached = String(before.last_modified ?? '').trim();
|
|
1038
|
-
const
|
|
1073
|
+
const checkedAt = Number(before.checked_at ?? 0);
|
|
1074
|
+
const fetchedAt = Number(before.fetched_at ?? 0);
|
|
1075
|
+
const checkedAtCached = checkedAt > 0 ? checkedAt : fetchedAt;
|
|
1039
1076
|
const cachedInSync = !!(localFound && localEtag && remoteEtagCached && localEtag === remoteEtagCached);
|
|
1040
1077
|
// max_unsynced_days > 0 且距上次 HEAD 在窗口内 → 直接返回缓存;否则强制 HEAD。
|
|
1041
1078
|
if (cachedInSync && this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
|
|
@@ -1053,6 +1090,25 @@ export class AUNClient {
|
|
|
1053
1090
|
verify_error: String(before.verify_error ?? ''),
|
|
1054
1091
|
};
|
|
1055
1092
|
}
|
|
1093
|
+
const remoteFoundCached = !!(remoteEtagCached || String(before.remote_status ?? '') === 'found');
|
|
1094
|
+
if (!localFound &&
|
|
1095
|
+
!remoteFoundCached &&
|
|
1096
|
+
String(before.remote_status ?? '') === 'missing' &&
|
|
1097
|
+
this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
|
|
1098
|
+
return {
|
|
1099
|
+
aid: target,
|
|
1100
|
+
local_found: false,
|
|
1101
|
+
remote_found: false,
|
|
1102
|
+
local_etag: '',
|
|
1103
|
+
remote_etag: '',
|
|
1104
|
+
in_sync: false,
|
|
1105
|
+
last_modified: '',
|
|
1106
|
+
status: 404,
|
|
1107
|
+
cached: true,
|
|
1108
|
+
verify_status: '',
|
|
1109
|
+
verify_error: '',
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1056
1112
|
const now = Date.now();
|
|
1057
1113
|
let remote;
|
|
1058
1114
|
try {
|
|
@@ -1244,11 +1300,17 @@ export class AUNClient {
|
|
|
1244
1300
|
}
|
|
1245
1301
|
}
|
|
1246
1302
|
/**
|
|
1247
|
-
*
|
|
1303
|
+
* 列出本地身份摘要。
|
|
1304
|
+
*
|
|
1305
|
+
* @param opts.all=false(默认):仅返回严格校验通过的可用身份——
|
|
1306
|
+
* keypair 完整 + cert 公钥 == keypair 公钥 + cert 时间窗口有效
|
|
1307
|
+
* @param opts.all=true:返回所有 AIDs/ 子目录(不含 _pending/);
|
|
1308
|
+
* 每项含 valid=bool 和 reason=string 字段
|
|
1248
1309
|
*/
|
|
1249
|
-
listIdentities() {
|
|
1310
|
+
listIdentities(opts) {
|
|
1250
1311
|
const tStart = Date.now();
|
|
1251
|
-
|
|
1312
|
+
const includeAll = !!opts?.all;
|
|
1313
|
+
this._clientLog.debug(`listIdentities enter all=${includeAll}`);
|
|
1252
1314
|
try {
|
|
1253
1315
|
const listFn = this._keystore.listIdentities;
|
|
1254
1316
|
if (typeof listFn !== 'function') {
|
|
@@ -1258,10 +1320,12 @@ export class AUNClient {
|
|
|
1258
1320
|
const aids = listFn.call(this._keystore);
|
|
1259
1321
|
const summaries = [];
|
|
1260
1322
|
for (const aid of [...aids].sort()) {
|
|
1261
|
-
const
|
|
1262
|
-
if (!
|
|
1323
|
+
const { valid, reason } = this._validateLocalIdentity(aid);
|
|
1324
|
+
if (!includeAll && !valid)
|
|
1263
1325
|
continue;
|
|
1264
|
-
const summary = { aid };
|
|
1326
|
+
const summary = { aid, valid };
|
|
1327
|
+
if (reason)
|
|
1328
|
+
summary.reason = reason;
|
|
1265
1329
|
const loadMetadata = this._keystore.loadMetadata;
|
|
1266
1330
|
if (typeof loadMetadata === 'function') {
|
|
1267
1331
|
const md = loadMetadata.call(this._keystore, aid);
|
|
@@ -1270,7 +1334,7 @@ export class AUNClient {
|
|
|
1270
1334
|
}
|
|
1271
1335
|
summaries.push(summary);
|
|
1272
1336
|
}
|
|
1273
|
-
this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms count=${summaries.length}`);
|
|
1337
|
+
this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms all=${includeAll} count=${summaries.length}`);
|
|
1274
1338
|
return summaries;
|
|
1275
1339
|
}
|
|
1276
1340
|
catch (err) {
|
|
@@ -1278,6 +1342,40 @@ export class AUNClient {
|
|
|
1278
1342
|
throw err;
|
|
1279
1343
|
}
|
|
1280
1344
|
}
|
|
1345
|
+
/**
|
|
1346
|
+
* 严格校验本地身份的可用性。返回 {valid, reason}。
|
|
1347
|
+
* 4 项校验:keypair 完整 + cert 存在 + cert 公钥 == keypair 公钥 + cert 时间窗口有效。
|
|
1348
|
+
*/
|
|
1349
|
+
_validateLocalIdentity(aid) {
|
|
1350
|
+
const identity = this._keystore.loadIdentity(aid);
|
|
1351
|
+
if (!identity)
|
|
1352
|
+
return { valid: false, reason: 'no identity record' };
|
|
1353
|
+
const priv = String(identity.private_key_pem ?? '');
|
|
1354
|
+
const pubB64 = String(identity.public_key_der_b64 ?? '');
|
|
1355
|
+
const certPem = String(identity.cert ?? '');
|
|
1356
|
+
if (!priv || !pubB64)
|
|
1357
|
+
return { valid: false, reason: 'missing keypair' };
|
|
1358
|
+
if (!certPem)
|
|
1359
|
+
return { valid: false, reason: 'missing certificate' };
|
|
1360
|
+
try {
|
|
1361
|
+
const crypto = require('node:crypto');
|
|
1362
|
+
const cert = new crypto.X509Certificate(certPem);
|
|
1363
|
+
const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
1364
|
+
const localPubDer = Buffer.from(pubB64, 'base64');
|
|
1365
|
+
if (!certPubDer.equals(localPubDer)) {
|
|
1366
|
+
return { valid: false, reason: 'cert public key does not match keypair' };
|
|
1367
|
+
}
|
|
1368
|
+
const now = Date.now();
|
|
1369
|
+
if (now < new Date(cert.validFrom).getTime())
|
|
1370
|
+
return { valid: false, reason: 'cert not yet valid' };
|
|
1371
|
+
if (now > new Date(cert.validTo).getTime())
|
|
1372
|
+
return { valid: false, reason: 'cert expired' };
|
|
1373
|
+
return { valid: true, reason: '' };
|
|
1374
|
+
}
|
|
1375
|
+
catch (e) {
|
|
1376
|
+
return { valid: false, reason: `cert parse error: ${e instanceof Error ? e.message : String(e)}` };
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1281
1379
|
// ── RPC ───────────────────────────────────────────────────
|
|
1282
1380
|
/**
|
|
1283
1381
|
* 发送 JSON-RPC 调用。
|
|
@@ -1297,6 +1395,13 @@ export class AUNClient {
|
|
|
1297
1395
|
throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
|
|
1298
1396
|
}
|
|
1299
1397
|
const p = { ...(params ?? {}) };
|
|
1398
|
+
const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
|
|
1399
|
+
delete p._rpc_background;
|
|
1400
|
+
const runWithRpcPriority = async (operation) => {
|
|
1401
|
+
if (!rpcBackground)
|
|
1402
|
+
return await operation();
|
|
1403
|
+
return await this._withBackgroundRpc(operation);
|
|
1404
|
+
};
|
|
1300
1405
|
if (method === 'message.send' || method === 'group.send') {
|
|
1301
1406
|
this._normalizeOutboundMessagePayload(p, method);
|
|
1302
1407
|
}
|
|
@@ -1318,17 +1423,33 @@ export class AUNClient {
|
|
|
1318
1423
|
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
1319
1424
|
p.slot_id = this._slotId;
|
|
1320
1425
|
}
|
|
1426
|
+
const pullGateLocked = Boolean(p._pull_gate_locked);
|
|
1427
|
+
if ('_pull_gate_locked' in p) {
|
|
1428
|
+
delete p._pull_gate_locked;
|
|
1429
|
+
}
|
|
1430
|
+
const pullGateKey = this._pullGateKeyForCall(method, p);
|
|
1431
|
+
if (pullGateKey && this._isPullResponseProcessing(pullGateKey)) {
|
|
1432
|
+
this._clientLog.debug(`pull skipped while processing pull response: method=${method} key=${pullGateKey}`);
|
|
1433
|
+
return this._emptyPullResultForCall(method);
|
|
1434
|
+
}
|
|
1435
|
+
if (pullGateKey && !pullGateLocked) {
|
|
1436
|
+
const lockedParams = { ...p, _pull_gate_locked: true };
|
|
1437
|
+
if (rpcBackground)
|
|
1438
|
+
lockedParams._rpc_background = true;
|
|
1439
|
+
const result = await this._runPullSerialized(pullGateKey, async () => this.call(method, lockedParams));
|
|
1440
|
+
return result;
|
|
1441
|
+
}
|
|
1321
1442
|
// 自动加密:message.send 默认加密(encrypt 默认 true)— V2-only
|
|
1322
1443
|
if (method === 'message.send') {
|
|
1323
1444
|
const encrypt = p.encrypt ?? true;
|
|
1324
1445
|
delete p.encrypt;
|
|
1325
1446
|
if (encrypt) {
|
|
1326
|
-
return await this.sendV2(String(p.to ?? ''), p.payload, {
|
|
1447
|
+
return await runWithRpcPriority(() => this.sendV2(String(p.to ?? ''), p.payload, {
|
|
1327
1448
|
messageId: String(p.message_id ?? '') || undefined,
|
|
1328
1449
|
timestamp: p.timestamp,
|
|
1329
1450
|
protectedHeaders: this._protectedHeadersFromParams(p),
|
|
1330
1451
|
context: isJsonObject(p.context) ? p.context : undefined,
|
|
1331
|
-
});
|
|
1452
|
+
}));
|
|
1332
1453
|
}
|
|
1333
1454
|
// encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
|
|
1334
1455
|
this._maybeAppendEchoTraceSend(p);
|
|
@@ -1338,12 +1459,12 @@ export class AUNClient {
|
|
|
1338
1459
|
const encrypt = p.encrypt ?? true;
|
|
1339
1460
|
delete p.encrypt;
|
|
1340
1461
|
if (encrypt) {
|
|
1341
|
-
return await this.sendGroupV2(String(p.group_id ?? ''), p.payload, {
|
|
1462
|
+
return await runWithRpcPriority(() => this.sendGroupV2(String(p.group_id ?? ''), p.payload, {
|
|
1342
1463
|
messageId: String(p.message_id ?? '') || undefined,
|
|
1343
1464
|
timestamp: p.timestamp,
|
|
1344
1465
|
protectedHeaders: this._protectedHeadersFromParams(p),
|
|
1345
1466
|
context: isJsonObject(p.context) ? p.context : undefined,
|
|
1346
|
-
});
|
|
1467
|
+
}));
|
|
1347
1468
|
}
|
|
1348
1469
|
this._maybeAppendEchoTraceSend(p);
|
|
1349
1470
|
}
|
|
@@ -1355,7 +1476,7 @@ export class AUNClient {
|
|
|
1355
1476
|
if (!this._v2Session || !String(p.group_id ?? '').trim()) {
|
|
1356
1477
|
throw new StateError(v2Error);
|
|
1357
1478
|
}
|
|
1358
|
-
return await this._putGroupThoughtEncryptedV2(p);
|
|
1479
|
+
return await runWithRpcPriority(() => this._putGroupThoughtEncryptedV2(p));
|
|
1359
1480
|
}
|
|
1360
1481
|
}
|
|
1361
1482
|
if (method === 'message.thought.put') {
|
|
@@ -1363,26 +1484,42 @@ export class AUNClient {
|
|
|
1363
1484
|
delete p.encrypt;
|
|
1364
1485
|
if (encrypt) {
|
|
1365
1486
|
await this._ensureV2SessionReady('message.thought.put', 'V2 session not initialized; encrypted message.thought.put requires V2 (V1 E2EE removed)');
|
|
1366
|
-
return await this._putMessageThoughtEncryptedV2(p);
|
|
1487
|
+
return await runWithRpcPriority(() => this._putMessageThoughtEncryptedV2(p));
|
|
1367
1488
|
}
|
|
1368
1489
|
}
|
|
1369
|
-
|
|
1490
|
+
// V2-only:兼容入口名只作为 SDK 内部适配层存在,底层绝不能降级发 legacy RPC。
|
|
1491
|
+
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
1370
1492
|
await this._ensureV2SessionReady('message.pull');
|
|
1371
|
-
const
|
|
1493
|
+
const skipAutoAck = p._skip_auto_ack === true || p.skip_auto_ack === true;
|
|
1494
|
+
const afterSeq = Number(p.after_seq ?? 0) || 0;
|
|
1495
|
+
const limit = Number(p.limit ?? 50) || 50;
|
|
1496
|
+
const messages = skipAutoAck
|
|
1497
|
+
? await runWithRpcPriority(() => this.pullV2(afterSeq, limit, { skipAutoAck: true, gateLocked: true }))
|
|
1498
|
+
: await runWithRpcPriority(() => this.pullV2(afterSeq, limit, { gateLocked: true }));
|
|
1372
1499
|
return { messages };
|
|
1373
1500
|
}
|
|
1374
|
-
if (method === 'message.ack'
|
|
1501
|
+
if (method === 'message.ack' || method === 'message.v2.ack') {
|
|
1375
1502
|
await this._ensureV2SessionReady('message.ack');
|
|
1376
|
-
return await this.ackV2(Number(p.seq ?? p.up_to_seq ?? 0) || undefined);
|
|
1503
|
+
return await runWithRpcPriority(() => this.ackV2(Number(p.seq ?? p.up_to_seq ?? 0) || undefined));
|
|
1377
1504
|
}
|
|
1378
|
-
if (method === 'group.pull'
|
|
1505
|
+
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
1506
|
+
if (!String(p.group_id ?? '').trim()) {
|
|
1507
|
+
throw new ValidationError('group.pull requires group_id');
|
|
1508
|
+
}
|
|
1379
1509
|
await this._ensureV2SessionReady('group.pull');
|
|
1380
|
-
const messages = await this.pullGroupV2(String(p.group_id), Number(p.after_seq ?? p.after_message_seq ?? 0) || 0, Number(p.limit ?? 50) || 50);
|
|
1510
|
+
const messages = await runWithRpcPriority(() => this.pullGroupV2(String(p.group_id), Number(p.after_seq ?? p.after_message_seq ?? 0) || 0, Number(p.limit ?? 50) || 50, { gateLocked: true }));
|
|
1381
1511
|
return { messages };
|
|
1382
1512
|
}
|
|
1383
|
-
if (method === 'group.ack_messages'
|
|
1513
|
+
if (method === 'group.ack_messages' || method === 'group.v2.ack') {
|
|
1514
|
+
if (!String(p.group_id ?? '').trim()) {
|
|
1515
|
+
throw new ValidationError('group.ack_messages requires group_id');
|
|
1516
|
+
}
|
|
1384
1517
|
await this._ensureV2SessionReady('group.ack_messages');
|
|
1385
|
-
return await this.ackGroupV2(String(p.group_id), Number(p.seq ?? p.msg_seq ?? p.up_to_seq ?? 0) || undefined);
|
|
1518
|
+
return await runWithRpcPriority(() => this.ackGroupV2(String(p.group_id), Number(p.seq ?? p.msg_seq ?? p.up_to_seq ?? 0) || undefined));
|
|
1519
|
+
}
|
|
1520
|
+
if (method === 'message.pull') {
|
|
1521
|
+
delete p._skip_auto_ack;
|
|
1522
|
+
delete p.skip_auto_ack;
|
|
1386
1523
|
}
|
|
1387
1524
|
// 关键操作自动附加客户端签名
|
|
1388
1525
|
if (SIGNED_METHODS.has(method)) {
|
|
@@ -1399,8 +1536,12 @@ export class AUNClient {
|
|
|
1399
1536
|
this._clientLog.debug(`thought.get transport call start: method=${method}, params=${this._debugJson(this._messageEnvelopeFieldsForDebug(p))}`);
|
|
1400
1537
|
}
|
|
1401
1538
|
let result = callTimeout
|
|
1402
|
-
?
|
|
1403
|
-
|
|
1539
|
+
? (rpcBackground
|
|
1540
|
+
? await this._transport.call(method, p, callTimeout, undefined, true)
|
|
1541
|
+
: await this._transport.call(method, p, callTimeout))
|
|
1542
|
+
: (rpcBackground
|
|
1543
|
+
? await this._transport.call(method, p, undefined, undefined, true)
|
|
1544
|
+
: await this._transport.call(method, p));
|
|
1404
1545
|
if (method === 'group.thought.get' && isJsonObject(result)) {
|
|
1405
1546
|
this._clientLog.debug(`group.thought.get transport result: found=${String(result.found ?? '')}, raw_count=${Array.isArray(result.thoughts) ? result.thoughts.length : 0}`);
|
|
1406
1547
|
result = await this._decryptGroupThoughts(result);
|
|
@@ -1486,6 +1627,34 @@ export class AUNClient {
|
|
|
1486
1627
|
this._clientLog.debug(`on exit: elapsed=${Date.now() - tStart}ms event=${event}`);
|
|
1487
1628
|
return result;
|
|
1488
1629
|
}
|
|
1630
|
+
async _callRawV2Rpc(method, params) {
|
|
1631
|
+
const p = { ...(params ?? {}) };
|
|
1632
|
+
const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
|
|
1633
|
+
delete p._rpc_background;
|
|
1634
|
+
delete p._pull_gate_locked;
|
|
1635
|
+
delete p._skip_auto_ack;
|
|
1636
|
+
delete p.skip_auto_ack;
|
|
1637
|
+
if (method.startsWith('group.') && p.group_id !== undefined && p.group_id !== null) {
|
|
1638
|
+
p.group_id = normalizeGroupId(String(p.group_id)) || String(p.group_id);
|
|
1639
|
+
}
|
|
1640
|
+
if (method.startsWith('group.') && p.device_id === undefined) {
|
|
1641
|
+
p.device_id = this._deviceId;
|
|
1642
|
+
}
|
|
1643
|
+
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
1644
|
+
p.slot_id = this._slotId;
|
|
1645
|
+
}
|
|
1646
|
+
if (SIGNED_METHODS.has(method)) {
|
|
1647
|
+
if (this._shouldSkipClientSignature(method, p)) {
|
|
1648
|
+
delete p.client_signature;
|
|
1649
|
+
}
|
|
1650
|
+
else {
|
|
1651
|
+
this._signClientOperation(method, p);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
return rpcBackground
|
|
1655
|
+
? await this._transport.call(method, p, undefined, undefined, true)
|
|
1656
|
+
: await this._transport.call(method, p);
|
|
1657
|
+
}
|
|
1489
1658
|
/** P2-13: 取消订阅事件(对齐 Python/JS off 方法) */
|
|
1490
1659
|
off(event, handler) {
|
|
1491
1660
|
const tStart = Date.now();
|
|
@@ -1575,7 +1744,7 @@ export class AUNClient {
|
|
|
1575
1744
|
_decrypt_error: String(exc),
|
|
1576
1745
|
};
|
|
1577
1746
|
this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1578
|
-
this._publishAppEvent('message.undecryptable', safeEvent).catch(() => { });
|
|
1747
|
+
Promise.resolve(this._publishAppEvent('message.undecryptable', safeEvent)).catch(() => { });
|
|
1579
1748
|
}
|
|
1580
1749
|
});
|
|
1581
1750
|
this._clientLog.debug(`_onRawMessageReceived exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
|
|
@@ -1595,12 +1764,15 @@ export class AUNClient {
|
|
|
1595
1764
|
const seq = msg.seq;
|
|
1596
1765
|
if (seq !== undefined && seq !== null && this._aid) {
|
|
1597
1766
|
const ns = `p2p:${this._aid}`;
|
|
1598
|
-
// Push
|
|
1767
|
+
// Push 只先更新 maxSeenSeq;contiguous_seq 是已交付游标,必须等应用层发布返回后再推进。
|
|
1599
1768
|
if (seq > 0)
|
|
1600
1769
|
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1601
|
-
const
|
|
1770
|
+
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1771
|
+
const published = await this._publishOrderedMessage('message.received', ns, seq, msg);
|
|
1772
|
+
const contigAfter = this._seqTracker.getContiguousSeq(ns);
|
|
1773
|
+
const needPull = Number(seq) > contigAfter && !published;
|
|
1602
1774
|
if (needPull) {
|
|
1603
|
-
this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${
|
|
1775
|
+
this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${contigAfter}`);
|
|
1604
1776
|
this._fillP2pGap().catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
1605
1777
|
}
|
|
1606
1778
|
// auto-ack contiguous_seq
|
|
@@ -1609,23 +1781,16 @@ export class AUNClient {
|
|
|
1609
1781
|
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1610
1782
|
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1611
1783
|
this._clientLog.debug(`P2P push auto-ack send: ns=${ns}, seq=${ackSeq}, contiguous=${contig}, max_seen=${maxSeen}`);
|
|
1612
|
-
this.
|
|
1613
|
-
seq: ackSeq,
|
|
1614
|
-
device_id: this._deviceId,
|
|
1615
|
-
slot_id: this._slotId,
|
|
1616
|
-
})
|
|
1784
|
+
this._withBackgroundRpc(() => this.ackV2(ackSeq))
|
|
1617
1785
|
.then(() => { this._clientLog.debug(`P2P push auto-ack ok: ns=${ns}, seq=${ackSeq}`); })
|
|
1618
1786
|
.catch((e) => { this._clientLog.debug(`P2P auto-ack failed: ${formatCaughtError(e)}`); });
|
|
1619
1787
|
}
|
|
1620
1788
|
// 即时持久化 cursor,异常断连后不回退
|
|
1621
|
-
|
|
1622
|
-
|
|
1623
|
-
// V2-only:普通 _raw.message.received 只承载明文;V2 密文由 peer.v2.message_received 通知触发 pull。
|
|
1624
|
-
if (seq !== undefined && seq !== null && this._aid) {
|
|
1625
|
-
const ns = `p2p:${this._aid}`;
|
|
1626
|
-
await this._publishOrderedMessage('message.received', ns, seq, msg);
|
|
1789
|
+
if (contigAfter !== contigBefore)
|
|
1790
|
+
this._saveSeqTrackerState();
|
|
1627
1791
|
}
|
|
1628
1792
|
else {
|
|
1793
|
+
// V2-only:普通 _raw.message.received 只承载明文;V2 密文由 peer.v2.message_received 通知触发 pull。
|
|
1629
1794
|
await this._publishAppEvent('message.received', msg, 'push');
|
|
1630
1795
|
}
|
|
1631
1796
|
}
|
|
@@ -1652,7 +1817,7 @@ export class AUNClient {
|
|
|
1652
1817
|
_decrypt_error: String(exc),
|
|
1653
1818
|
};
|
|
1654
1819
|
this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1655
|
-
this._publishAppEvent('group.message_undecryptable', safeEvent).catch(() => { });
|
|
1820
|
+
Promise.resolve(this._publishAppEvent('group.message_undecryptable', safeEvent)).catch(() => { });
|
|
1656
1821
|
}
|
|
1657
1822
|
});
|
|
1658
1823
|
this._clientLog.debug(`_onRawGroupMessageCreated exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
|
|
@@ -1678,17 +1843,22 @@ export class AUNClient {
|
|
|
1678
1843
|
if (payload === undefined || payload === null
|
|
1679
1844
|
|| (typeof payload === 'object' && Object.keys(payload).length === 0)) {
|
|
1680
1845
|
// 不带 payload 的通知不能先推进 seq,否则 auto-pull 会用推进后的 cursor 跳过该消息。
|
|
1681
|
-
|
|
1846
|
+
void this._autoPullGroupMessages(msg).catch((exc) => {
|
|
1847
|
+
this._clientLog.warn(`auto pull group message task failed: ${formatCaughtError(exc)}`);
|
|
1848
|
+
});
|
|
1682
1849
|
return;
|
|
1683
1850
|
}
|
|
1684
1851
|
if (groupId && seq !== undefined && seq !== null) {
|
|
1685
1852
|
const ns = `group:${groupId}`;
|
|
1686
|
-
// Push
|
|
1853
|
+
// Push 只先更新 maxSeenSeq;contiguous_seq 是已交付游标,必须等应用层发布返回后再推进。
|
|
1687
1854
|
if (seq > 0)
|
|
1688
1855
|
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1689
|
-
const
|
|
1856
|
+
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1857
|
+
const published = await this._publishOrderedMessage('group.message_created', ns, seq, msg);
|
|
1858
|
+
const contigAfter = this._seqTracker.getContiguousSeq(ns);
|
|
1859
|
+
const needPull = Number(seq) > contigAfter && !published;
|
|
1690
1860
|
if (needPull) {
|
|
1691
|
-
this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${
|
|
1861
|
+
this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${contigAfter}`);
|
|
1692
1862
|
this._fillGroupGap(groupId).catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
1693
1863
|
}
|
|
1694
1864
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
@@ -1696,118 +1866,65 @@ export class AUNClient {
|
|
|
1696
1866
|
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1697
1867
|
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1698
1868
|
this._clientLog.debug(`group push auto-ack send: group=${groupId}, ns=${ns}, seq=${ackSeq}, contiguous=${contig}, max_seen=${maxSeen}`);
|
|
1699
|
-
this.
|
|
1700
|
-
group_id: groupId,
|
|
1701
|
-
msg_seq: ackSeq,
|
|
1702
|
-
device_id: this._deviceId,
|
|
1703
|
-
slot_id: this._slotId,
|
|
1704
|
-
})
|
|
1869
|
+
this._withBackgroundRpc(() => this.ackGroupV2(groupId, ackSeq))
|
|
1705
1870
|
.then(() => { this._clientLog.debug(`group push auto-ack ok: group=${groupId}, seq=${ackSeq}`); })
|
|
1706
1871
|
.catch((e) => { this._clientLog.debug(`group message auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
1707
1872
|
}
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
// V2-only:普通 group.message_created 只承载明文;V2 密文由 group.v2.message_created 通知触发 pull。
|
|
1711
|
-
if (groupId && seq !== undefined && seq !== null) {
|
|
1712
|
-
const nsKey = `group:${groupId}`;
|
|
1713
|
-
await this._publishOrderedMessage('group.message_created', nsKey, seq, msg);
|
|
1873
|
+
if (contigAfter !== contigBefore)
|
|
1874
|
+
this._saveSeqTrackerState();
|
|
1714
1875
|
}
|
|
1715
1876
|
else {
|
|
1877
|
+
// V2-only:普通 group.message_created 只承载明文;V2 密文由 group.v2.message_created 通知触发 pull。
|
|
1716
1878
|
await this._publishAppEvent('group.message_created', msg, 'group-push');
|
|
1717
1879
|
}
|
|
1718
1880
|
}
|
|
1719
1881
|
/** 收到不带 payload 的 group.message_created 通知后,自动 pull 最新消息 */
|
|
1720
1882
|
async _autoPullGroupMessages(notification) {
|
|
1721
|
-
|
|
1883
|
+
let groupId = String(notification.group_id ?? '').trim();
|
|
1722
1884
|
if (!groupId) {
|
|
1723
1885
|
await this._publishAppEvent('group.message_created', notification);
|
|
1724
1886
|
return;
|
|
1725
1887
|
}
|
|
1888
|
+
groupId = normalizeGroupId(groupId) || groupId;
|
|
1726
1889
|
const ns = `group:${groupId}`;
|
|
1727
1890
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1728
1891
|
this._clientLog.debug(`auto pull group messages start: group=${groupId}, after_seq=${afterSeq}, seq=${String(notification.seq ?? '')}`);
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
group_id: groupId,
|
|
1738
|
-
after_message_seq: afterSeq,
|
|
1739
|
-
device_id: this._deviceId,
|
|
1740
|
-
limit: 50,
|
|
1741
|
-
});
|
|
1742
|
-
if (isJsonObject(result)) {
|
|
1743
|
-
const messages = result.messages;
|
|
1744
|
-
if (Array.isArray(messages)) {
|
|
1745
|
-
// onPullResult 已在 call() 拦截器中调用,此处不再重复
|
|
1746
|
-
const pushed = this._pushedSeqs.get(ns);
|
|
1747
|
-
for (const msg of messages) {
|
|
1748
|
-
if (isJsonObject(msg)) {
|
|
1749
|
-
const s = msg.seq;
|
|
1750
|
-
if (pushed && s !== undefined && s !== null && pushed.has(s)) {
|
|
1751
|
-
this._clientLog.debug(`auto pull group message skipped duplicate: group=${groupId}, seq=${s}`);
|
|
1752
|
-
continue; // 已发布到应用层,跳过
|
|
1753
|
-
}
|
|
1754
|
-
if (s !== undefined && s !== null) {
|
|
1755
|
-
await this._publishPulledMessage('group.message_created', ns, s, msg);
|
|
1756
|
-
}
|
|
1757
|
-
else {
|
|
1758
|
-
await this._publishAppEvent('group.message_created', msg, 'pull');
|
|
1759
|
-
}
|
|
1760
|
-
}
|
|
1761
|
-
}
|
|
1762
|
-
this._prunePushedSeqs(ns);
|
|
1763
|
-
return;
|
|
1764
|
-
}
|
|
1765
|
-
}
|
|
1892
|
+
const started = await this._tryRunBackgroundPull(ns, async () => {
|
|
1893
|
+
const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1894
|
+
const messages = await this.pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
|
|
1895
|
+
this._prunePushedSeqs(ns);
|
|
1896
|
+
return messages.length;
|
|
1897
|
+
}, true);
|
|
1898
|
+
if (!started) {
|
|
1899
|
+
this._clientLog.debug(`auto pull group messages skipped: pull in-flight group=${groupId}`);
|
|
1766
1900
|
}
|
|
1767
|
-
catch (exc) {
|
|
1768
|
-
this._clientLog.debug(`auto pull group messages failed: ${formatCaughtError(exc)}`);
|
|
1769
|
-
}
|
|
1770
|
-
await this._publishAppEvent('group.message_created', notification, 'group-push-fallback');
|
|
1771
1901
|
}
|
|
1772
1902
|
/** 后台补齐群消息空洞 */
|
|
1773
1903
|
async _fillGroupGap(groupId) {
|
|
1904
|
+
groupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
1905
|
+
if (!groupId)
|
|
1906
|
+
return;
|
|
1774
1907
|
const ns = `group:${groupId}`;
|
|
1775
1908
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1776
1909
|
// 去重:同一 (group:id:after_seq) 只补一次
|
|
1777
1910
|
const dedupKey = `group_msg:${groupId}:${afterSeq}`;
|
|
1778
1911
|
if (this._gapFillDone.has(dedupKey))
|
|
1779
1912
|
return;
|
|
1913
|
+
const token = this._tryAcquirePullGate(ns);
|
|
1914
|
+
if (token === null) {
|
|
1915
|
+
this._clientLog.debug(`group message gap fill skipped: pull in-flight group=${groupId}`);
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1780
1918
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1781
1919
|
this._clientLog.debug(`group message gap fill start: group=${groupId}, after_seq=${afterSeq}`);
|
|
1920
|
+
let filled = 0;
|
|
1782
1921
|
try {
|
|
1783
|
-
const
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
|
|
1787
|
-
|
|
1788
|
-
|
|
1789
|
-
let filled = 0;
|
|
1790
|
-
if (isJsonObject(result)) {
|
|
1791
|
-
const messages = result.messages;
|
|
1792
|
-
if (Array.isArray(messages)) {
|
|
1793
|
-
// onPullResult 已在 call() 拦截器中调用,此处不再重复
|
|
1794
|
-
const pushed = this._pushedSeqs.get(ns);
|
|
1795
|
-
for (const msg of messages) {
|
|
1796
|
-
if (isJsonObject(msg)) {
|
|
1797
|
-
const s = msg.seq;
|
|
1798
|
-
if (pushed && s !== undefined && s !== null && pushed.has(s))
|
|
1799
|
-
continue;
|
|
1800
|
-
if (s !== undefined && s !== null) {
|
|
1801
|
-
await this._publishPulledMessage('group.message_created', ns, s, msg);
|
|
1802
|
-
}
|
|
1803
|
-
else {
|
|
1804
|
-
await this._publishAppEvent('group.message_created', msg);
|
|
1805
|
-
}
|
|
1806
|
-
filled += 1;
|
|
1807
|
-
}
|
|
1808
|
-
}
|
|
1809
|
-
this._prunePushedSeqs(ns);
|
|
1810
|
-
}
|
|
1922
|
+
const messages = await this._withBackgroundRpc(() => this.pullGroupV2(groupId, afterSeq, 50, { gateLocked: true }));
|
|
1923
|
+
filled = messages.length;
|
|
1924
|
+
this._prunePushedSeqs(ns);
|
|
1925
|
+
if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
|
|
1926
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
1927
|
+
this._saveSeqTrackerState();
|
|
1811
1928
|
}
|
|
1812
1929
|
this._clientLog.debug(`group message gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
|
|
1813
1930
|
}
|
|
@@ -1816,6 +1933,10 @@ export class AUNClient {
|
|
|
1816
1933
|
}
|
|
1817
1934
|
finally {
|
|
1818
1935
|
this._gapFillDone.delete(dedupKey);
|
|
1936
|
+
this._releasePullGate(ns, token);
|
|
1937
|
+
if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
|
|
1938
|
+
void this._fillGroupGap(groupId);
|
|
1939
|
+
}
|
|
1819
1940
|
}
|
|
1820
1941
|
}
|
|
1821
1942
|
/** 后台补齐 P2P 消息空洞 */
|
|
@@ -1828,35 +1949,25 @@ export class AUNClient {
|
|
|
1828
1949
|
const dedupKey = `p2p:${afterSeq}`;
|
|
1829
1950
|
if (this._gapFillDone.has(dedupKey))
|
|
1830
1951
|
return;
|
|
1952
|
+
const token = this._tryAcquirePullGate(ns);
|
|
1953
|
+
if (token === null) {
|
|
1954
|
+
this._clientLog.debug(`P2P message gap fill skipped: pull in-flight ns=${ns}`);
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1831
1957
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1832
1958
|
this._clientLog.debug(`P2P message gap fill start: after_seq=${afterSeq}`);
|
|
1959
|
+
let filled = 0;
|
|
1833
1960
|
try {
|
|
1834
|
-
const
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1842
|
-
|
|
1843
|
-
|
|
1844
|
-
for (const msg of messages) {
|
|
1845
|
-
if (isJsonObject(msg)) {
|
|
1846
|
-
const s = msg.seq;
|
|
1847
|
-
if (pushed && s !== undefined && s !== null && pushed.has(s))
|
|
1848
|
-
continue;
|
|
1849
|
-
if (s !== undefined && s !== null) {
|
|
1850
|
-
await this._publishPulledMessage('message.received', ns, s, msg);
|
|
1851
|
-
}
|
|
1852
|
-
else {
|
|
1853
|
-
await this._publishAppEvent('message.received', msg);
|
|
1854
|
-
}
|
|
1855
|
-
filled += 1;
|
|
1856
|
-
}
|
|
1857
|
-
}
|
|
1858
|
-
this._prunePushedSeqs(ns);
|
|
1859
|
-
}
|
|
1961
|
+
const messages = await this._withBackgroundRpc(() => this.pullV2(afterSeq, 50, { skipAutoAck: true, gateLocked: true }));
|
|
1962
|
+
filled = messages.length;
|
|
1963
|
+
this._prunePushedSeqs(ns);
|
|
1964
|
+
if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
|
|
1965
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
1966
|
+
this._saveSeqTrackerState();
|
|
1967
|
+
}
|
|
1968
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1969
|
+
if (contig > 0 && contig !== afterSeq) {
|
|
1970
|
+
await this._withBackgroundRpc(() => this.ackV2(contig));
|
|
1860
1971
|
}
|
|
1861
1972
|
this._clientLog.debug(`P2P message gap fill done: after_seq=${afterSeq}, filled=${filled}`);
|
|
1862
1973
|
}
|
|
@@ -1865,6 +1976,10 @@ export class AUNClient {
|
|
|
1865
1976
|
}
|
|
1866
1977
|
finally {
|
|
1867
1978
|
this._gapFillDone.delete(dedupKey);
|
|
1979
|
+
this._releasePullGate(ns, token);
|
|
1980
|
+
if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
|
|
1981
|
+
void this._fillP2pGap();
|
|
1982
|
+
}
|
|
1868
1983
|
}
|
|
1869
1984
|
}
|
|
1870
1985
|
/** 只按硬上限裁剪 published guard,不能按 contiguousSeq 清理。 */
|
|
@@ -1925,7 +2040,7 @@ export class AUNClient {
|
|
|
1925
2040
|
return payload;
|
|
1926
2041
|
return this._attachCurrentInstanceContext(payload);
|
|
1927
2042
|
}
|
|
1928
|
-
|
|
2043
|
+
_publishAppEvent(event, payload, source = 'direct') {
|
|
1929
2044
|
if ((event === 'message.received' || event === 'group.message_created') && isJsonObject(payload)) {
|
|
1930
2045
|
this._maybeAppendEchoTraceReceive(payload);
|
|
1931
2046
|
}
|
|
@@ -1949,7 +2064,7 @@ export class AUNClient {
|
|
|
1949
2064
|
this._clientLog.debug(`agent_md etag inject skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
1950
2065
|
}
|
|
1951
2066
|
}
|
|
1952
|
-
|
|
2067
|
+
return this._dispatcher.publishSyncAware(event, this._normalizePublishedMessagePayload(event, payload));
|
|
1953
2068
|
}
|
|
1954
2069
|
_echoTimestamp() {
|
|
1955
2070
|
const now = new Date();
|
|
@@ -2102,25 +2217,273 @@ export class AUNClient {
|
|
|
2102
2217
|
}
|
|
2103
2218
|
return true;
|
|
2104
2219
|
}
|
|
2105
|
-
|
|
2220
|
+
_tryAcquirePullGate(key) {
|
|
2221
|
+
if (!key)
|
|
2222
|
+
return 0;
|
|
2223
|
+
const now = Date.now();
|
|
2224
|
+
const gate = this._pullGates.get(key) ?? { inflight: false, startedAt: 0, token: 0 };
|
|
2225
|
+
if (gate.inflight && now - gate.startedAt <= AUNClient.PULL_GATE_STALE_MS) {
|
|
2226
|
+
return null;
|
|
2227
|
+
}
|
|
2228
|
+
if (gate.inflight) {
|
|
2229
|
+
this._clientLog.warn(`pull in-flight stale reset: key=${key} age=${now - gate.startedAt}ms`);
|
|
2230
|
+
}
|
|
2231
|
+
gate.token += 1;
|
|
2232
|
+
gate.inflight = true;
|
|
2233
|
+
gate.startedAt = now;
|
|
2234
|
+
this._pullGates.set(key, gate);
|
|
2235
|
+
return gate.token;
|
|
2236
|
+
}
|
|
2237
|
+
_releasePullGate(key, token) {
|
|
2238
|
+
if (!key || token == null)
|
|
2239
|
+
return;
|
|
2240
|
+
const gate = this._pullGates.get(key);
|
|
2241
|
+
if (!gate || gate.token !== token)
|
|
2242
|
+
return;
|
|
2243
|
+
gate.inflight = false;
|
|
2244
|
+
gate.startedAt = 0;
|
|
2245
|
+
}
|
|
2246
|
+
_pullGateKeyForCall(method, params) {
|
|
2247
|
+
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
2248
|
+
return this._aid ? `p2p:${this._aid}` : '';
|
|
2249
|
+
}
|
|
2250
|
+
if ((method === 'group.pull' || method === 'group.v2.pull') && String(params.group_id ?? '').trim()) {
|
|
2251
|
+
return `group:${String(params.group_id ?? '').trim()}`;
|
|
2252
|
+
}
|
|
2253
|
+
if (method === 'group.pull_events' && String(params.group_id ?? '').trim()) {
|
|
2254
|
+
return `group_event:${String(params.group_id ?? '').trim()}`;
|
|
2255
|
+
}
|
|
2256
|
+
return '';
|
|
2257
|
+
}
|
|
2258
|
+
_isPullResponseProcessing(key) {
|
|
2259
|
+
if (!key)
|
|
2260
|
+
return false;
|
|
2261
|
+
return (this._pullResponseKeys.get(key) ?? 0) > 0;
|
|
2262
|
+
}
|
|
2263
|
+
_emptyPullResultForCall(method) {
|
|
2264
|
+
if (method === 'group.pull_events')
|
|
2265
|
+
return { events: [], count: 0 };
|
|
2266
|
+
if (method === 'message.pull' || method === 'message.v2.pull' || method === 'group.pull' || method === 'group.v2.pull') {
|
|
2267
|
+
return { messages: [], count: 0 };
|
|
2268
|
+
}
|
|
2269
|
+
return {};
|
|
2270
|
+
}
|
|
2271
|
+
_withPullResponseProcessing(key, fn) {
|
|
2272
|
+
if (!key)
|
|
2273
|
+
return fn();
|
|
2274
|
+
this._pullResponseKeys.set(key, (this._pullResponseKeys.get(key) ?? 0) + 1);
|
|
2275
|
+
const release = () => {
|
|
2276
|
+
const next = (this._pullResponseKeys.get(key) ?? 1) - 1;
|
|
2277
|
+
if (next <= 0) {
|
|
2278
|
+
this._pullResponseKeys.delete(key);
|
|
2279
|
+
}
|
|
2280
|
+
else {
|
|
2281
|
+
this._pullResponseKeys.set(key, next);
|
|
2282
|
+
}
|
|
2283
|
+
};
|
|
2284
|
+
try {
|
|
2285
|
+
const result = fn();
|
|
2286
|
+
if (isPromiseLike(result)) {
|
|
2287
|
+
return Promise.resolve(result).finally(release);
|
|
2288
|
+
}
|
|
2289
|
+
release();
|
|
2290
|
+
return result;
|
|
2291
|
+
}
|
|
2292
|
+
catch (exc) {
|
|
2293
|
+
release();
|
|
2294
|
+
throw exc;
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
_pullResultCount(result) {
|
|
2298
|
+
if (Array.isArray(result))
|
|
2299
|
+
return result.length;
|
|
2300
|
+
if (!isJsonObject(result))
|
|
2301
|
+
return 0;
|
|
2302
|
+
const obj = result;
|
|
2303
|
+
const rawCount = Number(obj.raw_count ?? 0);
|
|
2304
|
+
if (Number.isFinite(rawCount) && rawCount > 0)
|
|
2305
|
+
return rawCount;
|
|
2306
|
+
if (Array.isArray(obj.messages))
|
|
2307
|
+
return obj.messages.length;
|
|
2308
|
+
if (Array.isArray(obj.events))
|
|
2309
|
+
return obj.events.length;
|
|
2310
|
+
return 0;
|
|
2311
|
+
}
|
|
2312
|
+
_nextPullParams(method, params) {
|
|
2313
|
+
const next = { ...params };
|
|
2314
|
+
delete next._pull_gate_locked;
|
|
2315
|
+
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
2316
|
+
if (!this._aid)
|
|
2317
|
+
return null;
|
|
2318
|
+
next.after_seq = this._seqTracker.getContiguousSeq(`p2p:${this._aid}`);
|
|
2319
|
+
return next;
|
|
2320
|
+
}
|
|
2321
|
+
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
2322
|
+
const groupId = normalizeGroupId(String(next.group_id ?? '').trim()) || String(next.group_id ?? '').trim();
|
|
2323
|
+
if (!groupId)
|
|
2324
|
+
return null;
|
|
2325
|
+
next.group_id = groupId;
|
|
2326
|
+
next.after_seq = this._seqTracker.getContiguousSeq(`group:${groupId}`);
|
|
2327
|
+
delete next.after_message_seq;
|
|
2328
|
+
return next;
|
|
2329
|
+
}
|
|
2330
|
+
if (method === 'group.pull_events') {
|
|
2331
|
+
const groupId = normalizeGroupId(String(next.group_id ?? '').trim()) || String(next.group_id ?? '').trim();
|
|
2332
|
+
if (!groupId)
|
|
2333
|
+
return null;
|
|
2334
|
+
next.group_id = groupId;
|
|
2335
|
+
next.after_event_seq = this._seqTracker.getContiguousSeq(`group_event:${groupId}`);
|
|
2336
|
+
return next;
|
|
2337
|
+
}
|
|
2338
|
+
return null;
|
|
2339
|
+
}
|
|
2340
|
+
_pullRequestAfter(method, params) {
|
|
2341
|
+
if (method === 'message.pull' || method === 'message.v2.pull')
|
|
2342
|
+
return Number(params.after_seq ?? 0) || 0;
|
|
2343
|
+
if (method === 'group.pull' || method === 'group.v2.pull')
|
|
2344
|
+
return Number(params.after_seq ?? params.after_message_seq ?? 0) || 0;
|
|
2345
|
+
if (method === 'group.pull_events')
|
|
2346
|
+
return Number(params.after_event_seq ?? 0) || 0;
|
|
2347
|
+
return 0;
|
|
2348
|
+
}
|
|
2349
|
+
_pullRetentionFloor(result, topLevelKey, cursorKey) {
|
|
2350
|
+
const values = [Number(result[topLevelKey] ?? 0)];
|
|
2351
|
+
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
2352
|
+
if (cursor) {
|
|
2353
|
+
values.push(Number(cursor[cursorKey] ?? 0));
|
|
2354
|
+
values.push(Number(cursor.retention_floor_seq ?? 0));
|
|
2355
|
+
}
|
|
2356
|
+
return Math.max(0, ...values.filter((value) => Number.isFinite(value)));
|
|
2357
|
+
}
|
|
2358
|
+
_schedulePullFollowup(method, params, result) {
|
|
2359
|
+
if (method === 'message.pull')
|
|
2360
|
+
method = 'message.v2.pull';
|
|
2361
|
+
else if (method === 'group.pull')
|
|
2362
|
+
method = 'group.v2.pull';
|
|
2363
|
+
if (this._pullResultCount(result) <= 0)
|
|
2364
|
+
return;
|
|
2365
|
+
const next = this._nextPullParams(method, params);
|
|
2366
|
+
if (!next)
|
|
2367
|
+
return;
|
|
2368
|
+
if (this._pullRequestAfter(method, next) <= this._pullRequestAfter(method, params))
|
|
2369
|
+
return;
|
|
2370
|
+
void (async () => {
|
|
2371
|
+
try {
|
|
2372
|
+
await this._withBackgroundRpc(async () => {
|
|
2373
|
+
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
2374
|
+
await this.pullV2(Number(next.after_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
2378
|
+
const groupId = String(next.group_id ?? '').trim();
|
|
2379
|
+
if (!groupId)
|
|
2380
|
+
return;
|
|
2381
|
+
await this.pullGroupV2(groupId, Number(next.after_seq ?? next.after_message_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
await this.call(method, next);
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2387
|
+
catch (exc) {
|
|
2388
|
+
this._clientLog.debug(`pull follow-up skipped/failed: method=${method} err=${formatCaughtError(exc)}`);
|
|
2389
|
+
}
|
|
2390
|
+
})();
|
|
2391
|
+
}
|
|
2392
|
+
async _withBackgroundRpc(operation) {
|
|
2393
|
+
this._backgroundRpcDepth += 1;
|
|
2394
|
+
try {
|
|
2395
|
+
return await operation();
|
|
2396
|
+
}
|
|
2397
|
+
finally {
|
|
2398
|
+
this._backgroundRpcDepth = Math.max(0, this._backgroundRpcDepth - 1);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
async _runPullSerialized(key, operation) {
|
|
2402
|
+
if (key && this._isPullResponseProcessing(key)) {
|
|
2403
|
+
this._clientLog.debug(`pull skipped while processing pull response: key=${key}`);
|
|
2404
|
+
return [];
|
|
2405
|
+
}
|
|
2406
|
+
let token = this._tryAcquirePullGate(key);
|
|
2407
|
+
if (token === null) {
|
|
2408
|
+
// 显式 pull 可能撞上 push/gap-fill 的后台 pull。这里不并行发第二个 pull,
|
|
2409
|
+
// 也不把后台 in-flight 暴露成业务错误;短等待 gate 释放后再进入连接级 RPC queue。
|
|
2410
|
+
const deadline = Date.now() + AUNClient.PULL_GATE_STALE_MS + 100;
|
|
2411
|
+
while (token === null && Date.now() <= deadline) {
|
|
2412
|
+
await this._sleep(25);
|
|
2413
|
+
token = this._tryAcquirePullGate(key);
|
|
2414
|
+
}
|
|
2415
|
+
if (token === null) {
|
|
2416
|
+
throw new StateError(`pull already in-flight for ${key}`);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
try {
|
|
2420
|
+
return await this._withBackgroundRpc(operation);
|
|
2421
|
+
}
|
|
2422
|
+
finally {
|
|
2423
|
+
this._releasePullGate(key, token);
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
async _tryRunBackgroundPull(key, operation, followupOnMessages = false) {
|
|
2427
|
+
if (key && this._isPullResponseProcessing(key))
|
|
2428
|
+
return false;
|
|
2429
|
+
const token = this._tryAcquirePullGate(key);
|
|
2430
|
+
if (token === null)
|
|
2431
|
+
return false;
|
|
2432
|
+
let count = 0;
|
|
2433
|
+
try {
|
|
2434
|
+
count = await this._withBackgroundRpc(operation);
|
|
2435
|
+
}
|
|
2436
|
+
finally {
|
|
2437
|
+
this._releasePullGate(key, token);
|
|
2438
|
+
}
|
|
2439
|
+
if (followupOnMessages && count > 0) {
|
|
2440
|
+
// 后台续拉是 fire-and-forget;关闭连接时 transport 会拒绝排队 RPC,
|
|
2441
|
+
// 这里必须本地收口,避免测试/宿主进程看到未处理的 Promise rejection。
|
|
2442
|
+
void this._tryRunBackgroundPull(key, operation, true).catch((exc) => {
|
|
2443
|
+
this._clientLog.debug(`background pull follow-up skipped/failed: key=${key} err=${formatCaughtError(exc)}`);
|
|
2444
|
+
});
|
|
2445
|
+
}
|
|
2446
|
+
return true;
|
|
2447
|
+
}
|
|
2448
|
+
async _drainOrderedMessages(ns, beforeSeq, pullResponse = false) {
|
|
2106
2449
|
const queue = this._pendingOrderedMsgs.get(ns);
|
|
2107
2450
|
if (!queue || queue.size === 0)
|
|
2108
2451
|
return;
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2452
|
+
while (true) {
|
|
2453
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2454
|
+
const ready = [...queue.keys()]
|
|
2455
|
+
.filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
|
|
2456
|
+
.sort((a, b) => a - b);
|
|
2457
|
+
let seq = ready[0];
|
|
2458
|
+
if (seq === undefined) {
|
|
2459
|
+
const nextSeq = contig + 1;
|
|
2460
|
+
if (beforeSeq !== undefined && nextSeq >= beforeSeq)
|
|
2461
|
+
break;
|
|
2462
|
+
if (!queue.has(nextSeq))
|
|
2463
|
+
break;
|
|
2464
|
+
seq = nextSeq;
|
|
2465
|
+
}
|
|
2114
2466
|
const item = queue.get(seq);
|
|
2115
2467
|
queue.delete(seq);
|
|
2116
2468
|
if (!item)
|
|
2117
2469
|
continue;
|
|
2118
2470
|
if (this._pushedSeqs.get(ns)?.has(seq)) {
|
|
2119
2471
|
this._clientLog.debug(`publish ordered drain skipped duplicate: ns=${ns}, seq=${seq}, event=${item.event}`);
|
|
2472
|
+
this._markOrderedSeqDelivered(ns, seq);
|
|
2120
2473
|
continue;
|
|
2121
2474
|
}
|
|
2122
|
-
|
|
2475
|
+
if (pullResponse) {
|
|
2476
|
+
const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(item.event, item.payload, 'ordered-drain'));
|
|
2477
|
+
if (isPromiseLike(published))
|
|
2478
|
+
await published;
|
|
2479
|
+
}
|
|
2480
|
+
else {
|
|
2481
|
+
const published = this._publishAppEvent(item.event, item.payload, 'ordered-drain');
|
|
2482
|
+
if (isPromiseLike(published))
|
|
2483
|
+
await published;
|
|
2484
|
+
}
|
|
2123
2485
|
this._markPublishedSeq(ns, seq);
|
|
2486
|
+
this._markOrderedSeqDelivered(ns, seq);
|
|
2124
2487
|
this._clientLog.debug(`publish ordered drain delivered: ns=${ns}, seq=${seq}, event=${item.event}`);
|
|
2125
2488
|
}
|
|
2126
2489
|
if (queue.size === 0)
|
|
@@ -2130,7 +2493,9 @@ export class AUNClient {
|
|
|
2130
2493
|
const seqNum = Number(seq);
|
|
2131
2494
|
if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0) {
|
|
2132
2495
|
this._clientLog.debug(`publish ordered direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
|
|
2133
|
-
|
|
2496
|
+
const published = this._publishAppEvent(event, payload, 'ordered');
|
|
2497
|
+
if (isPromiseLike(published))
|
|
2498
|
+
await published;
|
|
2134
2499
|
return true;
|
|
2135
2500
|
}
|
|
2136
2501
|
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
@@ -2142,7 +2507,15 @@ export class AUNClient {
|
|
|
2142
2507
|
return false;
|
|
2143
2508
|
}
|
|
2144
2509
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2145
|
-
if (seqNum
|
|
2510
|
+
if (seqNum <= contig) {
|
|
2511
|
+
this._clientLog.debug(`publish ordered stale covered: event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
|
|
2512
|
+
const queue = this._pendingOrderedMsgs.get(ns);
|
|
2513
|
+
queue?.delete(seqNum);
|
|
2514
|
+
if (queue && queue.size === 0)
|
|
2515
|
+
this._pendingOrderedMsgs.delete(ns);
|
|
2516
|
+
return false;
|
|
2517
|
+
}
|
|
2518
|
+
if (seqNum !== contig + 1) {
|
|
2146
2519
|
this._clientLog.debug(`publish ordered enqueue(gap): event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
|
|
2147
2520
|
this._enqueueOrderedMessage(ns, event, seqNum, payload);
|
|
2148
2521
|
return false;
|
|
@@ -2156,17 +2529,25 @@ export class AUNClient {
|
|
|
2156
2529
|
queue?.delete(seqNum);
|
|
2157
2530
|
if (queue && queue.size === 0)
|
|
2158
2531
|
this._pendingOrderedMsgs.delete(ns);
|
|
2159
|
-
|
|
2532
|
+
const published = this._publishAppEvent(event, payload, 'ordered');
|
|
2533
|
+
if (isPromiseLike(published))
|
|
2534
|
+
await published;
|
|
2160
2535
|
this._markPublishedSeq(ns, seqNum);
|
|
2536
|
+
this._markOrderedSeqDelivered(ns, seqNum);
|
|
2161
2537
|
this._clientLog.debug(`publish ordered delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
2162
2538
|
await this._drainOrderedMessages(ns);
|
|
2163
2539
|
return true;
|
|
2164
2540
|
}
|
|
2165
2541
|
async _publishPulledMessage(event, ns, seq, payload) {
|
|
2542
|
+
// Pull/gap-fill 批次是服务端对 after_seq 的可用结果集,可能跨过永久空洞。
|
|
2543
|
+
// 这里只能做 namespace+seq 去重并按返回顺序发布,不能套用 push 路径的
|
|
2544
|
+
// seq == contiguous_seq + 1 门控,否则会把空洞后的可用消息错误卡住。
|
|
2166
2545
|
const seqNum = Number(seq);
|
|
2167
2546
|
if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0 || !ns) {
|
|
2168
2547
|
this._clientLog.debug(`publish pulled direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
|
|
2169
|
-
|
|
2548
|
+
const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(event, payload, 'pull'));
|
|
2549
|
+
if (isPromiseLike(published))
|
|
2550
|
+
await published;
|
|
2170
2551
|
return true;
|
|
2171
2552
|
}
|
|
2172
2553
|
const queue = this._pendingOrderedMsgs.get(ns);
|
|
@@ -2180,22 +2561,51 @@ export class AUNClient {
|
|
|
2180
2561
|
queue?.delete(seqNum);
|
|
2181
2562
|
if (queue && queue.size === 0)
|
|
2182
2563
|
this._pendingOrderedMsgs.delete(ns);
|
|
2183
|
-
|
|
2564
|
+
const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(event, payload, 'pull'));
|
|
2565
|
+
if (isPromiseLike(published))
|
|
2566
|
+
await published;
|
|
2184
2567
|
this._markPublishedSeq(ns, seqNum);
|
|
2568
|
+
this._markPulledSeqDelivered(ns, seqNum);
|
|
2569
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
2185
2570
|
this._clientLog.debug(`publish pulled delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
2186
2571
|
return true;
|
|
2187
2572
|
}
|
|
2573
|
+
_markPulledSeqDelivered(ns, seq) {
|
|
2574
|
+
// Pull 批次是 after_seq 之后服务端当前可用的结果集,可能跨过永久空洞。
|
|
2575
|
+
// 这里仅在应用层发布返回后推进已交付游标,不能改成 push 的相邻 seq 门控。
|
|
2576
|
+
const seqNum = Number(seq);
|
|
2577
|
+
if (!ns || !Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0)
|
|
2578
|
+
return false;
|
|
2579
|
+
const before = this._seqTracker.getContiguousSeq(ns);
|
|
2580
|
+
this._seqTracker.forceContiguousSeq(ns, seqNum);
|
|
2581
|
+
return this._seqTracker.getContiguousSeq(ns) !== before;
|
|
2582
|
+
}
|
|
2583
|
+
_markOrderedSeqDelivered(ns, seq) {
|
|
2584
|
+
if (!ns || !Number.isFinite(seq) || !Number.isInteger(seq) || seq <= 0)
|
|
2585
|
+
return false;
|
|
2586
|
+
const before = this._seqTracker.getContiguousSeq(ns);
|
|
2587
|
+
this._seqTracker.onMessageSeq(ns, seq);
|
|
2588
|
+
return this._seqTracker.getContiguousSeq(ns) !== before;
|
|
2589
|
+
}
|
|
2188
2590
|
/** 后台补齐群事件空洞 */
|
|
2189
2591
|
async _fillGroupEventGap(groupId) {
|
|
2592
|
+
groupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
2593
|
+
if (!groupId)
|
|
2594
|
+
return;
|
|
2190
2595
|
const ns = `group_event:${groupId}`;
|
|
2191
2596
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
2192
2597
|
// 去重:同一 (group_evt:id:after_seq) 只补一次
|
|
2193
2598
|
const dedupKey = `group_evt:${groupId}:${afterSeq}`;
|
|
2194
2599
|
if (this._gapFillDone.has(dedupKey))
|
|
2195
2600
|
return;
|
|
2601
|
+
const token = this._tryAcquirePullGate(ns);
|
|
2602
|
+
if (token === null) {
|
|
2603
|
+
this._clientLog.debug(`group event gap fill skipped: pull in-flight group=${groupId}`);
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
2196
2606
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
2607
|
+
let filled = 0;
|
|
2197
2608
|
try {
|
|
2198
|
-
let filled = 0;
|
|
2199
2609
|
let nextAfterSeq = afterSeq;
|
|
2200
2610
|
const maxPages = 100;
|
|
2201
2611
|
let pageCount = 0;
|
|
@@ -2207,6 +2617,7 @@ export class AUNClient {
|
|
|
2207
2617
|
after_event_seq: nextAfterSeq,
|
|
2208
2618
|
device_id: this._deviceId,
|
|
2209
2619
|
limit: 50,
|
|
2620
|
+
_pull_gate_locked: true,
|
|
2210
2621
|
});
|
|
2211
2622
|
if (!isJsonObject(result))
|
|
2212
2623
|
return;
|
|
@@ -2215,16 +2626,12 @@ export class AUNClient {
|
|
|
2215
2626
|
return;
|
|
2216
2627
|
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
2217
2628
|
const eventObjects = events.filter(isJsonObject);
|
|
2218
|
-
|
|
2219
|
-
|
|
2220
|
-
}
|
|
2221
|
-
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
2222
|
-
const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
|
|
2223
|
-
if (serverAck > 0) {
|
|
2629
|
+
const retentionFloor = this._pullRetentionFloor(result, 'retention_floor_event_seq', 'retention_floor_event_seq');
|
|
2630
|
+
if (retentionFloor > 0) {
|
|
2224
2631
|
const contigBeforeFloor = this._seqTracker.getContiguousSeq(ns);
|
|
2225
|
-
if (contigBeforeFloor <
|
|
2226
|
-
this._clientLog.info(`group.pull_events retention-floor advance: ns=${ns} contiguous=${contigBeforeFloor} ->
|
|
2227
|
-
this._seqTracker.forceContiguousSeq(ns,
|
|
2632
|
+
if (contigBeforeFloor < retentionFloor) {
|
|
2633
|
+
this._clientLog.info(`group.pull_events retention-floor advance: ns=${ns} contiguous=${contigBeforeFloor} -> retention_floor=${retentionFloor}`);
|
|
2634
|
+
this._seqTracker.forceContiguousSeq(ns, retentionFloor);
|
|
2228
2635
|
}
|
|
2229
2636
|
}
|
|
2230
2637
|
const eventSeqs = [];
|
|
@@ -2235,20 +2642,23 @@ export class AUNClient {
|
|
|
2235
2642
|
evt._from_gap_fill = true;
|
|
2236
2643
|
const et = String(evt.event_type ?? '');
|
|
2237
2644
|
// 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
|
|
2238
|
-
if (et
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2645
|
+
if (et !== 'group.message_created') {
|
|
2646
|
+
// 验签:有 client_signature 就验(与实时事件路径对齐)
|
|
2647
|
+
const cs = evt.client_signature;
|
|
2648
|
+
if (cs && typeof cs === 'object') {
|
|
2649
|
+
if (this._shouldSkipEventSignature(evt)) {
|
|
2650
|
+
delete evt.client_signature;
|
|
2651
|
+
}
|
|
2652
|
+
else {
|
|
2653
|
+
evt._verified = await this._verifyEventSignatureAsync(evt, cs);
|
|
2654
|
+
}
|
|
2248
2655
|
}
|
|
2656
|
+
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
2657
|
+
await this._dispatcher.publish('group.changed', evt);
|
|
2658
|
+
}
|
|
2659
|
+
if (Number.isFinite(eventSeq) && eventSeq > 0) {
|
|
2660
|
+
this._markPulledSeqDelivered(ns, eventSeq);
|
|
2249
2661
|
}
|
|
2250
|
-
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
2251
|
-
await this._dispatcher.publish('group.changed', evt);
|
|
2252
2662
|
filled += 1;
|
|
2253
2663
|
}
|
|
2254
2664
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
@@ -2263,12 +2673,11 @@ export class AUNClient {
|
|
|
2263
2673
|
event_seq: ackSeq,
|
|
2264
2674
|
device_id: this._deviceId,
|
|
2265
2675
|
slot_id: this._slotId,
|
|
2266
|
-
}).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2676
|
+
}, undefined, undefined, true).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2267
2677
|
}
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
nextAfterSeq = nextAfter;
|
|
2678
|
+
// pull_events 与其它 pull 一样:一次后台任务只消费一个批次。
|
|
2679
|
+
// 非空批次返回后由 pull gate 的 fire-and-forget follow-up 重新排队,直到空批停止。
|
|
2680
|
+
break;
|
|
2272
2681
|
}
|
|
2273
2682
|
if (pageCount >= maxPages) {
|
|
2274
2683
|
this._clientLog.warn(`group event gap fill reached max_pages=${maxPages} group=${groupId} after_seq=${nextAfterSeq}`);
|
|
@@ -2280,6 +2689,10 @@ export class AUNClient {
|
|
|
2280
2689
|
}
|
|
2281
2690
|
finally {
|
|
2282
2691
|
this._gapFillDone.delete(dedupKey);
|
|
2692
|
+
this._releasePullGate(ns, token);
|
|
2693
|
+
if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
|
|
2694
|
+
void this._fillGroupEventGap(groupId);
|
|
2695
|
+
}
|
|
2283
2696
|
}
|
|
2284
2697
|
}
|
|
2285
2698
|
_extractGroupIdFromResult(result) {
|
|
@@ -2363,7 +2776,7 @@ export class AUNClient {
|
|
|
2363
2776
|
event_seq: contig,
|
|
2364
2777
|
device_id: this._deviceId,
|
|
2365
2778
|
slot_id: this._slotId,
|
|
2366
|
-
}).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2779
|
+
}, undefined, undefined, true).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2367
2780
|
}
|
|
2368
2781
|
}
|
|
2369
2782
|
// 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
|
|
@@ -2569,11 +2982,85 @@ export class AUNClient {
|
|
|
2569
2982
|
return false;
|
|
2570
2983
|
}
|
|
2571
2984
|
}
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2985
|
+
async _validateAndCachePeerCert(opts) {
|
|
2986
|
+
const aid = String(opts.aid ?? '').trim();
|
|
2987
|
+
const certPem = String(opts.certPem ?? '').trim();
|
|
2988
|
+
const certFingerprint = String(opts.certFingerprint ?? '').trim() || undefined;
|
|
2989
|
+
if (!aid)
|
|
2990
|
+
throw new ValidationError('peer aid is required for cert validation');
|
|
2991
|
+
if (!certPem)
|
|
2992
|
+
throw new ValidationError(`peer cert is empty for ${aid}`);
|
|
2993
|
+
const gatewayUrl = this._gatewayUrl;
|
|
2994
|
+
if (!gatewayUrl) {
|
|
2995
|
+
throw new ValidationError('gateway url unavailable for e2ee cert validation');
|
|
2996
|
+
}
|
|
2997
|
+
const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
2998
|
+
const x509Cert = new crypto.X509Certificate(certPem);
|
|
2999
|
+
// H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
|
|
3000
|
+
if (certFingerprint) {
|
|
3001
|
+
const expectedFP = certFingerprint.toLowerCase();
|
|
3002
|
+
if (!expectedFP.startsWith('sha256:')) {
|
|
3003
|
+
throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
|
|
3004
|
+
}
|
|
3005
|
+
const expectedHex = expectedFP.slice('sha256:'.length);
|
|
3006
|
+
const derHex = x509Cert.fingerprint256.replace(/:/g, '').toLowerCase();
|
|
3007
|
+
let spkiHex = '';
|
|
3008
|
+
try {
|
|
3009
|
+
const spkiDer = x509Cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
3010
|
+
spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
|
|
3011
|
+
}
|
|
3012
|
+
catch {
|
|
3013
|
+
spkiHex = '';
|
|
3014
|
+
}
|
|
3015
|
+
if (expectedHex !== derHex && (!spkiHex || expectedHex !== spkiHex)) {
|
|
3016
|
+
throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
let cachedBootstrapChain = false;
|
|
3020
|
+
const caChainPems = opts.caChainPems ?? [];
|
|
3021
|
+
if (caChainPems.length > 0) {
|
|
3022
|
+
try {
|
|
3023
|
+
this._auth.cacheGatewayCaChain(peerGatewayUrl, caChainPems, aid);
|
|
3024
|
+
cachedBootstrapChain = true;
|
|
3025
|
+
}
|
|
3026
|
+
catch (exc) {
|
|
3027
|
+
this._clientLog.debug(`bootstrap CA chain cache skipped: peer=${aid}, source=${opts.source ?? 'unknown'}, err=${formatCaughtError(exc)}`);
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
try {
|
|
3031
|
+
await this._auth.verifyPeerCertificate(peerGatewayUrl, certPem, aid);
|
|
3032
|
+
}
|
|
3033
|
+
catch (exc) {
|
|
3034
|
+
if (cachedBootstrapChain) {
|
|
3035
|
+
this._auth.discardGatewayCaChain(peerGatewayUrl, aid);
|
|
3036
|
+
}
|
|
3037
|
+
throw new ValidationError(`peer cert verification failed for ${aid}: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
3038
|
+
}
|
|
3039
|
+
const nowSec = Date.now() / 1000;
|
|
3040
|
+
const entry = {
|
|
3041
|
+
certPem,
|
|
3042
|
+
validatedAt: nowSec,
|
|
3043
|
+
refreshAfter: nowSec + PEER_CERT_CACHE_TTL,
|
|
3044
|
+
};
|
|
3045
|
+
const cacheKey = AUNClient._certCacheKey(aid, certFingerprint);
|
|
3046
|
+
this._certCache.set(cacheKey, entry);
|
|
3047
|
+
const bareKey = AUNClient._certCacheKey(aid);
|
|
3048
|
+
if (bareKey !== cacheKey)
|
|
3049
|
+
this._certCache.set(bareKey, entry);
|
|
3050
|
+
if (!certFingerprint) {
|
|
3051
|
+
const actualFp = `sha256:${x509Cert.fingerprint256.replace(/:/g, '').toLowerCase()}`;
|
|
3052
|
+
this._certCache.set(AUNClient._certCacheKey(aid, actualFp), entry);
|
|
3053
|
+
}
|
|
3054
|
+
try {
|
|
3055
|
+
// peer 证书只存版本目录,不覆盖 cert.pem
|
|
3056
|
+
this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
|
|
3057
|
+
}
|
|
3058
|
+
catch (exc) {
|
|
3059
|
+
this._clientLog.error(`failed to write cert to keystore (aid=${aid}, fp=${certFingerprint ?? ''}): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
3060
|
+
}
|
|
3061
|
+
return certPem;
|
|
3062
|
+
}
|
|
3063
|
+
/** 获取对方证书(带缓存 + 完整 PKI 验证),跨域时自动路由到 peer 所在域。 */
|
|
2577
3064
|
async _fetchPeerCert(aid, certFingerprint, timeoutMs = 30_000) {
|
|
2578
3065
|
const tStart = Date.now();
|
|
2579
3066
|
this._clientLog.debug(`_fetchPeerCert enter: aid=${aid}, fp=${certFingerprint ?? ''}`);
|
|
@@ -2589,67 +3076,107 @@ export class AUNClient {
|
|
|
2589
3076
|
if (!gatewayUrl) {
|
|
2590
3077
|
throw new ValidationError('gateway url unavailable for e2ee cert fetch');
|
|
2591
3078
|
}
|
|
2592
|
-
// 跨域时用 peer 所在域的 Gateway URL
|
|
2593
3079
|
const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
2594
3080
|
let certPem;
|
|
2595
3081
|
try {
|
|
2596
|
-
|
|
2597
|
-
certPem = await _httpGetText(certUrl, this._configModel.verifySsl, timeoutMs);
|
|
3082
|
+
certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid, certFingerprint), this._configModel.verifySsl, timeoutMs);
|
|
2598
3083
|
}
|
|
2599
3084
|
catch (exc) {
|
|
2600
|
-
if (!certFingerprint)
|
|
3085
|
+
if (!certFingerprint)
|
|
2601
3086
|
throw exc;
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
3087
|
+
certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid), this._configModel.verifySsl, timeoutMs);
|
|
3088
|
+
}
|
|
3089
|
+
const validated = await this._validateAndCachePeerCert({
|
|
3090
|
+
aid,
|
|
3091
|
+
certPem,
|
|
3092
|
+
certFingerprint,
|
|
3093
|
+
source: 'fetch',
|
|
3094
|
+
});
|
|
3095
|
+
this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} (fetched)`);
|
|
3096
|
+
return validated;
|
|
3097
|
+
}
|
|
3098
|
+
catch (err) {
|
|
3099
|
+
this._clientLog.debug(`_fetchPeerCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
3100
|
+
throw err;
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
_bootstrapCaChain(material) {
|
|
3104
|
+
let raw;
|
|
3105
|
+
for (const key of ['ca_chain', 'ca_chain_pems', 'cert_chain', 'chain']) {
|
|
3106
|
+
if (material[key] !== undefined && material[key] !== null) {
|
|
3107
|
+
raw = material[key];
|
|
3108
|
+
break;
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
if (!Array.isArray(raw))
|
|
3112
|
+
return [];
|
|
3113
|
+
const result = [];
|
|
3114
|
+
for (const item of raw) {
|
|
3115
|
+
let certType = '';
|
|
3116
|
+
let certPem = '';
|
|
3117
|
+
if (isJsonObject(item)) {
|
|
3118
|
+
certType = String(item.cert_type ?? '').trim().toLowerCase();
|
|
3119
|
+
if (certType === 'agent')
|
|
3120
|
+
continue;
|
|
3121
|
+
certPem = String(item.cert_pem ?? item.cert ?? '').trim();
|
|
3122
|
+
}
|
|
3123
|
+
else {
|
|
3124
|
+
certPem = String(item ?? '').trim();
|
|
3125
|
+
}
|
|
3126
|
+
if (!certPem)
|
|
3127
|
+
continue;
|
|
3128
|
+
if (!certType) {
|
|
2616
3129
|
try {
|
|
2617
|
-
|
|
2618
|
-
|
|
3130
|
+
if (!new crypto.X509Certificate(certPem).ca)
|
|
3131
|
+
continue;
|
|
2619
3132
|
}
|
|
2620
3133
|
catch {
|
|
2621
|
-
|
|
2622
|
-
}
|
|
2623
|
-
if (expectedHex !== derHex && (!spkiHex || expectedHex !== spkiHex)) {
|
|
2624
|
-
throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
|
|
3134
|
+
continue;
|
|
2625
3135
|
}
|
|
2626
3136
|
}
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
3137
|
+
result.push(certPem);
|
|
3138
|
+
}
|
|
3139
|
+
return result;
|
|
3140
|
+
}
|
|
3141
|
+
async _primeBootstrapPeerCerts(bootstrap, peerAid) {
|
|
3142
|
+
const certsRaw = bootstrap.certs;
|
|
3143
|
+
if (!isJsonObject(certsRaw))
|
|
3144
|
+
return;
|
|
3145
|
+
const materials = certsRaw;
|
|
3146
|
+
const expected = new Set();
|
|
3147
|
+
const normalizedPeer = String(peerAid ?? '').trim();
|
|
3148
|
+
if (normalizedPeer)
|
|
3149
|
+
expected.add(normalizedPeer);
|
|
3150
|
+
const audit = Array.isArray(bootstrap.audit_recipients) ? bootstrap.audit_recipients : [];
|
|
3151
|
+
for (const dev of audit) {
|
|
3152
|
+
if (!isJsonObject(dev))
|
|
3153
|
+
continue;
|
|
3154
|
+
const aid = String(dev.aid ?? '').trim();
|
|
3155
|
+
if (aid)
|
|
3156
|
+
expected.add(aid);
|
|
3157
|
+
}
|
|
3158
|
+
for (const aid of expected) {
|
|
3159
|
+
if (aid === this._aid)
|
|
3160
|
+
continue;
|
|
3161
|
+
const material = materials[aid];
|
|
3162
|
+
if (!isJsonObject(material))
|
|
3163
|
+
continue;
|
|
3164
|
+
const certPem = String(material.cert_pem ?? material.cert ?? '').trim();
|
|
3165
|
+
if (!certPem)
|
|
3166
|
+
continue;
|
|
3167
|
+
const certFingerprint = String(material.cert_fingerprint ?? material.fingerprint ?? material.fp ?? '').trim() || undefined;
|
|
2640
3168
|
try {
|
|
2641
|
-
|
|
2642
|
-
|
|
3169
|
+
await this._validateAndCachePeerCert({
|
|
3170
|
+
aid,
|
|
3171
|
+
certPem,
|
|
3172
|
+
certFingerprint,
|
|
3173
|
+
caChainPems: this._bootstrapCaChain(material),
|
|
3174
|
+
source: 'bootstrap',
|
|
3175
|
+
});
|
|
2643
3176
|
}
|
|
2644
3177
|
catch (exc) {
|
|
2645
|
-
this._clientLog.
|
|
3178
|
+
this._clientLog.debug(`bootstrap peer cert material ignored: peer=${aid}, err=${formatCaughtError(exc)}`);
|
|
2646
3179
|
}
|
|
2647
|
-
this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} (fetched)`);
|
|
2648
|
-
return certPem;
|
|
2649
|
-
}
|
|
2650
|
-
catch (err) {
|
|
2651
|
-
this._clientLog.debug(`_fetchPeerCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
2652
|
-
throw err;
|
|
2653
3180
|
}
|
|
2654
3181
|
}
|
|
2655
3182
|
async _decryptGroupThoughts(result) {
|
|
@@ -3108,7 +3635,7 @@ export class AUNClient {
|
|
|
3108
3635
|
catch (exc) {
|
|
3109
3636
|
this._clientLog.warn(`V2 session init failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
3110
3637
|
}
|
|
3111
|
-
// connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
|
|
3638
|
+
// connect/reconnect 成功后自动触发一次 P2P message.v2.pull,补齐离线期间积压
|
|
3112
3639
|
// 群消息按惰性触发,不在此处主动 pull
|
|
3113
3640
|
void this._fillP2pGap().catch((exc) => {
|
|
3114
3641
|
this._clientLog.warn(`schedule post-connect P2P gap fill failed: ${formatCaughtError(exc)}`);
|
|
@@ -3200,7 +3727,7 @@ export class AUNClient {
|
|
|
3200
3727
|
this._v2Session = new V2Session(v2Store, this._deviceId, this._aid, aidPriv, aidPubDer);
|
|
3201
3728
|
await this._v2Session.ensureRegistered(this._v2CallFn());
|
|
3202
3729
|
this._clientLog.debug(`V2 session initialized aid=${this._aid} device=${this._deviceId}`);
|
|
3203
|
-
|
|
3730
|
+
// 群 state proposal 由服务端在 client.online 时定向通知。
|
|
3204
3731
|
}
|
|
3205
3732
|
async _v2TrustedIKPubDer(aid) {
|
|
3206
3733
|
const normalizedAid = String(aid ?? '').trim();
|
|
@@ -3379,7 +3906,11 @@ export class AUNClient {
|
|
|
3379
3906
|
const session = this._v2Session;
|
|
3380
3907
|
if (session && fromAid) {
|
|
3381
3908
|
try {
|
|
3382
|
-
const bs = await this.call('message.v2.bootstrap', {
|
|
3909
|
+
const bs = await this.call('message.v2.bootstrap', {
|
|
3910
|
+
peer_aid: fromAid,
|
|
3911
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
3912
|
+
});
|
|
3913
|
+
await this._primeBootstrapPeerCerts(bs, fromAid);
|
|
3383
3914
|
const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
3384
3915
|
for (const dev of peers)
|
|
3385
3916
|
this._cacheV2PeerIKFromDevice(dev, fromAid);
|
|
@@ -3389,7 +3920,10 @@ export class AUNClient {
|
|
|
3389
3920
|
}
|
|
3390
3921
|
if (groupId) {
|
|
3391
3922
|
try {
|
|
3392
|
-
const gbs = await this.call('group.v2.bootstrap', {
|
|
3923
|
+
const gbs = await this.call('group.v2.bootstrap', {
|
|
3924
|
+
group_id: groupId,
|
|
3925
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
3926
|
+
});
|
|
3393
3927
|
const devices = (Array.isArray(gbs?.devices) ? gbs.devices : []);
|
|
3394
3928
|
const audit = (Array.isArray(gbs?.audit_recipients) ? gbs.audit_recipients : []);
|
|
3395
3929
|
for (const dev of devices)
|
|
@@ -3448,14 +3982,21 @@ export class AUNClient {
|
|
|
3448
3982
|
const useCache = opts.useCache !== false;
|
|
3449
3983
|
let peerDevices = [];
|
|
3450
3984
|
let auditRaw = [];
|
|
3985
|
+
let wrapPolicy = normalizeV2WrapPolicy(undefined);
|
|
3451
3986
|
const cached = useCache ? this._v2BootstrapCache.get(to) : undefined;
|
|
3452
3987
|
if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
3453
3988
|
peerDevices = cached.devices;
|
|
3454
3989
|
auditRaw = cached.auditRecipients;
|
|
3990
|
+
wrapPolicy = cached.wrapPolicy ?? wrapPolicy;
|
|
3455
3991
|
this._clientLog.debug(`message.v2.bootstrap cache hit: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
|
|
3456
3992
|
}
|
|
3457
3993
|
else {
|
|
3458
|
-
const bs = await this.call('message.v2.bootstrap', {
|
|
3994
|
+
const bs = await this.call('message.v2.bootstrap', {
|
|
3995
|
+
peer_aid: to,
|
|
3996
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
3997
|
+
});
|
|
3998
|
+
await this._primeBootstrapPeerCerts(bs, to);
|
|
3999
|
+
wrapPolicy = normalizeV2WrapPolicy(bs.e2ee_wrap_policy);
|
|
3459
4000
|
peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
3460
4001
|
auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
|
|
3461
4002
|
this._clientLog.debug(`message.v2.bootstrap fetched: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
|
|
@@ -3464,6 +4005,7 @@ export class AUNClient {
|
|
|
3464
4005
|
devices: peerDevices,
|
|
3465
4006
|
auditRecipients: auditRaw,
|
|
3466
4007
|
cachedAt: Date.now(),
|
|
4008
|
+
wrapPolicy,
|
|
3467
4009
|
});
|
|
3468
4010
|
}
|
|
3469
4011
|
}
|
|
@@ -3504,13 +4046,19 @@ export class AUNClient {
|
|
|
3504
4046
|
selfDevices = selfCached.devices;
|
|
3505
4047
|
}
|
|
3506
4048
|
else {
|
|
3507
|
-
const selfBs = await this.call('message.v2.bootstrap', {
|
|
4049
|
+
const selfBs = await this.call('message.v2.bootstrap', {
|
|
4050
|
+
peer_aid: this._aid,
|
|
4051
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
4052
|
+
});
|
|
4053
|
+
await this._primeBootstrapPeerCerts(selfBs, this._aid);
|
|
3508
4054
|
selfDevices = (Array.isArray(selfBs?.peer_devices) ? selfBs.peer_devices : []);
|
|
4055
|
+
const selfWrapPolicy = normalizeV2WrapPolicy(selfBs.e2ee_wrap_policy);
|
|
3509
4056
|
if (selfDevices.length > 0) {
|
|
3510
4057
|
this._v2BootstrapCache.set(this._aid, {
|
|
3511
4058
|
devices: selfDevices,
|
|
3512
4059
|
auditRecipients: [],
|
|
3513
4060
|
cachedAt: Date.now(),
|
|
4061
|
+
wrapPolicy: selfWrapPolicy,
|
|
3514
4062
|
});
|
|
3515
4063
|
}
|
|
3516
4064
|
}
|
|
@@ -3536,7 +4084,10 @@ export class AUNClient {
|
|
|
3536
4084
|
if (targets.length === 0) {
|
|
3537
4085
|
throw new E2EEError(`V2 bootstrap: no usable devices found for ${to}`);
|
|
3538
4086
|
}
|
|
3539
|
-
const envelope = encryptP2PMessage(session.getSenderIdentity(), {
|
|
4087
|
+
const envelope = encryptP2PMessage(session.getSenderIdentity(), {
|
|
4088
|
+
targets: applyV2WrapPolicyToTargets(targets, wrapPolicy),
|
|
4089
|
+
auditRecipients: applyV2WrapPolicyToTargets(auditTargets, wrapPolicy),
|
|
4090
|
+
}, opts.payload, {
|
|
3540
4091
|
messageId: opts.messageId,
|
|
3541
4092
|
timestamp: opts.timestamp,
|
|
3542
4093
|
protectedHeaders: opts.protectedHeaders,
|
|
@@ -3606,18 +4157,30 @@ export class AUNClient {
|
|
|
3606
4157
|
}
|
|
3607
4158
|
}
|
|
3608
4159
|
/** V2 P2P 拉取并解密;直接方法返回消息数组,call("message.pull") 会包装为 {messages}. */
|
|
3609
|
-
async pullV2(afterSeq = 0, limit = 50) {
|
|
4160
|
+
async pullV2(afterSeq = 0, limit = 50, opts) {
|
|
3610
4161
|
await this._ensureV2SessionReady('message.pull');
|
|
3611
4162
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
4163
|
+
if (ns && !opts?.gateLocked) {
|
|
4164
|
+
return await this._runPullSerialized(ns, async () => this.pullV2(afterSeq, limit, {
|
|
4165
|
+
...(opts ?? {}),
|
|
4166
|
+
gateLocked: true,
|
|
4167
|
+
scheduleFollowup: true,
|
|
4168
|
+
}));
|
|
4169
|
+
}
|
|
3612
4170
|
const decrypted = [];
|
|
4171
|
+
let totalRawCount = 0;
|
|
3613
4172
|
let nextAfterSeq = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
|
|
3614
4173
|
let pageCount = 0;
|
|
3615
4174
|
const maxPages = 100;
|
|
3616
4175
|
while (pageCount < maxPages) {
|
|
3617
4176
|
pageCount += 1;
|
|
3618
4177
|
this._clientLog.debug(`message.v2.pull page request: page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ns=${ns || '<none>'}`);
|
|
3619
|
-
const result = await this.
|
|
4178
|
+
const result = await this._callRawV2Rpc('message.v2.pull', {
|
|
4179
|
+
after_seq: nextAfterSeq,
|
|
4180
|
+
limit,
|
|
4181
|
+
});
|
|
3620
4182
|
const messages = (Array.isArray(result?.messages) ? result.messages : []);
|
|
4183
|
+
totalRawCount += messages.length;
|
|
3621
4184
|
this._clientLog.debug(`message.v2.pull page response: page=${pageCount}, raw_count=${messages.length}, has_more=${String(result.has_more ?? '')}, server_ack_seq=${String(result.server_ack_seq ?? '')}`);
|
|
3622
4185
|
for (const msg of messages) {
|
|
3623
4186
|
this._logMessageDebug('pull-raw', 'message.v2.pull', 'message.received', msg);
|
|
@@ -3626,10 +4189,13 @@ export class AUNClient {
|
|
|
3626
4189
|
.map((msg) => Number(msg.seq ?? 0))
|
|
3627
4190
|
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
3628
4191
|
const pageContigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
3629
|
-
|
|
3630
|
-
if (
|
|
3631
|
-
|
|
3632
|
-
|
|
4192
|
+
let pageMaxSeq = nextAfterSeq;
|
|
4193
|
+
if (seqs.length > 0) {
|
|
4194
|
+
pageMaxSeq = Math.max(...seqs);
|
|
4195
|
+
if (ns) {
|
|
4196
|
+
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
4197
|
+
this._clientLog.debug(`message.v2.pull force contiguous: ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
|
|
4198
|
+
}
|
|
3633
4199
|
}
|
|
3634
4200
|
for (const msg of messages) {
|
|
3635
4201
|
const seq = Number(msg.seq ?? 0);
|
|
@@ -3653,10 +4219,12 @@ export class AUNClient {
|
|
|
3653
4219
|
payload: legacyPayload,
|
|
3654
4220
|
encrypted: false,
|
|
3655
4221
|
};
|
|
3656
|
-
if (ns)
|
|
4222
|
+
if (ns) {
|
|
3657
4223
|
await this._publishPulledMessage('message.received', ns, seq, v1Msg);
|
|
3658
|
-
|
|
4224
|
+
}
|
|
4225
|
+
else {
|
|
3659
4226
|
await this._publishAppEvent('message.received', v1Msg, 'pull');
|
|
4227
|
+
}
|
|
3660
4228
|
decrypted.push(v1Msg);
|
|
3661
4229
|
this._clientLog.debug(`message.v2.pull plaintext V1 delivered: seq=${seq}, ns=${ns || '<none>'}`);
|
|
3662
4230
|
}
|
|
@@ -3678,10 +4246,12 @@ export class AUNClient {
|
|
|
3678
4246
|
this._clientLog.debug(`message.v2.pull decrypt returned null: seq=${seq}, ns=${ns || '<none>'}`);
|
|
3679
4247
|
continue;
|
|
3680
4248
|
}
|
|
3681
|
-
if (ns)
|
|
4249
|
+
if (ns) {
|
|
3682
4250
|
await this._publishPulledMessage('message.received', ns, seq, plaintext);
|
|
3683
|
-
|
|
4251
|
+
}
|
|
4252
|
+
else {
|
|
3684
4253
|
await this._publishAppEvent('message.received', plaintext, 'pull');
|
|
4254
|
+
}
|
|
3685
4255
|
decrypted.push(plaintext);
|
|
3686
4256
|
this._logMessageDebug('decrypt-ok', 'message.v2.pull', 'message.received', plaintext);
|
|
3687
4257
|
}
|
|
@@ -3697,10 +4267,10 @@ export class AUNClient {
|
|
|
3697
4267
|
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
3698
4268
|
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
3699
4269
|
if (contigAdvanced) {
|
|
3700
|
-
await this._drainOrderedMessages(ns);
|
|
4270
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
3701
4271
|
this._saveSeqTrackerState();
|
|
3702
4272
|
}
|
|
3703
|
-
if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
|
|
4273
|
+
if (messages.length > 0 && contigAdvanced && ackSeq > 0 && !opts?.skipAutoAck) {
|
|
3704
4274
|
this._clientLog.debug(`message.v2.pull scheduling auto-ack: ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
|
|
3705
4275
|
this._safeAsync(this.ackV2(ackSeq).then(() => undefined));
|
|
3706
4276
|
}
|
|
@@ -3733,7 +4303,7 @@ export class AUNClient {
|
|
|
3733
4303
|
}
|
|
3734
4304
|
}
|
|
3735
4305
|
this._clientLog.debug(`message.v2.ack send: ns=${ns || '<none>'}, up_to_seq=${seq}`);
|
|
3736
|
-
const raw = await this.
|
|
4306
|
+
const raw = await this._callRawV2Rpc('message.v2.ack', { up_to_seq: seq });
|
|
3737
4307
|
const result = isJsonObject(raw)
|
|
3738
4308
|
? { ...raw }
|
|
3739
4309
|
: { result: raw };
|
|
@@ -3839,19 +4409,25 @@ export class AUNClient {
|
|
|
3839
4409
|
let auditRecipientsRaw = [];
|
|
3840
4410
|
let epoch = 0;
|
|
3841
4411
|
let stateCommitment = { state_version: 0, state_hash: '', state_chain: '' };
|
|
4412
|
+
let wrapPolicy = normalizeV2WrapPolicy(undefined);
|
|
3842
4413
|
const cached = useCache ? this._v2BootstrapCache.get(cacheKey) : undefined;
|
|
3843
4414
|
if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
3844
4415
|
allDevices = cached.devices;
|
|
3845
4416
|
auditRecipientsRaw = cached.auditRecipients;
|
|
3846
4417
|
epoch = cached.epoch ?? 0;
|
|
3847
4418
|
stateCommitment = cached.stateCommitment ?? stateCommitment;
|
|
4419
|
+
wrapPolicy = cached.wrapPolicy ?? wrapPolicy;
|
|
3848
4420
|
this._clientLog.debug(`group.v2.bootstrap cache hit: group=${groupId}, devices=${allDevices.length}, audit=${auditRecipientsRaw.length}, epoch=${epoch}, state_version=${stateCommitment.state_version}`);
|
|
3849
4421
|
}
|
|
3850
4422
|
else {
|
|
3851
|
-
const bs = await this.call('group.v2.bootstrap', {
|
|
4423
|
+
const bs = await this.call('group.v2.bootstrap', {
|
|
4424
|
+
group_id: groupId,
|
|
4425
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
4426
|
+
});
|
|
3852
4427
|
allDevices = (Array.isArray(bs.devices) ? bs.devices : []);
|
|
3853
4428
|
auditRecipientsRaw = (Array.isArray(bs.audit_recipients) ? bs.audit_recipients : []);
|
|
3854
4429
|
epoch = Number(bs.epoch ?? 0) || 0;
|
|
4430
|
+
wrapPolicy = normalizeV2WrapPolicy(bs.e2ee_wrap_policy);
|
|
3855
4431
|
this._clientLog.debug(`group.v2.bootstrap fetched: group=${groupId}, devices=${allDevices.length}, audit=${auditRecipientsRaw.length}, epoch=${epoch}, members=${Array.isArray(bs.member_aids) ? bs.member_aids.length : 0}`);
|
|
3856
4432
|
const stateChain = String(bs.state_chain ?? '');
|
|
3857
4433
|
await this._v2CheckFork(groupId, stateChain);
|
|
@@ -3869,6 +4445,7 @@ export class AUNClient {
|
|
|
3869
4445
|
cachedAt: Date.now(),
|
|
3870
4446
|
epoch,
|
|
3871
4447
|
stateCommitment,
|
|
4448
|
+
wrapPolicy,
|
|
3872
4449
|
});
|
|
3873
4450
|
}
|
|
3874
4451
|
// lazy sync 触发:发现 pending members 时异步发起提案
|
|
@@ -3911,7 +4488,7 @@ export class AUNClient {
|
|
|
3911
4488
|
if (target)
|
|
3912
4489
|
targets.push(target);
|
|
3913
4490
|
}
|
|
3914
|
-
const envelope = encryptGroupMessage(session.getSenderIdentity(), groupId, epoch, targets, opts.payload, {
|
|
4491
|
+
const envelope = encryptGroupMessage(session.getSenderIdentity(), groupId, epoch, applyV2WrapPolicyToTargets(targets, wrapPolicy), opts.payload, {
|
|
3915
4492
|
messageId: opts.messageId,
|
|
3916
4493
|
timestamp: opts.timestamp,
|
|
3917
4494
|
protectedHeaders: opts.protectedHeaders,
|
|
@@ -3938,28 +4515,37 @@ export class AUNClient {
|
|
|
3938
4515
|
return envelope;
|
|
3939
4516
|
}
|
|
3940
4517
|
async _pullGroupV2Internal(params) {
|
|
3941
|
-
await this.pullGroupV2(params.group_id, params.after_seq, params.limit);
|
|
4518
|
+
await this.pullGroupV2(params.group_id, params.after_seq, params.limit, { gateLocked: true });
|
|
3942
4519
|
}
|
|
3943
4520
|
/** V2 Group 拉取并解密;直接方法返回消息数组,call("group.pull") 会包装为 {messages}. */
|
|
3944
|
-
async pullGroupV2(groupId, afterSeq = 0, limit = 50) {
|
|
4521
|
+
async pullGroupV2(groupId, afterSeq = 0, limit = 50, opts) {
|
|
3945
4522
|
await this._ensureV2SessionReady('group.pull');
|
|
3946
4523
|
const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
3947
4524
|
if (!gid)
|
|
3948
4525
|
throw new ValidationError('group.pull requires group_id');
|
|
3949
4526
|
const ns = `group:${gid}`;
|
|
4527
|
+
if (!opts?.gateLocked) {
|
|
4528
|
+
return await this._runPullSerialized(ns, async () => this.pullGroupV2(gid, afterSeq, limit, {
|
|
4529
|
+
...(opts ?? {}),
|
|
4530
|
+
gateLocked: true,
|
|
4531
|
+
scheduleFollowup: true,
|
|
4532
|
+
}));
|
|
4533
|
+
}
|
|
3950
4534
|
const decrypted = [];
|
|
4535
|
+
let totalRawCount = 0;
|
|
3951
4536
|
let nextAfterSeq = afterSeq || this._seqTracker.getContiguousSeq(ns);
|
|
3952
4537
|
let pageCount = 0;
|
|
3953
4538
|
const maxPages = 100;
|
|
3954
4539
|
while (pageCount < maxPages) {
|
|
3955
4540
|
pageCount += 1;
|
|
3956
4541
|
this._clientLog.debug(`group.v2.pull page request: group=${gid}, page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ns=${ns}`);
|
|
3957
|
-
const result = await this.
|
|
4542
|
+
const result = await this._callRawV2Rpc('group.v2.pull', {
|
|
3958
4543
|
group_id: gid,
|
|
3959
4544
|
after_seq: nextAfterSeq,
|
|
3960
4545
|
limit,
|
|
3961
4546
|
});
|
|
3962
4547
|
const messages = (Array.isArray(result.messages) ? result.messages : []);
|
|
4548
|
+
totalRawCount += messages.length;
|
|
3963
4549
|
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
3964
4550
|
this._clientLog.debug(`group.v2.pull page response: group=${gid}, page=${pageCount}, raw_count=${messages.length}, has_more=${String(result.has_more ?? '')}, cursor_current=${String(cursor?.current_seq ?? '')}`);
|
|
3965
4551
|
for (const msg of messages) {
|
|
@@ -3969,8 +4555,9 @@ export class AUNClient {
|
|
|
3969
4555
|
.map((msg) => Number(msg.seq ?? 0))
|
|
3970
4556
|
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
3971
4557
|
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
3972
|
-
|
|
4558
|
+
let pageMaxSeq = nextAfterSeq;
|
|
3973
4559
|
if (seqs.length > 0) {
|
|
4560
|
+
pageMaxSeq = Math.max(...seqs);
|
|
3974
4561
|
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
3975
4562
|
this._clientLog.debug(`group.v2.pull force contiguous: group=${gid}, ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
|
|
3976
4563
|
}
|
|
@@ -4034,18 +4621,18 @@ export class AUNClient {
|
|
|
4034
4621
|
decrypted.push(plaintext);
|
|
4035
4622
|
this._logMessageDebug('decrypt-ok', 'group.v2.pull', 'group.message_created', plaintext);
|
|
4036
4623
|
}
|
|
4037
|
-
const
|
|
4038
|
-
if (
|
|
4624
|
+
const retentionFloor = this._pullRetentionFloor(result, 'retention_floor_message_seq', 'retention_floor_message_seq');
|
|
4625
|
+
if (retentionFloor > 0) {
|
|
4039
4626
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
4040
|
-
if (contig <
|
|
4041
|
-
this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} ->
|
|
4042
|
-
this._seqTracker.forceContiguousSeq(ns,
|
|
4627
|
+
if (contig < retentionFloor) {
|
|
4628
|
+
this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> retention_floor=${retentionFloor}`);
|
|
4629
|
+
this._seqTracker.forceContiguousSeq(ns, retentionFloor);
|
|
4043
4630
|
}
|
|
4044
4631
|
}
|
|
4045
4632
|
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
4046
4633
|
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
4047
4634
|
if (contigAdvanced) {
|
|
4048
|
-
await this._drainOrderedMessages(ns);
|
|
4635
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
4049
4636
|
this._saveSeqTrackerState();
|
|
4050
4637
|
}
|
|
4051
4638
|
if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
|
|
@@ -4081,7 +4668,7 @@ export class AUNClient {
|
|
|
4081
4668
|
seq = maxSeen;
|
|
4082
4669
|
}
|
|
4083
4670
|
this._clientLog.debug(`group.v2.ack send: group=${gid}, ns=${ns}, up_to_seq=${seq}`);
|
|
4084
|
-
const result = await this.
|
|
4671
|
+
const result = await this._callRawV2Rpc('group.v2.ack', { group_id: gid, up_to_seq: seq });
|
|
4085
4672
|
this._clientLog.debug(`group.v2.ack ok: group=${gid}, ns=${ns}, requested=${seq}, result=${this._debugJson(result)}`);
|
|
4086
4673
|
return result;
|
|
4087
4674
|
}
|
|
@@ -4117,7 +4704,7 @@ export class AUNClient {
|
|
|
4117
4704
|
for (const row of recipients) {
|
|
4118
4705
|
if (Array.isArray(row) && row.length >= 6
|
|
4119
4706
|
&& String(row[0] ?? '') === this._aid
|
|
4120
|
-
&& String(row[1] ?? '') === this._deviceId) {
|
|
4707
|
+
&& (String(row[1] ?? '') === this._deviceId || String(row[1] ?? '') === '')) {
|
|
4121
4708
|
if (!spkId)
|
|
4122
4709
|
spkId = String(row[5] ?? '');
|
|
4123
4710
|
if (row.length > 3)
|
|
@@ -4465,7 +5052,8 @@ export class AUNClient {
|
|
|
4465
5052
|
for (const row of envelope.recipients) {
|
|
4466
5053
|
if (!Array.isArray(row) || row.length < 6)
|
|
4467
5054
|
continue;
|
|
4468
|
-
if (String(row[0] ?? '') === this._aid
|
|
5055
|
+
if (String(row[0] ?? '') === this._aid
|
|
5056
|
+
&& (String(row[1] ?? '') === this._deviceId || String(row[1] ?? '') === '')) {
|
|
4469
5057
|
spkId = String(row[5] ?? '');
|
|
4470
5058
|
recipientKeySource = String(row[3] ?? '');
|
|
4471
5059
|
break;
|
|
@@ -4749,7 +5337,10 @@ export class AUNClient {
|
|
|
4749
5337
|
}
|
|
4750
5338
|
if (myRole !== 'owner' && myRole !== 'admin')
|
|
4751
5339
|
return false;
|
|
4752
|
-
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5340
|
+
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5341
|
+
group_id: groupId,
|
|
5342
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5343
|
+
});
|
|
4753
5344
|
const devices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
|
|
4754
5345
|
? bootstrapResp.devices.filter(isJsonObject)
|
|
4755
5346
|
: [];
|
|
@@ -4858,7 +5449,10 @@ export class AUNClient {
|
|
|
4858
5449
|
}
|
|
4859
5450
|
}
|
|
4860
5451
|
}
|
|
4861
|
-
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5452
|
+
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5453
|
+
group_id: groupId,
|
|
5454
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5455
|
+
});
|
|
4862
5456
|
const allDevices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
|
|
4863
5457
|
? bootstrapResp.devices.filter(isJsonObject)
|
|
4864
5458
|
: [];
|
|
@@ -5064,11 +5658,10 @@ export class AUNClient {
|
|
|
5064
5658
|
try {
|
|
5065
5659
|
const decrypted = await this._decryptV2PushMessage(data);
|
|
5066
5660
|
if (decrypted) {
|
|
5067
|
-
//
|
|
5068
|
-
// (如果 pushSeq == contiguousSeq + 1 会自动推进到 pushSeq)
|
|
5069
|
-
const needPull = this._seqTracker.onMessageSeq(ns, pushSeq);
|
|
5661
|
+
// 解密成功也不能先推进 contiguousSeq;必须等应用层发布返回后再推进和 ACK。
|
|
5070
5662
|
const published = await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
|
|
5071
5663
|
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5664
|
+
const needPull = pushSeq > newContig && !published;
|
|
5072
5665
|
if (newContig !== contigBefore) {
|
|
5073
5666
|
this._saveSeqTrackerState();
|
|
5074
5667
|
}
|
|
@@ -5076,7 +5669,7 @@ export class AUNClient {
|
|
|
5076
5669
|
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
5077
5670
|
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
5078
5671
|
const ackSeq = maxSeen > 0 ? Math.min(newContig, maxSeen) : newContig;
|
|
5079
|
-
this.call('message.v2.ack', { up_to_seq: ackSeq })
|
|
5672
|
+
this.call('message.v2.ack', { up_to_seq: ackSeq, _rpc_background: true })
|
|
5080
5673
|
.catch(e => this._clientLog.debug(`V2 P2P push-ack failed: ${formatCaughtError(e)}`));
|
|
5081
5674
|
}
|
|
5082
5675
|
this._clientLog.debug(`_onV2PushNotification: push 带 payload 解密成功, contiguous_seq=${contigBefore}->${newContig} push_seq=${pushSeq}`);
|
|
@@ -5096,31 +5689,35 @@ export class AUNClient {
|
|
|
5096
5689
|
if (pushSeq > 0 && ns) {
|
|
5097
5690
|
this._clientLog.debug(`_onV2PushNotification: 纯通知 push_seq=${pushSeq} > contiguous_seq=${contigBefore}, 触发 pull(after_seq=${contigBefore})`);
|
|
5098
5691
|
}
|
|
5099
|
-
if (
|
|
5100
|
-
this._v2PullPending = true;
|
|
5692
|
+
if (!ns)
|
|
5101
5693
|
return;
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
|
|
5107
|
-
|
|
5108
|
-
|
|
5694
|
+
void this._tryRunBackgroundPull(ns, async () => {
|
|
5695
|
+
const operationBefore = this._seqTracker.getContiguousSeq(ns);
|
|
5696
|
+
const dedupKey = `p2p_pull:${ns}`;
|
|
5697
|
+
if (this._gapFillDone.has(dedupKey))
|
|
5698
|
+
return 0;
|
|
5699
|
+
this._gapFillDone.set(dedupKey, Date.now());
|
|
5700
|
+
try {
|
|
5701
|
+
const pulled = await this.pullV2(0, 50, { gateLocked: true });
|
|
5702
|
+
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5109
5703
|
this._clientLog.debug(`_onV2PushNotification pull done: contiguous_seq=${contigBefore}->${newContig} (push_seq=${pushSeq || 'null'})`);
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5704
|
+
if (newContig <= operationBefore)
|
|
5705
|
+
return 0;
|
|
5706
|
+
return pulled.length;
|
|
5707
|
+
}
|
|
5708
|
+
finally {
|
|
5709
|
+
this._gapFillDone.delete(dedupKey);
|
|
5710
|
+
}
|
|
5711
|
+
}, true).catch((exc) => {
|
|
5712
|
+
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5114
5713
|
this._clientLog.warn(`V2 push auto-pull failed: contiguous_seq=${contigBefore}->${newContig} err=${formatCaughtError(exc)}`);
|
|
5115
|
-
}
|
|
5116
|
-
finally {
|
|
5117
|
-
this._v2PullInflight = false;
|
|
5118
|
-
}
|
|
5714
|
+
});
|
|
5119
5715
|
}
|
|
5120
5716
|
async _onV2StateProposed(data) {
|
|
5121
5717
|
if (!isJsonObject(data) || !this._v2Session)
|
|
5122
5718
|
return;
|
|
5123
|
-
const
|
|
5719
|
+
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5720
|
+
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
5124
5721
|
if (!groupId)
|
|
5125
5722
|
return;
|
|
5126
5723
|
await this._dispatcher.publish('group.v2.state_proposed', data);
|
|
@@ -5134,7 +5731,8 @@ export class AUNClient {
|
|
|
5134
5731
|
async _onV2StateRetryNeeded(data) {
|
|
5135
5732
|
if (!isJsonObject(data) || !this._v2Session)
|
|
5136
5733
|
return;
|
|
5137
|
-
const
|
|
5734
|
+
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5735
|
+
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
5138
5736
|
if (!groupId)
|
|
5139
5737
|
return;
|
|
5140
5738
|
await this._dispatcher.publish('group.v2.state_retry_needed', data);
|
|
@@ -5148,7 +5746,8 @@ export class AUNClient {
|
|
|
5148
5746
|
async _onV2StateConfirmed(data) {
|
|
5149
5747
|
if (!isJsonObject(data))
|
|
5150
5748
|
return;
|
|
5151
|
-
const
|
|
5749
|
+
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5750
|
+
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
5152
5751
|
if (groupId) {
|
|
5153
5752
|
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
5154
5753
|
this._v2AutoProposeLastSnapshot.delete(groupId);
|
|
@@ -5161,7 +5760,8 @@ export class AUNClient {
|
|
|
5161
5760
|
return;
|
|
5162
5761
|
}
|
|
5163
5762
|
this._logMessageDebug('server-push', '_raw.group.v2.message_created', 'group.message_created', data);
|
|
5164
|
-
const
|
|
5763
|
+
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5764
|
+
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
5165
5765
|
const seq = Number(data.seq ?? 0);
|
|
5166
5766
|
if (!groupId || !Number.isFinite(seq) || seq <= 0) {
|
|
5167
5767
|
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: group=${groupId || '<empty>'}, seq=${String(data.seq ?? '')}`);
|
|
@@ -5178,22 +5778,28 @@ export class AUNClient {
|
|
|
5178
5778
|
}
|
|
5179
5779
|
const afterSeq = this._repairPushContiguousBound(ns, seq, false, '_raw.group.v2.message_created');
|
|
5180
5780
|
const dedupKey = `v2_group_push:${groupId}:${afterSeq}`;
|
|
5181
|
-
|
|
5182
|
-
this.
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
this.
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5781
|
+
void this._tryRunBackgroundPull(ns, async () => {
|
|
5782
|
+
const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
5783
|
+
if (this._gapFillDone.has(dedupKey)) {
|
|
5784
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped duplicate in-flight pull: group=${groupId}, dedup=${dedupKey}`);
|
|
5785
|
+
return 0;
|
|
5786
|
+
}
|
|
5787
|
+
this._gapFillDone.set(dedupKey, Date.now());
|
|
5788
|
+
try {
|
|
5789
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull start: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}`);
|
|
5790
|
+
const pulled = await this.pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
|
|
5791
|
+
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5792
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull done: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}, contiguous=${newContig}`);
|
|
5793
|
+
if (newContig <= pullAfterSeq)
|
|
5794
|
+
return 0;
|
|
5795
|
+
return pulled.length;
|
|
5796
|
+
}
|
|
5797
|
+
finally {
|
|
5798
|
+
this._gapFillDone.delete(dedupKey);
|
|
5799
|
+
}
|
|
5800
|
+
}, true).catch((exc) => {
|
|
5192
5801
|
this._clientLog.warn(`V2 group push auto-pull failed: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
5193
|
-
}
|
|
5194
|
-
finally {
|
|
5195
|
-
this._gapFillDone.delete(dedupKey);
|
|
5196
|
-
}
|
|
5802
|
+
});
|
|
5197
5803
|
}
|
|
5198
5804
|
/** Push 通知带 payload 时的就地解密(复用 _decryptV2Message) */
|
|
5199
5805
|
async _decryptV2PushMessage(data) {
|