@agentunion/fastaun 0.3.3 → 0.3.5
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 +108 -85
- package/_packed_docs/CHANGELOG.md +108 -85
- 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/E2EE_V2/347/256/200/345/214/226/344/270/2721DH/345/212/240Per-AID_Wrap/346/226/271/346/241/210.md +124 -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 +46 -6
- package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +89 -12
- 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 +64 -19
- package/dist/client.js +1094 -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 +35 -4
- package/dist/namespaces/auth.js +175 -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,10 +435,17 @@ 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) */
|
|
386
446
|
_pendingOrderedMsgs = new Map();
|
|
447
|
+
/** P2P pull 进行中到达的纯通知 push 上界;pull gate 释放后需要补拉一次。 */
|
|
448
|
+
_pendingP2pPullUpper = new Map();
|
|
387
449
|
/** 缺 sender IK 时暂存原始 V2 消息,后台补齐 IK 后重试解密。 */
|
|
388
450
|
_v2SenderIKPending = new Map();
|
|
389
451
|
/** sender IK 后台补齐任务去重。 */
|
|
@@ -409,10 +471,9 @@ export class AUNClient {
|
|
|
409
471
|
/** 最近一次已成功提交的 membership_snapshot;相同快照直接跳过。 */
|
|
410
472
|
_v2AutoProposeLastSnapshot = new Map();
|
|
411
473
|
_v2LazyProposeTriggered = new Map();
|
|
412
|
-
_v2PullInflight = false;
|
|
413
|
-
_v2PullPending = false;
|
|
414
474
|
static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
|
|
415
475
|
static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
|
|
476
|
+
static PULL_GATE_STALE_MS = 3000;
|
|
416
477
|
static V2_SIG_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
417
478
|
static V2_SIG_CACHE_MAX = 16_384;
|
|
418
479
|
_reconnectActive = false;
|
|
@@ -426,7 +487,7 @@ export class AUNClient {
|
|
|
426
487
|
const rawConfig = { ...(config ?? {}) };
|
|
427
488
|
this._configModel = configFromMap(rawConfig);
|
|
428
489
|
const initAid = String(rawConfig.aid ?? '').trim() || null;
|
|
429
|
-
this._agentMdPath = path.join(this._configModel.aunPath, '
|
|
490
|
+
this._agentMdPath = path.join(this._configModel.aunPath, 'AIDs');
|
|
430
491
|
this.config = {
|
|
431
492
|
aun_path: this._configModel.aunPath,
|
|
432
493
|
root_ca_path: this._configModel.rootCaPath,
|
|
@@ -456,6 +517,19 @@ export class AUNClient {
|
|
|
456
517
|
secretStoreLogger: this._logger.for('aun_core.secret-store'),
|
|
457
518
|
});
|
|
458
519
|
this._keystore = keystore;
|
|
520
|
+
// 启动时被动清理 registerAid 留下的孤儿临时目录(>10 分钟)
|
|
521
|
+
try {
|
|
522
|
+
const cleanup = keystore.cleanupPendingDirs;
|
|
523
|
+
if (typeof cleanup === 'function') {
|
|
524
|
+
const removed = cleanup.call(keystore, 600_000);
|
|
525
|
+
if (removed > 0) {
|
|
526
|
+
this._clientLog.info(`_pending cleanup removed=${removed}`);
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
catch (err) {
|
|
531
|
+
this._clientLog.warn(`_pending cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
532
|
+
}
|
|
459
533
|
this._slotId = '';
|
|
460
534
|
this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
|
|
461
535
|
this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
|
|
@@ -547,6 +621,23 @@ export class AUNClient {
|
|
|
547
621
|
if (!target) {
|
|
548
622
|
throw new ValidationError('fetchAgentMd requires aid (or local AID)');
|
|
549
623
|
}
|
|
624
|
+
return await this._startAgentMdFetchTask(target);
|
|
625
|
+
}
|
|
626
|
+
async _startAgentMdFetchTask(target) {
|
|
627
|
+
const existing = this._agentMdFetchInflight.get(target);
|
|
628
|
+
if (existing) {
|
|
629
|
+
return await existing;
|
|
630
|
+
}
|
|
631
|
+
const task = this._fetchAgentMdOnce(target);
|
|
632
|
+
this._agentMdFetchInflight.set(target, task);
|
|
633
|
+
task.finally(() => {
|
|
634
|
+
if (this._agentMdFetchInflight.get(target) === task) {
|
|
635
|
+
this._agentMdFetchInflight.delete(target);
|
|
636
|
+
}
|
|
637
|
+
}).catch(() => undefined);
|
|
638
|
+
return await task;
|
|
639
|
+
}
|
|
640
|
+
async _fetchAgentMdOnce(target) {
|
|
550
641
|
const content = await this.auth.downloadAgentMd(target);
|
|
551
642
|
const signature = await this.auth.verifyAgentMd(content, { aid: target });
|
|
552
643
|
const isSelf = target === (this._aid ?? '');
|
|
@@ -585,11 +676,11 @@ export class AUNClient {
|
|
|
585
676
|
};
|
|
586
677
|
}
|
|
587
678
|
/**
|
|
588
|
-
* 设置 agent.md 本地存储根目录;为空时恢复默认 {aun_path}/
|
|
679
|
+
* 设置 agent.md 本地存储根目录;为空时恢复默认 {aun_path}/AIDs。
|
|
589
680
|
*/
|
|
590
681
|
setAgentMdPath(root) {
|
|
591
682
|
const raw = String(root ?? '').trim();
|
|
592
|
-
const next = raw || path.join(this._configModel.aunPath, '
|
|
683
|
+
const next = raw || path.join(this._configModel.aunPath, 'AIDs');
|
|
593
684
|
fs.mkdirSync(next, { recursive: true });
|
|
594
685
|
this._agentMdPath = next;
|
|
595
686
|
this._agentMdCache.clear();
|
|
@@ -662,15 +753,15 @@ export class AUNClient {
|
|
|
662
753
|
return target;
|
|
663
754
|
}
|
|
664
755
|
_agentMdRoot() {
|
|
665
|
-
const root = this._agentMdPath || path.join(this._configModel.aunPath, '
|
|
756
|
+
const root = this._agentMdPath || path.join(this._configModel.aunPath, 'AIDs');
|
|
666
757
|
fs.mkdirSync(root, { recursive: true });
|
|
667
758
|
return root;
|
|
668
759
|
}
|
|
669
760
|
_agentMdFilePath(aid) {
|
|
670
761
|
return path.join(this._agentMdRoot(), this._agentMdSafeAid(aid), 'agent.md');
|
|
671
762
|
}
|
|
672
|
-
|
|
673
|
-
return path.join(this._agentMdRoot(), '
|
|
763
|
+
_agentMdMetaPath(aid) {
|
|
764
|
+
return path.join(this._agentMdRoot(), this._agentMdSafeAid(aid), 'agentmd.json');
|
|
674
765
|
}
|
|
675
766
|
_atomicWriteText(filePath, content) {
|
|
676
767
|
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
@@ -712,8 +803,9 @@ export class AUNClient {
|
|
|
712
803
|
_sleepSync(ms) {
|
|
713
804
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
714
805
|
}
|
|
715
|
-
|
|
716
|
-
const lockPath = path.join(this.
|
|
806
|
+
_withAgentMdRecordLock(aid, fn) {
|
|
807
|
+
const lockPath = path.join(path.dirname(this._agentMdMetaPath(aid)), 'agentmd.json.lock');
|
|
808
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
717
809
|
const deadline = Date.now() + 5000;
|
|
718
810
|
let fd = null;
|
|
719
811
|
while (fd === null) {
|
|
@@ -749,93 +841,39 @@ export class AUNClient {
|
|
|
749
841
|
catch { /* ignore */ }
|
|
750
842
|
}
|
|
751
843
|
}
|
|
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
|
-
}
|
|
844
|
+
_writeAgentMdRecordUnlocked(aid, record) {
|
|
845
|
+
const payload = {};
|
|
846
|
+
for (const [key, value] of Object.entries(record)) {
|
|
847
|
+
if (key !== 'content' && value !== undefined && value !== null)
|
|
848
|
+
payload[key] = value;
|
|
768
849
|
}
|
|
769
|
-
|
|
770
|
-
|
|
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;
|
|
788
|
-
}
|
|
789
|
-
return records;
|
|
850
|
+
payload.aid = this._agentMdSafeAid(aid);
|
|
851
|
+
this._atomicWriteText(this._agentMdMetaPath(aid), `${JSON.stringify(payload, null, 2)}\n`);
|
|
790
852
|
}
|
|
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
|
-
}
|
|
853
|
+
_normalizeAgentMdRecord(aid, data) {
|
|
854
|
+
if (!isJsonObject(data))
|
|
855
|
+
return {};
|
|
856
|
+
const record = {};
|
|
857
|
+
for (const [key, value] of Object.entries(data)) {
|
|
858
|
+
if (key !== 'content')
|
|
859
|
+
record[key] = value;
|
|
819
860
|
}
|
|
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();
|
|
861
|
+
record.aid = this._agentMdSafeAid(String(record.aid ?? aid));
|
|
862
|
+
for (const key of ['fetched_at', 'observed_at', 'checked_at', 'updated_at']) {
|
|
863
|
+
record[key] = Number(record[key] ?? 0) || 0;
|
|
829
864
|
}
|
|
865
|
+
return record;
|
|
866
|
+
}
|
|
867
|
+
_readAgentMdRecordUnlocked(aid) {
|
|
868
|
+
const filePath = this._agentMdMetaPath(aid);
|
|
869
|
+
if (!fs.existsSync(filePath))
|
|
870
|
+
return {};
|
|
830
871
|
try {
|
|
831
|
-
|
|
832
|
-
this._agentMdLastListRebuilt = false;
|
|
833
|
-
return this._normalizeAgentMdList(parsed);
|
|
872
|
+
return this._normalizeAgentMdRecord(aid, JSON.parse(fs.readFileSync(filePath, 'utf-8')));
|
|
834
873
|
}
|
|
835
874
|
catch (err) {
|
|
836
|
-
this._clientLog.warn(`agent.md
|
|
837
|
-
|
|
838
|
-
return this._rebuildAgentMdListUnlocked();
|
|
875
|
+
this._clientLog.warn(`agent.md metadata damaged, ignoring: aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
876
|
+
return {};
|
|
839
877
|
}
|
|
840
878
|
}
|
|
841
879
|
_readAgentMdContent(aid) {
|
|
@@ -861,21 +899,25 @@ export class AUNClient {
|
|
|
861
899
|
if (!target)
|
|
862
900
|
return null;
|
|
863
901
|
try {
|
|
864
|
-
const
|
|
865
|
-
|
|
866
|
-
|
|
867
|
-
const loaded = { ...record, aid: target };
|
|
902
|
+
const loaded = this._withAgentMdRecordLock(target, () => {
|
|
903
|
+
const record = this._readAgentMdRecordUnlocked(target);
|
|
904
|
+
const next = Object.keys(record).length > 0 ? { ...record, aid: target } : { aid: target };
|
|
868
905
|
try {
|
|
869
906
|
const content = this._readAgentMdContent(target);
|
|
870
|
-
|
|
871
|
-
|
|
907
|
+
next.content = content;
|
|
908
|
+
next.local_etag = this._agentMdContentEtag(content);
|
|
872
909
|
}
|
|
873
910
|
catch (err) {
|
|
874
|
-
this.
|
|
911
|
+
if (fs.existsSync(this._agentMdMetaPath(target))) {
|
|
912
|
+
this._clientLog.warn(`agent.md content read failed: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
913
|
+
}
|
|
875
914
|
}
|
|
876
|
-
|
|
877
|
-
|
|
878
|
-
|
|
915
|
+
return next;
|
|
916
|
+
});
|
|
917
|
+
if (Object.keys(loaded).length <= 1)
|
|
918
|
+
return null;
|
|
919
|
+
this._agentMdCache.set(target, { ...loaded });
|
|
920
|
+
return { ...loaded };
|
|
879
921
|
}
|
|
880
922
|
catch (err) {
|
|
881
923
|
this._clientLog.debug(`agent.md cache load skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
@@ -890,25 +932,23 @@ export class AUNClient {
|
|
|
890
932
|
const inputFields = { ...fields };
|
|
891
933
|
const hasContent = Object.prototype.hasOwnProperty.call(inputFields, 'content') && inputFields.content !== undefined && inputFields.content !== null;
|
|
892
934
|
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 };
|
|
935
|
+
const record = this._withAgentMdRecordLock(target, () => {
|
|
936
|
+
if (hasContent) {
|
|
937
|
+
const content = String(inputFields.content ?? '');
|
|
938
|
+
savedTo = this._writeAgentMdContent(target, content);
|
|
939
|
+
if (!inputFields.local_etag)
|
|
940
|
+
inputFields.local_etag = this._agentMdContentEtag(content);
|
|
941
|
+
if (!inputFields.fetched_at)
|
|
942
|
+
inputFields.fetched_at = Date.now();
|
|
943
|
+
}
|
|
944
|
+
delete inputFields.content;
|
|
945
|
+
const next = { ...this._readAgentMdRecordUnlocked(target), aid: target };
|
|
905
946
|
for (const [key, value] of Object.entries(inputFields)) {
|
|
906
947
|
if (value !== undefined && value !== null)
|
|
907
948
|
next[key] = value;
|
|
908
949
|
}
|
|
909
950
|
next.updated_at = Date.now();
|
|
910
|
-
|
|
911
|
-
this._writeAgentMdListUnlocked(records);
|
|
951
|
+
this._writeAgentMdRecordUnlocked(target, next);
|
|
912
952
|
return next;
|
|
913
953
|
});
|
|
914
954
|
const loaded = { ...record };
|
|
@@ -967,15 +1007,12 @@ export class AUNClient {
|
|
|
967
1007
|
return;
|
|
968
1008
|
if (this._agentMdFetchInflight.has(target))
|
|
969
1009
|
return;
|
|
970
|
-
this._agentMdFetchInflight.add(target);
|
|
971
1010
|
void this.fetchAgentMd(target).catch((err) => {
|
|
972
1011
|
this._saveAgentMdRecord(target, {
|
|
973
1012
|
last_error: err instanceof Error ? err.message : String(err),
|
|
974
1013
|
remote_status: 'found',
|
|
975
1014
|
});
|
|
976
1015
|
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
1016
|
});
|
|
980
1017
|
}
|
|
981
1018
|
_observeAgentMdMeta(aid, etag = '', lastModified = '', source = '') {
|
|
@@ -1026,7 +1063,7 @@ export class AUNClient {
|
|
|
1026
1063
|
}
|
|
1027
1064
|
this._observeAgentMdMeta(senderAid, String(sender.etag ?? '').trim(), String(sender.last_modified ?? sender.lastModified ?? '').trim(), 'envelope');
|
|
1028
1065
|
}
|
|
1029
|
-
async checkAgentMd(aid, maxUnsyncedDays =
|
|
1066
|
+
async checkAgentMd(aid, maxUnsyncedDays = 1) {
|
|
1030
1067
|
const target = String(aid ?? this._aid ?? '').trim();
|
|
1031
1068
|
if (!target)
|
|
1032
1069
|
throw new ValidationError('checkAgentMd requires aid (or local AID)');
|
|
@@ -1035,7 +1072,9 @@ export class AUNClient {
|
|
|
1035
1072
|
const localFound = !!(Object.keys(before).length > 0 && (String(before.content ?? '') || localEtag));
|
|
1036
1073
|
const remoteEtagCached = String(before.remote_etag ?? '').trim();
|
|
1037
1074
|
const lastModifiedCached = String(before.last_modified ?? '').trim();
|
|
1038
|
-
const
|
|
1075
|
+
const checkedAt = Number(before.checked_at ?? 0);
|
|
1076
|
+
const fetchedAt = Number(before.fetched_at ?? 0);
|
|
1077
|
+
const checkedAtCached = checkedAt > 0 ? checkedAt : fetchedAt;
|
|
1039
1078
|
const cachedInSync = !!(localFound && localEtag && remoteEtagCached && localEtag === remoteEtagCached);
|
|
1040
1079
|
// max_unsynced_days > 0 且距上次 HEAD 在窗口内 → 直接返回缓存;否则强制 HEAD。
|
|
1041
1080
|
if (cachedInSync && this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
|
|
@@ -1053,6 +1092,25 @@ export class AUNClient {
|
|
|
1053
1092
|
verify_error: String(before.verify_error ?? ''),
|
|
1054
1093
|
};
|
|
1055
1094
|
}
|
|
1095
|
+
const remoteFoundCached = !!(remoteEtagCached || String(before.remote_status ?? '') === 'found');
|
|
1096
|
+
if (!localFound &&
|
|
1097
|
+
!remoteFoundCached &&
|
|
1098
|
+
String(before.remote_status ?? '') === 'missing' &&
|
|
1099
|
+
this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
|
|
1100
|
+
return {
|
|
1101
|
+
aid: target,
|
|
1102
|
+
local_found: false,
|
|
1103
|
+
remote_found: false,
|
|
1104
|
+
local_etag: '',
|
|
1105
|
+
remote_etag: '',
|
|
1106
|
+
in_sync: false,
|
|
1107
|
+
last_modified: '',
|
|
1108
|
+
status: 404,
|
|
1109
|
+
cached: true,
|
|
1110
|
+
verify_status: '',
|
|
1111
|
+
verify_error: '',
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1056
1114
|
const now = Date.now();
|
|
1057
1115
|
let remote;
|
|
1058
1116
|
try {
|
|
@@ -1244,11 +1302,17 @@ export class AUNClient {
|
|
|
1244
1302
|
}
|
|
1245
1303
|
}
|
|
1246
1304
|
/**
|
|
1247
|
-
*
|
|
1305
|
+
* 列出本地身份摘要。
|
|
1306
|
+
*
|
|
1307
|
+
* @param opts.all=false(默认):仅返回严格校验通过的可用身份——
|
|
1308
|
+
* keypair 完整 + cert 公钥 == keypair 公钥 + cert 时间窗口有效
|
|
1309
|
+
* @param opts.all=true:返回所有 AIDs/ 子目录(不含 _pending/);
|
|
1310
|
+
* 每项含 valid=bool 和 reason=string 字段
|
|
1248
1311
|
*/
|
|
1249
|
-
listIdentities() {
|
|
1312
|
+
listIdentities(opts) {
|
|
1250
1313
|
const tStart = Date.now();
|
|
1251
|
-
|
|
1314
|
+
const includeAll = !!opts?.all;
|
|
1315
|
+
this._clientLog.debug(`listIdentities enter all=${includeAll}`);
|
|
1252
1316
|
try {
|
|
1253
1317
|
const listFn = this._keystore.listIdentities;
|
|
1254
1318
|
if (typeof listFn !== 'function') {
|
|
@@ -1258,10 +1322,12 @@ export class AUNClient {
|
|
|
1258
1322
|
const aids = listFn.call(this._keystore);
|
|
1259
1323
|
const summaries = [];
|
|
1260
1324
|
for (const aid of [...aids].sort()) {
|
|
1261
|
-
const
|
|
1262
|
-
if (!
|
|
1325
|
+
const { valid, reason } = this._validateLocalIdentity(aid);
|
|
1326
|
+
if (!includeAll && !valid)
|
|
1263
1327
|
continue;
|
|
1264
|
-
const summary = { aid };
|
|
1328
|
+
const summary = { aid, valid };
|
|
1329
|
+
if (reason)
|
|
1330
|
+
summary.reason = reason;
|
|
1265
1331
|
const loadMetadata = this._keystore.loadMetadata;
|
|
1266
1332
|
if (typeof loadMetadata === 'function') {
|
|
1267
1333
|
const md = loadMetadata.call(this._keystore, aid);
|
|
@@ -1270,7 +1336,7 @@ export class AUNClient {
|
|
|
1270
1336
|
}
|
|
1271
1337
|
summaries.push(summary);
|
|
1272
1338
|
}
|
|
1273
|
-
this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms count=${summaries.length}`);
|
|
1339
|
+
this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms all=${includeAll} count=${summaries.length}`);
|
|
1274
1340
|
return summaries;
|
|
1275
1341
|
}
|
|
1276
1342
|
catch (err) {
|
|
@@ -1278,6 +1344,40 @@ export class AUNClient {
|
|
|
1278
1344
|
throw err;
|
|
1279
1345
|
}
|
|
1280
1346
|
}
|
|
1347
|
+
/**
|
|
1348
|
+
* 严格校验本地身份的可用性。返回 {valid, reason}。
|
|
1349
|
+
* 4 项校验:keypair 完整 + cert 存在 + cert 公钥 == keypair 公钥 + cert 时间窗口有效。
|
|
1350
|
+
*/
|
|
1351
|
+
_validateLocalIdentity(aid) {
|
|
1352
|
+
const identity = this._keystore.loadIdentity(aid);
|
|
1353
|
+
if (!identity)
|
|
1354
|
+
return { valid: false, reason: 'no identity record' };
|
|
1355
|
+
const priv = String(identity.private_key_pem ?? '');
|
|
1356
|
+
const pubB64 = String(identity.public_key_der_b64 ?? '');
|
|
1357
|
+
const certPem = String(identity.cert ?? '');
|
|
1358
|
+
if (!priv || !pubB64)
|
|
1359
|
+
return { valid: false, reason: 'missing keypair' };
|
|
1360
|
+
if (!certPem)
|
|
1361
|
+
return { valid: false, reason: 'missing certificate' };
|
|
1362
|
+
try {
|
|
1363
|
+
const crypto = require('node:crypto');
|
|
1364
|
+
const cert = new crypto.X509Certificate(certPem);
|
|
1365
|
+
const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
1366
|
+
const localPubDer = Buffer.from(pubB64, 'base64');
|
|
1367
|
+
if (!certPubDer.equals(localPubDer)) {
|
|
1368
|
+
return { valid: false, reason: 'cert public key does not match keypair' };
|
|
1369
|
+
}
|
|
1370
|
+
const now = Date.now();
|
|
1371
|
+
if (now < new Date(cert.validFrom).getTime())
|
|
1372
|
+
return { valid: false, reason: 'cert not yet valid' };
|
|
1373
|
+
if (now > new Date(cert.validTo).getTime())
|
|
1374
|
+
return { valid: false, reason: 'cert expired' };
|
|
1375
|
+
return { valid: true, reason: '' };
|
|
1376
|
+
}
|
|
1377
|
+
catch (e) {
|
|
1378
|
+
return { valid: false, reason: `cert parse error: ${e instanceof Error ? e.message : String(e)}` };
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1281
1381
|
// ── RPC ───────────────────────────────────────────────────
|
|
1282
1382
|
/**
|
|
1283
1383
|
* 发送 JSON-RPC 调用。
|
|
@@ -1297,6 +1397,13 @@ export class AUNClient {
|
|
|
1297
1397
|
throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
|
|
1298
1398
|
}
|
|
1299
1399
|
const p = { ...(params ?? {}) };
|
|
1400
|
+
const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
|
|
1401
|
+
delete p._rpc_background;
|
|
1402
|
+
const runWithRpcPriority = async (operation) => {
|
|
1403
|
+
if (!rpcBackground)
|
|
1404
|
+
return await operation();
|
|
1405
|
+
return await this._withBackgroundRpc(operation);
|
|
1406
|
+
};
|
|
1300
1407
|
if (method === 'message.send' || method === 'group.send') {
|
|
1301
1408
|
this._normalizeOutboundMessagePayload(p, method);
|
|
1302
1409
|
}
|
|
@@ -1318,17 +1425,33 @@ export class AUNClient {
|
|
|
1318
1425
|
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
1319
1426
|
p.slot_id = this._slotId;
|
|
1320
1427
|
}
|
|
1428
|
+
const pullGateLocked = Boolean(p._pull_gate_locked);
|
|
1429
|
+
if ('_pull_gate_locked' in p) {
|
|
1430
|
+
delete p._pull_gate_locked;
|
|
1431
|
+
}
|
|
1432
|
+
const pullGateKey = this._pullGateKeyForCall(method, p);
|
|
1433
|
+
if (pullGateKey && this._isPullResponseProcessing(pullGateKey)) {
|
|
1434
|
+
this._clientLog.debug(`pull skipped while processing pull response: method=${method} key=${pullGateKey}`);
|
|
1435
|
+
return this._emptyPullResultForCall(method);
|
|
1436
|
+
}
|
|
1437
|
+
if (pullGateKey && !pullGateLocked) {
|
|
1438
|
+
const lockedParams = { ...p, _pull_gate_locked: true };
|
|
1439
|
+
if (rpcBackground)
|
|
1440
|
+
lockedParams._rpc_background = true;
|
|
1441
|
+
const result = await this._runPullSerialized(pullGateKey, async () => this.call(method, lockedParams));
|
|
1442
|
+
return result;
|
|
1443
|
+
}
|
|
1321
1444
|
// 自动加密:message.send 默认加密(encrypt 默认 true)— V2-only
|
|
1322
1445
|
if (method === 'message.send') {
|
|
1323
1446
|
const encrypt = p.encrypt ?? true;
|
|
1324
1447
|
delete p.encrypt;
|
|
1325
1448
|
if (encrypt) {
|
|
1326
|
-
return await this.sendV2(String(p.to ?? ''), p.payload, {
|
|
1449
|
+
return await runWithRpcPriority(() => this.sendV2(String(p.to ?? ''), p.payload, {
|
|
1327
1450
|
messageId: String(p.message_id ?? '') || undefined,
|
|
1328
1451
|
timestamp: p.timestamp,
|
|
1329
1452
|
protectedHeaders: this._protectedHeadersFromParams(p),
|
|
1330
1453
|
context: isJsonObject(p.context) ? p.context : undefined,
|
|
1331
|
-
});
|
|
1454
|
+
}));
|
|
1332
1455
|
}
|
|
1333
1456
|
// encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
|
|
1334
1457
|
this._maybeAppendEchoTraceSend(p);
|
|
@@ -1338,12 +1461,12 @@ export class AUNClient {
|
|
|
1338
1461
|
const encrypt = p.encrypt ?? true;
|
|
1339
1462
|
delete p.encrypt;
|
|
1340
1463
|
if (encrypt) {
|
|
1341
|
-
return await this.sendGroupV2(String(p.group_id ?? ''), p.payload, {
|
|
1464
|
+
return await runWithRpcPriority(() => this.sendGroupV2(String(p.group_id ?? ''), p.payload, {
|
|
1342
1465
|
messageId: String(p.message_id ?? '') || undefined,
|
|
1343
1466
|
timestamp: p.timestamp,
|
|
1344
1467
|
protectedHeaders: this._protectedHeadersFromParams(p),
|
|
1345
1468
|
context: isJsonObject(p.context) ? p.context : undefined,
|
|
1346
|
-
});
|
|
1469
|
+
}));
|
|
1347
1470
|
}
|
|
1348
1471
|
this._maybeAppendEchoTraceSend(p);
|
|
1349
1472
|
}
|
|
@@ -1355,7 +1478,7 @@ export class AUNClient {
|
|
|
1355
1478
|
if (!this._v2Session || !String(p.group_id ?? '').trim()) {
|
|
1356
1479
|
throw new StateError(v2Error);
|
|
1357
1480
|
}
|
|
1358
|
-
return await this._putGroupThoughtEncryptedV2(p);
|
|
1481
|
+
return await runWithRpcPriority(() => this._putGroupThoughtEncryptedV2(p));
|
|
1359
1482
|
}
|
|
1360
1483
|
}
|
|
1361
1484
|
if (method === 'message.thought.put') {
|
|
@@ -1363,26 +1486,42 @@ export class AUNClient {
|
|
|
1363
1486
|
delete p.encrypt;
|
|
1364
1487
|
if (encrypt) {
|
|
1365
1488
|
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);
|
|
1489
|
+
return await runWithRpcPriority(() => this._putMessageThoughtEncryptedV2(p));
|
|
1367
1490
|
}
|
|
1368
1491
|
}
|
|
1369
|
-
|
|
1492
|
+
// V2-only:兼容入口名只作为 SDK 内部适配层存在,底层绝不能降级发 legacy RPC。
|
|
1493
|
+
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
1370
1494
|
await this._ensureV2SessionReady('message.pull');
|
|
1371
|
-
const
|
|
1495
|
+
const skipAutoAck = p._skip_auto_ack === true || p.skip_auto_ack === true;
|
|
1496
|
+
const afterSeq = Number(p.after_seq ?? 0) || 0;
|
|
1497
|
+
const limit = Number(p.limit ?? 50) || 50;
|
|
1498
|
+
const messages = skipAutoAck
|
|
1499
|
+
? await runWithRpcPriority(() => this.pullV2(afterSeq, limit, { skipAutoAck: true, gateLocked: true }))
|
|
1500
|
+
: await runWithRpcPriority(() => this.pullV2(afterSeq, limit, { gateLocked: true }));
|
|
1372
1501
|
return { messages };
|
|
1373
1502
|
}
|
|
1374
|
-
if (method === 'message.ack'
|
|
1503
|
+
if (method === 'message.ack' || method === 'message.v2.ack') {
|
|
1375
1504
|
await this._ensureV2SessionReady('message.ack');
|
|
1376
|
-
return await this.ackV2(Number(p.seq ?? p.up_to_seq ?? 0) || undefined);
|
|
1505
|
+
return await runWithRpcPriority(() => this.ackV2(Number(p.seq ?? p.up_to_seq ?? 0) || undefined));
|
|
1377
1506
|
}
|
|
1378
|
-
if (method === 'group.pull'
|
|
1507
|
+
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
1508
|
+
if (!String(p.group_id ?? '').trim()) {
|
|
1509
|
+
throw new ValidationError('group.pull requires group_id');
|
|
1510
|
+
}
|
|
1379
1511
|
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);
|
|
1512
|
+
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
1513
|
return { messages };
|
|
1382
1514
|
}
|
|
1383
|
-
if (method === 'group.ack_messages'
|
|
1515
|
+
if (method === 'group.ack_messages' || method === 'group.v2.ack') {
|
|
1516
|
+
if (!String(p.group_id ?? '').trim()) {
|
|
1517
|
+
throw new ValidationError('group.ack_messages requires group_id');
|
|
1518
|
+
}
|
|
1384
1519
|
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);
|
|
1520
|
+
return await runWithRpcPriority(() => this.ackGroupV2(String(p.group_id), Number(p.seq ?? p.msg_seq ?? p.up_to_seq ?? 0) || undefined));
|
|
1521
|
+
}
|
|
1522
|
+
if (method === 'message.pull') {
|
|
1523
|
+
delete p._skip_auto_ack;
|
|
1524
|
+
delete p.skip_auto_ack;
|
|
1386
1525
|
}
|
|
1387
1526
|
// 关键操作自动附加客户端签名
|
|
1388
1527
|
if (SIGNED_METHODS.has(method)) {
|
|
@@ -1399,8 +1538,12 @@ export class AUNClient {
|
|
|
1399
1538
|
this._clientLog.debug(`thought.get transport call start: method=${method}, params=${this._debugJson(this._messageEnvelopeFieldsForDebug(p))}`);
|
|
1400
1539
|
}
|
|
1401
1540
|
let result = callTimeout
|
|
1402
|
-
?
|
|
1403
|
-
|
|
1541
|
+
? (rpcBackground
|
|
1542
|
+
? await this._transport.call(method, p, callTimeout, undefined, true)
|
|
1543
|
+
: await this._transport.call(method, p, callTimeout))
|
|
1544
|
+
: (rpcBackground
|
|
1545
|
+
? await this._transport.call(method, p, undefined, undefined, true)
|
|
1546
|
+
: await this._transport.call(method, p));
|
|
1404
1547
|
if (method === 'group.thought.get' && isJsonObject(result)) {
|
|
1405
1548
|
this._clientLog.debug(`group.thought.get transport result: found=${String(result.found ?? '')}, raw_count=${Array.isArray(result.thoughts) ? result.thoughts.length : 0}`);
|
|
1406
1549
|
result = await this._decryptGroupThoughts(result);
|
|
@@ -1486,6 +1629,34 @@ export class AUNClient {
|
|
|
1486
1629
|
this._clientLog.debug(`on exit: elapsed=${Date.now() - tStart}ms event=${event}`);
|
|
1487
1630
|
return result;
|
|
1488
1631
|
}
|
|
1632
|
+
async _callRawV2Rpc(method, params) {
|
|
1633
|
+
const p = { ...(params ?? {}) };
|
|
1634
|
+
const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
|
|
1635
|
+
delete p._rpc_background;
|
|
1636
|
+
delete p._pull_gate_locked;
|
|
1637
|
+
delete p._skip_auto_ack;
|
|
1638
|
+
delete p.skip_auto_ack;
|
|
1639
|
+
if (method.startsWith('group.') && p.group_id !== undefined && p.group_id !== null) {
|
|
1640
|
+
p.group_id = normalizeGroupId(String(p.group_id)) || String(p.group_id);
|
|
1641
|
+
}
|
|
1642
|
+
if (method.startsWith('group.') && p.device_id === undefined) {
|
|
1643
|
+
p.device_id = this._deviceId;
|
|
1644
|
+
}
|
|
1645
|
+
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
1646
|
+
p.slot_id = this._slotId;
|
|
1647
|
+
}
|
|
1648
|
+
if (SIGNED_METHODS.has(method)) {
|
|
1649
|
+
if (this._shouldSkipClientSignature(method, p)) {
|
|
1650
|
+
delete p.client_signature;
|
|
1651
|
+
}
|
|
1652
|
+
else {
|
|
1653
|
+
this._signClientOperation(method, p);
|
|
1654
|
+
}
|
|
1655
|
+
}
|
|
1656
|
+
return rpcBackground
|
|
1657
|
+
? await this._transport.call(method, p, undefined, undefined, true)
|
|
1658
|
+
: await this._transport.call(method, p);
|
|
1659
|
+
}
|
|
1489
1660
|
/** P2-13: 取消订阅事件(对齐 Python/JS off 方法) */
|
|
1490
1661
|
off(event, handler) {
|
|
1491
1662
|
const tStart = Date.now();
|
|
@@ -1575,7 +1746,7 @@ export class AUNClient {
|
|
|
1575
1746
|
_decrypt_error: String(exc),
|
|
1576
1747
|
};
|
|
1577
1748
|
this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1578
|
-
this._publishAppEvent('message.undecryptable', safeEvent).catch(() => { });
|
|
1749
|
+
Promise.resolve(this._publishAppEvent('message.undecryptable', safeEvent)).catch(() => { });
|
|
1579
1750
|
}
|
|
1580
1751
|
});
|
|
1581
1752
|
this._clientLog.debug(`_onRawMessageReceived exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
|
|
@@ -1595,12 +1766,15 @@ export class AUNClient {
|
|
|
1595
1766
|
const seq = msg.seq;
|
|
1596
1767
|
if (seq !== undefined && seq !== null && this._aid) {
|
|
1597
1768
|
const ns = `p2p:${this._aid}`;
|
|
1598
|
-
// Push
|
|
1769
|
+
// Push 只先更新 maxSeenSeq;contiguous_seq 是已交付游标,必须等应用层发布返回后再推进。
|
|
1599
1770
|
if (seq > 0)
|
|
1600
1771
|
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1601
|
-
const
|
|
1772
|
+
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1773
|
+
const published = await this._publishOrderedMessage('message.received', ns, seq, msg);
|
|
1774
|
+
const contigAfter = this._seqTracker.getContiguousSeq(ns);
|
|
1775
|
+
const needPull = Number(seq) > contigAfter && !published;
|
|
1602
1776
|
if (needPull) {
|
|
1603
|
-
this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${
|
|
1777
|
+
this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${contigAfter}`);
|
|
1604
1778
|
this._fillP2pGap().catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
1605
1779
|
}
|
|
1606
1780
|
// auto-ack contiguous_seq
|
|
@@ -1609,23 +1783,16 @@ export class AUNClient {
|
|
|
1609
1783
|
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1610
1784
|
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1611
1785
|
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
|
-
})
|
|
1786
|
+
this._withBackgroundRpc(() => this.ackV2(ackSeq))
|
|
1617
1787
|
.then(() => { this._clientLog.debug(`P2P push auto-ack ok: ns=${ns}, seq=${ackSeq}`); })
|
|
1618
1788
|
.catch((e) => { this._clientLog.debug(`P2P auto-ack failed: ${formatCaughtError(e)}`); });
|
|
1619
1789
|
}
|
|
1620
1790
|
// 即时持久化 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);
|
|
1791
|
+
if (contigAfter !== contigBefore)
|
|
1792
|
+
this._saveSeqTrackerState();
|
|
1627
1793
|
}
|
|
1628
1794
|
else {
|
|
1795
|
+
// V2-only:普通 _raw.message.received 只承载明文;V2 密文由 peer.v2.message_received 通知触发 pull。
|
|
1629
1796
|
await this._publishAppEvent('message.received', msg, 'push');
|
|
1630
1797
|
}
|
|
1631
1798
|
}
|
|
@@ -1652,7 +1819,7 @@ export class AUNClient {
|
|
|
1652
1819
|
_decrypt_error: String(exc),
|
|
1653
1820
|
};
|
|
1654
1821
|
this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1655
|
-
this._publishAppEvent('group.message_undecryptable', safeEvent).catch(() => { });
|
|
1822
|
+
Promise.resolve(this._publishAppEvent('group.message_undecryptable', safeEvent)).catch(() => { });
|
|
1656
1823
|
}
|
|
1657
1824
|
});
|
|
1658
1825
|
this._clientLog.debug(`_onRawGroupMessageCreated exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
|
|
@@ -1678,17 +1845,22 @@ export class AUNClient {
|
|
|
1678
1845
|
if (payload === undefined || payload === null
|
|
1679
1846
|
|| (typeof payload === 'object' && Object.keys(payload).length === 0)) {
|
|
1680
1847
|
// 不带 payload 的通知不能先推进 seq,否则 auto-pull 会用推进后的 cursor 跳过该消息。
|
|
1681
|
-
|
|
1848
|
+
void this._autoPullGroupMessages(msg).catch((exc) => {
|
|
1849
|
+
this._clientLog.warn(`auto pull group message task failed: ${formatCaughtError(exc)}`);
|
|
1850
|
+
});
|
|
1682
1851
|
return;
|
|
1683
1852
|
}
|
|
1684
1853
|
if (groupId && seq !== undefined && seq !== null) {
|
|
1685
1854
|
const ns = `group:${groupId}`;
|
|
1686
|
-
// Push
|
|
1855
|
+
// Push 只先更新 maxSeenSeq;contiguous_seq 是已交付游标,必须等应用层发布返回后再推进。
|
|
1687
1856
|
if (seq > 0)
|
|
1688
1857
|
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1689
|
-
const
|
|
1858
|
+
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1859
|
+
const published = await this._publishOrderedMessage('group.message_created', ns, seq, msg);
|
|
1860
|
+
const contigAfter = this._seqTracker.getContiguousSeq(ns);
|
|
1861
|
+
const needPull = Number(seq) > contigAfter && !published;
|
|
1690
1862
|
if (needPull) {
|
|
1691
|
-
this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${
|
|
1863
|
+
this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${contigAfter}`);
|
|
1692
1864
|
this._fillGroupGap(groupId).catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
1693
1865
|
}
|
|
1694
1866
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
@@ -1696,118 +1868,65 @@ export class AUNClient {
|
|
|
1696
1868
|
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1697
1869
|
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1698
1870
|
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
|
-
})
|
|
1871
|
+
this._withBackgroundRpc(() => this.ackGroupV2(groupId, ackSeq))
|
|
1705
1872
|
.then(() => { this._clientLog.debug(`group push auto-ack ok: group=${groupId}, seq=${ackSeq}`); })
|
|
1706
1873
|
.catch((e) => { this._clientLog.debug(`group message auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
1707
1874
|
}
|
|
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);
|
|
1875
|
+
if (contigAfter !== contigBefore)
|
|
1876
|
+
this._saveSeqTrackerState();
|
|
1714
1877
|
}
|
|
1715
1878
|
else {
|
|
1879
|
+
// V2-only:普通 group.message_created 只承载明文;V2 密文由 group.v2.message_created 通知触发 pull。
|
|
1716
1880
|
await this._publishAppEvent('group.message_created', msg, 'group-push');
|
|
1717
1881
|
}
|
|
1718
1882
|
}
|
|
1719
1883
|
/** 收到不带 payload 的 group.message_created 通知后,自动 pull 最新消息 */
|
|
1720
1884
|
async _autoPullGroupMessages(notification) {
|
|
1721
|
-
|
|
1885
|
+
let groupId = String(notification.group_id ?? '').trim();
|
|
1722
1886
|
if (!groupId) {
|
|
1723
1887
|
await this._publishAppEvent('group.message_created', notification);
|
|
1724
1888
|
return;
|
|
1725
1889
|
}
|
|
1890
|
+
groupId = normalizeGroupId(groupId) || groupId;
|
|
1726
1891
|
const ns = `group:${groupId}`;
|
|
1727
1892
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1728
1893
|
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
|
-
}
|
|
1766
|
-
}
|
|
1767
|
-
catch (exc) {
|
|
1768
|
-
this._clientLog.debug(`auto pull group messages failed: ${formatCaughtError(exc)}`);
|
|
1894
|
+
const started = await this._tryRunBackgroundPull(ns, async () => {
|
|
1895
|
+
const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1896
|
+
const messages = await this.pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
|
|
1897
|
+
this._prunePushedSeqs(ns);
|
|
1898
|
+
return messages.length;
|
|
1899
|
+
}, true);
|
|
1900
|
+
if (!started) {
|
|
1901
|
+
this._clientLog.debug(`auto pull group messages skipped: pull in-flight group=${groupId}`);
|
|
1769
1902
|
}
|
|
1770
|
-
await this._publishAppEvent('group.message_created', notification, 'group-push-fallback');
|
|
1771
1903
|
}
|
|
1772
1904
|
/** 后台补齐群消息空洞 */
|
|
1773
1905
|
async _fillGroupGap(groupId) {
|
|
1906
|
+
groupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
1907
|
+
if (!groupId)
|
|
1908
|
+
return;
|
|
1774
1909
|
const ns = `group:${groupId}`;
|
|
1775
1910
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1776
1911
|
// 去重:同一 (group:id:after_seq) 只补一次
|
|
1777
1912
|
const dedupKey = `group_msg:${groupId}:${afterSeq}`;
|
|
1778
1913
|
if (this._gapFillDone.has(dedupKey))
|
|
1779
1914
|
return;
|
|
1915
|
+
const token = this._tryAcquirePullGate(ns);
|
|
1916
|
+
if (token === null) {
|
|
1917
|
+
this._clientLog.debug(`group message gap fill skipped: pull in-flight group=${groupId}`);
|
|
1918
|
+
return;
|
|
1919
|
+
}
|
|
1780
1920
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1781
1921
|
this._clientLog.debug(`group message gap fill start: group=${groupId}, after_seq=${afterSeq}`);
|
|
1922
|
+
let filled = 0;
|
|
1782
1923
|
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
|
-
}
|
|
1924
|
+
const messages = await this._withBackgroundRpc(() => this.pullGroupV2(groupId, afterSeq, 50, { gateLocked: true }));
|
|
1925
|
+
filled = messages.length;
|
|
1926
|
+
this._prunePushedSeqs(ns);
|
|
1927
|
+
if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
|
|
1928
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
1929
|
+
this._saveSeqTrackerState();
|
|
1811
1930
|
}
|
|
1812
1931
|
this._clientLog.debug(`group message gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
|
|
1813
1932
|
}
|
|
@@ -1816,6 +1935,10 @@ export class AUNClient {
|
|
|
1816
1935
|
}
|
|
1817
1936
|
finally {
|
|
1818
1937
|
this._gapFillDone.delete(dedupKey);
|
|
1938
|
+
this._releasePullGate(ns, token);
|
|
1939
|
+
if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
|
|
1940
|
+
void this._fillGroupGap(groupId);
|
|
1941
|
+
}
|
|
1819
1942
|
}
|
|
1820
1943
|
}
|
|
1821
1944
|
/** 后台补齐 P2P 消息空洞 */
|
|
@@ -1828,35 +1951,25 @@ export class AUNClient {
|
|
|
1828
1951
|
const dedupKey = `p2p:${afterSeq}`;
|
|
1829
1952
|
if (this._gapFillDone.has(dedupKey))
|
|
1830
1953
|
return;
|
|
1954
|
+
const token = this._tryAcquirePullGate(ns);
|
|
1955
|
+
if (token === null) {
|
|
1956
|
+
this._clientLog.debug(`P2P message gap fill skipped: pull in-flight ns=${ns}`);
|
|
1957
|
+
return;
|
|
1958
|
+
}
|
|
1831
1959
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1832
1960
|
this._clientLog.debug(`P2P message gap fill start: after_seq=${afterSeq}`);
|
|
1961
|
+
let filled = 0;
|
|
1833
1962
|
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
|
-
}
|
|
1963
|
+
const messages = await this._withBackgroundRpc(() => this.pullV2(afterSeq, 50, { skipAutoAck: true, gateLocked: true }));
|
|
1964
|
+
filled = messages.length;
|
|
1965
|
+
this._prunePushedSeqs(ns);
|
|
1966
|
+
if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
|
|
1967
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
1968
|
+
this._saveSeqTrackerState();
|
|
1969
|
+
}
|
|
1970
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1971
|
+
if (contig > 0 && contig !== afterSeq) {
|
|
1972
|
+
await this._withBackgroundRpc(() => this.ackV2(contig));
|
|
1860
1973
|
}
|
|
1861
1974
|
this._clientLog.debug(`P2P message gap fill done: after_seq=${afterSeq}, filled=${filled}`);
|
|
1862
1975
|
}
|
|
@@ -1865,6 +1978,10 @@ export class AUNClient {
|
|
|
1865
1978
|
}
|
|
1866
1979
|
finally {
|
|
1867
1980
|
this._gapFillDone.delete(dedupKey);
|
|
1981
|
+
this._releasePullGate(ns, token);
|
|
1982
|
+
if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
|
|
1983
|
+
void this._fillP2pGap();
|
|
1984
|
+
}
|
|
1868
1985
|
}
|
|
1869
1986
|
}
|
|
1870
1987
|
/** 只按硬上限裁剪 published guard,不能按 contiguousSeq 清理。 */
|
|
@@ -1877,6 +1994,38 @@ export class AUNClient {
|
|
|
1877
1994
|
this._pushedSeqs.set(ns, new Set(keep));
|
|
1878
1995
|
}
|
|
1879
1996
|
}
|
|
1997
|
+
_recordPendingP2pPull(ns, seq) {
|
|
1998
|
+
if (!ns || seq <= 0)
|
|
1999
|
+
return;
|
|
2000
|
+
const previous = this._pendingP2pPullUpper.get(ns) ?? 0;
|
|
2001
|
+
if (seq > previous) {
|
|
2002
|
+
this._pendingP2pPullUpper.set(ns, seq);
|
|
2003
|
+
}
|
|
2004
|
+
this._clientLog.debug(`P2P pending pull upper recorded: ns=${ns}, seq=${seq}, previous=${previous}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
|
|
2005
|
+
}
|
|
2006
|
+
_schedulePendingP2pPullIfNeeded(ns, reason) {
|
|
2007
|
+
if (!ns)
|
|
2008
|
+
return false;
|
|
2009
|
+
const upperSeq = this._pendingP2pPullUpper.get(ns) ?? 0;
|
|
2010
|
+
if (upperSeq <= 0) {
|
|
2011
|
+
this._pendingP2pPullUpper.delete(ns);
|
|
2012
|
+
return false;
|
|
2013
|
+
}
|
|
2014
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2015
|
+
if (upperSeq <= contig) {
|
|
2016
|
+
this._pendingP2pPullUpper.delete(ns);
|
|
2017
|
+
this._clientLog.debug(`P2P pending pull upper already covered: ns=${ns}, upper_seq=${upperSeq}, contiguous=${contig}, reason=${reason}`);
|
|
2018
|
+
return false;
|
|
2019
|
+
}
|
|
2020
|
+
if (this._state !== 'connected' || this._closing) {
|
|
2021
|
+
this._clientLog.debug(`P2P pending pull postponed: ns=${ns}, upper_seq=${upperSeq}, contiguous=${contig}, state=${this._state}, closing=${this._closing}, reason=${reason}`);
|
|
2022
|
+
return false;
|
|
2023
|
+
}
|
|
2024
|
+
this._pendingP2pPullUpper.delete(ns);
|
|
2025
|
+
this._clientLog.info(`P2P pending push follow-up pull scheduled: ns=${ns}, upper_seq=${upperSeq}, contiguous=${contig}, reason=${reason}`);
|
|
2026
|
+
void this._fillP2pGap();
|
|
2027
|
+
return true;
|
|
2028
|
+
}
|
|
1880
2029
|
_markPublishedSeq(ns, seq) {
|
|
1881
2030
|
let pushed = this._pushedSeqs.get(ns);
|
|
1882
2031
|
if (!pushed) {
|
|
@@ -1925,7 +2074,7 @@ export class AUNClient {
|
|
|
1925
2074
|
return payload;
|
|
1926
2075
|
return this._attachCurrentInstanceContext(payload);
|
|
1927
2076
|
}
|
|
1928
|
-
|
|
2077
|
+
_publishAppEvent(event, payload, source = 'direct') {
|
|
1929
2078
|
if ((event === 'message.received' || event === 'group.message_created') && isJsonObject(payload)) {
|
|
1930
2079
|
this._maybeAppendEchoTraceReceive(payload);
|
|
1931
2080
|
}
|
|
@@ -1949,7 +2098,7 @@ export class AUNClient {
|
|
|
1949
2098
|
this._clientLog.debug(`agent_md etag inject skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
1950
2099
|
}
|
|
1951
2100
|
}
|
|
1952
|
-
|
|
2101
|
+
return this._dispatcher.publishSyncAware(event, this._normalizePublishedMessagePayload(event, payload));
|
|
1953
2102
|
}
|
|
1954
2103
|
_echoTimestamp() {
|
|
1955
2104
|
const now = new Date();
|
|
@@ -2102,25 +2251,280 @@ export class AUNClient {
|
|
|
2102
2251
|
}
|
|
2103
2252
|
return true;
|
|
2104
2253
|
}
|
|
2105
|
-
|
|
2254
|
+
_tryAcquirePullGate(key) {
|
|
2255
|
+
if (!key)
|
|
2256
|
+
return 0;
|
|
2257
|
+
const now = Date.now();
|
|
2258
|
+
const gate = this._pullGates.get(key) ?? { inflight: false, startedAt: 0, token: 0 };
|
|
2259
|
+
if (gate.inflight && now - gate.startedAt <= AUNClient.PULL_GATE_STALE_MS) {
|
|
2260
|
+
return null;
|
|
2261
|
+
}
|
|
2262
|
+
if (gate.inflight) {
|
|
2263
|
+
this._clientLog.warn(`pull in-flight stale reset: key=${key} age=${now - gate.startedAt}ms`);
|
|
2264
|
+
}
|
|
2265
|
+
gate.token += 1;
|
|
2266
|
+
gate.inflight = true;
|
|
2267
|
+
gate.startedAt = now;
|
|
2268
|
+
this._pullGates.set(key, gate);
|
|
2269
|
+
return gate.token;
|
|
2270
|
+
}
|
|
2271
|
+
_releasePullGate(key, token) {
|
|
2272
|
+
if (!key || token == null)
|
|
2273
|
+
return;
|
|
2274
|
+
const gate = this._pullGates.get(key);
|
|
2275
|
+
if (!gate || gate.token !== token)
|
|
2276
|
+
return;
|
|
2277
|
+
gate.inflight = false;
|
|
2278
|
+
gate.startedAt = 0;
|
|
2279
|
+
if (key.startsWith('p2p:')) {
|
|
2280
|
+
this._schedulePendingP2pPullIfNeeded(key, 'pull-gate-release');
|
|
2281
|
+
}
|
|
2282
|
+
}
|
|
2283
|
+
_pullGateKeyForCall(method, params) {
|
|
2284
|
+
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
2285
|
+
return this._aid ? `p2p:${this._aid}` : '';
|
|
2286
|
+
}
|
|
2287
|
+
if ((method === 'group.pull' || method === 'group.v2.pull') && String(params.group_id ?? '').trim()) {
|
|
2288
|
+
return `group:${String(params.group_id ?? '').trim()}`;
|
|
2289
|
+
}
|
|
2290
|
+
if (method === 'group.pull_events' && String(params.group_id ?? '').trim()) {
|
|
2291
|
+
return `group_event:${String(params.group_id ?? '').trim()}`;
|
|
2292
|
+
}
|
|
2293
|
+
return '';
|
|
2294
|
+
}
|
|
2295
|
+
_isPullResponseProcessing(key) {
|
|
2296
|
+
if (!key)
|
|
2297
|
+
return false;
|
|
2298
|
+
return (this._pullResponseKeys.get(key) ?? 0) > 0;
|
|
2299
|
+
}
|
|
2300
|
+
_emptyPullResultForCall(method) {
|
|
2301
|
+
if (method === 'group.pull_events')
|
|
2302
|
+
return { events: [], count: 0 };
|
|
2303
|
+
if (method === 'message.pull' || method === 'message.v2.pull' || method === 'group.pull' || method === 'group.v2.pull') {
|
|
2304
|
+
return { messages: [], count: 0 };
|
|
2305
|
+
}
|
|
2306
|
+
return {};
|
|
2307
|
+
}
|
|
2308
|
+
_withPullResponseProcessing(key, fn) {
|
|
2309
|
+
if (!key)
|
|
2310
|
+
return fn();
|
|
2311
|
+
this._pullResponseKeys.set(key, (this._pullResponseKeys.get(key) ?? 0) + 1);
|
|
2312
|
+
const release = () => {
|
|
2313
|
+
const next = (this._pullResponseKeys.get(key) ?? 1) - 1;
|
|
2314
|
+
if (next <= 0) {
|
|
2315
|
+
this._pullResponseKeys.delete(key);
|
|
2316
|
+
}
|
|
2317
|
+
else {
|
|
2318
|
+
this._pullResponseKeys.set(key, next);
|
|
2319
|
+
}
|
|
2320
|
+
};
|
|
2321
|
+
try {
|
|
2322
|
+
const result = fn();
|
|
2323
|
+
if (isPromiseLike(result)) {
|
|
2324
|
+
return Promise.resolve(result).finally(release);
|
|
2325
|
+
}
|
|
2326
|
+
release();
|
|
2327
|
+
return result;
|
|
2328
|
+
}
|
|
2329
|
+
catch (exc) {
|
|
2330
|
+
release();
|
|
2331
|
+
throw exc;
|
|
2332
|
+
}
|
|
2333
|
+
}
|
|
2334
|
+
_pullResultCount(result) {
|
|
2335
|
+
if (Array.isArray(result))
|
|
2336
|
+
return result.length;
|
|
2337
|
+
if (!isJsonObject(result))
|
|
2338
|
+
return 0;
|
|
2339
|
+
const obj = result;
|
|
2340
|
+
const rawCount = Number(obj.raw_count ?? 0);
|
|
2341
|
+
if (Number.isFinite(rawCount) && rawCount > 0)
|
|
2342
|
+
return rawCount;
|
|
2343
|
+
if (Array.isArray(obj.messages))
|
|
2344
|
+
return obj.messages.length;
|
|
2345
|
+
if (Array.isArray(obj.events))
|
|
2346
|
+
return obj.events.length;
|
|
2347
|
+
return 0;
|
|
2348
|
+
}
|
|
2349
|
+
_nextPullParams(method, params) {
|
|
2350
|
+
const next = { ...params };
|
|
2351
|
+
delete next._pull_gate_locked;
|
|
2352
|
+
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
2353
|
+
if (!this._aid)
|
|
2354
|
+
return null;
|
|
2355
|
+
next.after_seq = this._seqTracker.getContiguousSeq(`p2p:${this._aid}`);
|
|
2356
|
+
return next;
|
|
2357
|
+
}
|
|
2358
|
+
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
2359
|
+
const groupId = normalizeGroupId(String(next.group_id ?? '').trim()) || String(next.group_id ?? '').trim();
|
|
2360
|
+
if (!groupId)
|
|
2361
|
+
return null;
|
|
2362
|
+
next.group_id = groupId;
|
|
2363
|
+
next.after_seq = this._seqTracker.getContiguousSeq(`group:${groupId}`);
|
|
2364
|
+
delete next.after_message_seq;
|
|
2365
|
+
return next;
|
|
2366
|
+
}
|
|
2367
|
+
if (method === 'group.pull_events') {
|
|
2368
|
+
const groupId = normalizeGroupId(String(next.group_id ?? '').trim()) || String(next.group_id ?? '').trim();
|
|
2369
|
+
if (!groupId)
|
|
2370
|
+
return null;
|
|
2371
|
+
next.group_id = groupId;
|
|
2372
|
+
next.after_event_seq = this._seqTracker.getContiguousSeq(`group_event:${groupId}`);
|
|
2373
|
+
return next;
|
|
2374
|
+
}
|
|
2375
|
+
return null;
|
|
2376
|
+
}
|
|
2377
|
+
_pullRequestAfter(method, params) {
|
|
2378
|
+
if (method === 'message.pull' || method === 'message.v2.pull')
|
|
2379
|
+
return Number(params.after_seq ?? 0) || 0;
|
|
2380
|
+
if (method === 'group.pull' || method === 'group.v2.pull')
|
|
2381
|
+
return Number(params.after_seq ?? params.after_message_seq ?? 0) || 0;
|
|
2382
|
+
if (method === 'group.pull_events')
|
|
2383
|
+
return Number(params.after_event_seq ?? 0) || 0;
|
|
2384
|
+
return 0;
|
|
2385
|
+
}
|
|
2386
|
+
_pullRetentionFloor(result, topLevelKey, cursorKey) {
|
|
2387
|
+
const values = [Number(result[topLevelKey] ?? 0)];
|
|
2388
|
+
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
2389
|
+
if (cursor) {
|
|
2390
|
+
values.push(Number(cursor[cursorKey] ?? 0));
|
|
2391
|
+
values.push(Number(cursor.retention_floor_seq ?? 0));
|
|
2392
|
+
}
|
|
2393
|
+
return Math.max(0, ...values.filter((value) => Number.isFinite(value)));
|
|
2394
|
+
}
|
|
2395
|
+
_schedulePullFollowup(method, params, result) {
|
|
2396
|
+
if (method === 'message.pull')
|
|
2397
|
+
method = 'message.v2.pull';
|
|
2398
|
+
else if (method === 'group.pull')
|
|
2399
|
+
method = 'group.v2.pull';
|
|
2400
|
+
if (this._pullResultCount(result) <= 0)
|
|
2401
|
+
return;
|
|
2402
|
+
const next = this._nextPullParams(method, params);
|
|
2403
|
+
if (!next)
|
|
2404
|
+
return;
|
|
2405
|
+
if (this._pullRequestAfter(method, next) <= this._pullRequestAfter(method, params))
|
|
2406
|
+
return;
|
|
2407
|
+
void (async () => {
|
|
2408
|
+
try {
|
|
2409
|
+
await this._withBackgroundRpc(async () => {
|
|
2410
|
+
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
2411
|
+
await this.pullV2(Number(next.after_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
|
|
2412
|
+
return;
|
|
2413
|
+
}
|
|
2414
|
+
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
2415
|
+
const groupId = String(next.group_id ?? '').trim();
|
|
2416
|
+
if (!groupId)
|
|
2417
|
+
return;
|
|
2418
|
+
await this.pullGroupV2(groupId, Number(next.after_seq ?? next.after_message_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2421
|
+
await this.call(method, next);
|
|
2422
|
+
});
|
|
2423
|
+
}
|
|
2424
|
+
catch (exc) {
|
|
2425
|
+
this._clientLog.debug(`pull follow-up skipped/failed: method=${method} err=${formatCaughtError(exc)}`);
|
|
2426
|
+
}
|
|
2427
|
+
})();
|
|
2428
|
+
}
|
|
2429
|
+
async _withBackgroundRpc(operation) {
|
|
2430
|
+
this._backgroundRpcDepth += 1;
|
|
2431
|
+
try {
|
|
2432
|
+
return await operation();
|
|
2433
|
+
}
|
|
2434
|
+
finally {
|
|
2435
|
+
this._backgroundRpcDepth = Math.max(0, this._backgroundRpcDepth - 1);
|
|
2436
|
+
}
|
|
2437
|
+
}
|
|
2438
|
+
async _runPullSerialized(key, operation) {
|
|
2439
|
+
if (key && this._isPullResponseProcessing(key)) {
|
|
2440
|
+
this._clientLog.debug(`pull skipped while processing pull response: key=${key}`);
|
|
2441
|
+
return [];
|
|
2442
|
+
}
|
|
2443
|
+
let token = this._tryAcquirePullGate(key);
|
|
2444
|
+
if (token === null) {
|
|
2445
|
+
// 显式 pull 可能撞上 push/gap-fill 的后台 pull。这里不并行发第二个 pull,
|
|
2446
|
+
// 也不把后台 in-flight 暴露成业务错误;短等待 gate 释放后再进入连接级 RPC queue。
|
|
2447
|
+
const deadline = Date.now() + AUNClient.PULL_GATE_STALE_MS + 100;
|
|
2448
|
+
while (token === null && Date.now() <= deadline) {
|
|
2449
|
+
await this._sleep(25);
|
|
2450
|
+
token = this._tryAcquirePullGate(key);
|
|
2451
|
+
}
|
|
2452
|
+
if (token === null) {
|
|
2453
|
+
throw new StateError(`pull already in-flight for ${key}`);
|
|
2454
|
+
}
|
|
2455
|
+
}
|
|
2456
|
+
try {
|
|
2457
|
+
return await this._withBackgroundRpc(operation);
|
|
2458
|
+
}
|
|
2459
|
+
finally {
|
|
2460
|
+
this._releasePullGate(key, token);
|
|
2461
|
+
}
|
|
2462
|
+
}
|
|
2463
|
+
async _tryRunBackgroundPull(key, operation, followupOnMessages = false, onBusy) {
|
|
2464
|
+
if (key && this._isPullResponseProcessing(key)) {
|
|
2465
|
+
onBusy?.();
|
|
2466
|
+
return false;
|
|
2467
|
+
}
|
|
2468
|
+
const token = this._tryAcquirePullGate(key);
|
|
2469
|
+
if (token === null) {
|
|
2470
|
+
onBusy?.();
|
|
2471
|
+
return false;
|
|
2472
|
+
}
|
|
2473
|
+
let count = 0;
|
|
2474
|
+
try {
|
|
2475
|
+
count = await this._withBackgroundRpc(operation);
|
|
2476
|
+
}
|
|
2477
|
+
finally {
|
|
2478
|
+
this._releasePullGate(key, token);
|
|
2479
|
+
}
|
|
2480
|
+
if (followupOnMessages && count > 0) {
|
|
2481
|
+
// 后台续拉是 fire-and-forget;关闭连接时 transport 会拒绝排队 RPC,
|
|
2482
|
+
// 这里必须本地收口,避免测试/宿主进程看到未处理的 Promise rejection。
|
|
2483
|
+
void this._tryRunBackgroundPull(key, operation, true).catch((exc) => {
|
|
2484
|
+
this._clientLog.debug(`background pull follow-up skipped/failed: key=${key} err=${formatCaughtError(exc)}`);
|
|
2485
|
+
});
|
|
2486
|
+
}
|
|
2487
|
+
return true;
|
|
2488
|
+
}
|
|
2489
|
+
async _drainOrderedMessages(ns, beforeSeq, pullResponse = false) {
|
|
2106
2490
|
const queue = this._pendingOrderedMsgs.get(ns);
|
|
2107
2491
|
if (!queue || queue.size === 0)
|
|
2108
2492
|
return;
|
|
2109
|
-
|
|
2110
|
-
|
|
2111
|
-
|
|
2112
|
-
|
|
2113
|
-
|
|
2493
|
+
while (true) {
|
|
2494
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2495
|
+
const ready = [...queue.keys()]
|
|
2496
|
+
.filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
|
|
2497
|
+
.sort((a, b) => a - b);
|
|
2498
|
+
let seq = ready[0];
|
|
2499
|
+
if (seq === undefined) {
|
|
2500
|
+
const nextSeq = contig + 1;
|
|
2501
|
+
if (beforeSeq !== undefined && nextSeq >= beforeSeq)
|
|
2502
|
+
break;
|
|
2503
|
+
if (!queue.has(nextSeq))
|
|
2504
|
+
break;
|
|
2505
|
+
seq = nextSeq;
|
|
2506
|
+
}
|
|
2114
2507
|
const item = queue.get(seq);
|
|
2115
2508
|
queue.delete(seq);
|
|
2116
2509
|
if (!item)
|
|
2117
2510
|
continue;
|
|
2118
2511
|
if (this._pushedSeqs.get(ns)?.has(seq)) {
|
|
2119
2512
|
this._clientLog.debug(`publish ordered drain skipped duplicate: ns=${ns}, seq=${seq}, event=${item.event}`);
|
|
2513
|
+
this._markOrderedSeqDelivered(ns, seq);
|
|
2120
2514
|
continue;
|
|
2121
2515
|
}
|
|
2122
|
-
|
|
2516
|
+
if (pullResponse) {
|
|
2517
|
+
const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(item.event, item.payload, 'ordered-drain'));
|
|
2518
|
+
if (isPromiseLike(published))
|
|
2519
|
+
await published;
|
|
2520
|
+
}
|
|
2521
|
+
else {
|
|
2522
|
+
const published = this._publishAppEvent(item.event, item.payload, 'ordered-drain');
|
|
2523
|
+
if (isPromiseLike(published))
|
|
2524
|
+
await published;
|
|
2525
|
+
}
|
|
2123
2526
|
this._markPublishedSeq(ns, seq);
|
|
2527
|
+
this._markOrderedSeqDelivered(ns, seq);
|
|
2124
2528
|
this._clientLog.debug(`publish ordered drain delivered: ns=${ns}, seq=${seq}, event=${item.event}`);
|
|
2125
2529
|
}
|
|
2126
2530
|
if (queue.size === 0)
|
|
@@ -2130,7 +2534,9 @@ export class AUNClient {
|
|
|
2130
2534
|
const seqNum = Number(seq);
|
|
2131
2535
|
if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0) {
|
|
2132
2536
|
this._clientLog.debug(`publish ordered direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
|
|
2133
|
-
|
|
2537
|
+
const published = this._publishAppEvent(event, payload, 'ordered');
|
|
2538
|
+
if (isPromiseLike(published))
|
|
2539
|
+
await published;
|
|
2134
2540
|
return true;
|
|
2135
2541
|
}
|
|
2136
2542
|
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
@@ -2142,7 +2548,15 @@ export class AUNClient {
|
|
|
2142
2548
|
return false;
|
|
2143
2549
|
}
|
|
2144
2550
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2145
|
-
if (seqNum
|
|
2551
|
+
if (seqNum <= contig) {
|
|
2552
|
+
this._clientLog.debug(`publish ordered stale covered: event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
|
|
2553
|
+
const queue = this._pendingOrderedMsgs.get(ns);
|
|
2554
|
+
queue?.delete(seqNum);
|
|
2555
|
+
if (queue && queue.size === 0)
|
|
2556
|
+
this._pendingOrderedMsgs.delete(ns);
|
|
2557
|
+
return false;
|
|
2558
|
+
}
|
|
2559
|
+
if (seqNum !== contig + 1) {
|
|
2146
2560
|
this._clientLog.debug(`publish ordered enqueue(gap): event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
|
|
2147
2561
|
this._enqueueOrderedMessage(ns, event, seqNum, payload);
|
|
2148
2562
|
return false;
|
|
@@ -2156,17 +2570,25 @@ export class AUNClient {
|
|
|
2156
2570
|
queue?.delete(seqNum);
|
|
2157
2571
|
if (queue && queue.size === 0)
|
|
2158
2572
|
this._pendingOrderedMsgs.delete(ns);
|
|
2159
|
-
|
|
2573
|
+
const published = this._publishAppEvent(event, payload, 'ordered');
|
|
2574
|
+
if (isPromiseLike(published))
|
|
2575
|
+
await published;
|
|
2160
2576
|
this._markPublishedSeq(ns, seqNum);
|
|
2577
|
+
this._markOrderedSeqDelivered(ns, seqNum);
|
|
2161
2578
|
this._clientLog.debug(`publish ordered delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
2162
2579
|
await this._drainOrderedMessages(ns);
|
|
2163
2580
|
return true;
|
|
2164
2581
|
}
|
|
2165
2582
|
async _publishPulledMessage(event, ns, seq, payload) {
|
|
2583
|
+
// Pull/gap-fill 批次是服务端对 after_seq 的可用结果集,可能跨过永久空洞。
|
|
2584
|
+
// 这里只能做 namespace+seq 去重并按返回顺序发布,不能套用 push 路径的
|
|
2585
|
+
// seq == contiguous_seq + 1 门控,否则会把空洞后的可用消息错误卡住。
|
|
2166
2586
|
const seqNum = Number(seq);
|
|
2167
2587
|
if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0 || !ns) {
|
|
2168
2588
|
this._clientLog.debug(`publish pulled direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
|
|
2169
|
-
|
|
2589
|
+
const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(event, payload, 'pull'));
|
|
2590
|
+
if (isPromiseLike(published))
|
|
2591
|
+
await published;
|
|
2170
2592
|
return true;
|
|
2171
2593
|
}
|
|
2172
2594
|
const queue = this._pendingOrderedMsgs.get(ns);
|
|
@@ -2180,22 +2602,51 @@ export class AUNClient {
|
|
|
2180
2602
|
queue?.delete(seqNum);
|
|
2181
2603
|
if (queue && queue.size === 0)
|
|
2182
2604
|
this._pendingOrderedMsgs.delete(ns);
|
|
2183
|
-
|
|
2605
|
+
const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(event, payload, 'pull'));
|
|
2606
|
+
if (isPromiseLike(published))
|
|
2607
|
+
await published;
|
|
2184
2608
|
this._markPublishedSeq(ns, seqNum);
|
|
2609
|
+
this._markPulledSeqDelivered(ns, seqNum);
|
|
2610
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
2185
2611
|
this._clientLog.debug(`publish pulled delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
2186
2612
|
return true;
|
|
2187
2613
|
}
|
|
2614
|
+
_markPulledSeqDelivered(ns, seq) {
|
|
2615
|
+
// Pull 批次是 after_seq 之后服务端当前可用的结果集,可能跨过永久空洞。
|
|
2616
|
+
// 这里仅在应用层发布返回后推进已交付游标,不能改成 push 的相邻 seq 门控。
|
|
2617
|
+
const seqNum = Number(seq);
|
|
2618
|
+
if (!ns || !Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0)
|
|
2619
|
+
return false;
|
|
2620
|
+
const before = this._seqTracker.getContiguousSeq(ns);
|
|
2621
|
+
this._seqTracker.forceContiguousSeq(ns, seqNum);
|
|
2622
|
+
return this._seqTracker.getContiguousSeq(ns) !== before;
|
|
2623
|
+
}
|
|
2624
|
+
_markOrderedSeqDelivered(ns, seq) {
|
|
2625
|
+
if (!ns || !Number.isFinite(seq) || !Number.isInteger(seq) || seq <= 0)
|
|
2626
|
+
return false;
|
|
2627
|
+
const before = this._seqTracker.getContiguousSeq(ns);
|
|
2628
|
+
this._seqTracker.onMessageSeq(ns, seq);
|
|
2629
|
+
return this._seqTracker.getContiguousSeq(ns) !== before;
|
|
2630
|
+
}
|
|
2188
2631
|
/** 后台补齐群事件空洞 */
|
|
2189
2632
|
async _fillGroupEventGap(groupId) {
|
|
2633
|
+
groupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
2634
|
+
if (!groupId)
|
|
2635
|
+
return;
|
|
2190
2636
|
const ns = `group_event:${groupId}`;
|
|
2191
2637
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
2192
2638
|
// 去重:同一 (group_evt:id:after_seq) 只补一次
|
|
2193
2639
|
const dedupKey = `group_evt:${groupId}:${afterSeq}`;
|
|
2194
2640
|
if (this._gapFillDone.has(dedupKey))
|
|
2195
2641
|
return;
|
|
2642
|
+
const token = this._tryAcquirePullGate(ns);
|
|
2643
|
+
if (token === null) {
|
|
2644
|
+
this._clientLog.debug(`group event gap fill skipped: pull in-flight group=${groupId}`);
|
|
2645
|
+
return;
|
|
2646
|
+
}
|
|
2196
2647
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
2648
|
+
let filled = 0;
|
|
2197
2649
|
try {
|
|
2198
|
-
let filled = 0;
|
|
2199
2650
|
let nextAfterSeq = afterSeq;
|
|
2200
2651
|
const maxPages = 100;
|
|
2201
2652
|
let pageCount = 0;
|
|
@@ -2207,6 +2658,7 @@ export class AUNClient {
|
|
|
2207
2658
|
after_event_seq: nextAfterSeq,
|
|
2208
2659
|
device_id: this._deviceId,
|
|
2209
2660
|
limit: 50,
|
|
2661
|
+
_pull_gate_locked: true,
|
|
2210
2662
|
});
|
|
2211
2663
|
if (!isJsonObject(result))
|
|
2212
2664
|
return;
|
|
@@ -2215,16 +2667,12 @@ export class AUNClient {
|
|
|
2215
2667
|
return;
|
|
2216
2668
|
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
2217
2669
|
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) {
|
|
2670
|
+
const retentionFloor = this._pullRetentionFloor(result, 'retention_floor_event_seq', 'retention_floor_event_seq');
|
|
2671
|
+
if (retentionFloor > 0) {
|
|
2224
2672
|
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,
|
|
2673
|
+
if (contigBeforeFloor < retentionFloor) {
|
|
2674
|
+
this._clientLog.info(`group.pull_events retention-floor advance: ns=${ns} contiguous=${contigBeforeFloor} -> retention_floor=${retentionFloor}`);
|
|
2675
|
+
this._seqTracker.forceContiguousSeq(ns, retentionFloor);
|
|
2228
2676
|
}
|
|
2229
2677
|
}
|
|
2230
2678
|
const eventSeqs = [];
|
|
@@ -2235,20 +2683,23 @@ export class AUNClient {
|
|
|
2235
2683
|
evt._from_gap_fill = true;
|
|
2236
2684
|
const et = String(evt.event_type ?? '');
|
|
2237
2685
|
// 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
|
|
2238
|
-
if (et
|
|
2239
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
|
|
2246
|
-
|
|
2247
|
-
|
|
2686
|
+
if (et !== 'group.message_created') {
|
|
2687
|
+
// 验签:有 client_signature 就验(与实时事件路径对齐)
|
|
2688
|
+
const cs = evt.client_signature;
|
|
2689
|
+
if (cs && typeof cs === 'object') {
|
|
2690
|
+
if (this._shouldSkipEventSignature(evt)) {
|
|
2691
|
+
delete evt.client_signature;
|
|
2692
|
+
}
|
|
2693
|
+
else {
|
|
2694
|
+
evt._verified = await this._verifyEventSignatureAsync(evt, cs);
|
|
2695
|
+
}
|
|
2248
2696
|
}
|
|
2697
|
+
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
2698
|
+
await this._dispatcher.publish('group.changed', evt);
|
|
2699
|
+
}
|
|
2700
|
+
if (Number.isFinite(eventSeq) && eventSeq > 0) {
|
|
2701
|
+
this._markPulledSeqDelivered(ns, eventSeq);
|
|
2249
2702
|
}
|
|
2250
|
-
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
2251
|
-
await this._dispatcher.publish('group.changed', evt);
|
|
2252
2703
|
filled += 1;
|
|
2253
2704
|
}
|
|
2254
2705
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
@@ -2263,12 +2714,11 @@ export class AUNClient {
|
|
|
2263
2714
|
event_seq: ackSeq,
|
|
2264
2715
|
device_id: this._deviceId,
|
|
2265
2716
|
slot_id: this._slotId,
|
|
2266
|
-
}).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2717
|
+
}, undefined, undefined, true).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2267
2718
|
}
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
2271
|
-
nextAfterSeq = nextAfter;
|
|
2719
|
+
// pull_events 与其它 pull 一样:一次后台任务只消费一个批次。
|
|
2720
|
+
// 非空批次返回后由 pull gate 的 fire-and-forget follow-up 重新排队,直到空批停止。
|
|
2721
|
+
break;
|
|
2272
2722
|
}
|
|
2273
2723
|
if (pageCount >= maxPages) {
|
|
2274
2724
|
this._clientLog.warn(`group event gap fill reached max_pages=${maxPages} group=${groupId} after_seq=${nextAfterSeq}`);
|
|
@@ -2280,6 +2730,10 @@ export class AUNClient {
|
|
|
2280
2730
|
}
|
|
2281
2731
|
finally {
|
|
2282
2732
|
this._gapFillDone.delete(dedupKey);
|
|
2733
|
+
this._releasePullGate(ns, token);
|
|
2734
|
+
if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
|
|
2735
|
+
void this._fillGroupEventGap(groupId);
|
|
2736
|
+
}
|
|
2283
2737
|
}
|
|
2284
2738
|
}
|
|
2285
2739
|
_extractGroupIdFromResult(result) {
|
|
@@ -2363,7 +2817,7 @@ export class AUNClient {
|
|
|
2363
2817
|
event_seq: contig,
|
|
2364
2818
|
device_id: this._deviceId,
|
|
2365
2819
|
slot_id: this._slotId,
|
|
2366
|
-
}).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2820
|
+
}, undefined, undefined, true).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2367
2821
|
}
|
|
2368
2822
|
}
|
|
2369
2823
|
// 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
|
|
@@ -2569,11 +3023,85 @@ export class AUNClient {
|
|
|
2569
3023
|
return false;
|
|
2570
3024
|
}
|
|
2571
3025
|
}
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
3026
|
+
async _validateAndCachePeerCert(opts) {
|
|
3027
|
+
const aid = String(opts.aid ?? '').trim();
|
|
3028
|
+
const certPem = String(opts.certPem ?? '').trim();
|
|
3029
|
+
const certFingerprint = String(opts.certFingerprint ?? '').trim() || undefined;
|
|
3030
|
+
if (!aid)
|
|
3031
|
+
throw new ValidationError('peer aid is required for cert validation');
|
|
3032
|
+
if (!certPem)
|
|
3033
|
+
throw new ValidationError(`peer cert is empty for ${aid}`);
|
|
3034
|
+
const gatewayUrl = this._gatewayUrl;
|
|
3035
|
+
if (!gatewayUrl) {
|
|
3036
|
+
throw new ValidationError('gateway url unavailable for e2ee cert validation');
|
|
3037
|
+
}
|
|
3038
|
+
const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
3039
|
+
const x509Cert = new crypto.X509Certificate(certPem);
|
|
3040
|
+
// H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
|
|
3041
|
+
if (certFingerprint) {
|
|
3042
|
+
const expectedFP = certFingerprint.toLowerCase();
|
|
3043
|
+
if (!expectedFP.startsWith('sha256:')) {
|
|
3044
|
+
throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
|
|
3045
|
+
}
|
|
3046
|
+
const expectedHex = expectedFP.slice('sha256:'.length);
|
|
3047
|
+
const derHex = x509Cert.fingerprint256.replace(/:/g, '').toLowerCase();
|
|
3048
|
+
let spkiHex = '';
|
|
3049
|
+
try {
|
|
3050
|
+
const spkiDer = x509Cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
3051
|
+
spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
|
|
3052
|
+
}
|
|
3053
|
+
catch {
|
|
3054
|
+
spkiHex = '';
|
|
3055
|
+
}
|
|
3056
|
+
if (expectedHex !== derHex && (!spkiHex || expectedHex !== spkiHex)) {
|
|
3057
|
+
throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
|
|
3058
|
+
}
|
|
3059
|
+
}
|
|
3060
|
+
let cachedBootstrapChain = false;
|
|
3061
|
+
const caChainPems = opts.caChainPems ?? [];
|
|
3062
|
+
if (caChainPems.length > 0) {
|
|
3063
|
+
try {
|
|
3064
|
+
this._auth.cacheGatewayCaChain(peerGatewayUrl, caChainPems, aid);
|
|
3065
|
+
cachedBootstrapChain = true;
|
|
3066
|
+
}
|
|
3067
|
+
catch (exc) {
|
|
3068
|
+
this._clientLog.debug(`bootstrap CA chain cache skipped: peer=${aid}, source=${opts.source ?? 'unknown'}, err=${formatCaughtError(exc)}`);
|
|
3069
|
+
}
|
|
3070
|
+
}
|
|
3071
|
+
try {
|
|
3072
|
+
await this._auth.verifyPeerCertificate(peerGatewayUrl, certPem, aid);
|
|
3073
|
+
}
|
|
3074
|
+
catch (exc) {
|
|
3075
|
+
if (cachedBootstrapChain) {
|
|
3076
|
+
this._auth.discardGatewayCaChain(peerGatewayUrl, aid);
|
|
3077
|
+
}
|
|
3078
|
+
throw new ValidationError(`peer cert verification failed for ${aid}: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
3079
|
+
}
|
|
3080
|
+
const nowSec = Date.now() / 1000;
|
|
3081
|
+
const entry = {
|
|
3082
|
+
certPem,
|
|
3083
|
+
validatedAt: nowSec,
|
|
3084
|
+
refreshAfter: nowSec + PEER_CERT_CACHE_TTL,
|
|
3085
|
+
};
|
|
3086
|
+
const cacheKey = AUNClient._certCacheKey(aid, certFingerprint);
|
|
3087
|
+
this._certCache.set(cacheKey, entry);
|
|
3088
|
+
const bareKey = AUNClient._certCacheKey(aid);
|
|
3089
|
+
if (bareKey !== cacheKey)
|
|
3090
|
+
this._certCache.set(bareKey, entry);
|
|
3091
|
+
if (!certFingerprint) {
|
|
3092
|
+
const actualFp = `sha256:${x509Cert.fingerprint256.replace(/:/g, '').toLowerCase()}`;
|
|
3093
|
+
this._certCache.set(AUNClient._certCacheKey(aid, actualFp), entry);
|
|
3094
|
+
}
|
|
3095
|
+
try {
|
|
3096
|
+
// peer 证书只存版本目录,不覆盖 cert.pem
|
|
3097
|
+
this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
|
|
3098
|
+
}
|
|
3099
|
+
catch (exc) {
|
|
3100
|
+
this._clientLog.error(`failed to write cert to keystore (aid=${aid}, fp=${certFingerprint ?? ''}): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
3101
|
+
}
|
|
3102
|
+
return certPem;
|
|
3103
|
+
}
|
|
3104
|
+
/** 获取对方证书(带缓存 + 完整 PKI 验证),跨域时自动路由到 peer 所在域。 */
|
|
2577
3105
|
async _fetchPeerCert(aid, certFingerprint, timeoutMs = 30_000) {
|
|
2578
3106
|
const tStart = Date.now();
|
|
2579
3107
|
this._clientLog.debug(`_fetchPeerCert enter: aid=${aid}, fp=${certFingerprint ?? ''}`);
|
|
@@ -2589,67 +3117,107 @@ export class AUNClient {
|
|
|
2589
3117
|
if (!gatewayUrl) {
|
|
2590
3118
|
throw new ValidationError('gateway url unavailable for e2ee cert fetch');
|
|
2591
3119
|
}
|
|
2592
|
-
// 跨域时用 peer 所在域的 Gateway URL
|
|
2593
3120
|
const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
2594
3121
|
let certPem;
|
|
2595
3122
|
try {
|
|
2596
|
-
|
|
2597
|
-
certPem = await _httpGetText(certUrl, this._configModel.verifySsl, timeoutMs);
|
|
3123
|
+
certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid, certFingerprint), this._configModel.verifySsl, timeoutMs);
|
|
2598
3124
|
}
|
|
2599
3125
|
catch (exc) {
|
|
2600
|
-
if (!certFingerprint)
|
|
3126
|
+
if (!certFingerprint)
|
|
2601
3127
|
throw exc;
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
2608
|
-
|
|
2609
|
-
|
|
2610
|
-
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
3128
|
+
certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid), this._configModel.verifySsl, timeoutMs);
|
|
3129
|
+
}
|
|
3130
|
+
const validated = await this._validateAndCachePeerCert({
|
|
3131
|
+
aid,
|
|
3132
|
+
certPem,
|
|
3133
|
+
certFingerprint,
|
|
3134
|
+
source: 'fetch',
|
|
3135
|
+
});
|
|
3136
|
+
this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} (fetched)`);
|
|
3137
|
+
return validated;
|
|
3138
|
+
}
|
|
3139
|
+
catch (err) {
|
|
3140
|
+
this._clientLog.debug(`_fetchPeerCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
3141
|
+
throw err;
|
|
3142
|
+
}
|
|
3143
|
+
}
|
|
3144
|
+
_bootstrapCaChain(material) {
|
|
3145
|
+
let raw;
|
|
3146
|
+
for (const key of ['ca_chain', 'ca_chain_pems', 'cert_chain', 'chain']) {
|
|
3147
|
+
if (material[key] !== undefined && material[key] !== null) {
|
|
3148
|
+
raw = material[key];
|
|
3149
|
+
break;
|
|
3150
|
+
}
|
|
3151
|
+
}
|
|
3152
|
+
if (!Array.isArray(raw))
|
|
3153
|
+
return [];
|
|
3154
|
+
const result = [];
|
|
3155
|
+
for (const item of raw) {
|
|
3156
|
+
let certType = '';
|
|
3157
|
+
let certPem = '';
|
|
3158
|
+
if (isJsonObject(item)) {
|
|
3159
|
+
certType = String(item.cert_type ?? '').trim().toLowerCase();
|
|
3160
|
+
if (certType === 'agent')
|
|
3161
|
+
continue;
|
|
3162
|
+
certPem = String(item.cert_pem ?? item.cert ?? '').trim();
|
|
3163
|
+
}
|
|
3164
|
+
else {
|
|
3165
|
+
certPem = String(item ?? '').trim();
|
|
3166
|
+
}
|
|
3167
|
+
if (!certPem)
|
|
3168
|
+
continue;
|
|
3169
|
+
if (!certType) {
|
|
2616
3170
|
try {
|
|
2617
|
-
|
|
2618
|
-
|
|
3171
|
+
if (!new crypto.X509Certificate(certPem).ca)
|
|
3172
|
+
continue;
|
|
2619
3173
|
}
|
|
2620
3174
|
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)}...`);
|
|
3175
|
+
continue;
|
|
2625
3176
|
}
|
|
2626
3177
|
}
|
|
2627
|
-
|
|
2628
|
-
|
|
2629
|
-
|
|
2630
|
-
|
|
2631
|
-
|
|
2632
|
-
|
|
2633
|
-
|
|
2634
|
-
|
|
2635
|
-
|
|
2636
|
-
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
3178
|
+
result.push(certPem);
|
|
3179
|
+
}
|
|
3180
|
+
return result;
|
|
3181
|
+
}
|
|
3182
|
+
async _primeBootstrapPeerCerts(bootstrap, peerAid) {
|
|
3183
|
+
const certsRaw = bootstrap.certs;
|
|
3184
|
+
if (!isJsonObject(certsRaw))
|
|
3185
|
+
return;
|
|
3186
|
+
const materials = certsRaw;
|
|
3187
|
+
const expected = new Set();
|
|
3188
|
+
const normalizedPeer = String(peerAid ?? '').trim();
|
|
3189
|
+
if (normalizedPeer)
|
|
3190
|
+
expected.add(normalizedPeer);
|
|
3191
|
+
const audit = Array.isArray(bootstrap.audit_recipients) ? bootstrap.audit_recipients : [];
|
|
3192
|
+
for (const dev of audit) {
|
|
3193
|
+
if (!isJsonObject(dev))
|
|
3194
|
+
continue;
|
|
3195
|
+
const aid = String(dev.aid ?? '').trim();
|
|
3196
|
+
if (aid)
|
|
3197
|
+
expected.add(aid);
|
|
3198
|
+
}
|
|
3199
|
+
for (const aid of expected) {
|
|
3200
|
+
if (aid === this._aid)
|
|
3201
|
+
continue;
|
|
3202
|
+
const material = materials[aid];
|
|
3203
|
+
if (!isJsonObject(material))
|
|
3204
|
+
continue;
|
|
3205
|
+
const certPem = String(material.cert_pem ?? material.cert ?? '').trim();
|
|
3206
|
+
if (!certPem)
|
|
3207
|
+
continue;
|
|
3208
|
+
const certFingerprint = String(material.cert_fingerprint ?? material.fingerprint ?? material.fp ?? '').trim() || undefined;
|
|
2640
3209
|
try {
|
|
2641
|
-
|
|
2642
|
-
|
|
3210
|
+
await this._validateAndCachePeerCert({
|
|
3211
|
+
aid,
|
|
3212
|
+
certPem,
|
|
3213
|
+
certFingerprint,
|
|
3214
|
+
caChainPems: this._bootstrapCaChain(material),
|
|
3215
|
+
source: 'bootstrap',
|
|
3216
|
+
});
|
|
2643
3217
|
}
|
|
2644
3218
|
catch (exc) {
|
|
2645
|
-
this._clientLog.
|
|
3219
|
+
this._clientLog.debug(`bootstrap peer cert material ignored: peer=${aid}, err=${formatCaughtError(exc)}`);
|
|
2646
3220
|
}
|
|
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
3221
|
}
|
|
2654
3222
|
}
|
|
2655
3223
|
async _decryptGroupThoughts(result) {
|
|
@@ -2893,6 +3461,7 @@ export class AUNClient {
|
|
|
2893
3461
|
this._gapFillDone.clear();
|
|
2894
3462
|
this._pushedSeqs.clear();
|
|
2895
3463
|
this._pendingOrderedMsgs.clear();
|
|
3464
|
+
this._pendingP2pPullUpper.clear();
|
|
2896
3465
|
this._v2SenderIKPending.clear();
|
|
2897
3466
|
this._v2SenderIKFetching.clear();
|
|
2898
3467
|
this._groupSynced.clear();
|
|
@@ -2905,6 +3474,7 @@ export class AUNClient {
|
|
|
2905
3474
|
this._gapFillDone.clear();
|
|
2906
3475
|
this._pushedSeqs.clear();
|
|
2907
3476
|
this._pendingOrderedMsgs.clear();
|
|
3477
|
+
this._pendingP2pPullUpper.clear();
|
|
2908
3478
|
this._v2SenderIKPending.clear();
|
|
2909
3479
|
this._v2SenderIKFetching.clear();
|
|
2910
3480
|
this._groupSynced.clear();
|
|
@@ -3108,7 +3678,7 @@ export class AUNClient {
|
|
|
3108
3678
|
catch (exc) {
|
|
3109
3679
|
this._clientLog.warn(`V2 session init failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
3110
3680
|
}
|
|
3111
|
-
// connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
|
|
3681
|
+
// connect/reconnect 成功后自动触发一次 P2P message.v2.pull,补齐离线期间积压
|
|
3112
3682
|
// 群消息按惰性触发,不在此处主动 pull
|
|
3113
3683
|
void this._fillP2pGap().catch((exc) => {
|
|
3114
3684
|
this._clientLog.warn(`schedule post-connect P2P gap fill failed: ${formatCaughtError(exc)}`);
|
|
@@ -3200,7 +3770,7 @@ export class AUNClient {
|
|
|
3200
3770
|
this._v2Session = new V2Session(v2Store, this._deviceId, this._aid, aidPriv, aidPubDer);
|
|
3201
3771
|
await this._v2Session.ensureRegistered(this._v2CallFn());
|
|
3202
3772
|
this._clientLog.debug(`V2 session initialized aid=${this._aid} device=${this._deviceId}`);
|
|
3203
|
-
|
|
3773
|
+
// 群 state proposal 由服务端在 client.online 时定向通知。
|
|
3204
3774
|
}
|
|
3205
3775
|
async _v2TrustedIKPubDer(aid) {
|
|
3206
3776
|
const normalizedAid = String(aid ?? '').trim();
|
|
@@ -3379,7 +3949,11 @@ export class AUNClient {
|
|
|
3379
3949
|
const session = this._v2Session;
|
|
3380
3950
|
if (session && fromAid) {
|
|
3381
3951
|
try {
|
|
3382
|
-
const bs = await this.call('message.v2.bootstrap', {
|
|
3952
|
+
const bs = await this.call('message.v2.bootstrap', {
|
|
3953
|
+
peer_aid: fromAid,
|
|
3954
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
3955
|
+
});
|
|
3956
|
+
await this._primeBootstrapPeerCerts(bs, fromAid);
|
|
3383
3957
|
const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
3384
3958
|
for (const dev of peers)
|
|
3385
3959
|
this._cacheV2PeerIKFromDevice(dev, fromAid);
|
|
@@ -3389,7 +3963,10 @@ export class AUNClient {
|
|
|
3389
3963
|
}
|
|
3390
3964
|
if (groupId) {
|
|
3391
3965
|
try {
|
|
3392
|
-
const gbs = await this.call('group.v2.bootstrap', {
|
|
3966
|
+
const gbs = await this.call('group.v2.bootstrap', {
|
|
3967
|
+
group_id: groupId,
|
|
3968
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
3969
|
+
});
|
|
3393
3970
|
const devices = (Array.isArray(gbs?.devices) ? gbs.devices : []);
|
|
3394
3971
|
const audit = (Array.isArray(gbs?.audit_recipients) ? gbs.audit_recipients : []);
|
|
3395
3972
|
for (const dev of devices)
|
|
@@ -3448,14 +4025,21 @@ export class AUNClient {
|
|
|
3448
4025
|
const useCache = opts.useCache !== false;
|
|
3449
4026
|
let peerDevices = [];
|
|
3450
4027
|
let auditRaw = [];
|
|
4028
|
+
let wrapPolicy = normalizeV2WrapPolicy(undefined);
|
|
3451
4029
|
const cached = useCache ? this._v2BootstrapCache.get(to) : undefined;
|
|
3452
4030
|
if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
3453
4031
|
peerDevices = cached.devices;
|
|
3454
4032
|
auditRaw = cached.auditRecipients;
|
|
4033
|
+
wrapPolicy = cached.wrapPolicy ?? wrapPolicy;
|
|
3455
4034
|
this._clientLog.debug(`message.v2.bootstrap cache hit: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
|
|
3456
4035
|
}
|
|
3457
4036
|
else {
|
|
3458
|
-
const bs = await this.call('message.v2.bootstrap', {
|
|
4037
|
+
const bs = await this.call('message.v2.bootstrap', {
|
|
4038
|
+
peer_aid: to,
|
|
4039
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
4040
|
+
});
|
|
4041
|
+
await this._primeBootstrapPeerCerts(bs, to);
|
|
4042
|
+
wrapPolicy = normalizeV2WrapPolicy(bs.e2ee_wrap_policy);
|
|
3459
4043
|
peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
3460
4044
|
auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
|
|
3461
4045
|
this._clientLog.debug(`message.v2.bootstrap fetched: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
|
|
@@ -3464,6 +4048,7 @@ export class AUNClient {
|
|
|
3464
4048
|
devices: peerDevices,
|
|
3465
4049
|
auditRecipients: auditRaw,
|
|
3466
4050
|
cachedAt: Date.now(),
|
|
4051
|
+
wrapPolicy,
|
|
3467
4052
|
});
|
|
3468
4053
|
}
|
|
3469
4054
|
}
|
|
@@ -3504,13 +4089,19 @@ export class AUNClient {
|
|
|
3504
4089
|
selfDevices = selfCached.devices;
|
|
3505
4090
|
}
|
|
3506
4091
|
else {
|
|
3507
|
-
const selfBs = await this.call('message.v2.bootstrap', {
|
|
4092
|
+
const selfBs = await this.call('message.v2.bootstrap', {
|
|
4093
|
+
peer_aid: this._aid,
|
|
4094
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
4095
|
+
});
|
|
4096
|
+
await this._primeBootstrapPeerCerts(selfBs, this._aid);
|
|
3508
4097
|
selfDevices = (Array.isArray(selfBs?.peer_devices) ? selfBs.peer_devices : []);
|
|
4098
|
+
const selfWrapPolicy = normalizeV2WrapPolicy(selfBs.e2ee_wrap_policy);
|
|
3509
4099
|
if (selfDevices.length > 0) {
|
|
3510
4100
|
this._v2BootstrapCache.set(this._aid, {
|
|
3511
4101
|
devices: selfDevices,
|
|
3512
4102
|
auditRecipients: [],
|
|
3513
4103
|
cachedAt: Date.now(),
|
|
4104
|
+
wrapPolicy: selfWrapPolicy,
|
|
3514
4105
|
});
|
|
3515
4106
|
}
|
|
3516
4107
|
}
|
|
@@ -3536,7 +4127,10 @@ export class AUNClient {
|
|
|
3536
4127
|
if (targets.length === 0) {
|
|
3537
4128
|
throw new E2EEError(`V2 bootstrap: no usable devices found for ${to}`);
|
|
3538
4129
|
}
|
|
3539
|
-
const envelope = encryptP2PMessage(session.getSenderIdentity(), {
|
|
4130
|
+
const envelope = encryptP2PMessage(session.getSenderIdentity(), {
|
|
4131
|
+
targets: applyV2WrapPolicyToTargets(targets, wrapPolicy),
|
|
4132
|
+
auditRecipients: applyV2WrapPolicyToTargets(auditTargets, wrapPolicy),
|
|
4133
|
+
}, opts.payload, {
|
|
3540
4134
|
messageId: opts.messageId,
|
|
3541
4135
|
timestamp: opts.timestamp,
|
|
3542
4136
|
protectedHeaders: opts.protectedHeaders,
|
|
@@ -3606,18 +4200,30 @@ export class AUNClient {
|
|
|
3606
4200
|
}
|
|
3607
4201
|
}
|
|
3608
4202
|
/** V2 P2P 拉取并解密;直接方法返回消息数组,call("message.pull") 会包装为 {messages}. */
|
|
3609
|
-
async pullV2(afterSeq = 0, limit = 50) {
|
|
4203
|
+
async pullV2(afterSeq = 0, limit = 50, opts) {
|
|
3610
4204
|
await this._ensureV2SessionReady('message.pull');
|
|
3611
4205
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
4206
|
+
if (ns && !opts?.gateLocked) {
|
|
4207
|
+
return await this._runPullSerialized(ns, async () => this.pullV2(afterSeq, limit, {
|
|
4208
|
+
...(opts ?? {}),
|
|
4209
|
+
gateLocked: true,
|
|
4210
|
+
scheduleFollowup: true,
|
|
4211
|
+
}));
|
|
4212
|
+
}
|
|
3612
4213
|
const decrypted = [];
|
|
4214
|
+
let totalRawCount = 0;
|
|
3613
4215
|
let nextAfterSeq = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
|
|
3614
4216
|
let pageCount = 0;
|
|
3615
4217
|
const maxPages = 100;
|
|
3616
4218
|
while (pageCount < maxPages) {
|
|
3617
4219
|
pageCount += 1;
|
|
3618
4220
|
this._clientLog.debug(`message.v2.pull page request: page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ns=${ns || '<none>'}`);
|
|
3619
|
-
const result = await this.
|
|
4221
|
+
const result = await this._callRawV2Rpc('message.v2.pull', {
|
|
4222
|
+
after_seq: nextAfterSeq,
|
|
4223
|
+
limit,
|
|
4224
|
+
});
|
|
3620
4225
|
const messages = (Array.isArray(result?.messages) ? result.messages : []);
|
|
4226
|
+
totalRawCount += messages.length;
|
|
3621
4227
|
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
4228
|
for (const msg of messages) {
|
|
3623
4229
|
this._logMessageDebug('pull-raw', 'message.v2.pull', 'message.received', msg);
|
|
@@ -3626,10 +4232,13 @@ export class AUNClient {
|
|
|
3626
4232
|
.map((msg) => Number(msg.seq ?? 0))
|
|
3627
4233
|
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
3628
4234
|
const pageContigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
3629
|
-
|
|
3630
|
-
if (
|
|
3631
|
-
|
|
3632
|
-
|
|
4235
|
+
let pageMaxSeq = nextAfterSeq;
|
|
4236
|
+
if (seqs.length > 0) {
|
|
4237
|
+
pageMaxSeq = Math.max(...seqs);
|
|
4238
|
+
if (ns) {
|
|
4239
|
+
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
4240
|
+
this._clientLog.debug(`message.v2.pull force contiguous: ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
|
|
4241
|
+
}
|
|
3633
4242
|
}
|
|
3634
4243
|
for (const msg of messages) {
|
|
3635
4244
|
const seq = Number(msg.seq ?? 0);
|
|
@@ -3653,10 +4262,12 @@ export class AUNClient {
|
|
|
3653
4262
|
payload: legacyPayload,
|
|
3654
4263
|
encrypted: false,
|
|
3655
4264
|
};
|
|
3656
|
-
if (ns)
|
|
4265
|
+
if (ns) {
|
|
3657
4266
|
await this._publishPulledMessage('message.received', ns, seq, v1Msg);
|
|
3658
|
-
|
|
4267
|
+
}
|
|
4268
|
+
else {
|
|
3659
4269
|
await this._publishAppEvent('message.received', v1Msg, 'pull');
|
|
4270
|
+
}
|
|
3660
4271
|
decrypted.push(v1Msg);
|
|
3661
4272
|
this._clientLog.debug(`message.v2.pull plaintext V1 delivered: seq=${seq}, ns=${ns || '<none>'}`);
|
|
3662
4273
|
}
|
|
@@ -3678,10 +4289,12 @@ export class AUNClient {
|
|
|
3678
4289
|
this._clientLog.debug(`message.v2.pull decrypt returned null: seq=${seq}, ns=${ns || '<none>'}`);
|
|
3679
4290
|
continue;
|
|
3680
4291
|
}
|
|
3681
|
-
if (ns)
|
|
4292
|
+
if (ns) {
|
|
3682
4293
|
await this._publishPulledMessage('message.received', ns, seq, plaintext);
|
|
3683
|
-
|
|
4294
|
+
}
|
|
4295
|
+
else {
|
|
3684
4296
|
await this._publishAppEvent('message.received', plaintext, 'pull');
|
|
4297
|
+
}
|
|
3685
4298
|
decrypted.push(plaintext);
|
|
3686
4299
|
this._logMessageDebug('decrypt-ok', 'message.v2.pull', 'message.received', plaintext);
|
|
3687
4300
|
}
|
|
@@ -3697,10 +4310,10 @@ export class AUNClient {
|
|
|
3697
4310
|
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
3698
4311
|
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
3699
4312
|
if (contigAdvanced) {
|
|
3700
|
-
await this._drainOrderedMessages(ns);
|
|
4313
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
3701
4314
|
this._saveSeqTrackerState();
|
|
3702
4315
|
}
|
|
3703
|
-
if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
|
|
4316
|
+
if (messages.length > 0 && contigAdvanced && ackSeq > 0 && !opts?.skipAutoAck) {
|
|
3704
4317
|
this._clientLog.debug(`message.v2.pull scheduling auto-ack: ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
|
|
3705
4318
|
this._safeAsync(this.ackV2(ackSeq).then(() => undefined));
|
|
3706
4319
|
}
|
|
@@ -3733,7 +4346,7 @@ export class AUNClient {
|
|
|
3733
4346
|
}
|
|
3734
4347
|
}
|
|
3735
4348
|
this._clientLog.debug(`message.v2.ack send: ns=${ns || '<none>'}, up_to_seq=${seq}`);
|
|
3736
|
-
const raw = await this.
|
|
4349
|
+
const raw = await this._callRawV2Rpc('message.v2.ack', { up_to_seq: seq });
|
|
3737
4350
|
const result = isJsonObject(raw)
|
|
3738
4351
|
? { ...raw }
|
|
3739
4352
|
: { result: raw };
|
|
@@ -3839,19 +4452,25 @@ export class AUNClient {
|
|
|
3839
4452
|
let auditRecipientsRaw = [];
|
|
3840
4453
|
let epoch = 0;
|
|
3841
4454
|
let stateCommitment = { state_version: 0, state_hash: '', state_chain: '' };
|
|
4455
|
+
let wrapPolicy = normalizeV2WrapPolicy(undefined);
|
|
3842
4456
|
const cached = useCache ? this._v2BootstrapCache.get(cacheKey) : undefined;
|
|
3843
4457
|
if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
3844
4458
|
allDevices = cached.devices;
|
|
3845
4459
|
auditRecipientsRaw = cached.auditRecipients;
|
|
3846
4460
|
epoch = cached.epoch ?? 0;
|
|
3847
4461
|
stateCommitment = cached.stateCommitment ?? stateCommitment;
|
|
4462
|
+
wrapPolicy = cached.wrapPolicy ?? wrapPolicy;
|
|
3848
4463
|
this._clientLog.debug(`group.v2.bootstrap cache hit: group=${groupId}, devices=${allDevices.length}, audit=${auditRecipientsRaw.length}, epoch=${epoch}, state_version=${stateCommitment.state_version}`);
|
|
3849
4464
|
}
|
|
3850
4465
|
else {
|
|
3851
|
-
const bs = await this.call('group.v2.bootstrap', {
|
|
4466
|
+
const bs = await this.call('group.v2.bootstrap', {
|
|
4467
|
+
group_id: groupId,
|
|
4468
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
4469
|
+
});
|
|
3852
4470
|
allDevices = (Array.isArray(bs.devices) ? bs.devices : []);
|
|
3853
4471
|
auditRecipientsRaw = (Array.isArray(bs.audit_recipients) ? bs.audit_recipients : []);
|
|
3854
4472
|
epoch = Number(bs.epoch ?? 0) || 0;
|
|
4473
|
+
wrapPolicy = normalizeV2WrapPolicy(bs.e2ee_wrap_policy);
|
|
3855
4474
|
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
4475
|
const stateChain = String(bs.state_chain ?? '');
|
|
3857
4476
|
await this._v2CheckFork(groupId, stateChain);
|
|
@@ -3869,6 +4488,7 @@ export class AUNClient {
|
|
|
3869
4488
|
cachedAt: Date.now(),
|
|
3870
4489
|
epoch,
|
|
3871
4490
|
stateCommitment,
|
|
4491
|
+
wrapPolicy,
|
|
3872
4492
|
});
|
|
3873
4493
|
}
|
|
3874
4494
|
// lazy sync 触发:发现 pending members 时异步发起提案
|
|
@@ -3911,7 +4531,7 @@ export class AUNClient {
|
|
|
3911
4531
|
if (target)
|
|
3912
4532
|
targets.push(target);
|
|
3913
4533
|
}
|
|
3914
|
-
const envelope = encryptGroupMessage(session.getSenderIdentity(), groupId, epoch, targets, opts.payload, {
|
|
4534
|
+
const envelope = encryptGroupMessage(session.getSenderIdentity(), groupId, epoch, applyV2WrapPolicyToTargets(targets, wrapPolicy), opts.payload, {
|
|
3915
4535
|
messageId: opts.messageId,
|
|
3916
4536
|
timestamp: opts.timestamp,
|
|
3917
4537
|
protectedHeaders: opts.protectedHeaders,
|
|
@@ -3938,28 +4558,37 @@ export class AUNClient {
|
|
|
3938
4558
|
return envelope;
|
|
3939
4559
|
}
|
|
3940
4560
|
async _pullGroupV2Internal(params) {
|
|
3941
|
-
await this.pullGroupV2(params.group_id, params.after_seq, params.limit);
|
|
4561
|
+
await this.pullGroupV2(params.group_id, params.after_seq, params.limit, { gateLocked: true });
|
|
3942
4562
|
}
|
|
3943
4563
|
/** V2 Group 拉取并解密;直接方法返回消息数组,call("group.pull") 会包装为 {messages}. */
|
|
3944
|
-
async pullGroupV2(groupId, afterSeq = 0, limit = 50) {
|
|
4564
|
+
async pullGroupV2(groupId, afterSeq = 0, limit = 50, opts) {
|
|
3945
4565
|
await this._ensureV2SessionReady('group.pull');
|
|
3946
4566
|
const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
3947
4567
|
if (!gid)
|
|
3948
4568
|
throw new ValidationError('group.pull requires group_id');
|
|
3949
4569
|
const ns = `group:${gid}`;
|
|
4570
|
+
if (!opts?.gateLocked) {
|
|
4571
|
+
return await this._runPullSerialized(ns, async () => this.pullGroupV2(gid, afterSeq, limit, {
|
|
4572
|
+
...(opts ?? {}),
|
|
4573
|
+
gateLocked: true,
|
|
4574
|
+
scheduleFollowup: true,
|
|
4575
|
+
}));
|
|
4576
|
+
}
|
|
3950
4577
|
const decrypted = [];
|
|
4578
|
+
let totalRawCount = 0;
|
|
3951
4579
|
let nextAfterSeq = afterSeq || this._seqTracker.getContiguousSeq(ns);
|
|
3952
4580
|
let pageCount = 0;
|
|
3953
4581
|
const maxPages = 100;
|
|
3954
4582
|
while (pageCount < maxPages) {
|
|
3955
4583
|
pageCount += 1;
|
|
3956
4584
|
this._clientLog.debug(`group.v2.pull page request: group=${gid}, page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ns=${ns}`);
|
|
3957
|
-
const result = await this.
|
|
4585
|
+
const result = await this._callRawV2Rpc('group.v2.pull', {
|
|
3958
4586
|
group_id: gid,
|
|
3959
4587
|
after_seq: nextAfterSeq,
|
|
3960
4588
|
limit,
|
|
3961
4589
|
});
|
|
3962
4590
|
const messages = (Array.isArray(result.messages) ? result.messages : []);
|
|
4591
|
+
totalRawCount += messages.length;
|
|
3963
4592
|
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
3964
4593
|
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
4594
|
for (const msg of messages) {
|
|
@@ -3969,8 +4598,9 @@ export class AUNClient {
|
|
|
3969
4598
|
.map((msg) => Number(msg.seq ?? 0))
|
|
3970
4599
|
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
3971
4600
|
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
3972
|
-
|
|
4601
|
+
let pageMaxSeq = nextAfterSeq;
|
|
3973
4602
|
if (seqs.length > 0) {
|
|
4603
|
+
pageMaxSeq = Math.max(...seqs);
|
|
3974
4604
|
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
3975
4605
|
this._clientLog.debug(`group.v2.pull force contiguous: group=${gid}, ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
|
|
3976
4606
|
}
|
|
@@ -4034,18 +4664,18 @@ export class AUNClient {
|
|
|
4034
4664
|
decrypted.push(plaintext);
|
|
4035
4665
|
this._logMessageDebug('decrypt-ok', 'group.v2.pull', 'group.message_created', plaintext);
|
|
4036
4666
|
}
|
|
4037
|
-
const
|
|
4038
|
-
if (
|
|
4667
|
+
const retentionFloor = this._pullRetentionFloor(result, 'retention_floor_message_seq', 'retention_floor_message_seq');
|
|
4668
|
+
if (retentionFloor > 0) {
|
|
4039
4669
|
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,
|
|
4670
|
+
if (contig < retentionFloor) {
|
|
4671
|
+
this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> retention_floor=${retentionFloor}`);
|
|
4672
|
+
this._seqTracker.forceContiguousSeq(ns, retentionFloor);
|
|
4043
4673
|
}
|
|
4044
4674
|
}
|
|
4045
4675
|
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
4046
4676
|
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
4047
4677
|
if (contigAdvanced) {
|
|
4048
|
-
await this._drainOrderedMessages(ns);
|
|
4678
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
4049
4679
|
this._saveSeqTrackerState();
|
|
4050
4680
|
}
|
|
4051
4681
|
if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
|
|
@@ -4081,7 +4711,7 @@ export class AUNClient {
|
|
|
4081
4711
|
seq = maxSeen;
|
|
4082
4712
|
}
|
|
4083
4713
|
this._clientLog.debug(`group.v2.ack send: group=${gid}, ns=${ns}, up_to_seq=${seq}`);
|
|
4084
|
-
const result = await this.
|
|
4714
|
+
const result = await this._callRawV2Rpc('group.v2.ack', { group_id: gid, up_to_seq: seq });
|
|
4085
4715
|
this._clientLog.debug(`group.v2.ack ok: group=${gid}, ns=${ns}, requested=${seq}, result=${this._debugJson(result)}`);
|
|
4086
4716
|
return result;
|
|
4087
4717
|
}
|
|
@@ -4117,7 +4747,7 @@ export class AUNClient {
|
|
|
4117
4747
|
for (const row of recipients) {
|
|
4118
4748
|
if (Array.isArray(row) && row.length >= 6
|
|
4119
4749
|
&& String(row[0] ?? '') === this._aid
|
|
4120
|
-
&& String(row[1] ?? '') === this._deviceId) {
|
|
4750
|
+
&& (String(row[1] ?? '') === this._deviceId || String(row[1] ?? '') === '')) {
|
|
4121
4751
|
if (!spkId)
|
|
4122
4752
|
spkId = String(row[5] ?? '');
|
|
4123
4753
|
if (row.length > 3)
|
|
@@ -4465,7 +5095,8 @@ export class AUNClient {
|
|
|
4465
5095
|
for (const row of envelope.recipients) {
|
|
4466
5096
|
if (!Array.isArray(row) || row.length < 6)
|
|
4467
5097
|
continue;
|
|
4468
|
-
if (String(row[0] ?? '') === this._aid
|
|
5098
|
+
if (String(row[0] ?? '') === this._aid
|
|
5099
|
+
&& (String(row[1] ?? '') === this._deviceId || String(row[1] ?? '') === '')) {
|
|
4469
5100
|
spkId = String(row[5] ?? '');
|
|
4470
5101
|
recipientKeySource = String(row[3] ?? '');
|
|
4471
5102
|
break;
|
|
@@ -4749,7 +5380,10 @@ export class AUNClient {
|
|
|
4749
5380
|
}
|
|
4750
5381
|
if (myRole !== 'owner' && myRole !== 'admin')
|
|
4751
5382
|
return false;
|
|
4752
|
-
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5383
|
+
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5384
|
+
group_id: groupId,
|
|
5385
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5386
|
+
});
|
|
4753
5387
|
const devices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
|
|
4754
5388
|
? bootstrapResp.devices.filter(isJsonObject)
|
|
4755
5389
|
: [];
|
|
@@ -4858,7 +5492,10 @@ export class AUNClient {
|
|
|
4858
5492
|
}
|
|
4859
5493
|
}
|
|
4860
5494
|
}
|
|
4861
|
-
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5495
|
+
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5496
|
+
group_id: groupId,
|
|
5497
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5498
|
+
});
|
|
4862
5499
|
const allDevices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
|
|
4863
5500
|
? bootstrapResp.devices.filter(isJsonObject)
|
|
4864
5501
|
: [];
|
|
@@ -5064,11 +5701,10 @@ export class AUNClient {
|
|
|
5064
5701
|
try {
|
|
5065
5702
|
const decrypted = await this._decryptV2PushMessage(data);
|
|
5066
5703
|
if (decrypted) {
|
|
5067
|
-
//
|
|
5068
|
-
// (如果 pushSeq == contiguousSeq + 1 会自动推进到 pushSeq)
|
|
5069
|
-
const needPull = this._seqTracker.onMessageSeq(ns, pushSeq);
|
|
5704
|
+
// 解密成功也不能先推进 contiguousSeq;必须等应用层发布返回后再推进和 ACK。
|
|
5070
5705
|
const published = await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
|
|
5071
5706
|
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5707
|
+
const needPull = pushSeq > newContig && !published;
|
|
5072
5708
|
if (newContig !== contigBefore) {
|
|
5073
5709
|
this._saveSeqTrackerState();
|
|
5074
5710
|
}
|
|
@@ -5076,7 +5712,7 @@ export class AUNClient {
|
|
|
5076
5712
|
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
5077
5713
|
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
5078
5714
|
const ackSeq = maxSeen > 0 ? Math.min(newContig, maxSeen) : newContig;
|
|
5079
|
-
this.call('message.v2.ack', { up_to_seq: ackSeq })
|
|
5715
|
+
this.call('message.v2.ack', { up_to_seq: ackSeq, _rpc_background: true })
|
|
5080
5716
|
.catch(e => this._clientLog.debug(`V2 P2P push-ack failed: ${formatCaughtError(e)}`));
|
|
5081
5717
|
}
|
|
5082
5718
|
this._clientLog.debug(`_onV2PushNotification: push 带 payload 解密成功, contiguous_seq=${contigBefore}->${newContig} push_seq=${pushSeq}`);
|
|
@@ -5096,31 +5732,37 @@ export class AUNClient {
|
|
|
5096
5732
|
if (pushSeq > 0 && ns) {
|
|
5097
5733
|
this._clientLog.debug(`_onV2PushNotification: 纯通知 push_seq=${pushSeq} > contiguous_seq=${contigBefore}, 触发 pull(after_seq=${contigBefore})`);
|
|
5098
5734
|
}
|
|
5099
|
-
if (
|
|
5100
|
-
this._v2PullPending = true;
|
|
5735
|
+
if (!ns)
|
|
5101
5736
|
return;
|
|
5102
|
-
|
|
5103
|
-
|
|
5104
|
-
|
|
5105
|
-
|
|
5106
|
-
this.
|
|
5107
|
-
|
|
5108
|
-
|
|
5737
|
+
void this._tryRunBackgroundPull(ns, async () => {
|
|
5738
|
+
const operationBefore = this._seqTracker.getContiguousSeq(ns);
|
|
5739
|
+
const dedupKey = `p2p_pull:${ns}`;
|
|
5740
|
+
if (this._gapFillDone.has(dedupKey)) {
|
|
5741
|
+
this._recordPendingP2pPull(ns, pushSeq);
|
|
5742
|
+
return 0;
|
|
5743
|
+
}
|
|
5744
|
+
this._gapFillDone.set(dedupKey, Date.now());
|
|
5745
|
+
try {
|
|
5746
|
+
const pulled = await this.pullV2(0, 50, { gateLocked: true });
|
|
5747
|
+
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5109
5748
|
this._clientLog.debug(`_onV2PushNotification pull done: contiguous_seq=${contigBefore}->${newContig} (push_seq=${pushSeq || 'null'})`);
|
|
5110
|
-
|
|
5111
|
-
|
|
5112
|
-
|
|
5113
|
-
|
|
5749
|
+
if (newContig <= operationBefore)
|
|
5750
|
+
return 0;
|
|
5751
|
+
return pulled.length;
|
|
5752
|
+
}
|
|
5753
|
+
finally {
|
|
5754
|
+
this._gapFillDone.delete(dedupKey);
|
|
5755
|
+
}
|
|
5756
|
+
}, true, () => this._recordPendingP2pPull(ns, pushSeq)).catch((exc) => {
|
|
5757
|
+
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5114
5758
|
this._clientLog.warn(`V2 push auto-pull failed: contiguous_seq=${contigBefore}->${newContig} err=${formatCaughtError(exc)}`);
|
|
5115
|
-
}
|
|
5116
|
-
finally {
|
|
5117
|
-
this._v2PullInflight = false;
|
|
5118
|
-
}
|
|
5759
|
+
});
|
|
5119
5760
|
}
|
|
5120
5761
|
async _onV2StateProposed(data) {
|
|
5121
5762
|
if (!isJsonObject(data) || !this._v2Session)
|
|
5122
5763
|
return;
|
|
5123
|
-
const
|
|
5764
|
+
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5765
|
+
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
5124
5766
|
if (!groupId)
|
|
5125
5767
|
return;
|
|
5126
5768
|
await this._dispatcher.publish('group.v2.state_proposed', data);
|
|
@@ -5134,7 +5776,8 @@ export class AUNClient {
|
|
|
5134
5776
|
async _onV2StateRetryNeeded(data) {
|
|
5135
5777
|
if (!isJsonObject(data) || !this._v2Session)
|
|
5136
5778
|
return;
|
|
5137
|
-
const
|
|
5779
|
+
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5780
|
+
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
5138
5781
|
if (!groupId)
|
|
5139
5782
|
return;
|
|
5140
5783
|
await this._dispatcher.publish('group.v2.state_retry_needed', data);
|
|
@@ -5148,7 +5791,8 @@ export class AUNClient {
|
|
|
5148
5791
|
async _onV2StateConfirmed(data) {
|
|
5149
5792
|
if (!isJsonObject(data))
|
|
5150
5793
|
return;
|
|
5151
|
-
const
|
|
5794
|
+
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5795
|
+
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
5152
5796
|
if (groupId) {
|
|
5153
5797
|
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
5154
5798
|
this._v2AutoProposeLastSnapshot.delete(groupId);
|
|
@@ -5161,7 +5805,8 @@ export class AUNClient {
|
|
|
5161
5805
|
return;
|
|
5162
5806
|
}
|
|
5163
5807
|
this._logMessageDebug('server-push', '_raw.group.v2.message_created', 'group.message_created', data);
|
|
5164
|
-
const
|
|
5808
|
+
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5809
|
+
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
5165
5810
|
const seq = Number(data.seq ?? 0);
|
|
5166
5811
|
if (!groupId || !Number.isFinite(seq) || seq <= 0) {
|
|
5167
5812
|
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: group=${groupId || '<empty>'}, seq=${String(data.seq ?? '')}`);
|
|
@@ -5178,22 +5823,28 @@ export class AUNClient {
|
|
|
5178
5823
|
}
|
|
5179
5824
|
const afterSeq = this._repairPushContiguousBound(ns, seq, false, '_raw.group.v2.message_created');
|
|
5180
5825
|
const dedupKey = `v2_group_push:${groupId}:${afterSeq}`;
|
|
5181
|
-
|
|
5182
|
-
this.
|
|
5183
|
-
|
|
5184
|
-
|
|
5185
|
-
|
|
5186
|
-
|
|
5187
|
-
this.
|
|
5188
|
-
|
|
5189
|
-
|
|
5190
|
-
|
|
5191
|
-
|
|
5826
|
+
void this._tryRunBackgroundPull(ns, async () => {
|
|
5827
|
+
const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
5828
|
+
if (this._gapFillDone.has(dedupKey)) {
|
|
5829
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped duplicate in-flight pull: group=${groupId}, dedup=${dedupKey}`);
|
|
5830
|
+
return 0;
|
|
5831
|
+
}
|
|
5832
|
+
this._gapFillDone.set(dedupKey, Date.now());
|
|
5833
|
+
try {
|
|
5834
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull start: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}`);
|
|
5835
|
+
const pulled = await this.pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
|
|
5836
|
+
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5837
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull done: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}, contiguous=${newContig}`);
|
|
5838
|
+
if (newContig <= pullAfterSeq)
|
|
5839
|
+
return 0;
|
|
5840
|
+
return pulled.length;
|
|
5841
|
+
}
|
|
5842
|
+
finally {
|
|
5843
|
+
this._gapFillDone.delete(dedupKey);
|
|
5844
|
+
}
|
|
5845
|
+
}, true).catch((exc) => {
|
|
5192
5846
|
this._clientLog.warn(`V2 group push auto-pull failed: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
5193
|
-
}
|
|
5194
|
-
finally {
|
|
5195
|
-
this._gapFillDone.delete(dedupKey);
|
|
5196
|
-
}
|
|
5847
|
+
});
|
|
5197
5848
|
}
|
|
5198
5849
|
/** Push 通知带 payload 时的就地解密(复用 _decryptV2Message) */
|
|
5199
5850
|
async _decryptV2PushMessage(data) {
|