@agentunion/fastaun 0.3.2 → 0.3.3
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 +22 -0
- package/_packed_docs/CHANGELOG.md +22 -0
- package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +48 -15
- package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +182 -28
- package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +7 -5
- package/_packed_docs/sdk/INDEX.md +17 -12
- package/dist/auth.d.ts +3 -0
- package/dist/auth.js +18 -18
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +71 -8
- package/dist/client.js +1811 -440
- package/dist/client.js.map +1 -1
- package/dist/discovery.d.ts +4 -0
- package/dist/discovery.js +28 -13
- package/dist/discovery.js.map +1 -1
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/keystore/aid-db.d.ts +4 -0
- package/dist/keystore/aid-db.js +94 -0
- package/dist/keystore/aid-db.js.map +1 -1
- package/dist/keystore/file.d.ts +3 -1
- package/dist/keystore/file.js +18 -0
- package/dist/keystore/file.js.map +1 -1
- package/dist/keystore/index.d.ts +20 -0
- package/dist/logger.d.ts +2 -0
- package/dist/logger.js +7 -4
- package/dist/logger.js.map +1 -1
- package/dist/namespaces/auth.d.ts +1 -0
- package/dist/namespaces/auth.js +38 -0
- package/dist/namespaces/auth.js.map +1 -1
- package/dist/net.d.ts +43 -0
- package/dist/net.js +192 -0
- package/dist/net.js.map +1 -0
- package/dist/seq-tracker.d.ts +32 -3
- package/dist/seq-tracker.js +60 -3
- package/dist/seq-tracker.js.map +1 -1
- package/dist/tools/cross-sdk-agent.d.ts +2 -0
- package/dist/tools/cross-sdk-agent.js +695 -0
- package/dist/tools/cross-sdk-agent.js.map +1 -0
- package/dist/transport.d.ts +2 -0
- package/dist/transport.js +45 -0
- package/dist/transport.js.map +1 -1
- package/dist/v2/crypto/canonical.d.ts +1 -1
- package/dist/v2/crypto/canonical.js +42 -17
- package/dist/v2/crypto/canonical.js.map +1 -1
- package/dist/v2/e2ee/decrypt.js +56 -2
- package/dist/v2/e2ee/decrypt.js.map +1 -1
- package/dist/v2/e2ee/encrypt-group.js +16 -7
- package/dist/v2/e2ee/encrypt-group.js.map +1 -1
- package/dist/v2/e2ee/encrypt-p2p.js +41 -9
- package/dist/v2/e2ee/encrypt-p2p.js.map +1 -1
- package/dist/v2/e2ee/metadata-auth.d.ts +1 -0
- package/dist/v2/e2ee/metadata-auth.js +37 -1
- package/dist/v2/e2ee/metadata-auth.js.map +1 -1
- package/dist/v2/e2ee/types.d.ts +2 -2
- package/dist/v2/session/keystore.d.ts +10 -3
- package/dist/v2/session/keystore.js +158 -30
- package/dist/v2/session/keystore.js.map +1 -1
- package/dist/v2/session/session.d.ts +6 -3
- package/dist/v2/session/session.js +58 -12
- package/dist/v2/session/session.js.map +1 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -14,10 +14,12 @@ import * as crypto from 'node:crypto';
|
|
|
14
14
|
import * as fs from 'node:fs';
|
|
15
15
|
import * as http from 'node:http';
|
|
16
16
|
import * as https from 'node:https';
|
|
17
|
+
import * as path from 'node:path';
|
|
17
18
|
import { URL } from 'node:url';
|
|
18
19
|
import { configFromMap, getDeviceId, normalizeInstanceId } from './config.js';
|
|
19
20
|
import { CryptoProvider } from './crypto.js';
|
|
20
21
|
import { GatewayDiscovery } from './discovery.js';
|
|
22
|
+
import { DnsResilientNet } from './net.js';
|
|
21
23
|
import { AUNError, AuthError, ConnectionError, E2EEError, PermissionError, StateError, TimeoutError, ValidationError, } from './errors.js';
|
|
22
24
|
import { EventDispatcher } from './events.js';
|
|
23
25
|
import { FileKeyStore } from './keystore/file.js';
|
|
@@ -31,6 +33,7 @@ import { AuthFlow } from './auth.js';
|
|
|
31
33
|
import { SeqTracker } from './seq-tracker.js';
|
|
32
34
|
import { V2Session } from './v2/session/index.js';
|
|
33
35
|
import { encryptP2PMessage, encryptGroupMessage, decryptMessage, } from './v2/e2ee/index.js';
|
|
36
|
+
import { ecdsaVerifyRaw } from './v2/crypto/ecdsa.js';
|
|
34
37
|
import { computeStateCommitment } from './v2/state/index.js';
|
|
35
38
|
import { isJsonObject, } from './types.js';
|
|
36
39
|
/**
|
|
@@ -58,6 +61,15 @@ export function stableStringify(obj) {
|
|
|
58
61
|
}
|
|
59
62
|
return JSON.stringify(obj);
|
|
60
63
|
}
|
|
64
|
+
function getV2DeviceId(dev) {
|
|
65
|
+
if (Object.prototype.hasOwnProperty.call(dev, 'device_id')) {
|
|
66
|
+
return { present: true, value: String(dev.device_id ?? '').trim() };
|
|
67
|
+
}
|
|
68
|
+
if (Object.prototype.hasOwnProperty.call(dev, 'owner_device_id')) {
|
|
69
|
+
return { present: true, value: String(dev.owner_device_id ?? '').trim() };
|
|
70
|
+
}
|
|
71
|
+
return { present: false, value: '' };
|
|
72
|
+
}
|
|
61
73
|
function computeStateHash(params) {
|
|
62
74
|
const sortedMembers = [...params.members].sort((a, b) => a.aid.localeCompare(b.aid));
|
|
63
75
|
const membershipBlock = sortedMembers.map(m => `${m.aid}:${m.role}`).join('|');
|
|
@@ -156,7 +168,16 @@ function reconnectSleepDelayMs(baseDelay, maxBaseDelay) {
|
|
|
156
168
|
}
|
|
157
169
|
/** 需要客户端签名的关键方法 */
|
|
158
170
|
const SIGNED_METHODS = new Set([
|
|
159
|
-
'
|
|
171
|
+
'message.send',
|
|
172
|
+
'message.v2.put_peer_pk', 'message.v2.bootstrap',
|
|
173
|
+
'message.v2.group_bootstrap', 'message.v2.pull',
|
|
174
|
+
'message.v2.ack',
|
|
175
|
+
'group.send',
|
|
176
|
+
'group.v2.put_group_pk', 'group.v2.bootstrap',
|
|
177
|
+
'group.v2.send', 'group.v2.pull', 'group.v2.ack',
|
|
178
|
+
'group.v2.propose_state', 'group.v2.confirm_state',
|
|
179
|
+
'group.v2.get_proposal',
|
|
180
|
+
'group.kick', 'group.add_member',
|
|
160
181
|
'group.leave', 'group.remove_member', 'group.update_rules',
|
|
161
182
|
'group.update', 'group.update_announcement',
|
|
162
183
|
'group.update_join_requirements', 'group.set_role',
|
|
@@ -189,6 +210,21 @@ function _v2B64ToBytes(s) {
|
|
|
189
210
|
const buf = Buffer.from(String(s ?? '').trim(), 'base64');
|
|
190
211
|
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
191
212
|
}
|
|
213
|
+
function _v2B64ToBytesStrict(s) {
|
|
214
|
+
const text = String(s ?? '').trim();
|
|
215
|
+
if (!text || text.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(text)) {
|
|
216
|
+
throw new Error('invalid base64');
|
|
217
|
+
}
|
|
218
|
+
return _v2B64ToBytes(text);
|
|
219
|
+
}
|
|
220
|
+
function _v2BytesEqual(a, b) {
|
|
221
|
+
if (a.length !== b.length)
|
|
222
|
+
return false;
|
|
223
|
+
let diff = 0;
|
|
224
|
+
for (let i = 0; i < a.length; i++)
|
|
225
|
+
diff |= a[i] ^ b[i];
|
|
226
|
+
return diff === 0;
|
|
227
|
+
}
|
|
192
228
|
function _v2B64uToBytes(s) {
|
|
193
229
|
const std = String(s ?? '').replace(/-/g, '+').replace(/_/g, '/');
|
|
194
230
|
const pad = std.length % 4 === 0 ? '' : '='.repeat(4 - (std.length % 4));
|
|
@@ -245,11 +281,11 @@ function normalizeDeliveryModeConfig(raw, opts = {}) {
|
|
|
245
281
|
}
|
|
246
282
|
// ── HTTP 辅助 ─────────────────────────────────────────────────
|
|
247
283
|
/** 发起 HTTP GET 请求,返回文本内容 */
|
|
248
|
-
function _httpGetText(url, verifySsl) {
|
|
284
|
+
function _httpGetText(url, verifySsl, timeoutMs = 30_000) {
|
|
249
285
|
return new Promise((resolve, reject) => {
|
|
250
286
|
const parsed = new URL(url);
|
|
251
287
|
const mod = parsed.protocol === 'https:' ? https : http;
|
|
252
|
-
const options = { timeout:
|
|
288
|
+
const options = { timeout: timeoutMs };
|
|
253
289
|
if (!verifySsl) {
|
|
254
290
|
options.rejectUnauthorized = false;
|
|
255
291
|
}
|
|
@@ -274,6 +310,17 @@ function _httpGetText(url, verifySsl) {
|
|
|
274
310
|
/**
|
|
275
311
|
* AUN Core SDK 主客户端
|
|
276
312
|
*/
|
|
313
|
+
function lengthPrefixedTextKey(...parts) {
|
|
314
|
+
return parts.map((part) => `${Buffer.byteLength(part, 'utf8')}:${part};`).join('');
|
|
315
|
+
}
|
|
316
|
+
function lengthPrefixedBytesKey(...parts) {
|
|
317
|
+
const chunks = [];
|
|
318
|
+
for (const part of parts) {
|
|
319
|
+
const bytes = Buffer.from(part.buffer, part.byteOffset, part.byteLength);
|
|
320
|
+
chunks.push(Buffer.from(`${bytes.length}:`, 'ascii'), bytes, Buffer.from(';', 'ascii'));
|
|
321
|
+
}
|
|
322
|
+
return Buffer.concat(chunks);
|
|
323
|
+
}
|
|
277
324
|
export class AUNClient {
|
|
278
325
|
/** 原始配置 */
|
|
279
326
|
config;
|
|
@@ -317,13 +364,15 @@ export class AUNClient {
|
|
|
317
364
|
_defaultConnectDeliveryMode;
|
|
318
365
|
/** peer 证书缓存 */
|
|
319
366
|
_certCache = new Map();
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
// 触发"本地未发布到服务端"或"服务端版本更新"的 UI 提示。
|
|
367
|
+
// AgentMDs 目录:{agentMdPath}/list.json 保存元数据,{agentMdPath}/{aid}/agent.md 保存正文。
|
|
368
|
+
_agentMdPath = '';
|
|
323
369
|
_localAgentMdPath = '';
|
|
324
370
|
_localAgentMdEtag = '';
|
|
325
371
|
// gateway 在 RPC envelope._meta.agent_md_etag 注入的服务端 etag;纯观察,无下游依赖。
|
|
326
372
|
_remoteAgentMdEtag = '';
|
|
373
|
+
_agentMdCache = new Map();
|
|
374
|
+
_agentMdFetchInflight = new Set();
|
|
375
|
+
_agentMdLastListRebuilt = false;
|
|
327
376
|
/** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
|
|
328
377
|
_seqTracker = new SeqTracker();
|
|
329
378
|
_seqTrackerContext = null;
|
|
@@ -335,6 +384,10 @@ export class AUNClient {
|
|
|
335
384
|
_pushedSeqs = new Map();
|
|
336
385
|
/** 已解密但因 seq 空洞暂缓发布的应用层消息(按 namespace -> seq) */
|
|
337
386
|
_pendingOrderedMsgs = new Map();
|
|
387
|
+
/** 缺 sender IK 时暂存原始 V2 消息,后台补齐 IK 后重试解密。 */
|
|
388
|
+
_v2SenderIKPending = new Map();
|
|
389
|
+
/** sender IK 后台补齐任务去重。 */
|
|
390
|
+
_v2SenderIKFetching = new Set();
|
|
338
391
|
// ── 后台任务定时器 ──────────────────────────────────────────
|
|
339
392
|
_heartbeatTimer = null;
|
|
340
393
|
_tokenRefreshTimer = null;
|
|
@@ -360,7 +413,7 @@ export class AUNClient {
|
|
|
360
413
|
_v2PullPending = false;
|
|
361
414
|
static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
|
|
362
415
|
static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
|
|
363
|
-
static V2_SIG_CACHE_TTL_MS =
|
|
416
|
+
static V2_SIG_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
364
417
|
static V2_SIG_CACHE_MAX = 16_384;
|
|
365
418
|
_reconnectActive = false;
|
|
366
419
|
_reconnectAbort = null;
|
|
@@ -373,30 +426,36 @@ export class AUNClient {
|
|
|
373
426
|
const rawConfig = { ...(config ?? {}) };
|
|
374
427
|
this._configModel = configFromMap(rawConfig);
|
|
375
428
|
const initAid = String(rawConfig.aid ?? '').trim() || null;
|
|
429
|
+
this._agentMdPath = path.join(this._configModel.aunPath, 'AgentMDs');
|
|
376
430
|
this.config = {
|
|
377
431
|
aun_path: this._configModel.aunPath,
|
|
378
432
|
root_ca_path: this._configModel.rootCaPath,
|
|
379
433
|
seed_password: this._configModel.seedPassword,
|
|
380
434
|
};
|
|
435
|
+
this._deviceId = getDeviceId(this._configModel.aunPath);
|
|
381
436
|
// 初始化 Logger(per-client 单例,必须最早创建)
|
|
382
437
|
const debugFlag = this._configModel.debug || debug;
|
|
383
438
|
this._logger = new AUNLogger({
|
|
384
439
|
debug: debugFlag,
|
|
385
440
|
aunPath: this._configModel.aunPath,
|
|
386
441
|
});
|
|
442
|
+
this._logger.bindDeviceId(this._deviceId);
|
|
387
443
|
this._clientLog = this._logger.for('aun_core.client');
|
|
388
444
|
if (debugFlag) {
|
|
389
445
|
this._clientLog.info(`AUNClient initialized (debug=true, aunPath=${this._configModel.aunPath})`);
|
|
390
446
|
}
|
|
391
447
|
this._dispatcher = new EventDispatcher(this._logger.for('aun_core.events'));
|
|
392
|
-
|
|
448
|
+
const dnsNet = new DnsResilientNet({
|
|
449
|
+
verifySsl: this._configModel.verifySsl,
|
|
450
|
+
logger: this._clientLog,
|
|
451
|
+
});
|
|
452
|
+
this._discovery = new GatewayDiscovery({ verifySsl: this._configModel.verifySsl, logger: this._clientLog, net: dnsNet });
|
|
393
453
|
const keystore = new FileKeyStore(this._configModel.aunPath, {
|
|
394
454
|
encryptionSeed: this._configModel.seedPassword ?? undefined,
|
|
395
455
|
logger: this._logger.for('aun_core.keystore'),
|
|
396
456
|
secretStoreLogger: this._logger.for('aun_core.secret-store'),
|
|
397
457
|
});
|
|
398
458
|
this._keystore = keystore;
|
|
399
|
-
this._deviceId = getDeviceId(this._configModel.aunPath);
|
|
400
459
|
this._slotId = '';
|
|
401
460
|
this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
|
|
402
461
|
this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
|
|
@@ -409,6 +468,7 @@ export class AUNClient {
|
|
|
409
468
|
rootCaPath: this._configModel.rootCaPath ?? undefined,
|
|
410
469
|
verifySsl: this._configModel.verifySsl,
|
|
411
470
|
logger: this._logger.for('aun_core.auth'),
|
|
471
|
+
net: dnsNet,
|
|
412
472
|
});
|
|
413
473
|
this._aid = initAid;
|
|
414
474
|
this._transport = new RPCTransport({
|
|
@@ -417,6 +477,7 @@ export class AUNClient {
|
|
|
417
477
|
onDisconnect: (err, closeCode) => this._handleTransportDisconnect(err, closeCode),
|
|
418
478
|
verifySsl: this._configModel.verifySsl,
|
|
419
479
|
logger: this._logger.for('aun_core.transport'),
|
|
480
|
+
dnsNet,
|
|
420
481
|
});
|
|
421
482
|
this._transport.setMetaObserver((meta) => this._observeRpcMeta(meta));
|
|
422
483
|
this.auth = new AuthNamespace(this);
|
|
@@ -453,25 +514,35 @@ export class AUNClient {
|
|
|
453
514
|
return this._aid;
|
|
454
515
|
}
|
|
455
516
|
/**
|
|
456
|
-
*
|
|
517
|
+
* 读取 {agentMdPath}/{self_aid}/agent.md,签名后上传,并把签名结果原子写回本地。
|
|
457
518
|
*/
|
|
458
|
-
async publishAgentMd(
|
|
459
|
-
const
|
|
460
|
-
if (!
|
|
461
|
-
throw new ValidationError('publishAgentMd requires
|
|
519
|
+
async publishAgentMd() {
|
|
520
|
+
const target = this._agentMdOwnerAid();
|
|
521
|
+
if (!target) {
|
|
522
|
+
throw new ValidationError('publishAgentMd requires local AID');
|
|
462
523
|
}
|
|
463
|
-
const content =
|
|
524
|
+
const content = this._readAgentMdContent(target);
|
|
464
525
|
const signed = await this.auth.signAgentMd(content);
|
|
465
526
|
const result = await this.auth.uploadAgentMd(signed);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
527
|
+
this._localAgentMdEtag = this._agentMdContentEtag(signed);
|
|
528
|
+
const remoteEtag = isJsonObject(result) ? String(result.etag ?? '').trim() : '';
|
|
529
|
+
if (remoteEtag)
|
|
530
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
531
|
+
this._saveAgentMdRecord(target, {
|
|
532
|
+
content: signed,
|
|
533
|
+
local_etag: this._localAgentMdEtag,
|
|
534
|
+
remote_etag: remoteEtag || undefined,
|
|
535
|
+
last_modified: isJsonObject(result) ? String(result.last_modified ?? '').trim() : '',
|
|
536
|
+
fetched_at: Date.now(),
|
|
537
|
+
remote_status: remoteEtag ? 'found' : 'unknown',
|
|
538
|
+
last_error: '',
|
|
539
|
+
});
|
|
469
540
|
return result;
|
|
470
541
|
}
|
|
471
542
|
/**
|
|
472
|
-
* 下载 agent.md
|
|
543
|
+
* 下载 agent.md 并自动验签;内容固定保存到 {agentMdPath}/{aid}/agent.md。
|
|
473
544
|
*/
|
|
474
|
-
async fetchAgentMd(aid
|
|
545
|
+
async fetchAgentMd(aid) {
|
|
475
546
|
const target = String(aid ?? this._aid ?? '').trim();
|
|
476
547
|
if (!target) {
|
|
477
548
|
throw new ValidationError('fetchAgentMd requires aid (or local AID)');
|
|
@@ -479,35 +550,54 @@ export class AUNClient {
|
|
|
479
550
|
const content = await this.auth.downloadAgentMd(target);
|
|
480
551
|
const signature = await this.auth.verifyAgentMd(content, { aid: target });
|
|
481
552
|
const isSelf = target === (this._aid ?? '');
|
|
553
|
+
const localEtag = this._agentMdContentEtag(content);
|
|
554
|
+
const cacheMeta = this._agentMdAuthCacheMeta(target);
|
|
555
|
+
const remoteEtag = String(cacheMeta.etag ?? '').trim();
|
|
556
|
+
const lastModified = String(cacheMeta.lastModified ?? cacheMeta.last_modified ?? '').trim();
|
|
557
|
+
if (isSelf) {
|
|
558
|
+
this._localAgentMdEtag = localEtag;
|
|
559
|
+
if (remoteEtag)
|
|
560
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
561
|
+
}
|
|
562
|
+
const saved = this._saveAgentMdRecord(target, {
|
|
563
|
+
content,
|
|
564
|
+
local_etag: localEtag,
|
|
565
|
+
remote_etag: remoteEtag || undefined,
|
|
566
|
+
last_modified: lastModified || undefined,
|
|
567
|
+
fetched_at: Date.now(),
|
|
568
|
+
remote_status: 'found',
|
|
569
|
+
verify_status: isJsonObject(signature) ? String(signature.status ?? '') : '',
|
|
570
|
+
verify_error: isJsonObject(signature) ? String(signature.reason ?? '') : '',
|
|
571
|
+
last_error: '',
|
|
572
|
+
});
|
|
482
573
|
let inSync = null;
|
|
483
574
|
if (isSelf) {
|
|
484
|
-
const
|
|
485
|
-
|
|
486
|
-
const local = this._localAgentMdEtag || '';
|
|
487
|
-
const remote = this._remoteAgentMdEtag || '';
|
|
488
|
-
inSync = local && remote ? local === remote : false;
|
|
489
|
-
}
|
|
490
|
-
let savedTo = null;
|
|
491
|
-
let saveError = null;
|
|
492
|
-
const rawSavePath = String(savePath ?? '').trim();
|
|
493
|
-
if (rawSavePath) {
|
|
494
|
-
try {
|
|
495
|
-
fs.writeFileSync(rawSavePath, content, 'utf-8');
|
|
496
|
-
savedTo = rawSavePath;
|
|
497
|
-
}
|
|
498
|
-
catch (exc) {
|
|
499
|
-
saveError = exc instanceof Error ? exc.message : String(exc);
|
|
500
|
-
}
|
|
575
|
+
const remote = remoteEtag || this._remoteAgentMdEtag || '';
|
|
576
|
+
inSync = localEtag && remote ? localEtag === remote : false;
|
|
501
577
|
}
|
|
502
578
|
return {
|
|
503
579
|
aid: target,
|
|
504
580
|
content,
|
|
505
581
|
signature: signature,
|
|
506
582
|
in_sync: inSync,
|
|
507
|
-
saved_to:
|
|
508
|
-
save_error:
|
|
583
|
+
saved_to: String(saved.saved_to ?? this._agentMdFilePath(target)),
|
|
584
|
+
save_error: null,
|
|
509
585
|
};
|
|
510
586
|
}
|
|
587
|
+
/**
|
|
588
|
+
* 设置 agent.md 本地存储根目录;为空时恢复默认 {aun_path}/AgentMDs。
|
|
589
|
+
*/
|
|
590
|
+
setAgentMdPath(root) {
|
|
591
|
+
const raw = String(root ?? '').trim();
|
|
592
|
+
const next = raw || path.join(this._configModel.aunPath, 'AgentMDs');
|
|
593
|
+
fs.mkdirSync(next, { recursive: true });
|
|
594
|
+
this._agentMdPath = next;
|
|
595
|
+
this._agentMdCache.clear();
|
|
596
|
+
return this._agentMdPath;
|
|
597
|
+
}
|
|
598
|
+
SetAgentMDPath(root) {
|
|
599
|
+
return this.setAgentMdPath(root);
|
|
600
|
+
}
|
|
511
601
|
/**
|
|
512
602
|
* 记录本地 agent.md 文件路径并一次性计算 etag(quoted sha256,与服务端一致)。
|
|
513
603
|
*
|
|
@@ -558,6 +648,447 @@ export class AUNClient {
|
|
|
558
648
|
getRemoteAgentMdEtag() {
|
|
559
649
|
return this._remoteAgentMdEtag;
|
|
560
650
|
}
|
|
651
|
+
_agentMdContentEtag(content) {
|
|
652
|
+
return `"${crypto.createHash('sha256').update(String(content ?? ''), 'utf-8').digest('hex')}"`;
|
|
653
|
+
}
|
|
654
|
+
_agentMdOwnerAid() {
|
|
655
|
+
return String(this._aid ?? '').trim();
|
|
656
|
+
}
|
|
657
|
+
_agentMdSafeAid(aid) {
|
|
658
|
+
const target = String(aid ?? '').trim();
|
|
659
|
+
if (!target || target.includes('/') || target.includes('\\') || target.includes('\0')) {
|
|
660
|
+
throw new ValidationError('agent.md aid is empty or contains path separators');
|
|
661
|
+
}
|
|
662
|
+
return target;
|
|
663
|
+
}
|
|
664
|
+
_agentMdRoot() {
|
|
665
|
+
const root = this._agentMdPath || path.join(this._configModel.aunPath, 'AgentMDs');
|
|
666
|
+
fs.mkdirSync(root, { recursive: true });
|
|
667
|
+
return root;
|
|
668
|
+
}
|
|
669
|
+
_agentMdFilePath(aid) {
|
|
670
|
+
return path.join(this._agentMdRoot(), this._agentMdSafeAid(aid), 'agent.md');
|
|
671
|
+
}
|
|
672
|
+
_agentMdListPath() {
|
|
673
|
+
return path.join(this._agentMdRoot(), 'list.json');
|
|
674
|
+
}
|
|
675
|
+
_atomicWriteText(filePath, content) {
|
|
676
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
677
|
+
const tmp = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
678
|
+
let fd = null;
|
|
679
|
+
try {
|
|
680
|
+
fd = fs.openSync(tmp, 'w');
|
|
681
|
+
fs.writeFileSync(fd, content, 'utf-8');
|
|
682
|
+
fs.fsyncSync(fd);
|
|
683
|
+
fs.closeSync(fd);
|
|
684
|
+
fd = null;
|
|
685
|
+
fs.renameSync(tmp, filePath);
|
|
686
|
+
try {
|
|
687
|
+
const dirFd = fs.openSync(path.dirname(filePath), 'r');
|
|
688
|
+
try {
|
|
689
|
+
fs.fsyncSync(dirFd);
|
|
690
|
+
}
|
|
691
|
+
finally {
|
|
692
|
+
fs.closeSync(dirFd);
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
catch { /* best effort */ }
|
|
696
|
+
}
|
|
697
|
+
finally {
|
|
698
|
+
if (fd !== null) {
|
|
699
|
+
try {
|
|
700
|
+
fs.closeSync(fd);
|
|
701
|
+
}
|
|
702
|
+
catch { /* ignore */ }
|
|
703
|
+
}
|
|
704
|
+
if (fs.existsSync(tmp)) {
|
|
705
|
+
try {
|
|
706
|
+
fs.unlinkSync(tmp);
|
|
707
|
+
}
|
|
708
|
+
catch { /* ignore */ }
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
}
|
|
712
|
+
_sleepSync(ms) {
|
|
713
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
714
|
+
}
|
|
715
|
+
_withAgentMdListLock(fn) {
|
|
716
|
+
const lockPath = path.join(this._agentMdRoot(), 'list.json.lock');
|
|
717
|
+
const deadline = Date.now() + 5000;
|
|
718
|
+
let fd = null;
|
|
719
|
+
while (fd === null) {
|
|
720
|
+
try {
|
|
721
|
+
fd = fs.openSync(lockPath, 'wx');
|
|
722
|
+
fs.writeFileSync(fd, `${process.pid}\n`, 'utf-8');
|
|
723
|
+
}
|
|
724
|
+
catch (err) {
|
|
725
|
+
if (err?.code !== 'EEXIST' || Date.now() >= deadline)
|
|
726
|
+
throw err;
|
|
727
|
+
try {
|
|
728
|
+
const st = fs.statSync(lockPath);
|
|
729
|
+
if (Date.now() - st.mtimeMs > 30000)
|
|
730
|
+
fs.unlinkSync(lockPath);
|
|
731
|
+
}
|
|
732
|
+
catch { /* ignore */ }
|
|
733
|
+
this._sleepSync(25);
|
|
734
|
+
}
|
|
735
|
+
}
|
|
736
|
+
try {
|
|
737
|
+
return fn();
|
|
738
|
+
}
|
|
739
|
+
finally {
|
|
740
|
+
if (fd !== null) {
|
|
741
|
+
try {
|
|
742
|
+
fs.closeSync(fd);
|
|
743
|
+
}
|
|
744
|
+
catch { /* ignore */ }
|
|
745
|
+
}
|
|
746
|
+
try {
|
|
747
|
+
fs.unlinkSync(lockPath);
|
|
748
|
+
}
|
|
749
|
+
catch { /* ignore */ }
|
|
750
|
+
}
|
|
751
|
+
}
|
|
752
|
+
_writeAgentMdListUnlocked(records) {
|
|
753
|
+
const sorted = {};
|
|
754
|
+
for (const aid of Object.keys(records).sort())
|
|
755
|
+
sorted[aid] = records[aid];
|
|
756
|
+
this._atomicWriteText(this._agentMdListPath(), `${JSON.stringify({ version: 1, updated_at: Date.now(), records: sorted }, null, 2)}\n`);
|
|
757
|
+
}
|
|
758
|
+
_normalizeAgentMdList(data) {
|
|
759
|
+
const records = {};
|
|
760
|
+
let iterable = [];
|
|
761
|
+
if (isJsonObject(data)) {
|
|
762
|
+
const rawRecords = data.records;
|
|
763
|
+
if (Array.isArray(rawRecords))
|
|
764
|
+
iterable = rawRecords;
|
|
765
|
+
else if (isJsonObject(rawRecords)) {
|
|
766
|
+
iterable = Object.values(rawRecords);
|
|
767
|
+
}
|
|
768
|
+
}
|
|
769
|
+
else if (Array.isArray(data)) {
|
|
770
|
+
iterable = data;
|
|
771
|
+
}
|
|
772
|
+
for (const item of iterable) {
|
|
773
|
+
if (!isJsonObject(item))
|
|
774
|
+
continue;
|
|
775
|
+
const aid = String(item.aid ?? '').trim();
|
|
776
|
+
if (!aid)
|
|
777
|
+
continue;
|
|
778
|
+
const record = {};
|
|
779
|
+
for (const [key, value] of Object.entries(item)) {
|
|
780
|
+
if (key !== 'content')
|
|
781
|
+
record[key] = value;
|
|
782
|
+
}
|
|
783
|
+
record.aid = aid;
|
|
784
|
+
for (const key of ['fetched_at', 'observed_at', 'checked_at', 'updated_at']) {
|
|
785
|
+
record[key] = Number(record[key] ?? 0) || 0;
|
|
786
|
+
}
|
|
787
|
+
records[aid] = record;
|
|
788
|
+
}
|
|
789
|
+
return records;
|
|
790
|
+
}
|
|
791
|
+
_rebuildAgentMdListUnlocked() {
|
|
792
|
+
const records = {};
|
|
793
|
+
const root = this._agentMdRoot();
|
|
794
|
+
const now = Date.now();
|
|
795
|
+
for (const entry of fs.readdirSync(root, { withFileTypes: true })) {
|
|
796
|
+
if (!entry.isDirectory())
|
|
797
|
+
continue;
|
|
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
|
+
}
|
|
819
|
+
}
|
|
820
|
+
this._writeAgentMdListUnlocked(records);
|
|
821
|
+
this._agentMdCache.clear();
|
|
822
|
+
return records;
|
|
823
|
+
}
|
|
824
|
+
_readAgentMdListUnlocked() {
|
|
825
|
+
const filePath = this._agentMdListPath();
|
|
826
|
+
if (!fs.existsSync(filePath)) {
|
|
827
|
+
this._agentMdLastListRebuilt = true;
|
|
828
|
+
return this._rebuildAgentMdListUnlocked();
|
|
829
|
+
}
|
|
830
|
+
try {
|
|
831
|
+
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
832
|
+
this._agentMdLastListRebuilt = false;
|
|
833
|
+
return this._normalizeAgentMdList(parsed);
|
|
834
|
+
}
|
|
835
|
+
catch (err) {
|
|
836
|
+
this._clientLog.warn(`agent.md list.json damaged, rebuilding: ${err instanceof Error ? err.message : String(err)}`);
|
|
837
|
+
this._agentMdLastListRebuilt = true;
|
|
838
|
+
return this._rebuildAgentMdListUnlocked();
|
|
839
|
+
}
|
|
840
|
+
}
|
|
841
|
+
_readAgentMdContent(aid) {
|
|
842
|
+
return fs.readFileSync(this._agentMdFilePath(aid), 'utf-8');
|
|
843
|
+
}
|
|
844
|
+
_writeAgentMdContent(aid, content) {
|
|
845
|
+
const filePath = this._agentMdFilePath(aid);
|
|
846
|
+
this._atomicWriteText(filePath, String(content ?? ''));
|
|
847
|
+
return filePath;
|
|
848
|
+
}
|
|
849
|
+
_agentMdAuthCacheMeta(aid) {
|
|
850
|
+
try {
|
|
851
|
+
const store = this.auth._agentMdCache;
|
|
852
|
+
const record = store?.get(String(aid ?? '').trim());
|
|
853
|
+
return record && typeof record === 'object' ? { ...record } : {};
|
|
854
|
+
}
|
|
855
|
+
catch {
|
|
856
|
+
return {};
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
_loadAgentMdRecord(aid) {
|
|
860
|
+
const target = String(aid ?? '').trim();
|
|
861
|
+
if (!target)
|
|
862
|
+
return null;
|
|
863
|
+
try {
|
|
864
|
+
const records = this._withAgentMdListLock(() => this._readAgentMdListUnlocked());
|
|
865
|
+
const record = records[target];
|
|
866
|
+
if (record && typeof record === 'object') {
|
|
867
|
+
const loaded = { ...record, aid: target };
|
|
868
|
+
try {
|
|
869
|
+
const content = this._readAgentMdContent(target);
|
|
870
|
+
loaded.content = content;
|
|
871
|
+
loaded.local_etag = this._agentMdContentEtag(content);
|
|
872
|
+
}
|
|
873
|
+
catch (err) {
|
|
874
|
+
this._clientLog.warn(`agent.md content read failed: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
875
|
+
}
|
|
876
|
+
this._agentMdCache.set(target, { ...loaded });
|
|
877
|
+
return { ...loaded };
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
catch (err) {
|
|
881
|
+
this._clientLog.debug(`agent.md cache load skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
882
|
+
}
|
|
883
|
+
return null;
|
|
884
|
+
}
|
|
885
|
+
_saveAgentMdRecord(aid, fields) {
|
|
886
|
+
const target = String(aid ?? '').trim();
|
|
887
|
+
if (!target)
|
|
888
|
+
return {};
|
|
889
|
+
try {
|
|
890
|
+
const inputFields = { ...fields };
|
|
891
|
+
const hasContent = Object.prototype.hasOwnProperty.call(inputFields, 'content') && inputFields.content !== undefined && inputFields.content !== null;
|
|
892
|
+
let savedTo = '';
|
|
893
|
+
if (hasContent) {
|
|
894
|
+
const content = String(inputFields.content ?? '');
|
|
895
|
+
savedTo = this._writeAgentMdContent(target, content);
|
|
896
|
+
if (!inputFields.local_etag)
|
|
897
|
+
inputFields.local_etag = this._agentMdContentEtag(content);
|
|
898
|
+
if (!inputFields.fetched_at)
|
|
899
|
+
inputFields.fetched_at = Date.now();
|
|
900
|
+
}
|
|
901
|
+
delete inputFields.content;
|
|
902
|
+
const record = this._withAgentMdListLock(() => {
|
|
903
|
+
const records = this._readAgentMdListUnlocked();
|
|
904
|
+
const next = { ...(records[target] ?? {}), aid: target };
|
|
905
|
+
for (const [key, value] of Object.entries(inputFields)) {
|
|
906
|
+
if (value !== undefined && value !== null)
|
|
907
|
+
next[key] = value;
|
|
908
|
+
}
|
|
909
|
+
next.updated_at = Date.now();
|
|
910
|
+
records[target] = { ...next };
|
|
911
|
+
this._writeAgentMdListUnlocked(records);
|
|
912
|
+
return next;
|
|
913
|
+
});
|
|
914
|
+
const loaded = { ...record };
|
|
915
|
+
if (hasContent) {
|
|
916
|
+
loaded.content = String(fields.content ?? '');
|
|
917
|
+
if (savedTo)
|
|
918
|
+
loaded.saved_to = savedTo;
|
|
919
|
+
}
|
|
920
|
+
this._agentMdCache.set(target, { ...loaded });
|
|
921
|
+
const owner = this._agentMdOwnerAid();
|
|
922
|
+
if (target === owner) {
|
|
923
|
+
const localEtag = String(loaded.local_etag ?? '').trim();
|
|
924
|
+
const remoteEtag = String(loaded.remote_etag ?? '').trim();
|
|
925
|
+
if (localEtag)
|
|
926
|
+
this._localAgentMdEtag = localEtag;
|
|
927
|
+
if (remoteEtag)
|
|
928
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
929
|
+
}
|
|
930
|
+
return { ...loaded };
|
|
931
|
+
}
|
|
932
|
+
catch (err) {
|
|
933
|
+
this._clientLog.debug(`agent.md cache save skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
934
|
+
}
|
|
935
|
+
return {};
|
|
936
|
+
}
|
|
937
|
+
_agentMdHasLocalContent(aid, record) {
|
|
938
|
+
if (record && typeof record.content === 'string' && record.content.length > 0)
|
|
939
|
+
return true;
|
|
940
|
+
try {
|
|
941
|
+
return fs.existsSync(this._agentMdFilePath(aid));
|
|
942
|
+
}
|
|
943
|
+
catch {
|
|
944
|
+
return false;
|
|
945
|
+
}
|
|
946
|
+
}
|
|
947
|
+
_agentMdCheckedAtFresh(checkedAtMs, maxUnsyncedDays) {
|
|
948
|
+
const days = Number(maxUnsyncedDays || 0);
|
|
949
|
+
if (!Number.isFinite(days) || days <= 0)
|
|
950
|
+
return false;
|
|
951
|
+
if (!Number.isFinite(checkedAtMs) || checkedAtMs <= 0)
|
|
952
|
+
return false;
|
|
953
|
+
return Date.now() - checkedAtMs <= days * 86400000;
|
|
954
|
+
}
|
|
955
|
+
_agentMdLastModifiedFresh(lastModified, maxUnsyncedDays) {
|
|
956
|
+
const days = Number(maxUnsyncedDays || 0);
|
|
957
|
+
if (!Number.isFinite(days) || days <= 0)
|
|
958
|
+
return false;
|
|
959
|
+
const ts = Date.parse(String(lastModified ?? '').trim());
|
|
960
|
+
if (!Number.isFinite(ts))
|
|
961
|
+
return false;
|
|
962
|
+
return Date.now() <= ts + days * 86400000;
|
|
963
|
+
}
|
|
964
|
+
_scheduleAgentMdFetchIfMissing(aid, record, source = '') {
|
|
965
|
+
const target = String(aid ?? '').trim();
|
|
966
|
+
if (!target || this._agentMdHasLocalContent(target, record))
|
|
967
|
+
return;
|
|
968
|
+
if (this._agentMdFetchInflight.has(target))
|
|
969
|
+
return;
|
|
970
|
+
this._agentMdFetchInflight.add(target);
|
|
971
|
+
void this.fetchAgentMd(target).catch((err) => {
|
|
972
|
+
this._saveAgentMdRecord(target, {
|
|
973
|
+
last_error: err instanceof Error ? err.message : String(err),
|
|
974
|
+
remote_status: 'found',
|
|
975
|
+
});
|
|
976
|
+
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
|
+
});
|
|
980
|
+
}
|
|
981
|
+
_observeAgentMdMeta(aid, etag = '', lastModified = '', source = '') {
|
|
982
|
+
const target = String(aid ?? '').trim();
|
|
983
|
+
const remoteEtag = String(etag ?? '').trim();
|
|
984
|
+
const remoteLastModified = String(lastModified ?? '').trim();
|
|
985
|
+
if (!target || (!remoteEtag && !remoteLastModified))
|
|
986
|
+
return;
|
|
987
|
+
let before = this._agentMdCache.get(target);
|
|
988
|
+
if (!before || typeof before !== 'object')
|
|
989
|
+
before = this._loadAgentMdRecord(target) ?? {};
|
|
990
|
+
const same = (!remoteEtag || String(before.remote_etag ?? '').trim() === remoteEtag) &&
|
|
991
|
+
(!remoteLastModified || String(before.last_modified ?? '').trim() === remoteLastModified);
|
|
992
|
+
let record = { ...before };
|
|
993
|
+
if (!same || Object.keys(before).length === 0) {
|
|
994
|
+
const fields = {
|
|
995
|
+
observed_at: Date.now(),
|
|
996
|
+
remote_status: 'found',
|
|
997
|
+
};
|
|
998
|
+
if (remoteEtag)
|
|
999
|
+
fields.remote_etag = remoteEtag;
|
|
1000
|
+
if (remoteLastModified)
|
|
1001
|
+
fields.last_modified = remoteLastModified;
|
|
1002
|
+
record = this._saveAgentMdRecord(target, fields) || record;
|
|
1003
|
+
}
|
|
1004
|
+
if (target === this._agentMdOwnerAid() && remoteEtag)
|
|
1005
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
1006
|
+
this._scheduleAgentMdFetchIfMissing(target, record, source);
|
|
1007
|
+
this._clientLog.debug(`agent.md meta observed: aid=${target} etag=${remoteEtag || '-'} last_modified=${remoteLastModified || '-'} source=${source || '-'}`);
|
|
1008
|
+
}
|
|
1009
|
+
_observeAgentMdEtag(aid, etag, source = '') {
|
|
1010
|
+
this._observeAgentMdMeta(aid, etag, '', source);
|
|
1011
|
+
}
|
|
1012
|
+
_observeAgentMdFromEnvelope(envelope) {
|
|
1013
|
+
if (!isJsonObject(envelope))
|
|
1014
|
+
return;
|
|
1015
|
+
const env = envelope;
|
|
1016
|
+
if (!isJsonObject(env.agent_md))
|
|
1017
|
+
return;
|
|
1018
|
+
const agentMd = env.agent_md;
|
|
1019
|
+
if (!isJsonObject(agentMd.sender))
|
|
1020
|
+
return;
|
|
1021
|
+
const sender = agentMd.sender;
|
|
1022
|
+
let senderAid = String(sender.aid ?? '').trim();
|
|
1023
|
+
if (!senderAid) {
|
|
1024
|
+
const aad = isJsonObject(env.aad) ? env.aad : {};
|
|
1025
|
+
senderAid = String(aad.from ?? env.from ?? '').trim();
|
|
1026
|
+
}
|
|
1027
|
+
this._observeAgentMdMeta(senderAid, String(sender.etag ?? '').trim(), String(sender.last_modified ?? sender.lastModified ?? '').trim(), 'envelope');
|
|
1028
|
+
}
|
|
1029
|
+
async checkAgentMd(aid, maxUnsyncedDays = 0) {
|
|
1030
|
+
const target = String(aid ?? this._aid ?? '').trim();
|
|
1031
|
+
if (!target)
|
|
1032
|
+
throw new ValidationError('checkAgentMd requires aid (or local AID)');
|
|
1033
|
+
const before = this._loadAgentMdRecord(target) ?? {};
|
|
1034
|
+
const localEtag = String(before.local_etag ?? '').trim();
|
|
1035
|
+
const localFound = !!(Object.keys(before).length > 0 && (String(before.content ?? '') || localEtag));
|
|
1036
|
+
const remoteEtagCached = String(before.remote_etag ?? '').trim();
|
|
1037
|
+
const lastModifiedCached = String(before.last_modified ?? '').trim();
|
|
1038
|
+
const checkedAtCached = Number(before.checked_at ?? 0);
|
|
1039
|
+
const cachedInSync = !!(localFound && localEtag && remoteEtagCached && localEtag === remoteEtagCached);
|
|
1040
|
+
// max_unsynced_days > 0 且距上次 HEAD 在窗口内 → 直接返回缓存;否则强制 HEAD。
|
|
1041
|
+
if (cachedInSync && this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
|
|
1042
|
+
return {
|
|
1043
|
+
aid: target,
|
|
1044
|
+
local_found: true,
|
|
1045
|
+
remote_found: true,
|
|
1046
|
+
local_etag: localEtag,
|
|
1047
|
+
remote_etag: remoteEtagCached,
|
|
1048
|
+
in_sync: true,
|
|
1049
|
+
last_modified: lastModifiedCached,
|
|
1050
|
+
status: 200,
|
|
1051
|
+
cached: true,
|
|
1052
|
+
verify_status: String(before.verify_status ?? ''),
|
|
1053
|
+
verify_error: String(before.verify_error ?? ''),
|
|
1054
|
+
};
|
|
1055
|
+
}
|
|
1056
|
+
const now = Date.now();
|
|
1057
|
+
let remote;
|
|
1058
|
+
try {
|
|
1059
|
+
remote = await this.auth.headAgentMd(target);
|
|
1060
|
+
}
|
|
1061
|
+
catch (err) {
|
|
1062
|
+
this._saveAgentMdRecord(target, { checked_at: now, remote_status: 'error', last_error: err instanceof Error ? err.message : String(err) });
|
|
1063
|
+
throw err;
|
|
1064
|
+
}
|
|
1065
|
+
const remoteFound = !!remote.found;
|
|
1066
|
+
const remoteEtag = String(remote.etag ?? '').trim();
|
|
1067
|
+
const lastModified = String(remote.last_modified ?? remote.lastModified ?? '').trim();
|
|
1068
|
+
const saved = this._saveAgentMdRecord(target, {
|
|
1069
|
+
remote_etag: remoteFound ? remoteEtag : '',
|
|
1070
|
+
last_modified: lastModified,
|
|
1071
|
+
checked_at: now,
|
|
1072
|
+
remote_status: remoteFound ? 'found' : 'missing',
|
|
1073
|
+
last_error: '',
|
|
1074
|
+
});
|
|
1075
|
+
if (target === this._agentMdOwnerAid() && remoteEtag)
|
|
1076
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
1077
|
+
const inSync = !!(localFound && remoteFound && localEtag && remoteEtag && localEtag === remoteEtag);
|
|
1078
|
+
return {
|
|
1079
|
+
aid: target,
|
|
1080
|
+
local_found: localFound,
|
|
1081
|
+
remote_found: remoteFound,
|
|
1082
|
+
local_etag: localEtag,
|
|
1083
|
+
remote_etag: remoteEtag,
|
|
1084
|
+
in_sync: inSync,
|
|
1085
|
+
last_modified: lastModified,
|
|
1086
|
+
status: Number(remote.status ?? (remoteFound ? 200 : 404)),
|
|
1087
|
+
cached: false,
|
|
1088
|
+
verify_status: String(saved.verify_status ?? before.verify_status ?? ''),
|
|
1089
|
+
verify_error: String(saved.verify_error ?? before.verify_error ?? ''),
|
|
1090
|
+
};
|
|
1091
|
+
}
|
|
561
1092
|
/** transport 的 meta observer:吸收 gateway 注入的 _meta 字段。失败不影响业务。 */
|
|
562
1093
|
_observeRpcMeta(meta) {
|
|
563
1094
|
if (!meta || typeof meta !== 'object')
|
|
@@ -565,6 +1096,17 @@ export class AUNClient {
|
|
|
565
1096
|
const etag = String(meta.agent_md_etag ?? '').trim();
|
|
566
1097
|
if (etag) {
|
|
567
1098
|
this._remoteAgentMdEtag = etag;
|
|
1099
|
+
this._observeAgentMdMeta(this._aid ?? '', etag, '', 'rpc.self');
|
|
1100
|
+
}
|
|
1101
|
+
const etags = meta.agent_md_etags;
|
|
1102
|
+
if (isJsonObject(etags)) {
|
|
1103
|
+
// role key 优先级:requester / peer 是新规范,其余是兼容旧 SDK 的别名。
|
|
1104
|
+
for (const key of ['requester', 'peer', 'receiver', 'target', 'to', 'sender', 'from']) {
|
|
1105
|
+
const item = etags[key];
|
|
1106
|
+
if (!isJsonObject(item))
|
|
1107
|
+
continue;
|
|
1108
|
+
this._observeAgentMdMeta(String(item.aid ?? ''), String(item.etag ?? ''), String(item.last_modified ?? item.lastModified ?? ''), `rpc.${key}`);
|
|
1109
|
+
}
|
|
568
1110
|
}
|
|
569
1111
|
}
|
|
570
1112
|
/** 连接状态 */
|
|
@@ -613,19 +1155,31 @@ export class AUNClient {
|
|
|
613
1155
|
this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 35_000);
|
|
614
1156
|
this._closing = false;
|
|
615
1157
|
this._clientLog.debug(`connect enter: gateway=${String(normalized.gateway ?? '')}, device_id=${this._deviceId}`);
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
1158
|
+
const gateways = this._resolveGateways(normalized);
|
|
1159
|
+
let lastErr = null;
|
|
1160
|
+
for (const gw of gateways) {
|
|
1161
|
+
try {
|
|
1162
|
+
const gwParams = { ...normalized, gateway: gw };
|
|
1163
|
+
await this._connectOnce(gwParams, false);
|
|
1164
|
+
this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms aid=${this._aid ?? ''}, state=${this._state}`);
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
catch (err) {
|
|
1168
|
+
lastErr = err;
|
|
1169
|
+
if (gateways.length > 1) {
|
|
1170
|
+
this._clientLog.warn(`connect: gateway ${gw} failed, trying next: ${formatCaughtError(err)}`);
|
|
1171
|
+
}
|
|
1172
|
+
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
1173
|
+
this._state = 'connecting';
|
|
1174
|
+
}
|
|
624
1175
|
}
|
|
625
|
-
this._clientLog.error(`connect failed: ${formatCaughtError(err)}`, err instanceof Error ? err : undefined);
|
|
626
|
-
this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
627
|
-
throw err;
|
|
628
1176
|
}
|
|
1177
|
+
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
1178
|
+
this._state = 'disconnected';
|
|
1179
|
+
}
|
|
1180
|
+
this._clientLog.error(`connect failed: ${formatCaughtError(lastErr)}`, lastErr instanceof Error ? lastErr : undefined);
|
|
1181
|
+
this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
|
|
1182
|
+
throw lastErr;
|
|
629
1183
|
}
|
|
630
1184
|
/** 关闭连接 */
|
|
631
1185
|
async close() {
|
|
@@ -743,6 +1297,9 @@ export class AUNClient {
|
|
|
743
1297
|
throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
|
|
744
1298
|
}
|
|
745
1299
|
const p = { ...(params ?? {}) };
|
|
1300
|
+
if (method === 'message.send' || method === 'group.send') {
|
|
1301
|
+
this._normalizeOutboundMessagePayload(p, method);
|
|
1302
|
+
}
|
|
746
1303
|
this._validateOutboundCall(method, p);
|
|
747
1304
|
this._injectMessageCursorContext(method, p);
|
|
748
1305
|
// group.* 方法的 group_id 归一化为 canonical 格式(兼容老/污染数据)
|
|
@@ -755,7 +1312,7 @@ export class AUNClient {
|
|
|
755
1312
|
p.group_id = normalizedGroupId;
|
|
756
1313
|
}
|
|
757
1314
|
// group.* 方法注入 device_id(服务端用于多设备消息路由)
|
|
758
|
-
if (method.startsWith('group.') &&
|
|
1315
|
+
if (method.startsWith('group.') && p.device_id === undefined) {
|
|
759
1316
|
p.device_id = this._deviceId;
|
|
760
1317
|
}
|
|
761
1318
|
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
@@ -829,17 +1386,27 @@ export class AUNClient {
|
|
|
829
1386
|
}
|
|
830
1387
|
// 关键操作自动附加客户端签名
|
|
831
1388
|
if (SIGNED_METHODS.has(method)) {
|
|
832
|
-
this.
|
|
1389
|
+
if (this._shouldSkipClientSignature(method, p)) {
|
|
1390
|
+
delete p.client_signature;
|
|
1391
|
+
}
|
|
1392
|
+
else {
|
|
1393
|
+
this._signClientOperation(method, p);
|
|
1394
|
+
}
|
|
833
1395
|
}
|
|
834
1396
|
// P1-23: 非幂等方法使用更长超时
|
|
835
1397
|
const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT_MS : undefined;
|
|
1398
|
+
if (method === 'group.thought.get' || method === 'message.thought.get') {
|
|
1399
|
+
this._clientLog.debug(`thought.get transport call start: method=${method}, params=${this._debugJson(this._messageEnvelopeFieldsForDebug(p))}`);
|
|
1400
|
+
}
|
|
836
1401
|
let result = callTimeout
|
|
837
1402
|
? await this._transport.call(method, p, callTimeout)
|
|
838
1403
|
: await this._transport.call(method, p);
|
|
839
1404
|
if (method === 'group.thought.get' && isJsonObject(result)) {
|
|
1405
|
+
this._clientLog.debug(`group.thought.get transport result: found=${String(result.found ?? '')}, raw_count=${Array.isArray(result.thoughts) ? result.thoughts.length : 0}`);
|
|
840
1406
|
result = await this._decryptGroupThoughts(result);
|
|
841
1407
|
}
|
|
842
1408
|
if (method === 'message.thought.get' && isJsonObject(result)) {
|
|
1409
|
+
this._clientLog.debug(`message.thought.get transport result: found=${String(result.found ?? '')}, raw_count=${Array.isArray(result.thoughts) ? result.thoughts.length : 0}`);
|
|
843
1410
|
result = await this._decryptMessageThoughts(result);
|
|
844
1411
|
}
|
|
845
1412
|
// ── V2-only 群状态编排:成员变更后 propose+confirm state。
|
|
@@ -988,6 +1555,7 @@ export class AUNClient {
|
|
|
988
1555
|
async _onRawMessageReceived(data) {
|
|
989
1556
|
const tStart = Date.now();
|
|
990
1557
|
if (isJsonObject(data)) {
|
|
1558
|
+
this._logMessageDebug('server-push', '_raw.message.received', 'message.received', data);
|
|
991
1559
|
this._clientLog.debug(`_onRawMessageReceived enter: from=${String(data.from ?? '')}, message_id=${String(data.message_id ?? '')}, seq=${String(data.seq ?? '')}`);
|
|
992
1560
|
}
|
|
993
1561
|
else {
|
|
@@ -1006,6 +1574,7 @@ export class AUNClient {
|
|
|
1006
1574
|
timestamp: data.timestamp,
|
|
1007
1575
|
_decrypt_error: String(exc),
|
|
1008
1576
|
};
|
|
1577
|
+
this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1009
1578
|
this._publishAppEvent('message.undecryptable', safeEvent).catch(() => { });
|
|
1010
1579
|
}
|
|
1011
1580
|
});
|
|
@@ -1014,17 +1583,21 @@ export class AUNClient {
|
|
|
1014
1583
|
/** 实际处理推送消息的异步任务 */
|
|
1015
1584
|
async _processAndPublishMessage(data) {
|
|
1016
1585
|
if (!isJsonObject(data)) {
|
|
1017
|
-
await this._publishAppEvent('message.received', data);
|
|
1586
|
+
await this._publishAppEvent('message.received', data, 'push');
|
|
1018
1587
|
return;
|
|
1019
1588
|
}
|
|
1020
1589
|
const msg = { ...data };
|
|
1021
1590
|
if (!this._messageTargetsCurrentInstance(msg)) {
|
|
1591
|
+
this._clientLog.debug(`P2P push filtered by instance: message_id=${String(msg.message_id ?? '')}, seq=${String(msg.seq ?? '')}, target_device=${String(msg.device_id ?? '')}, target_slot=${String(msg.slot_id ?? '')}, local_device=${this._deviceId}, local_slot=${this._slotId}`);
|
|
1022
1592
|
return;
|
|
1023
1593
|
}
|
|
1024
1594
|
// P2P 空洞检测
|
|
1025
1595
|
const seq = msg.seq;
|
|
1026
1596
|
if (seq !== undefined && seq !== null && this._aid) {
|
|
1027
1597
|
const ns = `p2p:${this._aid}`;
|
|
1598
|
+
// Push 修上界:先更新 maxSeenSeq,让上界反映服务端状态
|
|
1599
|
+
if (seq > 0)
|
|
1600
|
+
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1028
1601
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1029
1602
|
if (needPull) {
|
|
1030
1603
|
this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
|
|
@@ -1033,11 +1606,16 @@ export class AUNClient {
|
|
|
1033
1606
|
// auto-ack contiguous_seq
|
|
1034
1607
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1035
1608
|
if (contig > 0) {
|
|
1609
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1610
|
+
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1611
|
+
this._clientLog.debug(`P2P push auto-ack send: ns=${ns}, seq=${ackSeq}, contiguous=${contig}, max_seen=${maxSeen}`);
|
|
1036
1612
|
this._transport.call('message.ack', {
|
|
1037
|
-
seq:
|
|
1613
|
+
seq: ackSeq,
|
|
1038
1614
|
device_id: this._deviceId,
|
|
1039
1615
|
slot_id: this._slotId,
|
|
1040
|
-
})
|
|
1616
|
+
})
|
|
1617
|
+
.then(() => { this._clientLog.debug(`P2P push auto-ack ok: ns=${ns}, seq=${ackSeq}`); })
|
|
1618
|
+
.catch((e) => { this._clientLog.debug(`P2P auto-ack failed: ${formatCaughtError(e)}`); });
|
|
1041
1619
|
}
|
|
1042
1620
|
// 即时持久化 cursor,异常断连后不回退
|
|
1043
1621
|
this._saveSeqTrackerState();
|
|
@@ -1048,13 +1626,14 @@ export class AUNClient {
|
|
|
1048
1626
|
await this._publishOrderedMessage('message.received', ns, seq, msg);
|
|
1049
1627
|
}
|
|
1050
1628
|
else {
|
|
1051
|
-
await this._publishAppEvent('message.received', msg);
|
|
1629
|
+
await this._publishAppEvent('message.received', msg, 'push');
|
|
1052
1630
|
}
|
|
1053
1631
|
}
|
|
1054
1632
|
/** 处理群组消息推送:自动解密后 re-publish */
|
|
1055
1633
|
async _onRawGroupMessageCreated(data) {
|
|
1056
1634
|
const tStart = Date.now();
|
|
1057
1635
|
if (isJsonObject(data)) {
|
|
1636
|
+
this._logMessageDebug('server-push', '_raw.group.message_created', 'group.message_created', data);
|
|
1058
1637
|
this._clientLog.debug(`_onRawGroupMessageCreated enter: group_id=${String(data.group_id ?? '')}, message_id=${String(data.message_id ?? '')}, seq=${String(data.seq ?? '')}`);
|
|
1059
1638
|
}
|
|
1060
1639
|
else {
|
|
@@ -1072,6 +1651,7 @@ export class AUNClient {
|
|
|
1072
1651
|
timestamp: data.timestamp,
|
|
1073
1652
|
_decrypt_error: String(exc),
|
|
1074
1653
|
};
|
|
1654
|
+
this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1075
1655
|
this._publishAppEvent('group.message_undecryptable', safeEvent).catch(() => { });
|
|
1076
1656
|
}
|
|
1077
1657
|
});
|
|
@@ -1085,7 +1665,7 @@ export class AUNClient {
|
|
|
1085
1665
|
*/
|
|
1086
1666
|
async _processAndPublishGroupMessage(data) {
|
|
1087
1667
|
if (!isJsonObject(data)) {
|
|
1088
|
-
await this._publishAppEvent('group.message_created', data);
|
|
1668
|
+
await this._publishAppEvent('group.message_created', data, 'group-push');
|
|
1089
1669
|
return;
|
|
1090
1670
|
}
|
|
1091
1671
|
const msg = { ...data };
|
|
@@ -1103,6 +1683,9 @@ export class AUNClient {
|
|
|
1103
1683
|
}
|
|
1104
1684
|
if (groupId && seq !== undefined && seq !== null) {
|
|
1105
1685
|
const ns = `group:${groupId}`;
|
|
1686
|
+
// Push 修上界:先更新 maxSeenSeq
|
|
1687
|
+
if (seq > 0)
|
|
1688
|
+
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1106
1689
|
const needPull = this._seqTracker.onMessageSeq(ns, seq);
|
|
1107
1690
|
if (needPull) {
|
|
1108
1691
|
this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
|
|
@@ -1110,12 +1693,17 @@ export class AUNClient {
|
|
|
1110
1693
|
}
|
|
1111
1694
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1112
1695
|
if (contig > 0) {
|
|
1696
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1697
|
+
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1698
|
+
this._clientLog.debug(`group push auto-ack send: group=${groupId}, ns=${ns}, seq=${ackSeq}, contiguous=${contig}, max_seen=${maxSeen}`);
|
|
1113
1699
|
this._transport.call('group.ack_messages', {
|
|
1114
1700
|
group_id: groupId,
|
|
1115
|
-
msg_seq:
|
|
1701
|
+
msg_seq: ackSeq,
|
|
1116
1702
|
device_id: this._deviceId,
|
|
1117
1703
|
slot_id: this._slotId,
|
|
1118
|
-
})
|
|
1704
|
+
})
|
|
1705
|
+
.then(() => { this._clientLog.debug(`group push auto-ack ok: group=${groupId}, seq=${ackSeq}`); })
|
|
1706
|
+
.catch((e) => { this._clientLog.debug(`group message auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
1119
1707
|
}
|
|
1120
1708
|
this._saveSeqTrackerState();
|
|
1121
1709
|
}
|
|
@@ -1125,7 +1713,7 @@ export class AUNClient {
|
|
|
1125
1713
|
await this._publishOrderedMessage('group.message_created', nsKey, seq, msg);
|
|
1126
1714
|
}
|
|
1127
1715
|
else {
|
|
1128
|
-
await this._publishAppEvent('group.message_created', msg);
|
|
1716
|
+
await this._publishAppEvent('group.message_created', msg, 'group-push');
|
|
1129
1717
|
}
|
|
1130
1718
|
}
|
|
1131
1719
|
/** 收到不带 payload 的 group.message_created 通知后,自动 pull 最新消息 */
|
|
@@ -1137,10 +1725,12 @@ export class AUNClient {
|
|
|
1137
1725
|
}
|
|
1138
1726
|
const ns = `group:${groupId}`;
|
|
1139
1727
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1728
|
+
this._clientLog.debug(`auto pull group messages start: group=${groupId}, after_seq=${afterSeq}, seq=${String(notification.seq ?? '')}`);
|
|
1140
1729
|
try {
|
|
1141
1730
|
// V2-only 模式:走 group.v2.pull(合并 V1 明文 + V2 密文并自动解密)
|
|
1142
1731
|
if (this._v2Session) {
|
|
1143
1732
|
await this._pullGroupV2Internal({ group_id: groupId, after_seq: afterSeq, limit: 50 });
|
|
1733
|
+
this._clientLog.debug(`auto pull group messages done(v2): group=${groupId}, after_seq=${afterSeq}`);
|
|
1144
1734
|
return;
|
|
1145
1735
|
}
|
|
1146
1736
|
const result = await this.call('group.pull', {
|
|
@@ -1158,13 +1748,14 @@ export class AUNClient {
|
|
|
1158
1748
|
if (isJsonObject(msg)) {
|
|
1159
1749
|
const s = msg.seq;
|
|
1160
1750
|
if (pushed && s !== undefined && s !== null && pushed.has(s)) {
|
|
1751
|
+
this._clientLog.debug(`auto pull group message skipped duplicate: group=${groupId}, seq=${s}`);
|
|
1161
1752
|
continue; // 已发布到应用层,跳过
|
|
1162
1753
|
}
|
|
1163
1754
|
if (s !== undefined && s !== null) {
|
|
1164
1755
|
await this._publishPulledMessage('group.message_created', ns, s, msg);
|
|
1165
1756
|
}
|
|
1166
1757
|
else {
|
|
1167
|
-
await this._publishAppEvent('group.message_created', msg);
|
|
1758
|
+
await this._publishAppEvent('group.message_created', msg, 'pull');
|
|
1168
1759
|
}
|
|
1169
1760
|
}
|
|
1170
1761
|
}
|
|
@@ -1176,7 +1767,7 @@ export class AUNClient {
|
|
|
1176
1767
|
catch (exc) {
|
|
1177
1768
|
this._clientLog.debug(`auto pull group messages failed: ${formatCaughtError(exc)}`);
|
|
1178
1769
|
}
|
|
1179
|
-
await this._publishAppEvent('group.message_created', notification);
|
|
1770
|
+
await this._publishAppEvent('group.message_created', notification, 'group-push-fallback');
|
|
1180
1771
|
}
|
|
1181
1772
|
/** 后台补齐群消息空洞 */
|
|
1182
1773
|
async _fillGroupGap(groupId) {
|
|
@@ -1321,10 +1912,10 @@ export class AUNClient {
|
|
|
1321
1912
|
if (!isJsonObject(payload))
|
|
1322
1913
|
return payload;
|
|
1323
1914
|
const result = { ...payload };
|
|
1324
|
-
if (
|
|
1915
|
+
if (!('device_id' in result)) {
|
|
1325
1916
|
result.device_id = this._deviceId;
|
|
1326
1917
|
}
|
|
1327
|
-
if (
|
|
1918
|
+
if (!('slot_id' in result)) {
|
|
1328
1919
|
result.slot_id = this._slotId;
|
|
1329
1920
|
}
|
|
1330
1921
|
return result;
|
|
@@ -1334,10 +1925,11 @@ export class AUNClient {
|
|
|
1334
1925
|
return payload;
|
|
1335
1926
|
return this._attachCurrentInstanceContext(payload);
|
|
1336
1927
|
}
|
|
1337
|
-
async _publishAppEvent(event, payload) {
|
|
1928
|
+
async _publishAppEvent(event, payload, source = 'direct') {
|
|
1338
1929
|
if ((event === 'message.received' || event === 'group.message_created') && isJsonObject(payload)) {
|
|
1339
1930
|
this._maybeAppendEchoTraceReceive(payload);
|
|
1340
1931
|
}
|
|
1932
|
+
this._logAppMessagePublish(event, payload, source);
|
|
1341
1933
|
// 注入本地/远端 agent.md etag,让应用层判断版本一致性;失败不影响业务。
|
|
1342
1934
|
if (isJsonObject(payload)) {
|
|
1343
1935
|
try {
|
|
@@ -1383,6 +1975,18 @@ export class AUNClient {
|
|
|
1383
1975
|
const trace = `${this._echoTimestamp()} [AUN-SDK.send] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
|
|
1384
1976
|
params.payload = { ...payload, text: payload.text + '\n' + trace };
|
|
1385
1977
|
}
|
|
1978
|
+
_shouldSkipClientSignature(method, params) {
|
|
1979
|
+
if (method !== 'message.send' && method !== 'group.send')
|
|
1980
|
+
return false;
|
|
1981
|
+
if (params.encrypted || params.encrypt)
|
|
1982
|
+
return false;
|
|
1983
|
+
return this._isEchoPayload(params.payload);
|
|
1984
|
+
}
|
|
1985
|
+
_shouldSkipEventSignature(event) {
|
|
1986
|
+
if (event.encrypted || event.encrypt)
|
|
1987
|
+
return false;
|
|
1988
|
+
return this._isEchoPayload(event.payload);
|
|
1989
|
+
}
|
|
1386
1990
|
_maybeAppendEchoTraceReceive(msg) {
|
|
1387
1991
|
if (msg.encrypted)
|
|
1388
1992
|
return;
|
|
@@ -1393,16 +1997,108 @@ export class AUNClient {
|
|
|
1393
1997
|
const trace = `${this._echoTimestamp()} [AUN-SDK.receive] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
|
|
1394
1998
|
msg.payload = { ...payload, text: payload.text + '\n' + trace };
|
|
1395
1999
|
}
|
|
2000
|
+
_debugJson(value) {
|
|
2001
|
+
const seen = new WeakSet();
|
|
2002
|
+
try {
|
|
2003
|
+
return JSON.stringify(value, (_key, item) => {
|
|
2004
|
+
if (typeof item === 'bigint')
|
|
2005
|
+
return item.toString();
|
|
2006
|
+
if (item instanceof Uint8Array) {
|
|
2007
|
+
return {
|
|
2008
|
+
_type: item.constructor.name,
|
|
2009
|
+
len: item.byteLength,
|
|
2010
|
+
base64: Buffer.from(item).toString('base64'),
|
|
2011
|
+
};
|
|
2012
|
+
}
|
|
2013
|
+
if (item && typeof item === 'object') {
|
|
2014
|
+
if (seen.has(item))
|
|
2015
|
+
return '[Circular]';
|
|
2016
|
+
seen.add(item);
|
|
2017
|
+
}
|
|
2018
|
+
return item;
|
|
2019
|
+
});
|
|
2020
|
+
}
|
|
2021
|
+
catch {
|
|
2022
|
+
return String(value);
|
|
2023
|
+
}
|
|
2024
|
+
}
|
|
2025
|
+
_messagePayloadForDebug(message) {
|
|
2026
|
+
if (!isJsonObject(message))
|
|
2027
|
+
return message;
|
|
2028
|
+
const msg = message;
|
|
2029
|
+
if ('payload' in msg)
|
|
2030
|
+
return msg.payload;
|
|
2031
|
+
if ('content' in msg)
|
|
2032
|
+
return msg.content;
|
|
2033
|
+
if (typeof msg.envelope_json === 'string' && msg.envelope_json) {
|
|
2034
|
+
try {
|
|
2035
|
+
return JSON.parse(msg.envelope_json);
|
|
2036
|
+
}
|
|
2037
|
+
catch {
|
|
2038
|
+
return msg.envelope_json;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
if (isJsonObject(msg.legacy_v1)) {
|
|
2042
|
+
const legacy = msg.legacy_v1;
|
|
2043
|
+
if ('payload' in legacy)
|
|
2044
|
+
return legacy.payload;
|
|
2045
|
+
if ('content' in legacy)
|
|
2046
|
+
return legacy.content;
|
|
2047
|
+
}
|
|
2048
|
+
return null;
|
|
2049
|
+
}
|
|
2050
|
+
_messageEnvelopeFieldsForDebug(message) {
|
|
2051
|
+
if (!isJsonObject(message)) {
|
|
2052
|
+
return { value_type: typeof message };
|
|
2053
|
+
}
|
|
2054
|
+
const msg = message;
|
|
2055
|
+
const keys = [
|
|
2056
|
+
'message_id', 'id', 'from', 'from_aid', 'sender_aid', 'to', 'to_aid',
|
|
2057
|
+
'group_id', 'seq', 'msg_seq', 'type', 'version', 'timestamp', 't_server',
|
|
2058
|
+
'device_id', 'slot_id', 'encrypted', 'dispatch_mode', 'dispatch',
|
|
2059
|
+
'e2ee', 'headers', 'protected_headers', 'context', 'status',
|
|
2060
|
+
'_decrypt_error', '_decrypt_stage',
|
|
2061
|
+
];
|
|
2062
|
+
const out = {};
|
|
2063
|
+
for (const key of keys) {
|
|
2064
|
+
if (Object.prototype.hasOwnProperty.call(msg, key))
|
|
2065
|
+
out[key] = msg[key];
|
|
2066
|
+
}
|
|
2067
|
+
return out;
|
|
2068
|
+
}
|
|
2069
|
+
_logMessageDebug(stage, source, event, message, opts = {}) {
|
|
2070
|
+
// 关键消息链路诊断日志长期保留在代码中;是否输出由 logger 的 debug/level 控制。
|
|
2071
|
+
const record = {
|
|
2072
|
+
stage,
|
|
2073
|
+
source,
|
|
2074
|
+
event,
|
|
2075
|
+
envelope: this._messageEnvelopeFieldsForDebug(message),
|
|
2076
|
+
payload: opts.payloadOverride !== undefined ? opts.payloadOverride : this._messagePayloadForDebug(message),
|
|
2077
|
+
};
|
|
2078
|
+
if (opts.extra)
|
|
2079
|
+
record.extra = opts.extra;
|
|
2080
|
+
this._clientLog.debug(`message.debug ${this._debugJson(record)}`);
|
|
2081
|
+
}
|
|
2082
|
+
_logAppMessagePublish(event, payload, source) {
|
|
2083
|
+
if (!['message.received', 'message.undecryptable', 'group.message_created', 'group.message_undecryptable'].includes(event)) {
|
|
2084
|
+
return;
|
|
2085
|
+
}
|
|
2086
|
+
this._logMessageDebug('publish', source, event, payload);
|
|
2087
|
+
}
|
|
1396
2088
|
_messageTargetsCurrentInstance(message) {
|
|
1397
2089
|
if (!isJsonObject(message))
|
|
1398
2090
|
return true;
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
2091
|
+
if ('device_id' in message) {
|
|
2092
|
+
const targetDeviceId = String(message.device_id ?? '').trim();
|
|
2093
|
+
if (targetDeviceId !== this._deviceId) {
|
|
2094
|
+
return false;
|
|
2095
|
+
}
|
|
1402
2096
|
}
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
2097
|
+
if ('slot_id' in message) {
|
|
2098
|
+
const targetSlotId = String(message.slot_id ?? '').trim();
|
|
2099
|
+
if (targetSlotId !== this._slotId) {
|
|
2100
|
+
return false;
|
|
2101
|
+
}
|
|
1406
2102
|
}
|
|
1407
2103
|
return true;
|
|
1408
2104
|
}
|
|
@@ -1417,10 +2113,15 @@ export class AUNClient {
|
|
|
1417
2113
|
for (const seq of ready) {
|
|
1418
2114
|
const item = queue.get(seq);
|
|
1419
2115
|
queue.delete(seq);
|
|
1420
|
-
if (!item
|
|
2116
|
+
if (!item)
|
|
2117
|
+
continue;
|
|
2118
|
+
if (this._pushedSeqs.get(ns)?.has(seq)) {
|
|
2119
|
+
this._clientLog.debug(`publish ordered drain skipped duplicate: ns=${ns}, seq=${seq}, event=${item.event}`);
|
|
1421
2120
|
continue;
|
|
1422
|
-
|
|
2121
|
+
}
|
|
2122
|
+
await this._publishAppEvent(item.event, item.payload, 'ordered-drain');
|
|
1423
2123
|
this._markPublishedSeq(ns, seq);
|
|
2124
|
+
this._clientLog.debug(`publish ordered drain delivered: ns=${ns}, seq=${seq}, event=${item.event}`);
|
|
1424
2125
|
}
|
|
1425
2126
|
if (queue.size === 0)
|
|
1426
2127
|
this._pendingOrderedMsgs.delete(ns);
|
|
@@ -1428,10 +2129,12 @@ export class AUNClient {
|
|
|
1428
2129
|
async _publishOrderedMessage(event, ns, seq, payload) {
|
|
1429
2130
|
const seqNum = Number(seq);
|
|
1430
2131
|
if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0) {
|
|
1431
|
-
|
|
2132
|
+
this._clientLog.debug(`publish ordered direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
|
|
2133
|
+
await this._publishAppEvent(event, payload, 'ordered');
|
|
1432
2134
|
return true;
|
|
1433
2135
|
}
|
|
1434
2136
|
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
2137
|
+
this._clientLog.debug(`publish ordered skipped duplicate: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
1435
2138
|
const queue = this._pendingOrderedMsgs.get(ns);
|
|
1436
2139
|
queue?.delete(seqNum);
|
|
1437
2140
|
if (queue && queue.size === 0)
|
|
@@ -1440,29 +2143,35 @@ export class AUNClient {
|
|
|
1440
2143
|
}
|
|
1441
2144
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1442
2145
|
if (seqNum > contig) {
|
|
2146
|
+
this._clientLog.debug(`publish ordered enqueue(gap): event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
|
|
1443
2147
|
this._enqueueOrderedMessage(ns, event, seqNum, payload);
|
|
1444
2148
|
return false;
|
|
1445
2149
|
}
|
|
1446
2150
|
await this._drainOrderedMessages(ns, seqNum);
|
|
1447
|
-
if (this._pushedSeqs.get(ns)?.has(seqNum))
|
|
2151
|
+
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
2152
|
+
this._clientLog.debug(`publish ordered skipped after-drain duplicate: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
1448
2153
|
return false;
|
|
2154
|
+
}
|
|
1449
2155
|
const queue = this._pendingOrderedMsgs.get(ns);
|
|
1450
2156
|
queue?.delete(seqNum);
|
|
1451
2157
|
if (queue && queue.size === 0)
|
|
1452
2158
|
this._pendingOrderedMsgs.delete(ns);
|
|
1453
|
-
await this._publishAppEvent(event, payload);
|
|
2159
|
+
await this._publishAppEvent(event, payload, 'ordered');
|
|
1454
2160
|
this._markPublishedSeq(ns, seqNum);
|
|
2161
|
+
this._clientLog.debug(`publish ordered delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
1455
2162
|
await this._drainOrderedMessages(ns);
|
|
1456
2163
|
return true;
|
|
1457
2164
|
}
|
|
1458
2165
|
async _publishPulledMessage(event, ns, seq, payload) {
|
|
1459
2166
|
const seqNum = Number(seq);
|
|
1460
2167
|
if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0 || !ns) {
|
|
1461
|
-
|
|
2168
|
+
this._clientLog.debug(`publish pulled direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
|
|
2169
|
+
await this._publishAppEvent(event, payload, 'pull');
|
|
1462
2170
|
return true;
|
|
1463
2171
|
}
|
|
1464
2172
|
const queue = this._pendingOrderedMsgs.get(ns);
|
|
1465
2173
|
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
2174
|
+
this._clientLog.debug(`publish pulled skipped duplicate: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
1466
2175
|
queue?.delete(seqNum);
|
|
1467
2176
|
if (queue && queue.size === 0)
|
|
1468
2177
|
this._pendingOrderedMsgs.delete(ns);
|
|
@@ -1471,8 +2180,9 @@ export class AUNClient {
|
|
|
1471
2180
|
queue?.delete(seqNum);
|
|
1472
2181
|
if (queue && queue.size === 0)
|
|
1473
2182
|
this._pendingOrderedMsgs.delete(ns);
|
|
1474
|
-
await this._publishAppEvent(event, payload);
|
|
2183
|
+
await this._publishAppEvent(event, payload, 'pull');
|
|
1475
2184
|
this._markPublishedSeq(ns, seqNum);
|
|
2185
|
+
this._clientLog.debug(`publish pulled delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
1476
2186
|
return true;
|
|
1477
2187
|
}
|
|
1478
2188
|
/** 后台补齐群事件空洞 */
|
|
@@ -1484,57 +2194,84 @@ export class AUNClient {
|
|
|
1484
2194
|
if (this._gapFillDone.has(dedupKey))
|
|
1485
2195
|
return;
|
|
1486
2196
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1487
|
-
this._clientLog.debug(`group event gap fill start: group=${groupId}, after_seq=${afterSeq}`);
|
|
1488
2197
|
try {
|
|
1489
|
-
const result = await this.call('group.pull_events', {
|
|
1490
|
-
group_id: groupId,
|
|
1491
|
-
after_event_seq: afterSeq,
|
|
1492
|
-
device_id: this._deviceId,
|
|
1493
|
-
limit: 50,
|
|
1494
|
-
});
|
|
1495
2198
|
let filled = 0;
|
|
1496
|
-
|
|
2199
|
+
let nextAfterSeq = afterSeq;
|
|
2200
|
+
const maxPages = 100;
|
|
2201
|
+
let pageCount = 0;
|
|
2202
|
+
while (pageCount < maxPages) {
|
|
2203
|
+
pageCount += 1;
|
|
2204
|
+
this._clientLog.debug(`group event gap fill start: group=${groupId}, after_seq=${nextAfterSeq}`);
|
|
2205
|
+
const result = await this.call('group.pull_events', {
|
|
2206
|
+
group_id: groupId,
|
|
2207
|
+
after_event_seq: nextAfterSeq,
|
|
2208
|
+
device_id: this._deviceId,
|
|
2209
|
+
limit: 50,
|
|
2210
|
+
});
|
|
2211
|
+
if (!isJsonObject(result))
|
|
2212
|
+
return;
|
|
1497
2213
|
const events = result.events;
|
|
1498
|
-
if (Array.isArray(events))
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
1512
|
-
if (contig > 0 && (events.length > 0 || serverAck > 0)) {
|
|
1513
|
-
this._transport.call('group.ack_events', {
|
|
1514
|
-
group_id: groupId,
|
|
1515
|
-
event_seq: contig,
|
|
1516
|
-
device_id: this._deviceId,
|
|
1517
|
-
slot_id: this._slotId,
|
|
1518
|
-
}).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2214
|
+
if (!Array.isArray(events))
|
|
2215
|
+
return;
|
|
2216
|
+
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
2217
|
+
const eventObjects = events.filter(isJsonObject);
|
|
2218
|
+
if (eventObjects.length > 0) {
|
|
2219
|
+
this._seqTracker.onPullResult(ns, eventObjects, nextAfterSeq);
|
|
2220
|
+
}
|
|
2221
|
+
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
2222
|
+
const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
|
|
2223
|
+
if (serverAck > 0) {
|
|
2224
|
+
const contigBeforeFloor = this._seqTracker.getContiguousSeq(ns);
|
|
2225
|
+
if (contigBeforeFloor < serverAck) {
|
|
2226
|
+
this._clientLog.info(`group.pull_events retention-floor advance: ns=${ns} contiguous=${contigBeforeFloor} -> cursor.current_seq=${serverAck}`);
|
|
2227
|
+
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
1519
2228
|
}
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
2229
|
+
}
|
|
2230
|
+
const eventSeqs = [];
|
|
2231
|
+
for (const evt of eventObjects) {
|
|
2232
|
+
const eventSeq = Number(evt.event_seq ?? 0);
|
|
2233
|
+
if (Number.isFinite(eventSeq) && eventSeq > 0)
|
|
2234
|
+
eventSeqs.push(eventSeq);
|
|
2235
|
+
evt._from_gap_fill = true;
|
|
2236
|
+
const et = String(evt.event_type ?? '');
|
|
2237
|
+
// 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
|
|
2238
|
+
if (et === 'group.message_created')
|
|
2239
|
+
continue;
|
|
2240
|
+
// 验签:有 client_signature 就验(与实时事件路径对齐)
|
|
2241
|
+
const cs = evt.client_signature;
|
|
2242
|
+
if (cs && typeof cs === 'object') {
|
|
2243
|
+
if (this._shouldSkipEventSignature(evt)) {
|
|
2244
|
+
delete evt.client_signature;
|
|
2245
|
+
}
|
|
2246
|
+
else {
|
|
2247
|
+
evt._verified = await this._verifyEventSignatureAsync(evt, cs);
|
|
1535
2248
|
}
|
|
1536
2249
|
}
|
|
2250
|
+
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
2251
|
+
await this._dispatcher.publish('group.changed', evt);
|
|
2252
|
+
filled += 1;
|
|
1537
2253
|
}
|
|
2254
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2255
|
+
if (contig !== pageContigBefore) {
|
|
2256
|
+
this._saveSeqTrackerState();
|
|
2257
|
+
}
|
|
2258
|
+
if (eventObjects.length > 0 && contig > 0 && contig !== pageContigBefore) {
|
|
2259
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
2260
|
+
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
2261
|
+
this._transport.call('group.ack_events', {
|
|
2262
|
+
group_id: groupId,
|
|
2263
|
+
event_seq: ackSeq,
|
|
2264
|
+
device_id: this._deviceId,
|
|
2265
|
+
slot_id: this._slotId,
|
|
2266
|
+
}).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2267
|
+
}
|
|
2268
|
+
const nextAfter = Math.max(eventSeqs.length > 0 ? Math.max(...eventSeqs) : nextAfterSeq, nextAfterSeq);
|
|
2269
|
+
if (eventObjects.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
2270
|
+
break;
|
|
2271
|
+
nextAfterSeq = nextAfter;
|
|
2272
|
+
}
|
|
2273
|
+
if (pageCount >= maxPages) {
|
|
2274
|
+
this._clientLog.warn(`group event gap fill reached max_pages=${maxPages} group=${groupId} after_seq=${nextAfterSeq}`);
|
|
1538
2275
|
}
|
|
1539
2276
|
this._clientLog.debug(`group event gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
|
|
1540
2277
|
}
|
|
@@ -1567,28 +2304,45 @@ export class AUNClient {
|
|
|
1567
2304
|
// 验签:有 client_signature 就验,没有默认安全(H20: 严格 boolean)
|
|
1568
2305
|
const cs = d.client_signature;
|
|
1569
2306
|
if (cs && isJsonObject(cs)) {
|
|
1570
|
-
|
|
2307
|
+
if (this._shouldSkipEventSignature(d)) {
|
|
2308
|
+
delete d.client_signature;
|
|
2309
|
+
}
|
|
2310
|
+
else {
|
|
2311
|
+
d._verified = await this._verifyEventSignatureAsync(d, cs);
|
|
2312
|
+
}
|
|
1571
2313
|
}
|
|
1572
2314
|
await this._dispatcher.publish('group.changed', d);
|
|
1573
2315
|
// V2-only:成员/设备变化会影响 group.v2.bootstrap 的设备集与 state commitment。
|
|
1574
2316
|
if (groupId) {
|
|
1575
2317
|
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
1576
2318
|
}
|
|
1577
|
-
|
|
2319
|
+
const membershipActions = new Set([
|
|
2320
|
+
'member_added', 'member_left', 'member_removed', 'role_changed',
|
|
2321
|
+
'owner_transferred', 'joined', 'join_approved', 'invite_code_used',
|
|
2322
|
+
]);
|
|
2323
|
+
if (groupId && this._v2Session && (action === 'upsert' || membershipActions.has(action))) {
|
|
1578
2324
|
this._safeAsync(this._v2AutoProposeState(groupId, { leaderDelay: true }));
|
|
1579
2325
|
}
|
|
1580
2326
|
// Group SPK 编排:成员变更触发注册/轮换
|
|
1581
2327
|
if (this._v2Session && groupId) {
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
2328
|
+
if (membershipActions.has(action)) {
|
|
2329
|
+
const callFn = async (method, params) => this.call(method, params);
|
|
2330
|
+
const joinedAid = String(d.joined_aid ?? d.member_aid ?? d.aid ?? '').trim();
|
|
2331
|
+
const actorAid = String(d.actor_aid ?? '').trim();
|
|
2332
|
+
const selfAid = String(this._aid ?? '').trim();
|
|
2333
|
+
const joinActions = new Set(['member_added', 'joined', 'join_approved', 'invite_code_used']);
|
|
2334
|
+
const isSelfJoin = joinActions.has(action) && !!selfAid && (joinedAid === selfAid ||
|
|
2335
|
+
(!joinedAid && (action === 'joined' || action === 'invite_code_used') && actorAid === selfAid));
|
|
2336
|
+
if (isSelfJoin) {
|
|
2337
|
+
this._v2Session.ensureGroupRegistered?.(groupId, callFn)?.catch(exc => {
|
|
2338
|
+
this._clientLog.debug(`group SPK registration failed (non-fatal): group=${groupId} action=${action} err=${formatCaughtError(exc)}`);
|
|
2339
|
+
});
|
|
2340
|
+
}
|
|
2341
|
+
else {
|
|
2342
|
+
this._v2Session.rotateGroupSPK?.(groupId, callFn)?.catch(exc => {
|
|
2343
|
+
this._clientLog.debug(`group SPK rotation failed (non-fatal): group=${groupId} action=${action} err=${formatCaughtError(exc)}`);
|
|
2344
|
+
});
|
|
2345
|
+
}
|
|
1592
2346
|
}
|
|
1593
2347
|
}
|
|
1594
2348
|
// event_seq 空洞检测:持久化后的 group.changed 会携带 event_seq。
|
|
@@ -1655,12 +2409,17 @@ export class AUNClient {
|
|
|
1655
2409
|
// 提交者签名验证(兼容旧版:无签名时继续)
|
|
1656
2410
|
const cs = d.client_signature;
|
|
1657
2411
|
if (cs && isJsonObject(cs)) {
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
2412
|
+
if (this._shouldSkipEventSignature(d)) {
|
|
2413
|
+
delete d.client_signature;
|
|
2414
|
+
}
|
|
2415
|
+
else {
|
|
2416
|
+
const verified = await this._verifyEventSignatureAsync(d, cs);
|
|
2417
|
+
if (verified === false) {
|
|
2418
|
+
this._clientLog.warn(`state_committed committer signature verification failed group=${groupId}`);
|
|
2419
|
+
return;
|
|
2420
|
+
}
|
|
2421
|
+
d._verified = verified;
|
|
1662
2422
|
}
|
|
1663
|
-
d._verified = verified;
|
|
1664
2423
|
}
|
|
1665
2424
|
const stateVersion = Number(d.state_version ?? 0);
|
|
1666
2425
|
const stateHash = String(d.state_hash ?? '').trim();
|
|
@@ -1815,7 +2574,7 @@ export class AUNClient {
|
|
|
1815
2574
|
* 获取对方证书(带缓存 + 完整 PKI 验证)。
|
|
1816
2575
|
* 跨域时自动路由到 peer 所在域的 Gateway。
|
|
1817
2576
|
*/
|
|
1818
|
-
async _fetchPeerCert(aid, certFingerprint) {
|
|
2577
|
+
async _fetchPeerCert(aid, certFingerprint, timeoutMs = 30_000) {
|
|
1819
2578
|
const tStart = Date.now();
|
|
1820
2579
|
this._clientLog.debug(`_fetchPeerCert enter: aid=${aid}, fp=${certFingerprint ?? ''}`);
|
|
1821
2580
|
try {
|
|
@@ -1835,13 +2594,13 @@ export class AUNClient {
|
|
|
1835
2594
|
let certPem;
|
|
1836
2595
|
try {
|
|
1837
2596
|
const certUrl = AUNClient._buildCertUrl(peerGatewayUrl, aid, certFingerprint);
|
|
1838
|
-
certPem = await _httpGetText(certUrl, this._configModel.verifySsl);
|
|
2597
|
+
certPem = await _httpGetText(certUrl, this._configModel.verifySsl, timeoutMs);
|
|
1839
2598
|
}
|
|
1840
2599
|
catch (exc) {
|
|
1841
2600
|
if (!certFingerprint) {
|
|
1842
2601
|
throw exc;
|
|
1843
2602
|
}
|
|
1844
|
-
const fallbackCert = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid), this._configModel.verifySsl);
|
|
2603
|
+
const fallbackCert = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid), this._configModel.verifySsl, timeoutMs);
|
|
1845
2604
|
certPem = fallbackCert;
|
|
1846
2605
|
}
|
|
1847
2606
|
// H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
|
|
@@ -1894,11 +2653,14 @@ export class AUNClient {
|
|
|
1894
2653
|
}
|
|
1895
2654
|
}
|
|
1896
2655
|
async _decryptGroupThoughts(result) {
|
|
2656
|
+
this._clientLog.debug(`group.thought.get decrypt enter: found=${String(result.found ?? '')}, group=${String(result.group_id ?? '')}, sender=${String(result.sender_aid ?? '')}`);
|
|
1897
2657
|
if (!result.found) {
|
|
2658
|
+
this._clientLog.debug('group.thought.get decrypt exit: not found');
|
|
1898
2659
|
return { ...result, thoughts: [] };
|
|
1899
2660
|
}
|
|
1900
2661
|
const items = Array.isArray(result.thoughts) ? result.thoughts.filter(isJsonObject) : [];
|
|
1901
2662
|
if (items.length === 0) {
|
|
2663
|
+
this._clientLog.debug('group.thought.get decrypt exit: empty thoughts');
|
|
1902
2664
|
return { ...result, thoughts: [] };
|
|
1903
2665
|
}
|
|
1904
2666
|
const groupId = String(result.group_id ?? '');
|
|
@@ -1908,26 +2670,32 @@ export class AUNClient {
|
|
|
1908
2670
|
const payload = isJsonObject(item.payload) ? item.payload : null;
|
|
1909
2671
|
const thoughtId = String(item.thought_id ?? item.message_id ?? '');
|
|
1910
2672
|
const fromAid = String(item.from ?? item.sender_aid ?? senderAid);
|
|
2673
|
+
this._logMessageDebug('thought-get-raw', 'group.thought.get', 'group.thought.get', item, {
|
|
2674
|
+
extra: { group_id: groupId, thought_id: thoughtId, from: fromAid },
|
|
2675
|
+
});
|
|
1911
2676
|
let decryptFailed = false;
|
|
1912
2677
|
let decryptedPayload = payload ?? {};
|
|
1913
2678
|
let e2ee;
|
|
1914
2679
|
if (payload?.type === 'e2ee.group_encrypted' && String(payload.version ?? '') === 'v2') {
|
|
2680
|
+
e2ee = this._v2E2eeMeta(payload);
|
|
1915
2681
|
const plain = await this._decryptV2EnvelopeForThought({ envelope: payload, fromAid });
|
|
1916
2682
|
if (plain === null) {
|
|
1917
2683
|
decryptFailed = true;
|
|
2684
|
+
this._clientLog.debug(`group.thought.get decrypt returned null: group=${groupId}, thought_id=${thoughtId}, from=${fromAid}`);
|
|
1918
2685
|
}
|
|
1919
2686
|
else {
|
|
1920
2687
|
decryptedPayload = plain;
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
};
|
|
2688
|
+
this._logMessageDebug('thought-decrypt-ok', 'group.thought.get', 'group.thought.get', {
|
|
2689
|
+
group_id: groupId,
|
|
2690
|
+
thought_id: thoughtId,
|
|
2691
|
+
from: fromAid,
|
|
2692
|
+
payload: plain,
|
|
2693
|
+
});
|
|
1927
2694
|
}
|
|
1928
2695
|
}
|
|
1929
2696
|
else if (payload?.type === 'e2ee.group_encrypted') {
|
|
1930
2697
|
decryptFailed = true;
|
|
2698
|
+
this._clientLog.debug(`group.thought.get unsupported encrypted payload: group=${groupId}, thought_id=${thoughtId}, type=${String(payload.type ?? '')}, version=${String(payload.version ?? '')}`);
|
|
1931
2699
|
}
|
|
1932
2700
|
const thought = {
|
|
1933
2701
|
thought_id: thoughtId,
|
|
@@ -1935,22 +2703,32 @@ export class AUNClient {
|
|
|
1935
2703
|
payload: decryptFailed ? (payload ?? {}) : decryptedPayload,
|
|
1936
2704
|
created_at: item.created_at,
|
|
1937
2705
|
};
|
|
1938
|
-
if (e2ee !== undefined)
|
|
2706
|
+
if (e2ee !== undefined) {
|
|
1939
2707
|
thought.e2ee = e2ee;
|
|
2708
|
+
if (isJsonObject(e2ee))
|
|
2709
|
+
this._attachV2EnvelopeMetadata(thought, e2ee);
|
|
2710
|
+
}
|
|
1940
2711
|
if (decryptFailed)
|
|
1941
2712
|
thought.decrypt_failed = true;
|
|
1942
2713
|
if ('context' in item)
|
|
1943
2714
|
thought.context = item.context;
|
|
2715
|
+
this._logMessageDebug(decryptFailed ? 'thought-decrypt-fail' : 'thought-result', 'group.thought.get', 'group.thought.get', thought, {
|
|
2716
|
+
extra: { group_id: groupId, thought_id: thoughtId },
|
|
2717
|
+
});
|
|
1944
2718
|
thoughts.push(thought);
|
|
1945
2719
|
}
|
|
2720
|
+
this._clientLog.debug(`group.thought.get decrypt exit: group=${groupId}, total=${items.length}, returned=${thoughts.length}`);
|
|
1946
2721
|
return { ...result, thoughts };
|
|
1947
2722
|
}
|
|
1948
2723
|
async _decryptMessageThoughts(result) {
|
|
2724
|
+
this._clientLog.debug(`message.thought.get decrypt enter: found=${String(result.found ?? '')}, peer=${String(result.peer_aid ?? '')}, sender=${String(result.sender_aid ?? '')}`);
|
|
1949
2725
|
if (!result.found) {
|
|
2726
|
+
this._clientLog.debug('message.thought.get decrypt exit: not found');
|
|
1950
2727
|
return { ...result, thoughts: [] };
|
|
1951
2728
|
}
|
|
1952
2729
|
const items = Array.isArray(result.thoughts) ? result.thoughts.filter(isJsonObject) : [];
|
|
1953
2730
|
if (items.length === 0) {
|
|
2731
|
+
this._clientLog.debug('message.thought.get decrypt exit: empty thoughts');
|
|
1954
2732
|
return { ...result, thoughts: [] };
|
|
1955
2733
|
}
|
|
1956
2734
|
const senderAid = String(result.sender_aid ?? '');
|
|
@@ -1961,26 +2739,32 @@ export class AUNClient {
|
|
|
1961
2739
|
const thoughtId = String(item.thought_id ?? item.message_id ?? '');
|
|
1962
2740
|
const fromAid = String(item.from ?? senderAid);
|
|
1963
2741
|
const toAid = String(item.to ?? peerAid);
|
|
2742
|
+
this._logMessageDebug('thought-get-raw', 'message.thought.get', 'message.thought.get', item, {
|
|
2743
|
+
extra: { thought_id: thoughtId, from: fromAid, to: toAid },
|
|
2744
|
+
});
|
|
1964
2745
|
let decryptFailed = false;
|
|
1965
2746
|
let decryptedPayload = payload ?? {};
|
|
1966
2747
|
let e2ee;
|
|
1967
2748
|
if (payload?.type === 'e2ee.p2p_encrypted' && String(payload.version ?? '') === 'v2') {
|
|
2749
|
+
e2ee = this._v2E2eeMeta(payload);
|
|
1968
2750
|
const plain = await this._decryptV2EnvelopeForThought({ envelope: payload, fromAid });
|
|
1969
2751
|
if (plain === null) {
|
|
1970
2752
|
decryptFailed = true;
|
|
2753
|
+
this._clientLog.debug(`message.thought.get decrypt returned null: thought_id=${thoughtId}, from=${fromAid}, to=${toAid}`);
|
|
1971
2754
|
}
|
|
1972
2755
|
else {
|
|
1973
2756
|
decryptedPayload = plain;
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
};
|
|
2757
|
+
this._logMessageDebug('thought-decrypt-ok', 'message.thought.get', 'message.thought.get', {
|
|
2758
|
+
thought_id: thoughtId,
|
|
2759
|
+
from: fromAid,
|
|
2760
|
+
to: toAid,
|
|
2761
|
+
payload: plain,
|
|
2762
|
+
});
|
|
1980
2763
|
}
|
|
1981
2764
|
}
|
|
1982
2765
|
else if (payload?.type === 'e2ee.encrypted' || payload?.type === 'e2ee.p2p_encrypted') {
|
|
1983
2766
|
decryptFailed = true;
|
|
2767
|
+
this._clientLog.debug(`message.thought.get unsupported encrypted payload: thought_id=${thoughtId}, type=${String(payload.type ?? '')}, version=${String(payload.version ?? '')}`);
|
|
1984
2768
|
}
|
|
1985
2769
|
const thought = {
|
|
1986
2770
|
thought_id: thoughtId,
|
|
@@ -1990,14 +2774,21 @@ export class AUNClient {
|
|
|
1990
2774
|
payload: decryptFailed ? (payload ?? {}) : decryptedPayload,
|
|
1991
2775
|
created_at: item.created_at,
|
|
1992
2776
|
};
|
|
1993
|
-
if (e2ee !== undefined)
|
|
2777
|
+
if (e2ee !== undefined) {
|
|
1994
2778
|
thought.e2ee = e2ee;
|
|
2779
|
+
if (isJsonObject(e2ee))
|
|
2780
|
+
this._attachV2EnvelopeMetadata(thought, e2ee);
|
|
2781
|
+
}
|
|
1995
2782
|
if (decryptFailed)
|
|
1996
2783
|
thought.decrypt_failed = true;
|
|
1997
2784
|
if ('context' in item)
|
|
1998
2785
|
thought.context = item.context;
|
|
2786
|
+
this._logMessageDebug(decryptFailed ? 'thought-decrypt-fail' : 'thought-result', 'message.thought.get', 'message.thought.get', thought, {
|
|
2787
|
+
extra: { thought_id: thoughtId },
|
|
2788
|
+
});
|
|
1999
2789
|
thoughts.push(thought);
|
|
2000
2790
|
}
|
|
2791
|
+
this._clientLog.debug(`message.thought.get decrypt exit: total=${items.length}, returned=${thoughts.length}`);
|
|
2001
2792
|
return { ...result, thoughts };
|
|
2002
2793
|
}
|
|
2003
2794
|
/** 从 keystore 恢复 SeqTracker 状态 */ /** 从 keystore 恢复 SeqTracker 状态 */
|
|
@@ -2102,6 +2893,8 @@ export class AUNClient {
|
|
|
2102
2893
|
this._gapFillDone.clear();
|
|
2103
2894
|
this._pushedSeqs.clear();
|
|
2104
2895
|
this._pendingOrderedMsgs.clear();
|
|
2896
|
+
this._v2SenderIKPending.clear();
|
|
2897
|
+
this._v2SenderIKFetching.clear();
|
|
2105
2898
|
this._groupSynced.clear();
|
|
2106
2899
|
}
|
|
2107
2900
|
_refreshSeqTrackerContext() {
|
|
@@ -2112,6 +2905,8 @@ export class AUNClient {
|
|
|
2112
2905
|
this._gapFillDone.clear();
|
|
2113
2906
|
this._pushedSeqs.clear();
|
|
2114
2907
|
this._pendingOrderedMsgs.clear();
|
|
2908
|
+
this._v2SenderIKPending.clear();
|
|
2909
|
+
this._v2SenderIKFetching.clear();
|
|
2115
2910
|
this._groupSynced.clear();
|
|
2116
2911
|
this._seqTrackerContext = nextContext;
|
|
2117
2912
|
}
|
|
@@ -2141,16 +2936,53 @@ export class AUNClient {
|
|
|
2141
2936
|
}
|
|
2142
2937
|
}
|
|
2143
2938
|
catch (exc) {
|
|
2144
|
-
this._clientLog.warn(`save SeqTracker state failed: ${formatCaughtError(exc)}`);
|
|
2145
|
-
// 通过内部 dispatcher 发布可观测事件,便于上层监控
|
|
2146
|
-
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
2147
|
-
phase: 'save',
|
|
2148
|
-
aid: this._aid,
|
|
2149
|
-
device_id: this._deviceId,
|
|
2150
|
-
slot_id: this._slotId,
|
|
2151
|
-
error: String(formatCaughtError(exc)),
|
|
2152
|
-
}).catch(() => { });
|
|
2939
|
+
this._clientLog.warn(`save SeqTracker state failed: ${formatCaughtError(exc)}`);
|
|
2940
|
+
// 通过内部 dispatcher 发布可观测事件,便于上层监控
|
|
2941
|
+
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
2942
|
+
phase: 'save',
|
|
2943
|
+
aid: this._aid,
|
|
2944
|
+
device_id: this._deviceId,
|
|
2945
|
+
slot_id: this._slotId,
|
|
2946
|
+
error: String(formatCaughtError(exc)),
|
|
2947
|
+
}).catch(() => { });
|
|
2948
|
+
}
|
|
2949
|
+
}
|
|
2950
|
+
_persistRepairedSeq(ns) {
|
|
2951
|
+
if (!this._aid || !ns)
|
|
2952
|
+
return;
|
|
2953
|
+
const seq = this._seqTracker.getContiguousSeq(ns);
|
|
2954
|
+
try {
|
|
2955
|
+
if (seq > 0 && typeof this._keystore.saveSeq === 'function') {
|
|
2956
|
+
this._keystore.saveSeq(this._aid, this._deviceId, this._slotId, ns, seq);
|
|
2957
|
+
return;
|
|
2958
|
+
}
|
|
2959
|
+
const deleteSeq = this._keystore.deleteSeq;
|
|
2960
|
+
if (seq <= 0 && typeof deleteSeq === 'function') {
|
|
2961
|
+
deleteSeq.call(this._keystore, this._aid, this._deviceId, this._slotId, ns);
|
|
2962
|
+
return;
|
|
2963
|
+
}
|
|
2964
|
+
if (seq > 0) {
|
|
2965
|
+
this._saveSeqTrackerState();
|
|
2966
|
+
}
|
|
2967
|
+
}
|
|
2968
|
+
catch (exc) {
|
|
2969
|
+
this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
|
|
2970
|
+
}
|
|
2971
|
+
}
|
|
2972
|
+
_repairPushContiguousBound(ns, pushSeq, hasPayload, label) {
|
|
2973
|
+
if (!ns || !Number.isFinite(pushSeq) || pushSeq <= 0) {
|
|
2974
|
+
return ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
2153
2975
|
}
|
|
2976
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2977
|
+
const shouldRepair = contig > pushSeq;
|
|
2978
|
+
if (!shouldRepair)
|
|
2979
|
+
return contig;
|
|
2980
|
+
const repairedTo = Math.max(0, pushSeq - 1);
|
|
2981
|
+
this._seqTracker.repairContiguousSeq(ns, repairedTo);
|
|
2982
|
+
const repaired = this._seqTracker.getContiguousSeq(ns);
|
|
2983
|
+
this._persistRepairedSeq(ns);
|
|
2984
|
+
this._clientLog.warn(`${label} push repaired contiguous_seq: ns=${ns} payload=${hasPayload} push_seq=${pushSeq} contiguous=${contig}->${repaired}`);
|
|
2985
|
+
return repaired;
|
|
2154
2986
|
}
|
|
2155
2987
|
/** 记录 E2EE 自动编排错误 */
|
|
2156
2988
|
_logE2eeError(stage, groupId, aid, exc) {
|
|
@@ -2370,43 +3202,236 @@ export class AUNClient {
|
|
|
2370
3202
|
this._clientLog.debug(`V2 session initialized aid=${this._aid} device=${this._deviceId}`);
|
|
2371
3203
|
this._safeAsync(this._v2AutoConfirmPendingProposals());
|
|
2372
3204
|
}
|
|
3205
|
+
async _v2TrustedIKPubDer(aid) {
|
|
3206
|
+
const normalizedAid = String(aid ?? '').trim();
|
|
3207
|
+
if (!normalizedAid)
|
|
3208
|
+
throw new E2EEError('spk_aid_missing');
|
|
3209
|
+
if (this._aid && normalizedAid === this._aid) {
|
|
3210
|
+
if (!this._v2Session)
|
|
3211
|
+
throw new E2EEError('V2 session not initialized');
|
|
3212
|
+
return this._v2Session.currentIkPubDer;
|
|
3213
|
+
}
|
|
3214
|
+
const certPem = await this._fetchPeerCert(normalizedAid);
|
|
3215
|
+
const cert = new crypto.X509Certificate(certPem);
|
|
3216
|
+
const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
3217
|
+
return new Uint8Array(certPubDer);
|
|
3218
|
+
}
|
|
3219
|
+
_v2SPKTimestampText(value, aid, deviceId, spkId) {
|
|
3220
|
+
if (value === null || value === undefined || value === '') {
|
|
3221
|
+
throw new E2EEError(`spk_timestamp_missing: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
3222
|
+
}
|
|
3223
|
+
if (typeof value === 'boolean') {
|
|
3224
|
+
throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
3225
|
+
}
|
|
3226
|
+
if (typeof value === 'number') {
|
|
3227
|
+
if (!Number.isSafeInteger(value)) {
|
|
3228
|
+
throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
3229
|
+
}
|
|
3230
|
+
return String(value);
|
|
3231
|
+
}
|
|
3232
|
+
const text = String(value).trim();
|
|
3233
|
+
if (!/^\d+$/.test(text)) {
|
|
3234
|
+
throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
3235
|
+
}
|
|
3236
|
+
return BigInt(text).toString();
|
|
3237
|
+
}
|
|
3238
|
+
async _v2VerifySPKDevice(args) {
|
|
3239
|
+
if (!this._v2Session)
|
|
3240
|
+
throw new E2EEError('V2 session not initialized');
|
|
3241
|
+
const spkId = String(args.dev.spk_id ?? '').trim();
|
|
3242
|
+
if (!spkId)
|
|
3243
|
+
return;
|
|
3244
|
+
if (args.keySource !== 'peer_device_prekey' && args.keySource !== 'group_device_prekey') {
|
|
3245
|
+
throw new E2EEError(`spk_key_source_invalid: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} key_source=${args.keySource}`);
|
|
3246
|
+
}
|
|
3247
|
+
if (!args.spkPkDer || args.spkPkDer.length === 0) {
|
|
3248
|
+
throw new E2EEError(`spk_public_key_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
3249
|
+
}
|
|
3250
|
+
const expectedSpkId = `sha256:${crypto.createHash('sha256').update(Buffer.from(args.spkPkDer)).digest('hex').slice(0, 16)}`;
|
|
3251
|
+
if (spkId !== expectedSpkId) {
|
|
3252
|
+
throw new E2EEError(`spk_id_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} expected=${expectedSpkId}`);
|
|
3253
|
+
}
|
|
3254
|
+
const trustedIK = await this._v2TrustedIKPubDer(args.aid);
|
|
3255
|
+
if (!_v2BytesEqual(trustedIK, args.ikPkDer)) {
|
|
3256
|
+
throw new E2EEError(`spk_ik_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
3257
|
+
}
|
|
3258
|
+
if (_v2BytesEqual(args.spkPkDer, trustedIK)) {
|
|
3259
|
+
this._v2Session.markPeerSPKVerified(args.aid, args.deviceId, spkId);
|
|
3260
|
+
return;
|
|
3261
|
+
}
|
|
3262
|
+
const sigB64 = String(args.dev.spk_signature ?? '').trim();
|
|
3263
|
+
if (!sigB64) {
|
|
3264
|
+
throw new E2EEError(`spk_signature_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
3265
|
+
}
|
|
3266
|
+
let signature;
|
|
3267
|
+
try {
|
|
3268
|
+
signature = _v2B64ToBytesStrict(sigB64);
|
|
3269
|
+
}
|
|
3270
|
+
catch {
|
|
3271
|
+
throw new E2EEError(`spk_signature_invalid_base64: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
3272
|
+
}
|
|
3273
|
+
const tsText = this._v2SPKTimestampText(args.dev.spk_timestamp, args.aid, args.deviceId, spkId);
|
|
3274
|
+
const signData = Buffer.concat([
|
|
3275
|
+
Buffer.from(args.spkPkDer),
|
|
3276
|
+
Buffer.from(spkId, 'utf8'),
|
|
3277
|
+
Buffer.from(tsText, 'utf8'),
|
|
3278
|
+
]);
|
|
3279
|
+
if (!ecdsaVerifyRaw(trustedIK, signature, new Uint8Array(signData))) {
|
|
3280
|
+
throw new E2EEError(`spk_signature_invalid: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
3281
|
+
}
|
|
3282
|
+
this._v2Session.markPeerSPKVerified(args.aid, args.deviceId, spkId);
|
|
3283
|
+
}
|
|
3284
|
+
async _v2BuildTargetFromDevice(args) {
|
|
3285
|
+
const aid = String(args.aid ?? '').trim();
|
|
3286
|
+
const devId = getV2DeviceId(args.dev);
|
|
3287
|
+
const deviceId = devId.present ? devId.value : String(args.deviceId ?? '').trim();
|
|
3288
|
+
const ikPk = String(args.dev.ik_pk ?? '').trim();
|
|
3289
|
+
if (!aid || !devId.present || !ikPk)
|
|
3290
|
+
return null;
|
|
3291
|
+
const ikPkDer = _v2B64ToBytes(ikPk);
|
|
3292
|
+
const spkPkDer = args.dev.spk_pk ? _v2B64ToBytes(String(args.dev.spk_pk)) : undefined;
|
|
3293
|
+
const keySource = String(args.dev.key_source ?? args.defaultKeySource).trim() || args.defaultKeySource;
|
|
3294
|
+
await this._v2VerifySPKDevice({ dev: args.dev, aid, deviceId, ikPkDer, spkPkDer, keySource });
|
|
3295
|
+
this._v2Session?.cachePeerIK(aid, deviceId, ikPkDer);
|
|
3296
|
+
return {
|
|
3297
|
+
aid,
|
|
3298
|
+
deviceId,
|
|
3299
|
+
role: args.role,
|
|
3300
|
+
keySource,
|
|
3301
|
+
ikPkDer,
|
|
3302
|
+
spkPkDer,
|
|
3303
|
+
spkId: String(args.dev.spk_id ?? '').trim(),
|
|
3304
|
+
};
|
|
3305
|
+
}
|
|
2373
3306
|
async _getV2SenderPubDer(fromAid, senderDeviceId) {
|
|
2374
3307
|
const session = this._v2Session;
|
|
2375
3308
|
if (!session || !fromAid)
|
|
2376
3309
|
return null;
|
|
2377
|
-
|
|
3310
|
+
const senderPubDer = session.getPeerIK(fromAid, senderDeviceId);
|
|
2378
3311
|
if (senderPubDer)
|
|
2379
3312
|
return senderPubDer;
|
|
2380
3313
|
try {
|
|
2381
|
-
const
|
|
2382
|
-
const
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
session.cachePeerIK(fromAid, devId, _v2B64ToBytes(ikPk));
|
|
2389
|
-
}
|
|
2390
|
-
senderPubDer = session.getPeerIK(fromAid, senderDeviceId);
|
|
2391
|
-
if (senderPubDer)
|
|
2392
|
-
return senderPubDer;
|
|
3314
|
+
const certPem = await this._fetchPeerCert(fromAid, undefined, 3000);
|
|
3315
|
+
const cert = new crypto.X509Certificate(certPem);
|
|
3316
|
+
const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
3317
|
+
const certPub = new Uint8Array(certPubDer);
|
|
3318
|
+
session.cachePeerIK(fromAid, senderDeviceId, certPub);
|
|
3319
|
+
this._clientLog.debug(`V2 decrypt: sender IK fallback from PKI cert for ${fromAid}`);
|
|
3320
|
+
return certPub;
|
|
2393
3321
|
}
|
|
2394
3322
|
catch (exc) {
|
|
2395
|
-
this._clientLog.warn(`V2 decrypt:
|
|
3323
|
+
this._clientLog.warn(`V2 decrypt: PKI cert sender IK fallback failed for ${fromAid}: ${formatCaughtError(exc)}`);
|
|
3324
|
+
return null;
|
|
2396
3325
|
}
|
|
3326
|
+
}
|
|
3327
|
+
_v2PendingSenderIKMessageKey(msg, groupId) {
|
|
3328
|
+
const messageId = String(msg.message_id ?? '').trim();
|
|
3329
|
+
const seq = String(msg.seq ?? '').trim();
|
|
3330
|
+
const prefix = groupId ? `group:${groupId}` : `p2p:${this._aid ?? ''}`;
|
|
3331
|
+
return `${prefix}:${messageId || seq || Math.random().toString(36).slice(2)}`;
|
|
3332
|
+
}
|
|
3333
|
+
_v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId) {
|
|
3334
|
+
return `${fromAid}#${senderDeviceId}#${groupId || ''}`;
|
|
3335
|
+
}
|
|
3336
|
+
_cacheV2PeerIKFromDevice(dev, fallbackAid = '') {
|
|
3337
|
+
const session = this._v2Session;
|
|
3338
|
+
if (!session || !isJsonObject(dev))
|
|
3339
|
+
return;
|
|
3340
|
+
const device = dev;
|
|
3341
|
+
const devId = getV2DeviceId(device);
|
|
3342
|
+
const aid = String(device.aid ?? fallbackAid ?? '').trim();
|
|
3343
|
+
const ikPk = String(device.ik_pk ?? '').trim();
|
|
3344
|
+
if (!devId.present || !aid || !ikPk)
|
|
3345
|
+
return;
|
|
2397
3346
|
try {
|
|
2398
|
-
|
|
2399
|
-
const cert = new crypto.X509Certificate(certPem);
|
|
2400
|
-
const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
2401
|
-
senderPubDer = new Uint8Array(certPubDer);
|
|
2402
|
-
if (senderDeviceId) {
|
|
2403
|
-
session.cachePeerIK(fromAid, senderDeviceId, senderPubDer);
|
|
2404
|
-
}
|
|
2405
|
-
return senderPubDer;
|
|
3347
|
+
session.cachePeerIK(aid, devId.value, _v2B64ToBytes(ikPk));
|
|
2406
3348
|
}
|
|
2407
3349
|
catch (exc) {
|
|
2408
|
-
this._clientLog.
|
|
2409
|
-
|
|
3350
|
+
this._clientLog.debug(`V2 sender IK cache from bootstrap skipped aid=${aid} dev=${devId.value}: ${formatCaughtError(exc)}`);
|
|
3351
|
+
}
|
|
3352
|
+
}
|
|
3353
|
+
_scheduleV2SenderIKPending(args) {
|
|
3354
|
+
const fromAid = String(args.fromAid ?? '').trim();
|
|
3355
|
+
if (!fromAid)
|
|
3356
|
+
return;
|
|
3357
|
+
const senderDeviceId = String(args.senderDeviceId ?? '');
|
|
3358
|
+
const groupId = String(args.groupId ?? '').trim();
|
|
3359
|
+
const messageKey = this._v2PendingSenderIKMessageKey(args.msg, groupId);
|
|
3360
|
+
this._v2SenderIKPending.set(messageKey, {
|
|
3361
|
+
msg: { ...args.msg },
|
|
3362
|
+
fromAid,
|
|
3363
|
+
senderDeviceId,
|
|
3364
|
+
groupId,
|
|
3365
|
+
createdAt: Date.now(),
|
|
3366
|
+
});
|
|
3367
|
+
this._clientLog.debug(`V2 decrypt pending sender IK: key=${messageKey} from=${fromAid} device=${senderDeviceId || '-'} group=${groupId || '<p2p>'} pending=${this._v2SenderIKPending.size}`);
|
|
3368
|
+
this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId);
|
|
3369
|
+
}
|
|
3370
|
+
_scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId) {
|
|
3371
|
+
const fetchKey = this._v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId);
|
|
3372
|
+
if (!fromAid || this._v2SenderIKFetching.has(fetchKey))
|
|
3373
|
+
return;
|
|
3374
|
+
this._v2SenderIKFetching.add(fetchKey);
|
|
3375
|
+
this._safeAsync(this._resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey));
|
|
3376
|
+
}
|
|
3377
|
+
async _resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey) {
|
|
3378
|
+
try {
|
|
3379
|
+
const session = this._v2Session;
|
|
3380
|
+
if (session && fromAid) {
|
|
3381
|
+
try {
|
|
3382
|
+
const bs = await this.call('message.v2.bootstrap', { peer_aid: fromAid });
|
|
3383
|
+
const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
3384
|
+
for (const dev of peers)
|
|
3385
|
+
this._cacheV2PeerIKFromDevice(dev, fromAid);
|
|
3386
|
+
}
|
|
3387
|
+
catch (exc) {
|
|
3388
|
+
this._clientLog.warn(`V2 sender IK pending bootstrap failed peer=${fromAid}: ${formatCaughtError(exc)}`);
|
|
3389
|
+
}
|
|
3390
|
+
if (groupId) {
|
|
3391
|
+
try {
|
|
3392
|
+
const gbs = await this.call('group.v2.bootstrap', { group_id: groupId });
|
|
3393
|
+
const devices = (Array.isArray(gbs?.devices) ? gbs.devices : []);
|
|
3394
|
+
const audit = (Array.isArray(gbs?.audit_recipients) ? gbs.audit_recipients : []);
|
|
3395
|
+
for (const dev of devices)
|
|
3396
|
+
this._cacheV2PeerIKFromDevice(dev);
|
|
3397
|
+
for (const dev of audit)
|
|
3398
|
+
this._cacheV2PeerIKFromDevice(dev);
|
|
3399
|
+
}
|
|
3400
|
+
catch (exc) {
|
|
3401
|
+
this._clientLog.warn(`V2 sender IK pending group bootstrap failed group=${groupId}: ${formatCaughtError(exc)}`);
|
|
3402
|
+
}
|
|
3403
|
+
}
|
|
3404
|
+
if (!session.getPeerIK(fromAid, senderDeviceId)) {
|
|
3405
|
+
await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
const pendingItems = [...this._v2SenderIKPending.entries()].filter(([, entry]) => entry.fromAid === fromAid && entry.senderDeviceId === senderDeviceId && entry.groupId === groupId);
|
|
3409
|
+
for (const [key, entry] of pendingItems) {
|
|
3410
|
+
let plaintext = null;
|
|
3411
|
+
try {
|
|
3412
|
+
plaintext = await this._decryptV2Message(entry.msg, false);
|
|
3413
|
+
}
|
|
3414
|
+
catch (exc) {
|
|
3415
|
+
this._clientLog.warn(`V2 sender IK pending retry raised: key=${key} err=${formatCaughtError(exc)}`);
|
|
3416
|
+
}
|
|
3417
|
+
this._v2SenderIKPending.delete(key);
|
|
3418
|
+
if (plaintext === null) {
|
|
3419
|
+
this._clientLog.debug(`V2 sender IK pending retry failed: key=${key}`);
|
|
3420
|
+
continue;
|
|
3421
|
+
}
|
|
3422
|
+
const seq = Number(entry.msg.seq ?? 0);
|
|
3423
|
+
if (entry.groupId) {
|
|
3424
|
+
plaintext.group_id = entry.groupId;
|
|
3425
|
+
await this._publishPulledMessage('group.message_created', `group:${entry.groupId}`, seq, plaintext);
|
|
3426
|
+
}
|
|
3427
|
+
else {
|
|
3428
|
+
await this._publishPulledMessage('message.received', `p2p:${this._aid ?? ''}`, seq, plaintext);
|
|
3429
|
+
}
|
|
3430
|
+
this._clientLog.debug(`V2 sender IK pending retry delivered: key=${key}`);
|
|
3431
|
+
}
|
|
3432
|
+
}
|
|
3433
|
+
finally {
|
|
3434
|
+
this._v2SenderIKFetching.delete(fetchKey);
|
|
2410
3435
|
}
|
|
2411
3436
|
}
|
|
2412
3437
|
/**
|
|
@@ -2427,11 +3452,13 @@ export class AUNClient {
|
|
|
2427
3452
|
if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
2428
3453
|
peerDevices = cached.devices;
|
|
2429
3454
|
auditRaw = cached.auditRecipients;
|
|
3455
|
+
this._clientLog.debug(`message.v2.bootstrap cache hit: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
|
|
2430
3456
|
}
|
|
2431
3457
|
else {
|
|
2432
3458
|
const bs = await this.call('message.v2.bootstrap', { peer_aid: to });
|
|
2433
3459
|
peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
2434
3460
|
auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
|
|
3461
|
+
this._clientLog.debug(`message.v2.bootstrap fetched: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
|
|
2435
3462
|
if (peerDevices.length > 0) {
|
|
2436
3463
|
this._v2BootstrapCache.set(to, {
|
|
2437
3464
|
devices: peerDevices,
|
|
@@ -2445,37 +3472,28 @@ export class AUNClient {
|
|
|
2445
3472
|
}
|
|
2446
3473
|
const targets = [];
|
|
2447
3474
|
for (const dev of peerDevices) {
|
|
2448
|
-
const
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
const devId = String(dev.device_id ?? dev.owner_device_id ?? '');
|
|
2452
|
-
const ikDer = _v2B64ToBytes(ikPk);
|
|
2453
|
-
const spkDer = dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined;
|
|
2454
|
-
session.cachePeerIK(to, devId, ikDer);
|
|
2455
|
-
targets.push({
|
|
3475
|
+
const devId = getV2DeviceId(dev);
|
|
3476
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
3477
|
+
dev,
|
|
2456
3478
|
aid: to,
|
|
2457
|
-
deviceId: devId,
|
|
3479
|
+
deviceId: devId.value,
|
|
2458
3480
|
role: 'peer',
|
|
2459
|
-
|
|
2460
|
-
ikPkDer: ikDer,
|
|
2461
|
-
spkPkDer: spkDer,
|
|
2462
|
-
spkId: String(dev.spk_id ?? ''),
|
|
3481
|
+
defaultKeySource: 'peer_device_prekey',
|
|
2463
3482
|
});
|
|
3483
|
+
if (target)
|
|
3484
|
+
targets.push(target);
|
|
2464
3485
|
}
|
|
2465
3486
|
const auditTargets = [];
|
|
2466
3487
|
for (const dev of auditRaw) {
|
|
2467
|
-
const
|
|
2468
|
-
|
|
2469
|
-
continue;
|
|
2470
|
-
auditTargets.push({
|
|
3488
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
3489
|
+
dev,
|
|
2471
3490
|
aid: String(dev.aid ?? ''),
|
|
2472
3491
|
deviceId: String(dev.device_id ?? ''),
|
|
2473
3492
|
role: 'audit',
|
|
2474
|
-
|
|
2475
|
-
ikPkDer: _v2B64ToBytes(ikPk),
|
|
2476
|
-
spkPkDer: dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined,
|
|
2477
|
-
spkId: String(dev.spk_id ?? ''),
|
|
3493
|
+
defaultKeySource: 'peer_device_prekey',
|
|
2478
3494
|
});
|
|
3495
|
+
if (target)
|
|
3496
|
+
auditTargets.push(target);
|
|
2479
3497
|
}
|
|
2480
3498
|
// self-sync:给同 AID 其它在线/注册设备也 wrap 一份。
|
|
2481
3499
|
if (this._aid && this._aid !== to) {
|
|
@@ -2497,19 +3515,18 @@ export class AUNClient {
|
|
|
2497
3515
|
}
|
|
2498
3516
|
}
|
|
2499
3517
|
for (const dev of selfDevices) {
|
|
2500
|
-
const devId =
|
|
2501
|
-
|
|
2502
|
-
if (!devId || devId === this._deviceId || !ikPk)
|
|
3518
|
+
const devId = getV2DeviceId(dev);
|
|
3519
|
+
if (!devId.present || devId.value === this._deviceId)
|
|
2503
3520
|
continue;
|
|
2504
|
-
|
|
3521
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
3522
|
+
dev,
|
|
2505
3523
|
aid: this._aid,
|
|
2506
|
-
deviceId: devId,
|
|
3524
|
+
deviceId: devId.value,
|
|
2507
3525
|
role: 'self_sync',
|
|
2508
|
-
|
|
2509
|
-
ikPkDer: _v2B64ToBytes(ikPk),
|
|
2510
|
-
spkPkDer: dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined,
|
|
2511
|
-
spkId: String(dev.spk_id ?? ''),
|
|
3526
|
+
defaultKeySource: 'peer_device_prekey',
|
|
2512
3527
|
});
|
|
3528
|
+
if (target)
|
|
3529
|
+
targets.push(target);
|
|
2513
3530
|
}
|
|
2514
3531
|
}
|
|
2515
3532
|
catch (exc) {
|
|
@@ -2519,12 +3536,29 @@ export class AUNClient {
|
|
|
2519
3536
|
if (targets.length === 0) {
|
|
2520
3537
|
throw new E2EEError(`V2 bootstrap: no usable devices found for ${to}`);
|
|
2521
3538
|
}
|
|
2522
|
-
|
|
3539
|
+
const envelope = encryptP2PMessage(session.getSenderIdentity(), { targets, auditRecipients: auditTargets }, opts.payload, {
|
|
2523
3540
|
messageId: opts.messageId,
|
|
2524
3541
|
timestamp: opts.timestamp,
|
|
2525
3542
|
protectedHeaders: opts.protectedHeaders,
|
|
2526
3543
|
context: opts.context,
|
|
2527
3544
|
});
|
|
3545
|
+
this._logMessageDebug('send-envelope', 'message.send.v2', 'message.send', {
|
|
3546
|
+
message_id: envelope.message_id,
|
|
3547
|
+
to,
|
|
3548
|
+
type: envelope.type,
|
|
3549
|
+
version: envelope.version,
|
|
3550
|
+
protected_headers: envelope.protected_headers,
|
|
3551
|
+
context: envelope.context,
|
|
3552
|
+
}, {
|
|
3553
|
+
payloadOverride: envelope,
|
|
3554
|
+
extra: {
|
|
3555
|
+
plaintext_payload: opts.payload,
|
|
3556
|
+
target_count: targets.length,
|
|
3557
|
+
audit_count: auditTargets.length,
|
|
3558
|
+
use_cache: useCache,
|
|
3559
|
+
},
|
|
3560
|
+
});
|
|
3561
|
+
return envelope;
|
|
2528
3562
|
}
|
|
2529
3563
|
/** V2 P2P 加密发送,推测性缓存失败后刷新 bootstrap 重试一次。 */
|
|
2530
3564
|
async sendV2(to, payload, opts) {
|
|
@@ -2534,7 +3568,13 @@ export class AUNClient {
|
|
|
2534
3568
|
throw new ValidationError("message.send requires 'to'");
|
|
2535
3569
|
if (!isJsonObject(payload))
|
|
2536
3570
|
throw new ValidationError('message.send payload must be a dict for V2 encryption');
|
|
3571
|
+
this._logMessageDebug('send-plaintext', 'message.send.v2', 'message.send', {
|
|
3572
|
+
to: toAid,
|
|
3573
|
+
message_id: opts?.messageId ?? '',
|
|
3574
|
+
payload,
|
|
3575
|
+
}, { payloadOverride: payload });
|
|
2537
3576
|
const attempt = async (useCache) => {
|
|
3577
|
+
this._clientLog.debug(`message.v2.send attempt: to=${toAid}, use_cache=${useCache}`);
|
|
2538
3578
|
const envelope = await this._buildV2P2PEnvelope({
|
|
2539
3579
|
to: toAid,
|
|
2540
3580
|
payload,
|
|
@@ -2544,11 +3584,13 @@ export class AUNClient {
|
|
|
2544
3584
|
context: opts?.context,
|
|
2545
3585
|
useCache,
|
|
2546
3586
|
});
|
|
2547
|
-
|
|
3587
|
+
const result = await this.call('message.send', {
|
|
2548
3588
|
to: toAid,
|
|
2549
3589
|
payload: envelope,
|
|
2550
3590
|
encrypt: false,
|
|
2551
3591
|
});
|
|
3592
|
+
this._clientLog.debug(`message.v2.send ok: to=${toAid}, use_cache=${useCache}, seq=${String((isJsonObject(result) ? result.seq : '') ?? '')}`);
|
|
3593
|
+
return result;
|
|
2552
3594
|
};
|
|
2553
3595
|
try {
|
|
2554
3596
|
return await attempt(true);
|
|
@@ -2567,90 +3609,130 @@ export class AUNClient {
|
|
|
2567
3609
|
async pullV2(afterSeq = 0, limit = 50) {
|
|
2568
3610
|
await this._ensureV2SessionReady('message.pull');
|
|
2569
3611
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
2570
|
-
const effective = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
|
|
2571
|
-
const result = await this.call('message.v2.pull', { after_seq: effective, limit });
|
|
2572
|
-
const messages = (Array.isArray(result?.messages) ? result.messages : []);
|
|
2573
3612
|
const decrypted = [];
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
|
|
2578
|
-
|
|
2579
|
-
this.
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
3613
|
+
let nextAfterSeq = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
|
|
3614
|
+
let pageCount = 0;
|
|
3615
|
+
const maxPages = 100;
|
|
3616
|
+
while (pageCount < maxPages) {
|
|
3617
|
+
pageCount += 1;
|
|
3618
|
+
this._clientLog.debug(`message.v2.pull page request: page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ns=${ns || '<none>'}`);
|
|
3619
|
+
const result = await this.call('message.v2.pull', { after_seq: nextAfterSeq, limit });
|
|
3620
|
+
const messages = (Array.isArray(result?.messages) ? result.messages : []);
|
|
3621
|
+
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
|
+
for (const msg of messages) {
|
|
3623
|
+
this._logMessageDebug('pull-raw', 'message.v2.pull', 'message.received', msg);
|
|
3624
|
+
}
|
|
3625
|
+
const seqs = messages
|
|
3626
|
+
.map((msg) => Number(msg.seq ?? 0))
|
|
3627
|
+
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
3628
|
+
const pageContigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
3629
|
+
const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
|
|
3630
|
+
if (ns && seqs.length > 0) {
|
|
3631
|
+
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
3632
|
+
this._clientLog.debug(`message.v2.pull force contiguous: ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
|
|
3633
|
+
}
|
|
3634
|
+
for (const msg of messages) {
|
|
3635
|
+
const seq = Number(msg.seq ?? 0);
|
|
3636
|
+
if (!Number.isFinite(seq) || seq <= 0)
|
|
3637
|
+
continue;
|
|
3638
|
+
const version = String(msg.version ?? 'v2');
|
|
3639
|
+
if (version === 'v1') {
|
|
3640
|
+
const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
|
|
3641
|
+
const legacyPayload = legacy.payload;
|
|
3642
|
+
const payloadType = isJsonObject(legacyPayload)
|
|
3643
|
+
? String(legacyPayload.type ?? '').trim()
|
|
3644
|
+
: '';
|
|
3645
|
+
if (legacyPayload !== undefined && legacyPayload !== null && payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
|
|
3646
|
+
const v1Msg = {
|
|
3647
|
+
message_id: String(msg.message_id ?? ''),
|
|
3648
|
+
from: String(msg.from_aid ?? ''),
|
|
3649
|
+
to: String(legacy.to ?? this._aid ?? ''),
|
|
3650
|
+
seq: msg.seq,
|
|
3651
|
+
type: String(msg.type ?? ''),
|
|
3652
|
+
timestamp: msg.t_server,
|
|
3653
|
+
payload: legacyPayload,
|
|
3654
|
+
encrypted: false,
|
|
3655
|
+
};
|
|
3656
|
+
if (ns)
|
|
3657
|
+
await this._publishPulledMessage('message.received', ns, seq, v1Msg);
|
|
3658
|
+
else
|
|
3659
|
+
await this._publishAppEvent('message.received', v1Msg, 'pull');
|
|
3660
|
+
decrypted.push(v1Msg);
|
|
3661
|
+
this._clientLog.debug(`message.v2.pull plaintext V1 delivered: seq=${seq}, ns=${ns || '<none>'}`);
|
|
3662
|
+
}
|
|
3663
|
+
else {
|
|
3664
|
+
this._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
|
|
3665
|
+
}
|
|
3666
|
+
continue;
|
|
2608
3667
|
}
|
|
2609
|
-
|
|
2610
|
-
this._clientLog.debug(`message.v2.pull skipping
|
|
3668
|
+
if (version !== 'v2') {
|
|
3669
|
+
this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
|
|
3670
|
+
continue;
|
|
3671
|
+
}
|
|
3672
|
+
const spkId = String(msg.spk_id ?? '');
|
|
3673
|
+
if (spkId && this._v2Session && !this._v2Session.isCurrentSPK(spkId)) {
|
|
3674
|
+
this._v2Session.trackOldSPKMaxSeq(spkId, seq);
|
|
3675
|
+
}
|
|
3676
|
+
const plaintext = await this._decryptV2Message(msg);
|
|
3677
|
+
if (plaintext === null) {
|
|
3678
|
+
this._clientLog.debug(`message.v2.pull decrypt returned null: seq=${seq}, ns=${ns || '<none>'}`);
|
|
3679
|
+
continue;
|
|
3680
|
+
}
|
|
3681
|
+
if (ns)
|
|
3682
|
+
await this._publishPulledMessage('message.received', ns, seq, plaintext);
|
|
3683
|
+
else
|
|
3684
|
+
await this._publishAppEvent('message.received', plaintext, 'pull');
|
|
3685
|
+
decrypted.push(plaintext);
|
|
3686
|
+
this._logMessageDebug('decrypt-ok', 'message.v2.pull', 'message.received', plaintext);
|
|
3687
|
+
}
|
|
3688
|
+
const serverAckSeq = Number(result.server_ack_seq ?? 0);
|
|
3689
|
+
if (ns && Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
|
|
3690
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
3691
|
+
if (contig < serverAckSeq) {
|
|
3692
|
+
this._clientLog.info(`message.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAckSeq}`);
|
|
3693
|
+
this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
|
|
2611
3694
|
}
|
|
2612
|
-
continue;
|
|
2613
|
-
}
|
|
2614
|
-
if (version !== 'v2') {
|
|
2615
|
-
this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
|
|
2616
|
-
continue;
|
|
2617
|
-
}
|
|
2618
|
-
const spkId = String(msg.spk_id ?? '');
|
|
2619
|
-
if (spkId && this._v2Session && !this._v2Session.isCurrentSPK(spkId)) {
|
|
2620
|
-
this._v2Session.trackOldSPKMaxSeq(spkId, seq);
|
|
2621
|
-
}
|
|
2622
|
-
const plaintext = await this._decryptV2Message(msg);
|
|
2623
|
-
if (plaintext === null)
|
|
2624
|
-
continue;
|
|
2625
|
-
if (ns)
|
|
2626
|
-
await this._publishPulledMessage('message.received', ns, seq, plaintext);
|
|
2627
|
-
else
|
|
2628
|
-
await this._publishAppEvent('message.received', plaintext);
|
|
2629
|
-
decrypted.push(plaintext);
|
|
2630
|
-
}
|
|
2631
|
-
if (ns && seqs.length > 0) {
|
|
2632
|
-
const maxSeq = Math.max(...seqs);
|
|
2633
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2634
|
-
if (maxSeq > contig) {
|
|
2635
|
-
this._seqTracker.forceContiguousSeq(ns, maxSeq);
|
|
2636
|
-
await this._drainOrderedMessages(ns);
|
|
2637
|
-
}
|
|
2638
|
-
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
2639
|
-
if (ackSeq !== contigBefore) {
|
|
2640
|
-
this._saveSeqTrackerState();
|
|
2641
3695
|
}
|
|
2642
|
-
if (
|
|
2643
|
-
this.
|
|
3696
|
+
if (ns) {
|
|
3697
|
+
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
3698
|
+
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
3699
|
+
if (contigAdvanced) {
|
|
3700
|
+
await this._drainOrderedMessages(ns);
|
|
3701
|
+
this._saveSeqTrackerState();
|
|
3702
|
+
}
|
|
3703
|
+
if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
|
|
3704
|
+
this._clientLog.debug(`message.v2.pull scheduling auto-ack: ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
|
|
3705
|
+
this._safeAsync(this.ackV2(ackSeq).then(() => undefined));
|
|
3706
|
+
}
|
|
2644
3707
|
}
|
|
3708
|
+
const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
|
|
3709
|
+
if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
3710
|
+
break;
|
|
3711
|
+
nextAfterSeq = nextAfter;
|
|
2645
3712
|
}
|
|
3713
|
+
if (pageCount >= maxPages) {
|
|
3714
|
+
this._clientLog.warn(`message.v2.pull reached max_pages=${maxPages} after_seq=${nextAfterSeq}`);
|
|
3715
|
+
}
|
|
3716
|
+
this._clientLog.debug(`message.v2.pull done: requested_after_seq=${afterSeq}, pages=${pageCount}, decrypted=${decrypted.length}, ns=${ns || '<none>'}`);
|
|
2646
3717
|
return decrypted;
|
|
2647
3718
|
}
|
|
2648
3719
|
/** V2 P2P ack,并触发旧 SPK 销毁自检。 */
|
|
2649
3720
|
async ackV2(upToSeq) {
|
|
2650
3721
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
2651
|
-
|
|
2652
|
-
if (!Number.isFinite(seq) || seq <= 0)
|
|
3722
|
+
let seq = Number(upToSeq ?? (ns ? this._seqTracker.getContiguousSeq(ns) : 0));
|
|
3723
|
+
if (!Number.isFinite(seq) || seq <= 0) {
|
|
3724
|
+
this._clientLog.debug(`message.v2.ack skipped: ns=${ns || '<none>'}, up_to_seq=${String(upToSeq ?? '')}`);
|
|
2653
3725
|
return { acked: 0 };
|
|
3726
|
+
}
|
|
3727
|
+
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
3728
|
+
if (ns) {
|
|
3729
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
3730
|
+
if (maxSeen > 0 && seq > maxSeen) {
|
|
3731
|
+
this._clientLog.warn(`ackV2 clamp: up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
|
|
3732
|
+
seq = maxSeen;
|
|
3733
|
+
}
|
|
3734
|
+
}
|
|
3735
|
+
this._clientLog.debug(`message.v2.ack send: ns=${ns || '<none>'}, up_to_seq=${seq}`);
|
|
2654
3736
|
const raw = await this.call('message.v2.ack', { up_to_seq: seq });
|
|
2655
3737
|
const result = isJsonObject(raw)
|
|
2656
3738
|
? { ...raw }
|
|
@@ -2679,6 +3761,7 @@ export class AUNClient {
|
|
|
2679
3761
|
this._clientLog.debug(`V2 SPK destroy failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
2680
3762
|
}
|
|
2681
3763
|
}
|
|
3764
|
+
this._clientLog.debug(`message.v2.ack ok: ns=${ns || '<none>'}, requested=${seq}, effective=${actualAckSeq}, acked=${String(result.acked ?? '')}`);
|
|
2682
3765
|
return result;
|
|
2683
3766
|
}
|
|
2684
3767
|
/** V2 Group 加密发送,推测性缓存失败后刷新 bootstrap 重试一次。 */
|
|
@@ -2689,7 +3772,13 @@ export class AUNClient {
|
|
|
2689
3772
|
throw new ValidationError("group.send requires 'group_id'");
|
|
2690
3773
|
if (!isJsonObject(payload))
|
|
2691
3774
|
throw new ValidationError('group.send payload must be a dict for V2 encryption');
|
|
3775
|
+
this._logMessageDebug('send-plaintext', 'group.send.v2', 'group.send', {
|
|
3776
|
+
group_id: gid,
|
|
3777
|
+
message_id: opts?.messageId ?? '',
|
|
3778
|
+
payload,
|
|
3779
|
+
}, { payloadOverride: payload });
|
|
2692
3780
|
const attempt = async (useCache) => {
|
|
3781
|
+
this._clientLog.debug(`group.v2.send attempt: group=${gid}, use_cache=${useCache}`);
|
|
2693
3782
|
const envelope = await this._buildV2GroupEnvelope({
|
|
2694
3783
|
groupId: gid,
|
|
2695
3784
|
payload,
|
|
@@ -2699,10 +3788,12 @@ export class AUNClient {
|
|
|
2699
3788
|
context: opts?.context,
|
|
2700
3789
|
useCache,
|
|
2701
3790
|
});
|
|
2702
|
-
|
|
3791
|
+
const result = await this.call('group.v2.send', {
|
|
2703
3792
|
group_id: gid,
|
|
2704
3793
|
envelope: envelope,
|
|
2705
3794
|
});
|
|
3795
|
+
this._clientLog.debug(`group.v2.send ok: group=${gid}, use_cache=${useCache}, seq=${String((isJsonObject(result) ? result.seq : '') ?? '')}`);
|
|
3796
|
+
return result;
|
|
2706
3797
|
};
|
|
2707
3798
|
const markSentSeq = (result) => {
|
|
2708
3799
|
if (!isJsonObject(result))
|
|
@@ -2715,6 +3806,7 @@ export class AUNClient {
|
|
|
2715
3806
|
this._seqTracker.onMessageSeq(ns, seq);
|
|
2716
3807
|
this._markPublishedSeq(ns, seq);
|
|
2717
3808
|
this._saveSeqTrackerState();
|
|
3809
|
+
this._clientLog.debug(`group.v2.send marked own seq: group=${gid}, ns=${ns}, seq=${seq}`);
|
|
2718
3810
|
};
|
|
2719
3811
|
try {
|
|
2720
3812
|
const result = await attempt(true);
|
|
@@ -2753,12 +3845,14 @@ export class AUNClient {
|
|
|
2753
3845
|
auditRecipientsRaw = cached.auditRecipients;
|
|
2754
3846
|
epoch = cached.epoch ?? 0;
|
|
2755
3847
|
stateCommitment = cached.stateCommitment ?? stateCommitment;
|
|
3848
|
+
this._clientLog.debug(`group.v2.bootstrap cache hit: group=${groupId}, devices=${allDevices.length}, audit=${auditRecipientsRaw.length}, epoch=${epoch}, state_version=${stateCommitment.state_version}`);
|
|
2756
3849
|
}
|
|
2757
3850
|
else {
|
|
2758
3851
|
const bs = await this.call('group.v2.bootstrap', { group_id: groupId });
|
|
2759
3852
|
allDevices = (Array.isArray(bs.devices) ? bs.devices : []);
|
|
2760
3853
|
auditRecipientsRaw = (Array.isArray(bs.audit_recipients) ? bs.audit_recipients : []);
|
|
2761
3854
|
epoch = Number(bs.epoch ?? 0) || 0;
|
|
3855
|
+
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}`);
|
|
2762
3856
|
const stateChain = String(bs.state_chain ?? '');
|
|
2763
3857
|
await this._v2CheckFork(groupId, stateChain);
|
|
2764
3858
|
await this._v2VerifyStateSignature(groupId, bs);
|
|
@@ -2789,46 +3883,59 @@ export class AUNClient {
|
|
|
2789
3883
|
const targets = [];
|
|
2790
3884
|
for (const dev of allDevices) {
|
|
2791
3885
|
const devAid = String(dev.aid ?? '').trim();
|
|
2792
|
-
const devId =
|
|
2793
|
-
|
|
2794
|
-
if (!devAid || !devId || !ikPk)
|
|
2795
|
-
continue;
|
|
2796
|
-
if (devAid === this._aid && devId === this._deviceId)
|
|
3886
|
+
const devId = getV2DeviceId(dev);
|
|
3887
|
+
if (devAid === this._aid && devId.present && devId.value === this._deviceId)
|
|
2797
3888
|
continue;
|
|
2798
3889
|
const role = devAid === this._aid ? 'self_sync' : 'member';
|
|
2799
|
-
|
|
3890
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
3891
|
+
dev,
|
|
2800
3892
|
aid: devAid,
|
|
2801
|
-
deviceId: devId,
|
|
3893
|
+
deviceId: devId.value,
|
|
2802
3894
|
role,
|
|
2803
|
-
|
|
2804
|
-
ikPkDer: _v2B64ToBytes(ikPk),
|
|
2805
|
-
spkPkDer: dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined,
|
|
2806
|
-
spkId: String(dev.spk_id ?? ''),
|
|
3895
|
+
defaultKeySource: 'peer_device_prekey',
|
|
2807
3896
|
});
|
|
3897
|
+
if (target)
|
|
3898
|
+
targets.push(target);
|
|
2808
3899
|
}
|
|
2809
3900
|
if (targets.length === 0) {
|
|
2810
3901
|
throw new E2EEError(`V2 group: no target devices for group ${groupId}`);
|
|
2811
3902
|
}
|
|
2812
3903
|
for (const dev of auditRecipientsRaw) {
|
|
2813
|
-
const
|
|
2814
|
-
|
|
2815
|
-
continue;
|
|
2816
|
-
targets.push({
|
|
3904
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
3905
|
+
dev,
|
|
2817
3906
|
aid: String(dev.aid ?? ''),
|
|
2818
3907
|
deviceId: String(dev.device_id ?? ''),
|
|
2819
3908
|
role: 'audit',
|
|
2820
|
-
|
|
2821
|
-
ikPkDer: _v2B64ToBytes(ikPk),
|
|
2822
|
-
spkPkDer: dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined,
|
|
2823
|
-
spkId: String(dev.spk_id ?? ''),
|
|
3909
|
+
defaultKeySource: 'peer_device_prekey',
|
|
2824
3910
|
});
|
|
3911
|
+
if (target)
|
|
3912
|
+
targets.push(target);
|
|
2825
3913
|
}
|
|
2826
|
-
|
|
3914
|
+
const envelope = encryptGroupMessage(session.getSenderIdentity(), groupId, epoch, targets, opts.payload, {
|
|
2827
3915
|
messageId: opts.messageId,
|
|
2828
3916
|
timestamp: opts.timestamp,
|
|
2829
3917
|
protectedHeaders: opts.protectedHeaders,
|
|
2830
3918
|
context: opts.context,
|
|
2831
3919
|
}, stateCommitment);
|
|
3920
|
+
this._logMessageDebug('send-envelope', 'group.send.v2', 'group.send', {
|
|
3921
|
+
group_id: groupId,
|
|
3922
|
+
message_id: envelope.message_id,
|
|
3923
|
+
type: envelope.type,
|
|
3924
|
+
version: envelope.version,
|
|
3925
|
+
protected_headers: envelope.protected_headers,
|
|
3926
|
+
context: envelope.context,
|
|
3927
|
+
}, {
|
|
3928
|
+
payloadOverride: envelope,
|
|
3929
|
+
extra: {
|
|
3930
|
+
plaintext_payload: opts.payload,
|
|
3931
|
+
epoch,
|
|
3932
|
+
target_count: targets.length,
|
|
3933
|
+
audit_count: auditRecipientsRaw.length,
|
|
3934
|
+
state_version: stateCommitment.state_version,
|
|
3935
|
+
use_cache: useCache,
|
|
3936
|
+
},
|
|
3937
|
+
});
|
|
3938
|
+
return envelope;
|
|
2832
3939
|
}
|
|
2833
3940
|
async _pullGroupV2Internal(params) {
|
|
2834
3941
|
await this.pullGroupV2(params.group_id, params.after_seq, params.limit);
|
|
@@ -2840,32 +3947,61 @@ export class AUNClient {
|
|
|
2840
3947
|
if (!gid)
|
|
2841
3948
|
throw new ValidationError('group.pull requires group_id');
|
|
2842
3949
|
const ns = `group:${gid}`;
|
|
2843
|
-
const effective = afterSeq || this._seqTracker.getContiguousSeq(ns);
|
|
2844
|
-
const result = await this.call('group.v2.pull', {
|
|
2845
|
-
group_id: gid,
|
|
2846
|
-
after_seq: effective,
|
|
2847
|
-
limit,
|
|
2848
|
-
});
|
|
2849
|
-
const messages = (Array.isArray(result.messages) ? result.messages : []);
|
|
2850
3950
|
const decrypted = [];
|
|
2851
|
-
|
|
2852
|
-
|
|
2853
|
-
|
|
2854
|
-
|
|
2855
|
-
|
|
2856
|
-
this.
|
|
2857
|
-
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
const
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
3951
|
+
let nextAfterSeq = afterSeq || this._seqTracker.getContiguousSeq(ns);
|
|
3952
|
+
let pageCount = 0;
|
|
3953
|
+
const maxPages = 100;
|
|
3954
|
+
while (pageCount < maxPages) {
|
|
3955
|
+
pageCount += 1;
|
|
3956
|
+
this._clientLog.debug(`group.v2.pull page request: group=${gid}, page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ns=${ns}`);
|
|
3957
|
+
const result = await this.call('group.v2.pull', {
|
|
3958
|
+
group_id: gid,
|
|
3959
|
+
after_seq: nextAfterSeq,
|
|
3960
|
+
limit,
|
|
3961
|
+
});
|
|
3962
|
+
const messages = (Array.isArray(result.messages) ? result.messages : []);
|
|
3963
|
+
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
3964
|
+
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
|
+
for (const msg of messages) {
|
|
3966
|
+
this._logMessageDebug('pull-raw', 'group.v2.pull', 'group.message_created', msg);
|
|
3967
|
+
}
|
|
3968
|
+
const seqs = messages
|
|
3969
|
+
.map((msg) => Number(msg.seq ?? 0))
|
|
3970
|
+
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
3971
|
+
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
3972
|
+
const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
|
|
3973
|
+
if (seqs.length > 0) {
|
|
3974
|
+
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
3975
|
+
this._clientLog.debug(`group.v2.pull force contiguous: group=${gid}, ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
|
|
3976
|
+
}
|
|
3977
|
+
for (const msg of messages) {
|
|
3978
|
+
const seq = Number(msg.seq ?? 0);
|
|
3979
|
+
if (!Number.isFinite(seq) || seq <= 0)
|
|
3980
|
+
continue;
|
|
3981
|
+
const version = String(msg.version ?? 'v2');
|
|
3982
|
+
if (version === 'v1') {
|
|
3983
|
+
const payload = msg.payload;
|
|
3984
|
+
const payloadObj = isJsonObject(payload) ? payload : null;
|
|
3985
|
+
if (payloadObj) {
|
|
3986
|
+
const payloadType = String(payloadObj.type ?? '').trim();
|
|
3987
|
+
if (payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
|
|
3988
|
+
const v1Msg = {
|
|
3989
|
+
message_id: String(msg.message_id ?? ''),
|
|
3990
|
+
from: String(msg.from_aid ?? ''),
|
|
3991
|
+
group_id: gid,
|
|
3992
|
+
seq: msg.seq,
|
|
3993
|
+
type: String(msg.type ?? ''),
|
|
3994
|
+
timestamp: msg.t_server,
|
|
3995
|
+
payload,
|
|
3996
|
+
encrypted: false,
|
|
3997
|
+
};
|
|
3998
|
+
await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
|
|
3999
|
+
decrypted.push(v1Msg);
|
|
4000
|
+
this._clientLog.debug(`group.v2.pull plaintext V1 delivered: group=${gid}, seq=${seq}`);
|
|
4001
|
+
continue;
|
|
4002
|
+
}
|
|
4003
|
+
}
|
|
4004
|
+
else if (payload !== undefined && payload !== null) {
|
|
2869
4005
|
const v1Msg = {
|
|
2870
4006
|
message_id: String(msg.message_id ?? ''),
|
|
2871
4007
|
from: String(msg.from_aid ?? ''),
|
|
@@ -2878,53 +4014,53 @@ export class AUNClient {
|
|
|
2878
4014
|
};
|
|
2879
4015
|
await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
|
|
2880
4016
|
decrypted.push(v1Msg);
|
|
4017
|
+
this._clientLog.debug(`group.v2.pull plaintext V1 delivered: group=${gid}, seq=${seq}`);
|
|
2881
4018
|
continue;
|
|
2882
4019
|
}
|
|
4020
|
+
this._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
|
|
4021
|
+
continue;
|
|
2883
4022
|
}
|
|
2884
|
-
|
|
2885
|
-
|
|
2886
|
-
message_id: String(msg.message_id ?? ''),
|
|
2887
|
-
from: String(msg.from_aid ?? ''),
|
|
2888
|
-
group_id: gid,
|
|
2889
|
-
seq: msg.seq,
|
|
2890
|
-
type: String(msg.type ?? ''),
|
|
2891
|
-
timestamp: msg.t_server,
|
|
2892
|
-
payload,
|
|
2893
|
-
encrypted: false,
|
|
2894
|
-
};
|
|
2895
|
-
await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
|
|
2896
|
-
decrypted.push(v1Msg);
|
|
4023
|
+
if (version !== 'v2') {
|
|
4024
|
+
this._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
|
|
2897
4025
|
continue;
|
|
2898
4026
|
}
|
|
2899
|
-
|
|
2900
|
-
|
|
2901
|
-
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
4027
|
+
const plaintext = await this._decryptV2Message(msg);
|
|
4028
|
+
if (plaintext === null) {
|
|
4029
|
+
this._clientLog.debug(`group.v2.pull decrypt returned null: group=${gid}, seq=${seq}`);
|
|
4030
|
+
continue;
|
|
4031
|
+
}
|
|
4032
|
+
plaintext.group_id = gid;
|
|
4033
|
+
await this._publishPulledMessage('group.message_created', ns, seq, plaintext);
|
|
4034
|
+
decrypted.push(plaintext);
|
|
4035
|
+
this._logMessageDebug('decrypt-ok', 'group.v2.pull', 'group.message_created', plaintext);
|
|
4036
|
+
}
|
|
4037
|
+
const serverAckSeq = Number(cursor?.current_seq ?? 0);
|
|
4038
|
+
if (Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
|
|
4039
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
4040
|
+
if (contig < serverAckSeq) {
|
|
4041
|
+
this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> cursor.current_seq=${serverAckSeq}`);
|
|
4042
|
+
this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
|
|
4043
|
+
}
|
|
2905
4044
|
}
|
|
2906
|
-
const
|
|
2907
|
-
|
|
2908
|
-
|
|
2909
|
-
plaintext.group_id = gid;
|
|
2910
|
-
await this._publishPulledMessage('group.message_created', ns, seq, plaintext);
|
|
2911
|
-
decrypted.push(plaintext);
|
|
2912
|
-
}
|
|
2913
|
-
if (seqs.length > 0) {
|
|
2914
|
-
const maxSeq = Math.max(...seqs);
|
|
2915
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2916
|
-
if (maxSeq > contig) {
|
|
2917
|
-
this._seqTracker.forceContiguousSeq(ns, maxSeq);
|
|
4045
|
+
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
4046
|
+
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
4047
|
+
if (contigAdvanced) {
|
|
2918
4048
|
await this._drainOrderedMessages(ns);
|
|
4049
|
+
this._saveSeqTrackerState();
|
|
2919
4050
|
}
|
|
4051
|
+
if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
|
|
4052
|
+
this._clientLog.debug(`group.v2.pull scheduling auto-ack: group=${gid}, ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
|
|
4053
|
+
this._safeAsync(this.ackGroupV2(gid, ackSeq).then(() => undefined));
|
|
4054
|
+
}
|
|
4055
|
+
const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
|
|
4056
|
+
if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
4057
|
+
break;
|
|
4058
|
+
nextAfterSeq = nextAfter;
|
|
2920
4059
|
}
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
this._saveSeqTrackerState();
|
|
2924
|
-
}
|
|
2925
|
-
if (ackSeq > 0 && ackSeq !== contigBefore) {
|
|
2926
|
-
this._safeAsync(this.ackGroupV2(gid, ackSeq).then(() => undefined));
|
|
4060
|
+
if (pageCount >= maxPages) {
|
|
4061
|
+
this._clientLog.warn(`group.v2.pull reached max_pages=${maxPages} group=${gid} after_seq=${nextAfterSeq}`);
|
|
2927
4062
|
}
|
|
4063
|
+
this._clientLog.debug(`group.v2.pull done: group=${gid}, requested_after_seq=${afterSeq}, pages=${pageCount}, decrypted=${decrypted.length}, ns=${ns}`);
|
|
2928
4064
|
return decrypted;
|
|
2929
4065
|
}
|
|
2930
4066
|
/** V2 Group ack。 */
|
|
@@ -2933,13 +4069,24 @@ export class AUNClient {
|
|
|
2933
4069
|
if (!gid)
|
|
2934
4070
|
throw new ValidationError('group.ack_messages requires group_id');
|
|
2935
4071
|
const ns = `group:${gid}`;
|
|
2936
|
-
|
|
2937
|
-
if (!Number.isFinite(seq) || seq <= 0)
|
|
4072
|
+
let seq = Number(upToSeq ?? this._seqTracker.getContiguousSeq(ns));
|
|
4073
|
+
if (!Number.isFinite(seq) || seq <= 0) {
|
|
4074
|
+
this._clientLog.debug(`group.v2.ack skipped: group=${gid}, ns=${ns}, up_to_seq=${String(upToSeq ?? '')}`);
|
|
2938
4075
|
return { acked: 0 };
|
|
2939
|
-
|
|
4076
|
+
}
|
|
4077
|
+
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
4078
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
4079
|
+
if (maxSeen > 0 && seq > maxSeen) {
|
|
4080
|
+
this._clientLog.warn(`ackGroupV2 clamp: group=${gid} up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
|
|
4081
|
+
seq = maxSeen;
|
|
4082
|
+
}
|
|
4083
|
+
this._clientLog.debug(`group.v2.ack send: group=${gid}, ns=${ns}, up_to_seq=${seq}`);
|
|
4084
|
+
const result = await this.call('group.v2.ack', { group_id: gid, up_to_seq: seq });
|
|
4085
|
+
this._clientLog.debug(`group.v2.ack ok: group=${gid}, ns=${ns}, requested=${seq}, result=${this._debugJson(result)}`);
|
|
4086
|
+
return result;
|
|
2940
4087
|
}
|
|
2941
|
-
/** 解密单条 V2 pull
|
|
2942
|
-
async _decryptV2Message(msg) {
|
|
4088
|
+
/** 解密单条 V2 pull 消息。缺 sender IK 时先入 pending,后台补齐后重试。 */
|
|
4089
|
+
async _decryptV2Message(msg, allowPending = true) {
|
|
2943
4090
|
const session = this._v2Session;
|
|
2944
4091
|
if (!session)
|
|
2945
4092
|
return null;
|
|
@@ -2954,6 +4101,8 @@ export class AUNClient {
|
|
|
2954
4101
|
this._clientLog.warn(`V2 decrypt: invalid envelope_json for msg seq=${String(msg.seq)}`);
|
|
2955
4102
|
return null;
|
|
2956
4103
|
}
|
|
4104
|
+
const e2eeMeta = this._v2E2eeMeta(envelope);
|
|
4105
|
+
this._observeAgentMdFromEnvelope(envelope);
|
|
2957
4106
|
let spkId = '';
|
|
2958
4107
|
let recipientKeySource = '';
|
|
2959
4108
|
if (isJsonObject(envelope.recipient)) {
|
|
@@ -2977,31 +4126,73 @@ export class AUNClient {
|
|
|
2977
4126
|
}
|
|
2978
4127
|
}
|
|
2979
4128
|
}
|
|
2980
|
-
//
|
|
4129
|
+
// group_id 只表示群上下文;getGroupDecryptKeys 内部必须按 group SPK -> P2P device SPK -> IK fallback 查找。
|
|
2981
4130
|
const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
|
|
2982
4131
|
const groupIdForKeys = String(aad.group_id ?? msg.group_id ?? '').trim();
|
|
4132
|
+
const undecryptableEvent = groupIdForKeys ? 'group.message_undecryptable' : 'message.undecryptable';
|
|
4133
|
+
this._clientLog.debug(`V2 decrypt start: seq=${String(msg.seq ?? '')}, message_id=${String(msg.message_id ?? '')}, group=${groupIdForKeys || '<p2p>'}, from=${String(msg.from_aid ?? '')}, spk_id=${spkId || '<empty>'}, key_source=${recipientKeySource || '<empty>'}, has_recipient=${String(isJsonObject(envelope.recipient))}, has_recipients=${String(Array.isArray(envelope.recipients))}`);
|
|
2983
4134
|
let ikPriv;
|
|
2984
4135
|
let spkPriv;
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
4136
|
+
try {
|
|
4137
|
+
if (groupIdForKeys) {
|
|
4138
|
+
const keys = session.getGroupDecryptKeys(groupIdForKeys, spkId);
|
|
4139
|
+
ikPriv = keys.ikPriv;
|
|
4140
|
+
spkPriv = keys.spkPriv ?? undefined;
|
|
4141
|
+
}
|
|
4142
|
+
else {
|
|
4143
|
+
const keys = session.getDecryptKeys(spkId);
|
|
4144
|
+
ikPriv = keys.ikPriv;
|
|
4145
|
+
spkPriv = keys.spkPriv;
|
|
4146
|
+
}
|
|
2989
4147
|
}
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
4148
|
+
catch (exc) {
|
|
4149
|
+
this._clientLog.warn(`V2 decrypt: SPK lookup failed seq=${String(msg.seq)} spk_id=${spkId}: ${formatCaughtError(exc)}`);
|
|
4150
|
+
const event = {
|
|
4151
|
+
message_id: String(msg.message_id ?? ''),
|
|
4152
|
+
from: String(msg.from_aid ?? ''),
|
|
4153
|
+
to: String(msg.to ?? ''),
|
|
4154
|
+
seq: msg.seq,
|
|
4155
|
+
timestamp: (msg.t_server ?? msg.timestamp),
|
|
4156
|
+
device_id: String(msg.device_id ?? ''),
|
|
4157
|
+
slot_id: String(msg.slot_id ?? ''),
|
|
4158
|
+
_decrypt_error: String(formatCaughtError(exc)),
|
|
4159
|
+
_decrypt_stage: 'spk_lookup',
|
|
4160
|
+
_envelope_type: String(envelope.type ?? ''),
|
|
4161
|
+
_suite: String(envelope.suite ?? ''),
|
|
4162
|
+
_spk_id: spkId,
|
|
4163
|
+
};
|
|
4164
|
+
this._attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4165
|
+
this._logMessageDebug('decrypt-fail', 'v2.decrypt', undecryptableEvent, event);
|
|
4166
|
+
await this._dispatcher.publish(undecryptableEvent, event);
|
|
4167
|
+
return null;
|
|
2994
4168
|
}
|
|
4169
|
+
this._clientLog.debug(`V2 decrypt key lookup ok: seq=${String(msg.seq ?? '')}, group=${groupIdForKeys || '<p2p>'}, ik_len=${ikPriv.byteLength}, spk_len=${spkPriv?.byteLength ?? 0}`);
|
|
2995
4170
|
const fromAid = String(msg.from_aid ?? '');
|
|
2996
4171
|
const senderDeviceId = String(aad.from_device ?? '');
|
|
2997
4172
|
const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
2998
4173
|
if (!senderPubDer) {
|
|
2999
|
-
|
|
4174
|
+
this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
|
|
4175
|
+
if (allowPending) {
|
|
4176
|
+
this._scheduleV2SenderIKPending({ msg, fromAid, senderDeviceId, groupId: groupIdForKeys });
|
|
4177
|
+
return null;
|
|
4178
|
+
}
|
|
4179
|
+
const event = {
|
|
3000
4180
|
message_id: String(msg.message_id ?? ''),
|
|
3001
4181
|
from: fromAid,
|
|
4182
|
+
to: String(msg.to ?? ''),
|
|
3002
4183
|
seq: msg.seq,
|
|
4184
|
+
timestamp: (msg.t_server ?? msg.timestamp),
|
|
4185
|
+
device_id: String(msg.device_id ?? ''),
|
|
4186
|
+
slot_id: String(msg.slot_id ?? ''),
|
|
3003
4187
|
_decrypt_error: 'sender_ik_not_found',
|
|
3004
|
-
|
|
4188
|
+
_decrypt_stage: 'sender_ik',
|
|
4189
|
+
_envelope_type: String(envelope.type ?? ''),
|
|
4190
|
+
_suite: String(envelope.suite ?? ''),
|
|
4191
|
+
_sender_device_id: String(aad.from_device ?? ''),
|
|
4192
|
+
};
|
|
4193
|
+
this._attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4194
|
+
this._logMessageDebug('decrypt-fail', 'v2.decrypt', undecryptableEvent, event);
|
|
4195
|
+
await this._dispatcher.publish(undecryptableEvent, event);
|
|
3005
4196
|
return null;
|
|
3006
4197
|
}
|
|
3007
4198
|
let plaintext;
|
|
@@ -3010,16 +4201,29 @@ export class AUNClient {
|
|
|
3010
4201
|
}
|
|
3011
4202
|
catch (exc) {
|
|
3012
4203
|
this._clientLog.warn(`V2 decrypt failed for msg seq=${String(msg.seq)}: ${formatCaughtError(exc)}`);
|
|
3013
|
-
|
|
4204
|
+
const event = {
|
|
3014
4205
|
message_id: String(msg.message_id ?? ''),
|
|
3015
4206
|
from: fromAid,
|
|
4207
|
+
to: String(msg.to ?? ''),
|
|
3016
4208
|
seq: msg.seq,
|
|
4209
|
+
timestamp: (msg.t_server ?? msg.timestamp),
|
|
4210
|
+
device_id: String(msg.device_id ?? ''),
|
|
4211
|
+
slot_id: String(msg.slot_id ?? ''),
|
|
3017
4212
|
_decrypt_error: String(formatCaughtError(exc)),
|
|
3018
|
-
|
|
4213
|
+
_decrypt_stage: 'decrypt',
|
|
4214
|
+
_envelope_type: String(envelope.type ?? ''),
|
|
4215
|
+
_suite: String(envelope.suite ?? ''),
|
|
4216
|
+
_sender_device_id: String(aad.from_device ?? ''),
|
|
4217
|
+
};
|
|
4218
|
+
this._attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4219
|
+
this._logMessageDebug('decrypt-fail', 'v2.decrypt', undecryptableEvent, event);
|
|
4220
|
+
await this._dispatcher.publish(undecryptableEvent, event);
|
|
3019
4221
|
return null;
|
|
3020
4222
|
}
|
|
3021
|
-
if (plaintext === null)
|
|
4223
|
+
if (plaintext === null) {
|
|
4224
|
+
this._clientLog.debug(`V2 decrypt returned null plaintext: seq=${String(msg.seq ?? '')}, group=${groupIdForKeys || '<p2p>'}`);
|
|
3022
4225
|
return null;
|
|
4226
|
+
}
|
|
3023
4227
|
// 消费触发 SPK 轮换
|
|
3024
4228
|
if (groupIdForKeys && recipientKeySource === 'group_device_prekey' && session.isLastUploadedGroupSPK(groupIdForKeys, spkId)) {
|
|
3025
4229
|
// Group SPK 消费触发轮换
|
|
@@ -3042,8 +4246,8 @@ export class AUNClient {
|
|
|
3042
4246
|
this._clientLog.debug(`V2 SPK rotation failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
3043
4247
|
});
|
|
3044
4248
|
}
|
|
3045
|
-
const
|
|
3046
|
-
|
|
4249
|
+
const e2ee = this._v2E2eeMeta(envelope);
|
|
4250
|
+
const result = {
|
|
3047
4251
|
message_id: String(msg.message_id ?? ''),
|
|
3048
4252
|
from: fromAid,
|
|
3049
4253
|
to: this._aid ?? '',
|
|
@@ -3051,13 +4255,84 @@ export class AUNClient {
|
|
|
3051
4255
|
t_server: msg.t_server,
|
|
3052
4256
|
payload: plaintext,
|
|
3053
4257
|
encrypted: true,
|
|
3054
|
-
e2ee
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
4258
|
+
e2ee,
|
|
4259
|
+
};
|
|
4260
|
+
this._attachV2EnvelopeMetadata(result, e2ee);
|
|
4261
|
+
this._logMessageDebug('decrypt-ok', 'v2.decrypt', groupIdForKeys ? 'group.message_created' : 'message.received', result);
|
|
4262
|
+
return result;
|
|
4263
|
+
}
|
|
4264
|
+
_v2E2eeMeta(envelope) {
|
|
4265
|
+
const suite = String(envelope.suite ?? '');
|
|
4266
|
+
const meta = {
|
|
4267
|
+
version: 'v2',
|
|
4268
|
+
suite,
|
|
4269
|
+
encryption_mode: `v2_${suite || 'unknown'}`,
|
|
4270
|
+
forward_secrecy: true,
|
|
3060
4271
|
};
|
|
4272
|
+
const protectedHeaders = this._metadataWithoutAuth(envelope.protected_headers);
|
|
4273
|
+
if (protectedHeaders && Object.keys(protectedHeaders).length > 0) {
|
|
4274
|
+
meta.protected_headers = protectedHeaders;
|
|
4275
|
+
}
|
|
4276
|
+
const payloadType = String(envelope.payload_type ?? protectedHeaders?.payload_type ?? '').trim();
|
|
4277
|
+
if (payloadType) {
|
|
4278
|
+
meta.payload_type = payloadType;
|
|
4279
|
+
}
|
|
4280
|
+
const context = this._metadataWithoutAuth(envelope.context);
|
|
4281
|
+
if (context && Object.keys(context).length > 0) {
|
|
4282
|
+
meta.context = context;
|
|
4283
|
+
}
|
|
4284
|
+
const agentMd = this._metadataWithoutAuth(envelope.agent_md);
|
|
4285
|
+
if (agentMd && Object.keys(agentMd).length > 0) {
|
|
4286
|
+
meta.agent_md = agentMd;
|
|
4287
|
+
}
|
|
4288
|
+
return meta;
|
|
4289
|
+
}
|
|
4290
|
+
_attachV2EnvelopeMetadata(message, meta) {
|
|
4291
|
+
const payloadType = typeof meta.payload_type === 'string' ? meta.payload_type.trim() : '';
|
|
4292
|
+
if (payloadType)
|
|
4293
|
+
message.payload_type = payloadType;
|
|
4294
|
+
if (isJsonObject(meta.protected_headers)) {
|
|
4295
|
+
message.protected_headers = { ...meta.protected_headers };
|
|
4296
|
+
}
|
|
4297
|
+
if (isJsonObject(meta.agent_md)) {
|
|
4298
|
+
message.agent_md = { ...meta.agent_md };
|
|
4299
|
+
}
|
|
4300
|
+
}
|
|
4301
|
+
_attachV2EnvelopeMetadataFromSource(message, source) {
|
|
4302
|
+
const envelope = this._extractV2EnvelopeFromSource(source);
|
|
4303
|
+
if (envelope) {
|
|
4304
|
+
this._observeAgentMdFromEnvelope(envelope);
|
|
4305
|
+
this._attachV2EnvelopeMetadata(message, this._v2E2eeMeta(envelope));
|
|
4306
|
+
}
|
|
4307
|
+
}
|
|
4308
|
+
_extractV2EnvelopeFromSource(source) {
|
|
4309
|
+
const candidate = source;
|
|
4310
|
+
if (!isJsonObject(candidate))
|
|
4311
|
+
return null;
|
|
4312
|
+
if (isJsonObject(candidate.payload))
|
|
4313
|
+
return candidate.payload;
|
|
4314
|
+
if (typeof candidate.envelope_json === 'string' && candidate.envelope_json) {
|
|
4315
|
+
try {
|
|
4316
|
+
const parsed = JSON.parse(candidate.envelope_json);
|
|
4317
|
+
if (isJsonObject(parsed))
|
|
4318
|
+
return parsed;
|
|
4319
|
+
}
|
|
4320
|
+
catch {
|
|
4321
|
+
return null;
|
|
4322
|
+
}
|
|
4323
|
+
}
|
|
4324
|
+
return null;
|
|
4325
|
+
}
|
|
4326
|
+
_metadataWithoutAuth(value) {
|
|
4327
|
+
const candidate = value;
|
|
4328
|
+
if (!isJsonObject(candidate))
|
|
4329
|
+
return null;
|
|
4330
|
+
const body = {};
|
|
4331
|
+
for (const [key, item] of Object.entries(candidate)) {
|
|
4332
|
+
if (key !== '_auth')
|
|
4333
|
+
body[key] = item;
|
|
4334
|
+
}
|
|
4335
|
+
return body;
|
|
3061
4336
|
}
|
|
3062
4337
|
async _putMessageThoughtEncryptedV2(params) {
|
|
3063
4338
|
const toAid = String(params.to ?? '').trim();
|
|
@@ -3070,7 +4345,14 @@ export class AUNClient {
|
|
|
3070
4345
|
const thoughtId = String(params.thought_id ?? '').trim() || `mt-${crypto.randomUUID()}`;
|
|
3071
4346
|
const timestamp = Number(params.timestamp ?? Date.now());
|
|
3072
4347
|
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
4348
|
+
this._logMessageDebug('thought-send-plaintext', 'message.thought.put.v2', 'message.thought.put', {
|
|
4349
|
+
to: toAid,
|
|
4350
|
+
thought_id: thoughtId,
|
|
4351
|
+
timestamp,
|
|
4352
|
+
payload,
|
|
4353
|
+
}, { payloadOverride: payload });
|
|
3073
4354
|
const attempt = async (useCache) => {
|
|
4355
|
+
this._clientLog.debug(`message.thought.put attempt: to=${toAid}, thought_id=${thoughtId}, use_cache=${useCache}`);
|
|
3074
4356
|
const context = isJsonObject(params.context) ? params.context : undefined;
|
|
3075
4357
|
const envelope = await this._buildV2P2PEnvelope({
|
|
3076
4358
|
to: toAid,
|
|
@@ -3091,7 +4373,13 @@ export class AUNClient {
|
|
|
3091
4373
|
if ('context' in params)
|
|
3092
4374
|
sendParams.context = params.context;
|
|
3093
4375
|
this._signClientOperation('message.thought.put', sendParams);
|
|
3094
|
-
|
|
4376
|
+
this._logMessageDebug('thought-send-envelope', 'message.thought.put.v2', 'message.thought.put', sendParams, {
|
|
4377
|
+
payloadOverride: envelope,
|
|
4378
|
+
extra: { to: toAid, thought_id: thoughtId, use_cache: useCache },
|
|
4379
|
+
});
|
|
4380
|
+
const result = await this._transport.call('message.thought.put', sendParams);
|
|
4381
|
+
this._clientLog.debug(`message.thought.put ok: to=${toAid}, thought_id=${thoughtId}, use_cache=${useCache}`);
|
|
4382
|
+
return result;
|
|
3095
4383
|
};
|
|
3096
4384
|
try {
|
|
3097
4385
|
return await attempt(true);
|
|
@@ -3116,7 +4404,14 @@ export class AUNClient {
|
|
|
3116
4404
|
const thoughtId = String(params.thought_id ?? '').trim() || `gt-${crypto.randomUUID()}`;
|
|
3117
4405
|
const timestamp = Number(params.timestamp ?? Date.now());
|
|
3118
4406
|
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
4407
|
+
this._logMessageDebug('thought-send-plaintext', 'group.thought.put.v2', 'group.thought.put', {
|
|
4408
|
+
group_id: groupId,
|
|
4409
|
+
thought_id: thoughtId,
|
|
4410
|
+
timestamp,
|
|
4411
|
+
payload,
|
|
4412
|
+
}, { payloadOverride: payload });
|
|
3119
4413
|
const attempt = async (useCache) => {
|
|
4414
|
+
this._clientLog.debug(`group.thought.put attempt: group=${groupId}, thought_id=${thoughtId}, use_cache=${useCache}`);
|
|
3120
4415
|
const context = isJsonObject(params.context) ? params.context : undefined;
|
|
3121
4416
|
const envelope = await this._buildV2GroupEnvelope({
|
|
3122
4417
|
groupId,
|
|
@@ -3137,7 +4432,13 @@ export class AUNClient {
|
|
|
3137
4432
|
if ('context' in params)
|
|
3138
4433
|
sendParams.context = params.context;
|
|
3139
4434
|
this._signClientOperation('group.thought.put', sendParams);
|
|
3140
|
-
|
|
4435
|
+
this._logMessageDebug('thought-send-envelope', 'group.thought.put.v2', 'group.thought.put', sendParams, {
|
|
4436
|
+
payloadOverride: envelope,
|
|
4437
|
+
extra: { group_id: groupId, thought_id: thoughtId, use_cache: useCache },
|
|
4438
|
+
});
|
|
4439
|
+
const result = await this._transport.call('group.thought.put', sendParams);
|
|
4440
|
+
this._clientLog.debug(`group.thought.put ok: group=${groupId}, thought_id=${thoughtId}, use_cache=${useCache}`);
|
|
4441
|
+
return result;
|
|
3141
4442
|
};
|
|
3142
4443
|
try {
|
|
3143
4444
|
return await attempt(true);
|
|
@@ -3159,30 +4460,57 @@ export class AUNClient {
|
|
|
3159
4460
|
return null;
|
|
3160
4461
|
const envelope = opts.envelope;
|
|
3161
4462
|
let spkId = '';
|
|
4463
|
+
let recipientKeySource = '';
|
|
3162
4464
|
if (Array.isArray(envelope.recipients)) {
|
|
3163
4465
|
for (const row of envelope.recipients) {
|
|
3164
|
-
if (!Array.isArray(row) || row.length <
|
|
4466
|
+
if (!Array.isArray(row) || row.length < 6)
|
|
3165
4467
|
continue;
|
|
3166
4468
|
if (String(row[0] ?? '') === this._aid && String(row[1] ?? '') === this._deviceId) {
|
|
3167
4469
|
spkId = String(row[5] ?? '');
|
|
4470
|
+
recipientKeySource = String(row[3] ?? '');
|
|
3168
4471
|
break;
|
|
3169
4472
|
}
|
|
3170
4473
|
}
|
|
3171
4474
|
}
|
|
3172
4475
|
else if (isJsonObject(envelope.recipient)) {
|
|
3173
|
-
|
|
4476
|
+
const recipient = envelope.recipient;
|
|
4477
|
+
spkId = String(recipient.spk_id ?? '');
|
|
4478
|
+
recipientKeySource = String(recipient.key_source ?? '');
|
|
3174
4479
|
}
|
|
3175
|
-
const { ikPriv, spkPriv } = session.getDecryptKeys(spkId);
|
|
3176
4480
|
const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
|
|
4481
|
+
const groupIdForKeys = String(aad.group_id ?? envelope.group_id ?? '').trim();
|
|
3177
4482
|
const fromAid = String(opts.fromAid || aad.from || '').trim();
|
|
3178
4483
|
const senderDeviceId = String(aad.from_device ?? '');
|
|
4484
|
+
this._clientLog.debug(`V2 thought decrypt start: from=${fromAid}, sender_device=${senderDeviceId}, group=${groupIdForKeys || '<p2p>'}, spk_id=${spkId || '<empty>'}, key_source=${recipientKeySource || '<empty>'}, type=${String(envelope.type ?? '')}`);
|
|
4485
|
+
// group_id 只表示群上下文;group lookup 内部按 group SPK -> P2P device SPK -> IK fallback。
|
|
4486
|
+
let ikPriv;
|
|
4487
|
+
let spkPriv;
|
|
4488
|
+
try {
|
|
4489
|
+
if (groupIdForKeys) {
|
|
4490
|
+
const keys = session.getGroupDecryptKeys(groupIdForKeys, spkId);
|
|
4491
|
+
ikPriv = keys.ikPriv;
|
|
4492
|
+
spkPriv = keys.spkPriv ?? undefined;
|
|
4493
|
+
}
|
|
4494
|
+
else {
|
|
4495
|
+
const keys = session.getDecryptKeys(spkId);
|
|
4496
|
+
ikPriv = keys.ikPriv;
|
|
4497
|
+
spkPriv = keys.spkPriv;
|
|
4498
|
+
}
|
|
4499
|
+
}
|
|
4500
|
+
catch (exc) {
|
|
4501
|
+
this._clientLog.warn(`V2 thought decrypt: SPK lookup failed from=${fromAid}, group=${groupIdForKeys || '<p2p>'}, spk_id=${spkId || '<empty>'}: ${formatCaughtError(exc)}`);
|
|
4502
|
+
return null;
|
|
4503
|
+
}
|
|
3179
4504
|
const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
3180
4505
|
if (!senderPubDer) {
|
|
3181
4506
|
this._clientLog.warn(`V2 thought decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
|
|
4507
|
+
this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupIdForKeys);
|
|
3182
4508
|
return null;
|
|
3183
4509
|
}
|
|
3184
4510
|
try {
|
|
3185
|
-
|
|
4511
|
+
const plain = decryptMessage(envelope, this._aid ?? '', this._deviceId, ikPriv, spkPriv, senderPubDer);
|
|
4512
|
+
this._clientLog.debug(`V2 thought decrypt ok: from=${fromAid}, sender_device=${senderDeviceId}, group=${groupIdForKeys || '<p2p>'}`);
|
|
4513
|
+
return plain;
|
|
3186
4514
|
}
|
|
3187
4515
|
catch (exc) {
|
|
3188
4516
|
this._clientLog.warn(`V2 thought decrypt failed from=${fromAid}: ${formatCaughtError(exc)}`);
|
|
@@ -3219,11 +4547,7 @@ export class AUNClient {
|
|
|
3219
4547
|
});
|
|
3220
4548
|
const sigBytes = Buffer.from(stateSignature, 'base64');
|
|
3221
4549
|
const cacheKey = crypto.createHash('sha256')
|
|
3222
|
-
.update(actorAid, 'utf-8')
|
|
3223
|
-
.update(Buffer.from([0]))
|
|
3224
|
-
.update(signPayload, 'utf-8')
|
|
3225
|
-
.update(Buffer.from([0]))
|
|
3226
|
-
.update(sigBytes)
|
|
4550
|
+
.update(lengthPrefixedBytesKey(Buffer.from(actorAid, 'utf-8'), Buffer.from(signPayload, 'utf-8'), sigBytes))
|
|
3227
4551
|
.digest('hex');
|
|
3228
4552
|
const now = Date.now();
|
|
3229
4553
|
const cachedExp = this._v2SigCache.get(cacheKey);
|
|
@@ -3432,8 +4756,9 @@ export class AUNClient {
|
|
|
3432
4756
|
const candidates = [];
|
|
3433
4757
|
for (const dev of devices) {
|
|
3434
4758
|
const aid = String(dev.aid ?? '').trim();
|
|
4759
|
+
const hasDeviceId = 'device_id' in dev;
|
|
3435
4760
|
const deviceId = String(dev.device_id ?? '').trim();
|
|
3436
|
-
if (aid &&
|
|
4761
|
+
if (aid && hasDeviceId && onlineAdminAids.has(aid)) {
|
|
3437
4762
|
candidates.push(`${aid}\x1f${deviceId}`);
|
|
3438
4763
|
}
|
|
3439
4764
|
}
|
|
@@ -3449,7 +4774,7 @@ export class AUNClient {
|
|
|
3449
4774
|
this._clientLog.debug(`V2 auto propose leader elected: group=${groupId} leader=${leader}`);
|
|
3450
4775
|
return true;
|
|
3451
4776
|
}
|
|
3452
|
-
const delayMs = this._v2LeaderDelayMs(
|
|
4777
|
+
const delayMs = this._v2LeaderDelayMs(lengthPrefixedTextKey(groupId, myKey));
|
|
3453
4778
|
this._clientLog.debug(`V2 auto propose non-leader delay: group=${groupId} leader=${leader} self=${myKey} delay_ms=${delayMs}`);
|
|
3454
4779
|
await this._sleep(delayMs);
|
|
3455
4780
|
return true;
|
|
@@ -3719,30 +5044,46 @@ export class AUNClient {
|
|
|
3719
5044
|
const pushFrom = isJsonObject(data) ? String(data.from_aid ?? '') : '';
|
|
3720
5045
|
const pushMsgId = isJsonObject(data) ? String(data.message_id ?? '') : '';
|
|
3721
5046
|
const envelopeJson = isJsonObject(data) ? data.envelope_json : undefined;
|
|
5047
|
+
const hasPayload = !!envelopeJson;
|
|
3722
5048
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
3723
5049
|
let contigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
3724
|
-
this._clientLog.debug(`_onV2PushNotification: push_seq=${pushSeq || 'null'} push_from=${pushFrom} push_msg_id=${pushMsgId} has_payload=${
|
|
5050
|
+
this._clientLog.debug(`_onV2PushNotification: push_seq=${pushSeq || 'null'} push_from=${pushFrom} push_msg_id=${pushMsgId} has_payload=${hasPayload} contiguous_seq=${contigBefore}`);
|
|
5051
|
+
// ── Push 修上界:只更新 maxSeenSeq,不动 contiguousSeq ──
|
|
5052
|
+
// 即使 pushSeq 是脏数据(如服务端 bug 导致的 99999),也只影响"已知上界",
|
|
5053
|
+
// 不会污染下界 contiguousSeq,更不会导致 SDK 把脏数据 ack 回服务端。
|
|
5054
|
+
if (pushSeq > 0 && ns) {
|
|
5055
|
+
this._seqTracker.updateMaxSeen(ns, pushSeq);
|
|
5056
|
+
if (contigBefore === pushSeq) {
|
|
5057
|
+
this._clientLog.debug(`_onV2PushNotification: push seq=${pushSeq} already covered by contiguous_seq=${contigBefore}, ignore duplicate push`);
|
|
5058
|
+
return;
|
|
5059
|
+
}
|
|
5060
|
+
contigBefore = this._repairPushContiguousBound(ns, pushSeq, hasPayload, '_raw.peer.v2.message_received');
|
|
5061
|
+
}
|
|
3725
5062
|
// ── 带 payload 的 push:尝试就地解密 ──
|
|
3726
|
-
if (
|
|
5063
|
+
if (hasPayload && pushSeq > 0 && ns) {
|
|
3727
5064
|
try {
|
|
3728
5065
|
const decrypted = await this._decryptV2PushMessage(data);
|
|
3729
5066
|
if (decrypted) {
|
|
3730
|
-
//
|
|
3731
|
-
|
|
3732
|
-
|
|
3733
|
-
|
|
3734
|
-
}
|
|
3735
|
-
await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
|
|
5067
|
+
// 解密成功:把 pushSeq 加入 receivedSeqs,让 _tryAdvance 自然推进
|
|
5068
|
+
// (如果 pushSeq == contiguousSeq + 1 会自动推进到 pushSeq)
|
|
5069
|
+
const needPull = this._seqTracker.onMessageSeq(ns, pushSeq);
|
|
5070
|
+
const published = await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
|
|
3736
5071
|
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
3737
5072
|
if (newContig !== contigBefore) {
|
|
3738
5073
|
this._saveSeqTrackerState();
|
|
3739
5074
|
}
|
|
3740
5075
|
if (newContig > 0 && newContig !== contigBefore) {
|
|
3741
|
-
|
|
5076
|
+
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
5077
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
5078
|
+
const ackSeq = maxSeen > 0 ? Math.min(newContig, maxSeen) : newContig;
|
|
5079
|
+
this.call('message.v2.ack', { up_to_seq: ackSeq })
|
|
3742
5080
|
.catch(e => this._clientLog.debug(`V2 P2P push-ack failed: ${formatCaughtError(e)}`));
|
|
3743
5081
|
}
|
|
3744
5082
|
this._clientLog.debug(`_onV2PushNotification: push 带 payload 解密成功, contiguous_seq=${contigBefore}->${newContig} push_seq=${pushSeq}`);
|
|
3745
|
-
|
|
5083
|
+
if (!needPull && (published || newContig >= pushSeq || pushSeq <= contigBefore)) {
|
|
5084
|
+
return;
|
|
5085
|
+
}
|
|
5086
|
+
this._clientLog.debug(`_onV2PushNotification: payload push seq=${pushSeq} 因空洞挂起,继续 pull 补齐 after_seq=${newContig}`);
|
|
3746
5087
|
}
|
|
3747
5088
|
}
|
|
3748
5089
|
catch (exc) {
|
|
@@ -3753,11 +5094,6 @@ export class AUNClient {
|
|
|
3753
5094
|
// 纯通知只表示服务端已有 pushSeq 这条消息,内容还没有进入本地,不能先推进 contiguousSeq。
|
|
3754
5095
|
// 后续 pull 必须从当前 contiguousSeq 开始,否则会跳过 pushSeq 本身。
|
|
3755
5096
|
if (pushSeq > 0 && ns) {
|
|
3756
|
-
if (contigBefore >= pushSeq) {
|
|
3757
|
-
this._clientLog.warn(`_onV2PushNotification: contiguous_seq=${contigBefore} 越界(>= push_seq=${pushSeq}),强制修复为 ${pushSeq - 1}`);
|
|
3758
|
-
this._seqTracker.forceContiguousSeq(ns, pushSeq - 1);
|
|
3759
|
-
contigBefore = pushSeq - 1;
|
|
3760
|
-
}
|
|
3761
5097
|
this._clientLog.debug(`_onV2PushNotification: 纯通知 push_seq=${pushSeq} > contiguous_seq=${contigBefore}, 触发 pull(after_seq=${contigBefore})`);
|
|
3762
5098
|
}
|
|
3763
5099
|
if (this._v2PullInflight) {
|
|
@@ -3820,22 +5156,37 @@ export class AUNClient {
|
|
|
3820
5156
|
await this._dispatcher.publish('group.v2.state_confirmed', data);
|
|
3821
5157
|
}
|
|
3822
5158
|
async _onRawGroupV2MessageCreated(data) {
|
|
3823
|
-
if (!isJsonObject(data) || !this._v2Session)
|
|
5159
|
+
if (!isJsonObject(data) || !this._v2Session) {
|
|
5160
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: is_object=${String(isJsonObject(data))}, has_v2_session=${String(!!this._v2Session)}`);
|
|
3824
5161
|
return;
|
|
5162
|
+
}
|
|
5163
|
+
this._logMessageDebug('server-push', '_raw.group.v2.message_created', 'group.message_created', data);
|
|
3825
5164
|
const groupId = String(data.group_id ?? '').trim();
|
|
3826
5165
|
const seq = Number(data.seq ?? 0);
|
|
3827
|
-
if (!groupId || !Number.isFinite(seq) || seq <= 0)
|
|
5166
|
+
if (!groupId || !Number.isFinite(seq) || seq <= 0) {
|
|
5167
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: group=${groupId || '<empty>'}, seq=${String(data.seq ?? '')}`);
|
|
3828
5168
|
return;
|
|
5169
|
+
}
|
|
3829
5170
|
const ns = `group:${groupId}`;
|
|
3830
|
-
|
|
5171
|
+
// Push 修上界:先更新 maxSeenSeq
|
|
5172
|
+
this._seqTracker.updateMaxSeen(ns, seq);
|
|
5173
|
+
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
5174
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated enter: group=${groupId}, seq=${seq}, contiguous=${contigBefore}, max_seen=${this._seqTracker.getMaxSeenSeq(ns)}`);
|
|
5175
|
+
if (contigBefore === seq) {
|
|
5176
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated duplicate push already covered: group=${groupId} seq=${seq}`);
|
|
3831
5177
|
return;
|
|
3832
|
-
|
|
5178
|
+
}
|
|
5179
|
+
const afterSeq = this._repairPushContiguousBound(ns, seq, false, '_raw.group.v2.message_created');
|
|
3833
5180
|
const dedupKey = `v2_group_push:${groupId}:${afterSeq}`;
|
|
3834
|
-
if (this._gapFillDone.has(dedupKey))
|
|
5181
|
+
if (this._gapFillDone.has(dedupKey)) {
|
|
5182
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped duplicate in-flight pull: group=${groupId}, dedup=${dedupKey}`);
|
|
3835
5183
|
return;
|
|
5184
|
+
}
|
|
3836
5185
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
3837
5186
|
try {
|
|
5187
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull start: group=${groupId}, after_seq=${afterSeq}, push_seq=${seq}`);
|
|
3838
5188
|
await this.pullGroupV2(groupId, afterSeq, 50);
|
|
5189
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull done: group=${groupId}, after_seq=${afterSeq}, push_seq=${seq}, contiguous=${this._seqTracker.getContiguousSeq(ns)}`);
|
|
3839
5190
|
}
|
|
3840
5191
|
catch (exc) {
|
|
3841
5192
|
this._clientLog.warn(`V2 group push auto-pull failed: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
@@ -3869,6 +5220,11 @@ export class AUNClient {
|
|
|
3869
5220
|
}
|
|
3870
5221
|
/** 从参数中解析 Gateway URL */
|
|
3871
5222
|
_resolveGateway(params) {
|
|
5223
|
+
const gateways = this._resolveGateways(params);
|
|
5224
|
+
return gateways[0];
|
|
5225
|
+
}
|
|
5226
|
+
/** 从参数中解析所有 Gateway URL(支持 string 或 string[]) */
|
|
5227
|
+
_resolveGateways(params) {
|
|
3872
5228
|
const topology = params.topology;
|
|
3873
5229
|
if (isJsonObject(topology)) {
|
|
3874
5230
|
const topo = topology;
|
|
@@ -3880,11 +5236,16 @@ export class AUNClient {
|
|
|
3880
5236
|
throw new ValidationError('relay topology is not implemented in the TypeScript SDK');
|
|
3881
5237
|
}
|
|
3882
5238
|
}
|
|
3883
|
-
const
|
|
3884
|
-
if (
|
|
3885
|
-
|
|
5239
|
+
const gw = params.gateway ?? params.gateways;
|
|
5240
|
+
if (Array.isArray(gw)) {
|
|
5241
|
+
const urls = gw.map(g => String(g ?? '')).filter(u => u.length > 0);
|
|
5242
|
+
if (urls.length > 0)
|
|
5243
|
+
return urls;
|
|
5244
|
+
}
|
|
5245
|
+
if (typeof gw === 'string' && gw) {
|
|
5246
|
+
return [gw];
|
|
3886
5247
|
}
|
|
3887
|
-
|
|
5248
|
+
throw new StateError('missing gateway in connect params');
|
|
3888
5249
|
}
|
|
3889
5250
|
/** 连接后同步身份信息 */
|
|
3890
5251
|
_syncIdentityAfterConnect(accessToken) {
|
|
@@ -4138,6 +5499,16 @@ export class AUNClient {
|
|
|
4138
5499
|
};
|
|
4139
5500
|
scheduleNext(0);
|
|
4140
5501
|
}
|
|
5502
|
+
_normalizeOutboundMessagePayload(params, method = '') {
|
|
5503
|
+
if (!Object.prototype.hasOwnProperty.call(params, 'payload') && Object.prototype.hasOwnProperty.call(params, 'content')) {
|
|
5504
|
+
params.payload = params.content;
|
|
5505
|
+
delete params.content;
|
|
5506
|
+
}
|
|
5507
|
+
const payload = params.payload;
|
|
5508
|
+
if (isJsonObject(payload) && !Object.prototype.hasOwnProperty.call(payload, 'type') && typeof payload.text === 'string') {
|
|
5509
|
+
params.payload = { type: 'text', ...payload };
|
|
5510
|
+
}
|
|
5511
|
+
}
|
|
4141
5512
|
_validateMessageRecipient(toAid) {
|
|
4142
5513
|
if (isGroupServiceAid(toAid)) {
|
|
4143
5514
|
throw new ValidationError('message.send receiver cannot be group.{issuer}; use group.send instead');
|