@agentunion/fastaun 0.3.2 → 0.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +43 -0
- package/_packed_docs/CHANGELOG.md +43 -0
- package/_packed_docs/INDEX.md +81 -0
- package/_packed_docs/KITE_DOCS_GUIDE.md +55 -0
- package/_packed_docs/agent.md//350/277/234/347/250/213agent.md/347/274/223/345/255/230/344/270/216etag/351/200/217/344/274/240/346/226/271/346/241/210.md +328 -0
- package/_packed_docs/cli/AUN-CLI/350/256/276/350/256/241/346/226/207/346/241/243.md +686 -0
- package/_packed_docs/design//350/267/250/350/257/255/350/250/200/345/256/271/345/231/250E2E/346/265/213/350/257/225/346/226/271/346/241/210.md +665 -0
- package/_packed_docs/protocol//351/231/204/345/275/225N-/345/210/206/345/270/203/345/274/217Trace/345/215/217/350/256/256.md +257 -0
- package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +5 -5
- package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +1 -1
- package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +2 -2
- package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +454 -396
- package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +1410 -1244
- package/_packed_docs/sdk/07-/351/224/231/350/257/257/345/244/204/347/220/206.md +19 -1
- package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +20 -5
- package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +6 -4
- package/_packed_docs/sdk/E2EE_V2/346/266/210/346/201/257/351/200/232/344/277/241/346/227/266/345/272/217/345/233/276.md +171 -0
- package/_packed_docs/sdk/INDEX.md +9 -4
- package/_packed_docs/sdk/README.md +3 -3
- package/dist/auth.d.ts +44 -8
- package/dist/auth.js +398 -119
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +123 -19
- package/dist/client.js +2650 -673
- 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/errors.d.ts +4 -0
- package/dist/errors.js +7 -0
- package/dist/errors.js.map +1 -1
- package/dist/events.d.ts +9 -0
- package/dist/events.js +42 -12
- package/dist/events.js.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/index.js.map +1 -1
- package/dist/keystore/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 +23 -1
- package/dist/keystore/file.js +109 -1
- 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 +34 -4
- package/dist/namespaces/auth.js +194 -51
- 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/secret-store/file-store.d.ts +21 -2
- package/dist/secret-store/file-store.js +166 -11
- package/dist/secret-store/file-store.js.map +1 -1
- package/dist/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 +10 -1
- package/dist/transport.js +196 -32
- 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 +57 -3
- 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 +42 -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 +7 -3
- package/dist/v2/session/session.js +64 -12
- package/dist/v2/session/session.js.map +1 -1
- package/package.json +46 -46
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,8 +33,12 @@ 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';
|
|
39
|
+
function isPromiseLike(value) {
|
|
40
|
+
return Boolean(value && typeof value.then === 'function');
|
|
41
|
+
}
|
|
36
42
|
/**
|
|
37
43
|
* 递归排序键的 JSON 序列化(Canonical JSON for AUN)
|
|
38
44
|
* 等价于 Python json.dumps(sort_keys=True, separators=(",",":"), ensure_ascii=False)
|
|
@@ -58,6 +64,15 @@ export function stableStringify(obj) {
|
|
|
58
64
|
}
|
|
59
65
|
return JSON.stringify(obj);
|
|
60
66
|
}
|
|
67
|
+
function getV2DeviceId(dev) {
|
|
68
|
+
if (Object.prototype.hasOwnProperty.call(dev, 'device_id')) {
|
|
69
|
+
return { present: true, value: String(dev.device_id ?? '').trim() };
|
|
70
|
+
}
|
|
71
|
+
if (Object.prototype.hasOwnProperty.call(dev, 'owner_device_id')) {
|
|
72
|
+
return { present: true, value: String(dev.owner_device_id ?? '').trim() };
|
|
73
|
+
}
|
|
74
|
+
return { present: false, value: '' };
|
|
75
|
+
}
|
|
61
76
|
function computeStateHash(params) {
|
|
62
77
|
const sortedMembers = [...params.members].sort((a, b) => a.aid.localeCompare(b.aid));
|
|
63
78
|
const membershipBlock = sortedMembers.map(m => `${m.aid}:${m.role}`).join('|');
|
|
@@ -156,7 +171,16 @@ function reconnectSleepDelayMs(baseDelay, maxBaseDelay) {
|
|
|
156
171
|
}
|
|
157
172
|
/** 需要客户端签名的关键方法 */
|
|
158
173
|
const SIGNED_METHODS = new Set([
|
|
159
|
-
'
|
|
174
|
+
'message.send',
|
|
175
|
+
'message.v2.put_peer_pk', 'message.v2.bootstrap',
|
|
176
|
+
'message.v2.group_bootstrap', 'message.v2.pull',
|
|
177
|
+
'message.v2.ack',
|
|
178
|
+
'group.send',
|
|
179
|
+
'group.v2.put_group_pk', 'group.v2.bootstrap',
|
|
180
|
+
'group.v2.send', 'group.v2.pull', 'group.v2.ack',
|
|
181
|
+
'group.v2.propose_state', 'group.v2.confirm_state',
|
|
182
|
+
'group.v2.get_proposal',
|
|
183
|
+
'group.kick', 'group.add_member',
|
|
160
184
|
'group.leave', 'group.remove_member', 'group.update_rules',
|
|
161
185
|
'group.update', 'group.update_announcement',
|
|
162
186
|
'group.update_join_requirements', 'group.set_role',
|
|
@@ -176,6 +200,59 @@ const SIGNED_METHODS = new Set([
|
|
|
176
200
|
]);
|
|
177
201
|
/** peer 证书缓存 TTL(1 小时) */
|
|
178
202
|
const PEER_CERT_CACHE_TTL = 3600;
|
|
203
|
+
function normalizeV2WrapPolicy(raw) {
|
|
204
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) {
|
|
205
|
+
return { explicit: false, version: '', protocol: '', scope: 'device' };
|
|
206
|
+
}
|
|
207
|
+
const obj = raw;
|
|
208
|
+
let protocol = String(obj.protocol ?? '').trim().toUpperCase();
|
|
209
|
+
if (protocol !== '1DH' && protocol !== '3DH')
|
|
210
|
+
protocol = '';
|
|
211
|
+
let scope = String(obj.scope ?? '').trim().toLowerCase();
|
|
212
|
+
if (scope !== 'aid' && scope !== 'device') {
|
|
213
|
+
scope = obj.per_aid_wrap === true ? 'aid' : 'device';
|
|
214
|
+
}
|
|
215
|
+
if (scope === 'aid')
|
|
216
|
+
protocol = '1DH';
|
|
217
|
+
return {
|
|
218
|
+
explicit: true,
|
|
219
|
+
version: String(obj.version ?? ''),
|
|
220
|
+
protocol,
|
|
221
|
+
scope: scope,
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
function v2WrapCapabilities() {
|
|
225
|
+
return {
|
|
226
|
+
version: 'v2.1',
|
|
227
|
+
protocols: ['1DH', '3DH'],
|
|
228
|
+
scopes: ['aid', 'device'],
|
|
229
|
+
per_aid_wrap: true,
|
|
230
|
+
per_device_wrap: true,
|
|
231
|
+
};
|
|
232
|
+
}
|
|
233
|
+
function applyV2WrapPolicyToTargets(targets, policy) {
|
|
234
|
+
if (!policy.explicit)
|
|
235
|
+
return targets;
|
|
236
|
+
const out = [];
|
|
237
|
+
const seen = new Set();
|
|
238
|
+
for (const target of targets) {
|
|
239
|
+
const row = { ...target };
|
|
240
|
+
if (policy.protocol === '1DH') {
|
|
241
|
+
row.keySource = 'aid_master';
|
|
242
|
+
row.spkPkDer = undefined;
|
|
243
|
+
row.spkId = '';
|
|
244
|
+
}
|
|
245
|
+
if (policy.scope === 'aid') {
|
|
246
|
+
const key = `${row.aid}\x1f${row.role}`;
|
|
247
|
+
if (seen.has(key))
|
|
248
|
+
continue;
|
|
249
|
+
seen.add(key);
|
|
250
|
+
row.deviceId = '';
|
|
251
|
+
}
|
|
252
|
+
out.push(row);
|
|
253
|
+
}
|
|
254
|
+
return out;
|
|
255
|
+
}
|
|
179
256
|
function _v2LeftPad32(b) {
|
|
180
257
|
if (b.length === 32)
|
|
181
258
|
return b;
|
|
@@ -189,6 +266,21 @@ function _v2B64ToBytes(s) {
|
|
|
189
266
|
const buf = Buffer.from(String(s ?? '').trim(), 'base64');
|
|
190
267
|
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
191
268
|
}
|
|
269
|
+
function _v2B64ToBytesStrict(s) {
|
|
270
|
+
const text = String(s ?? '').trim();
|
|
271
|
+
if (!text || text.length % 4 === 1 || !/^[A-Za-z0-9+/]*={0,2}$/.test(text)) {
|
|
272
|
+
throw new Error('invalid base64');
|
|
273
|
+
}
|
|
274
|
+
return _v2B64ToBytes(text);
|
|
275
|
+
}
|
|
276
|
+
function _v2BytesEqual(a, b) {
|
|
277
|
+
if (a.length !== b.length)
|
|
278
|
+
return false;
|
|
279
|
+
let diff = 0;
|
|
280
|
+
for (let i = 0; i < a.length; i++)
|
|
281
|
+
diff |= a[i] ^ b[i];
|
|
282
|
+
return diff === 0;
|
|
283
|
+
}
|
|
192
284
|
function _v2B64uToBytes(s) {
|
|
193
285
|
const std = String(s ?? '').replace(/-/g, '+').replace(/_/g, '/');
|
|
194
286
|
const pad = std.length % 4 === 0 ? '' : '='.repeat(4 - (std.length % 4));
|
|
@@ -245,11 +337,11 @@ function normalizeDeliveryModeConfig(raw, opts = {}) {
|
|
|
245
337
|
}
|
|
246
338
|
// ── HTTP 辅助 ─────────────────────────────────────────────────
|
|
247
339
|
/** 发起 HTTP GET 请求,返回文本内容 */
|
|
248
|
-
function _httpGetText(url, verifySsl) {
|
|
340
|
+
function _httpGetText(url, verifySsl, timeoutMs = 30_000) {
|
|
249
341
|
return new Promise((resolve, reject) => {
|
|
250
342
|
const parsed = new URL(url);
|
|
251
343
|
const mod = parsed.protocol === 'https:' ? https : http;
|
|
252
|
-
const options = { timeout:
|
|
344
|
+
const options = { timeout: timeoutMs };
|
|
253
345
|
if (!verifySsl) {
|
|
254
346
|
options.rejectUnauthorized = false;
|
|
255
347
|
}
|
|
@@ -274,6 +366,17 @@ function _httpGetText(url, verifySsl) {
|
|
|
274
366
|
/**
|
|
275
367
|
* AUN Core SDK 主客户端
|
|
276
368
|
*/
|
|
369
|
+
function lengthPrefixedTextKey(...parts) {
|
|
370
|
+
return parts.map((part) => `${Buffer.byteLength(part, 'utf8')}:${part};`).join('');
|
|
371
|
+
}
|
|
372
|
+
function lengthPrefixedBytesKey(...parts) {
|
|
373
|
+
const chunks = [];
|
|
374
|
+
for (const part of parts) {
|
|
375
|
+
const bytes = Buffer.from(part.buffer, part.byteOffset, part.byteLength);
|
|
376
|
+
chunks.push(Buffer.from(`${bytes.length}:`, 'ascii'), bytes, Buffer.from(';', 'ascii'));
|
|
377
|
+
}
|
|
378
|
+
return Buffer.concat(chunks);
|
|
379
|
+
}
|
|
277
380
|
export class AUNClient {
|
|
278
381
|
/** 原始配置 */
|
|
279
382
|
config;
|
|
@@ -317,13 +420,14 @@ export class AUNClient {
|
|
|
317
420
|
_defaultConnectDeliveryMode;
|
|
318
421
|
/** peer 证书缓存 */
|
|
319
422
|
_certCache = new Map();
|
|
320
|
-
//
|
|
321
|
-
|
|
322
|
-
// 触发"本地未发布到服务端"或"服务端版本更新"的 UI 提示。
|
|
423
|
+
// AIDs 目录:{agentMdPath}/{aid}/agentmd.json 保存元数据,{agentMdPath}/{aid}/agent.md 保存正文。
|
|
424
|
+
_agentMdPath = '';
|
|
323
425
|
_localAgentMdPath = '';
|
|
324
426
|
_localAgentMdEtag = '';
|
|
325
427
|
// gateway 在 RPC envelope._meta.agent_md_etag 注入的服务端 etag;纯观察,无下游依赖。
|
|
326
428
|
_remoteAgentMdEtag = '';
|
|
429
|
+
_agentMdCache = new Map();
|
|
430
|
+
_agentMdFetchInflight = new Map();
|
|
327
431
|
/** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
|
|
328
432
|
_seqTracker = new SeqTracker();
|
|
329
433
|
_seqTrackerContext = null;
|
|
@@ -331,10 +435,19 @@ export class AUNClient {
|
|
|
331
435
|
_groupSynced = new Set();
|
|
332
436
|
/** 补洞去重:已完成/进行中的 key -> 开始时间戳,防止重复 pull 同一区间 */
|
|
333
437
|
_gapFillDone = new Map();
|
|
438
|
+
/** pull gate:按消费单元串行化 public pull / gap fill / push auto-pull。 */
|
|
439
|
+
_pullGates = new Map();
|
|
440
|
+
_pullResponseKeys = new Map();
|
|
441
|
+
/** 当前异步调用栈是否属于通知触发的后台 RPC。 */
|
|
442
|
+
_backgroundRpcDepth = 0;
|
|
334
443
|
/** 已发布到应用层的 seq 集合(按命名空间),补洞路径 publish 前检查以避免重复分发 */
|
|
335
444
|
_pushedSeqs = new Map();
|
|
336
445
|
/** 已解密但因 seq 空洞暂缓发布的应用层消息(按 namespace -> seq) */
|
|
337
446
|
_pendingOrderedMsgs = new Map();
|
|
447
|
+
/** 缺 sender IK 时暂存原始 V2 消息,后台补齐 IK 后重试解密。 */
|
|
448
|
+
_v2SenderIKPending = new Map();
|
|
449
|
+
/** sender IK 后台补齐任务去重。 */
|
|
450
|
+
_v2SenderIKFetching = new Set();
|
|
338
451
|
// ── 后台任务定时器 ──────────────────────────────────────────
|
|
339
452
|
_heartbeatTimer = null;
|
|
340
453
|
_tokenRefreshTimer = null;
|
|
@@ -356,11 +469,10 @@ export class AUNClient {
|
|
|
356
469
|
/** 最近一次已成功提交的 membership_snapshot;相同快照直接跳过。 */
|
|
357
470
|
_v2AutoProposeLastSnapshot = new Map();
|
|
358
471
|
_v2LazyProposeTriggered = new Map();
|
|
359
|
-
_v2PullInflight = false;
|
|
360
|
-
_v2PullPending = false;
|
|
361
472
|
static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
|
|
362
473
|
static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
|
|
363
|
-
static
|
|
474
|
+
static PULL_GATE_STALE_MS = 3000;
|
|
475
|
+
static V2_SIG_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
364
476
|
static V2_SIG_CACHE_MAX = 16_384;
|
|
365
477
|
_reconnectActive = false;
|
|
366
478
|
_reconnectAbort = null;
|
|
@@ -373,30 +485,49 @@ export class AUNClient {
|
|
|
373
485
|
const rawConfig = { ...(config ?? {}) };
|
|
374
486
|
this._configModel = configFromMap(rawConfig);
|
|
375
487
|
const initAid = String(rawConfig.aid ?? '').trim() || null;
|
|
488
|
+
this._agentMdPath = path.join(this._configModel.aunPath, 'AIDs');
|
|
376
489
|
this.config = {
|
|
377
490
|
aun_path: this._configModel.aunPath,
|
|
378
491
|
root_ca_path: this._configModel.rootCaPath,
|
|
379
492
|
seed_password: this._configModel.seedPassword,
|
|
380
493
|
};
|
|
494
|
+
this._deviceId = getDeviceId(this._configModel.aunPath);
|
|
381
495
|
// 初始化 Logger(per-client 单例,必须最早创建)
|
|
382
496
|
const debugFlag = this._configModel.debug || debug;
|
|
383
497
|
this._logger = new AUNLogger({
|
|
384
498
|
debug: debugFlag,
|
|
385
499
|
aunPath: this._configModel.aunPath,
|
|
386
500
|
});
|
|
501
|
+
this._logger.bindDeviceId(this._deviceId);
|
|
387
502
|
this._clientLog = this._logger.for('aun_core.client');
|
|
388
503
|
if (debugFlag) {
|
|
389
504
|
this._clientLog.info(`AUNClient initialized (debug=true, aunPath=${this._configModel.aunPath})`);
|
|
390
505
|
}
|
|
391
506
|
this._dispatcher = new EventDispatcher(this._logger.for('aun_core.events'));
|
|
392
|
-
|
|
507
|
+
const dnsNet = new DnsResilientNet({
|
|
508
|
+
verifySsl: this._configModel.verifySsl,
|
|
509
|
+
logger: this._clientLog,
|
|
510
|
+
});
|
|
511
|
+
this._discovery = new GatewayDiscovery({ verifySsl: this._configModel.verifySsl, logger: this._clientLog, net: dnsNet });
|
|
393
512
|
const keystore = new FileKeyStore(this._configModel.aunPath, {
|
|
394
513
|
encryptionSeed: this._configModel.seedPassword ?? undefined,
|
|
395
514
|
logger: this._logger.for('aun_core.keystore'),
|
|
396
515
|
secretStoreLogger: this._logger.for('aun_core.secret-store'),
|
|
397
516
|
});
|
|
398
517
|
this._keystore = keystore;
|
|
399
|
-
|
|
518
|
+
// 启动时被动清理 registerAid 留下的孤儿临时目录(>10 分钟)
|
|
519
|
+
try {
|
|
520
|
+
const cleanup = keystore.cleanupPendingDirs;
|
|
521
|
+
if (typeof cleanup === 'function') {
|
|
522
|
+
const removed = cleanup.call(keystore, 600_000);
|
|
523
|
+
if (removed > 0) {
|
|
524
|
+
this._clientLog.info(`_pending cleanup removed=${removed}`);
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
catch (err) {
|
|
529
|
+
this._clientLog.warn(`_pending cleanup failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
530
|
+
}
|
|
400
531
|
this._slotId = '';
|
|
401
532
|
this._connectDeliveryMode = normalizeDeliveryModeConfig({ mode: 'fanout' });
|
|
402
533
|
this._defaultConnectDeliveryMode = { ...this._connectDeliveryMode };
|
|
@@ -409,6 +540,7 @@ export class AUNClient {
|
|
|
409
540
|
rootCaPath: this._configModel.rootCaPath ?? undefined,
|
|
410
541
|
verifySsl: this._configModel.verifySsl,
|
|
411
542
|
logger: this._logger.for('aun_core.auth'),
|
|
543
|
+
net: dnsNet,
|
|
412
544
|
});
|
|
413
545
|
this._aid = initAid;
|
|
414
546
|
this._transport = new RPCTransport({
|
|
@@ -417,6 +549,7 @@ export class AUNClient {
|
|
|
417
549
|
onDisconnect: (err, closeCode) => this._handleTransportDisconnect(err, closeCode),
|
|
418
550
|
verifySsl: this._configModel.verifySsl,
|
|
419
551
|
logger: this._logger.for('aun_core.transport'),
|
|
552
|
+
dnsNet,
|
|
420
553
|
});
|
|
421
554
|
this._transport.setMetaObserver((meta) => this._observeRpcMeta(meta));
|
|
422
555
|
this.auth = new AuthNamespace(this);
|
|
@@ -453,61 +586,107 @@ export class AUNClient {
|
|
|
453
586
|
return this._aid;
|
|
454
587
|
}
|
|
455
588
|
/**
|
|
456
|
-
*
|
|
589
|
+
* 读取 {agentMdPath}/{self_aid}/agent.md,签名后上传,并把签名结果原子写回本地。
|
|
457
590
|
*/
|
|
458
|
-
async publishAgentMd(
|
|
459
|
-
const
|
|
460
|
-
if (!
|
|
461
|
-
throw new ValidationError('publishAgentMd requires
|
|
591
|
+
async publishAgentMd() {
|
|
592
|
+
const target = this._agentMdOwnerAid();
|
|
593
|
+
if (!target) {
|
|
594
|
+
throw new ValidationError('publishAgentMd requires local AID');
|
|
462
595
|
}
|
|
463
|
-
const content =
|
|
596
|
+
const content = this._readAgentMdContent(target);
|
|
464
597
|
const signed = await this.auth.signAgentMd(content);
|
|
465
598
|
const result = await this.auth.uploadAgentMd(signed);
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
599
|
+
this._localAgentMdEtag = this._agentMdContentEtag(signed);
|
|
600
|
+
const remoteEtag = isJsonObject(result) ? String(result.etag ?? '').trim() : '';
|
|
601
|
+
if (remoteEtag)
|
|
602
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
603
|
+
this._saveAgentMdRecord(target, {
|
|
604
|
+
content: signed,
|
|
605
|
+
local_etag: this._localAgentMdEtag,
|
|
606
|
+
remote_etag: remoteEtag || undefined,
|
|
607
|
+
last_modified: isJsonObject(result) ? String(result.last_modified ?? '').trim() : '',
|
|
608
|
+
fetched_at: Date.now(),
|
|
609
|
+
remote_status: remoteEtag ? 'found' : 'unknown',
|
|
610
|
+
last_error: '',
|
|
611
|
+
});
|
|
469
612
|
return result;
|
|
470
613
|
}
|
|
471
614
|
/**
|
|
472
|
-
* 下载 agent.md
|
|
615
|
+
* 下载 agent.md 并自动验签;内容固定保存到 {agentMdPath}/{aid}/agent.md。
|
|
473
616
|
*/
|
|
474
|
-
async fetchAgentMd(aid
|
|
617
|
+
async fetchAgentMd(aid) {
|
|
475
618
|
const target = String(aid ?? this._aid ?? '').trim();
|
|
476
619
|
if (!target) {
|
|
477
620
|
throw new ValidationError('fetchAgentMd requires aid (or local AID)');
|
|
478
621
|
}
|
|
622
|
+
return await this._startAgentMdFetchTask(target);
|
|
623
|
+
}
|
|
624
|
+
async _startAgentMdFetchTask(target) {
|
|
625
|
+
const existing = this._agentMdFetchInflight.get(target);
|
|
626
|
+
if (existing) {
|
|
627
|
+
return await existing;
|
|
628
|
+
}
|
|
629
|
+
const task = this._fetchAgentMdOnce(target);
|
|
630
|
+
this._agentMdFetchInflight.set(target, task);
|
|
631
|
+
task.finally(() => {
|
|
632
|
+
if (this._agentMdFetchInflight.get(target) === task) {
|
|
633
|
+
this._agentMdFetchInflight.delete(target);
|
|
634
|
+
}
|
|
635
|
+
}).catch(() => undefined);
|
|
636
|
+
return await task;
|
|
637
|
+
}
|
|
638
|
+
async _fetchAgentMdOnce(target) {
|
|
479
639
|
const content = await this.auth.downloadAgentMd(target);
|
|
480
640
|
const signature = await this.auth.verifyAgentMd(content, { aid: target });
|
|
481
641
|
const isSelf = target === (this._aid ?? '');
|
|
642
|
+
const localEtag = this._agentMdContentEtag(content);
|
|
643
|
+
const cacheMeta = this._agentMdAuthCacheMeta(target);
|
|
644
|
+
const remoteEtag = String(cacheMeta.etag ?? '').trim();
|
|
645
|
+
const lastModified = String(cacheMeta.lastModified ?? cacheMeta.last_modified ?? '').trim();
|
|
646
|
+
if (isSelf) {
|
|
647
|
+
this._localAgentMdEtag = localEtag;
|
|
648
|
+
if (remoteEtag)
|
|
649
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
650
|
+
}
|
|
651
|
+
const saved = this._saveAgentMdRecord(target, {
|
|
652
|
+
content,
|
|
653
|
+
local_etag: localEtag,
|
|
654
|
+
remote_etag: remoteEtag || undefined,
|
|
655
|
+
last_modified: lastModified || undefined,
|
|
656
|
+
fetched_at: Date.now(),
|
|
657
|
+
remote_status: 'found',
|
|
658
|
+
verify_status: isJsonObject(signature) ? String(signature.status ?? '') : '',
|
|
659
|
+
verify_error: isJsonObject(signature) ? String(signature.reason ?? '') : '',
|
|
660
|
+
last_error: '',
|
|
661
|
+
});
|
|
482
662
|
let inSync = null;
|
|
483
663
|
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
|
-
}
|
|
664
|
+
const remote = remoteEtag || this._remoteAgentMdEtag || '';
|
|
665
|
+
inSync = localEtag && remote ? localEtag === remote : false;
|
|
501
666
|
}
|
|
502
667
|
return {
|
|
503
668
|
aid: target,
|
|
504
669
|
content,
|
|
505
670
|
signature: signature,
|
|
506
671
|
in_sync: inSync,
|
|
507
|
-
saved_to:
|
|
508
|
-
save_error:
|
|
672
|
+
saved_to: String(saved.saved_to ?? this._agentMdFilePath(target)),
|
|
673
|
+
save_error: null,
|
|
509
674
|
};
|
|
510
675
|
}
|
|
676
|
+
/**
|
|
677
|
+
* 设置 agent.md 本地存储根目录;为空时恢复默认 {aun_path}/AIDs。
|
|
678
|
+
*/
|
|
679
|
+
setAgentMdPath(root) {
|
|
680
|
+
const raw = String(root ?? '').trim();
|
|
681
|
+
const next = raw || path.join(this._configModel.aunPath, 'AIDs');
|
|
682
|
+
fs.mkdirSync(next, { recursive: true });
|
|
683
|
+
this._agentMdPath = next;
|
|
684
|
+
this._agentMdCache.clear();
|
|
685
|
+
return this._agentMdPath;
|
|
686
|
+
}
|
|
687
|
+
SetAgentMDPath(root) {
|
|
688
|
+
return this.setAgentMdPath(root);
|
|
689
|
+
}
|
|
511
690
|
/**
|
|
512
691
|
* 记录本地 agent.md 文件路径并一次性计算 etag(quoted sha256,与服务端一致)。
|
|
513
692
|
*
|
|
@@ -558,6 +737,414 @@ export class AUNClient {
|
|
|
558
737
|
getRemoteAgentMdEtag() {
|
|
559
738
|
return this._remoteAgentMdEtag;
|
|
560
739
|
}
|
|
740
|
+
_agentMdContentEtag(content) {
|
|
741
|
+
return `"${crypto.createHash('sha256').update(String(content ?? ''), 'utf-8').digest('hex')}"`;
|
|
742
|
+
}
|
|
743
|
+
_agentMdOwnerAid() {
|
|
744
|
+
return String(this._aid ?? '').trim();
|
|
745
|
+
}
|
|
746
|
+
_agentMdSafeAid(aid) {
|
|
747
|
+
const target = String(aid ?? '').trim();
|
|
748
|
+
if (!target || target.includes('/') || target.includes('\\') || target.includes('\0')) {
|
|
749
|
+
throw new ValidationError('agent.md aid is empty or contains path separators');
|
|
750
|
+
}
|
|
751
|
+
return target;
|
|
752
|
+
}
|
|
753
|
+
_agentMdRoot() {
|
|
754
|
+
const root = this._agentMdPath || path.join(this._configModel.aunPath, 'AIDs');
|
|
755
|
+
fs.mkdirSync(root, { recursive: true });
|
|
756
|
+
return root;
|
|
757
|
+
}
|
|
758
|
+
_agentMdFilePath(aid) {
|
|
759
|
+
return path.join(this._agentMdRoot(), this._agentMdSafeAid(aid), 'agent.md');
|
|
760
|
+
}
|
|
761
|
+
_agentMdMetaPath(aid) {
|
|
762
|
+
return path.join(this._agentMdRoot(), this._agentMdSafeAid(aid), 'agentmd.json');
|
|
763
|
+
}
|
|
764
|
+
_atomicWriteText(filePath, content) {
|
|
765
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true });
|
|
766
|
+
const tmp = path.join(path.dirname(filePath), `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
767
|
+
let fd = null;
|
|
768
|
+
try {
|
|
769
|
+
fd = fs.openSync(tmp, 'w');
|
|
770
|
+
fs.writeFileSync(fd, content, 'utf-8');
|
|
771
|
+
fs.fsyncSync(fd);
|
|
772
|
+
fs.closeSync(fd);
|
|
773
|
+
fd = null;
|
|
774
|
+
fs.renameSync(tmp, filePath);
|
|
775
|
+
try {
|
|
776
|
+
const dirFd = fs.openSync(path.dirname(filePath), 'r');
|
|
777
|
+
try {
|
|
778
|
+
fs.fsyncSync(dirFd);
|
|
779
|
+
}
|
|
780
|
+
finally {
|
|
781
|
+
fs.closeSync(dirFd);
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
catch { /* best effort */ }
|
|
785
|
+
}
|
|
786
|
+
finally {
|
|
787
|
+
if (fd !== null) {
|
|
788
|
+
try {
|
|
789
|
+
fs.closeSync(fd);
|
|
790
|
+
}
|
|
791
|
+
catch { /* ignore */ }
|
|
792
|
+
}
|
|
793
|
+
if (fs.existsSync(tmp)) {
|
|
794
|
+
try {
|
|
795
|
+
fs.unlinkSync(tmp);
|
|
796
|
+
}
|
|
797
|
+
catch { /* ignore */ }
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
_sleepSync(ms) {
|
|
802
|
+
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
803
|
+
}
|
|
804
|
+
_withAgentMdRecordLock(aid, fn) {
|
|
805
|
+
const lockPath = path.join(path.dirname(this._agentMdMetaPath(aid)), 'agentmd.json.lock');
|
|
806
|
+
fs.mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
807
|
+
const deadline = Date.now() + 5000;
|
|
808
|
+
let fd = null;
|
|
809
|
+
while (fd === null) {
|
|
810
|
+
try {
|
|
811
|
+
fd = fs.openSync(lockPath, 'wx');
|
|
812
|
+
fs.writeFileSync(fd, `${process.pid}\n`, 'utf-8');
|
|
813
|
+
}
|
|
814
|
+
catch (err) {
|
|
815
|
+
if (err?.code !== 'EEXIST' || Date.now() >= deadline)
|
|
816
|
+
throw err;
|
|
817
|
+
try {
|
|
818
|
+
const st = fs.statSync(lockPath);
|
|
819
|
+
if (Date.now() - st.mtimeMs > 30000)
|
|
820
|
+
fs.unlinkSync(lockPath);
|
|
821
|
+
}
|
|
822
|
+
catch { /* ignore */ }
|
|
823
|
+
this._sleepSync(25);
|
|
824
|
+
}
|
|
825
|
+
}
|
|
826
|
+
try {
|
|
827
|
+
return fn();
|
|
828
|
+
}
|
|
829
|
+
finally {
|
|
830
|
+
if (fd !== null) {
|
|
831
|
+
try {
|
|
832
|
+
fs.closeSync(fd);
|
|
833
|
+
}
|
|
834
|
+
catch { /* ignore */ }
|
|
835
|
+
}
|
|
836
|
+
try {
|
|
837
|
+
fs.unlinkSync(lockPath);
|
|
838
|
+
}
|
|
839
|
+
catch { /* ignore */ }
|
|
840
|
+
}
|
|
841
|
+
}
|
|
842
|
+
_writeAgentMdRecordUnlocked(aid, record) {
|
|
843
|
+
const payload = {};
|
|
844
|
+
for (const [key, value] of Object.entries(record)) {
|
|
845
|
+
if (key !== 'content' && value !== undefined && value !== null)
|
|
846
|
+
payload[key] = value;
|
|
847
|
+
}
|
|
848
|
+
payload.aid = this._agentMdSafeAid(aid);
|
|
849
|
+
this._atomicWriteText(this._agentMdMetaPath(aid), `${JSON.stringify(payload, null, 2)}\n`);
|
|
850
|
+
}
|
|
851
|
+
_normalizeAgentMdRecord(aid, data) {
|
|
852
|
+
if (!isJsonObject(data))
|
|
853
|
+
return {};
|
|
854
|
+
const record = {};
|
|
855
|
+
for (const [key, value] of Object.entries(data)) {
|
|
856
|
+
if (key !== 'content')
|
|
857
|
+
record[key] = value;
|
|
858
|
+
}
|
|
859
|
+
record.aid = this._agentMdSafeAid(String(record.aid ?? aid));
|
|
860
|
+
for (const key of ['fetched_at', 'observed_at', 'checked_at', 'updated_at']) {
|
|
861
|
+
record[key] = Number(record[key] ?? 0) || 0;
|
|
862
|
+
}
|
|
863
|
+
return record;
|
|
864
|
+
}
|
|
865
|
+
_readAgentMdRecordUnlocked(aid) {
|
|
866
|
+
const filePath = this._agentMdMetaPath(aid);
|
|
867
|
+
if (!fs.existsSync(filePath))
|
|
868
|
+
return {};
|
|
869
|
+
try {
|
|
870
|
+
return this._normalizeAgentMdRecord(aid, JSON.parse(fs.readFileSync(filePath, 'utf-8')));
|
|
871
|
+
}
|
|
872
|
+
catch (err) {
|
|
873
|
+
this._clientLog.warn(`agent.md metadata damaged, ignoring: aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
874
|
+
return {};
|
|
875
|
+
}
|
|
876
|
+
}
|
|
877
|
+
_readAgentMdContent(aid) {
|
|
878
|
+
return fs.readFileSync(this._agentMdFilePath(aid), 'utf-8');
|
|
879
|
+
}
|
|
880
|
+
_writeAgentMdContent(aid, content) {
|
|
881
|
+
const filePath = this._agentMdFilePath(aid);
|
|
882
|
+
this._atomicWriteText(filePath, String(content ?? ''));
|
|
883
|
+
return filePath;
|
|
884
|
+
}
|
|
885
|
+
_agentMdAuthCacheMeta(aid) {
|
|
886
|
+
try {
|
|
887
|
+
const store = this.auth._agentMdCache;
|
|
888
|
+
const record = store?.get(String(aid ?? '').trim());
|
|
889
|
+
return record && typeof record === 'object' ? { ...record } : {};
|
|
890
|
+
}
|
|
891
|
+
catch {
|
|
892
|
+
return {};
|
|
893
|
+
}
|
|
894
|
+
}
|
|
895
|
+
_loadAgentMdRecord(aid) {
|
|
896
|
+
const target = String(aid ?? '').trim();
|
|
897
|
+
if (!target)
|
|
898
|
+
return null;
|
|
899
|
+
try {
|
|
900
|
+
const loaded = this._withAgentMdRecordLock(target, () => {
|
|
901
|
+
const record = this._readAgentMdRecordUnlocked(target);
|
|
902
|
+
const next = Object.keys(record).length > 0 ? { ...record, aid: target } : { aid: target };
|
|
903
|
+
try {
|
|
904
|
+
const content = this._readAgentMdContent(target);
|
|
905
|
+
next.content = content;
|
|
906
|
+
next.local_etag = this._agentMdContentEtag(content);
|
|
907
|
+
}
|
|
908
|
+
catch (err) {
|
|
909
|
+
if (fs.existsSync(this._agentMdMetaPath(target))) {
|
|
910
|
+
this._clientLog.warn(`agent.md content read failed: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
return next;
|
|
914
|
+
});
|
|
915
|
+
if (Object.keys(loaded).length <= 1)
|
|
916
|
+
return null;
|
|
917
|
+
this._agentMdCache.set(target, { ...loaded });
|
|
918
|
+
return { ...loaded };
|
|
919
|
+
}
|
|
920
|
+
catch (err) {
|
|
921
|
+
this._clientLog.debug(`agent.md cache load skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
922
|
+
}
|
|
923
|
+
return null;
|
|
924
|
+
}
|
|
925
|
+
_saveAgentMdRecord(aid, fields) {
|
|
926
|
+
const target = String(aid ?? '').trim();
|
|
927
|
+
if (!target)
|
|
928
|
+
return {};
|
|
929
|
+
try {
|
|
930
|
+
const inputFields = { ...fields };
|
|
931
|
+
const hasContent = Object.prototype.hasOwnProperty.call(inputFields, 'content') && inputFields.content !== undefined && inputFields.content !== null;
|
|
932
|
+
let savedTo = '';
|
|
933
|
+
const record = this._withAgentMdRecordLock(target, () => {
|
|
934
|
+
if (hasContent) {
|
|
935
|
+
const content = String(inputFields.content ?? '');
|
|
936
|
+
savedTo = this._writeAgentMdContent(target, content);
|
|
937
|
+
if (!inputFields.local_etag)
|
|
938
|
+
inputFields.local_etag = this._agentMdContentEtag(content);
|
|
939
|
+
if (!inputFields.fetched_at)
|
|
940
|
+
inputFields.fetched_at = Date.now();
|
|
941
|
+
}
|
|
942
|
+
delete inputFields.content;
|
|
943
|
+
const next = { ...this._readAgentMdRecordUnlocked(target), aid: target };
|
|
944
|
+
for (const [key, value] of Object.entries(inputFields)) {
|
|
945
|
+
if (value !== undefined && value !== null)
|
|
946
|
+
next[key] = value;
|
|
947
|
+
}
|
|
948
|
+
next.updated_at = Date.now();
|
|
949
|
+
this._writeAgentMdRecordUnlocked(target, next);
|
|
950
|
+
return next;
|
|
951
|
+
});
|
|
952
|
+
const loaded = { ...record };
|
|
953
|
+
if (hasContent) {
|
|
954
|
+
loaded.content = String(fields.content ?? '');
|
|
955
|
+
if (savedTo)
|
|
956
|
+
loaded.saved_to = savedTo;
|
|
957
|
+
}
|
|
958
|
+
this._agentMdCache.set(target, { ...loaded });
|
|
959
|
+
const owner = this._agentMdOwnerAid();
|
|
960
|
+
if (target === owner) {
|
|
961
|
+
const localEtag = String(loaded.local_etag ?? '').trim();
|
|
962
|
+
const remoteEtag = String(loaded.remote_etag ?? '').trim();
|
|
963
|
+
if (localEtag)
|
|
964
|
+
this._localAgentMdEtag = localEtag;
|
|
965
|
+
if (remoteEtag)
|
|
966
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
967
|
+
}
|
|
968
|
+
return { ...loaded };
|
|
969
|
+
}
|
|
970
|
+
catch (err) {
|
|
971
|
+
this._clientLog.debug(`agent.md cache save skipped: aid=${target} err=${err instanceof Error ? err.message : String(err)}`);
|
|
972
|
+
}
|
|
973
|
+
return {};
|
|
974
|
+
}
|
|
975
|
+
_agentMdHasLocalContent(aid, record) {
|
|
976
|
+
if (record && typeof record.content === 'string' && record.content.length > 0)
|
|
977
|
+
return true;
|
|
978
|
+
try {
|
|
979
|
+
return fs.existsSync(this._agentMdFilePath(aid));
|
|
980
|
+
}
|
|
981
|
+
catch {
|
|
982
|
+
return false;
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
_agentMdCheckedAtFresh(checkedAtMs, maxUnsyncedDays) {
|
|
986
|
+
const days = Number(maxUnsyncedDays || 0);
|
|
987
|
+
if (!Number.isFinite(days) || days <= 0)
|
|
988
|
+
return false;
|
|
989
|
+
if (!Number.isFinite(checkedAtMs) || checkedAtMs <= 0)
|
|
990
|
+
return false;
|
|
991
|
+
return Date.now() - checkedAtMs <= days * 86400000;
|
|
992
|
+
}
|
|
993
|
+
_agentMdLastModifiedFresh(lastModified, maxUnsyncedDays) {
|
|
994
|
+
const days = Number(maxUnsyncedDays || 0);
|
|
995
|
+
if (!Number.isFinite(days) || days <= 0)
|
|
996
|
+
return false;
|
|
997
|
+
const ts = Date.parse(String(lastModified ?? '').trim());
|
|
998
|
+
if (!Number.isFinite(ts))
|
|
999
|
+
return false;
|
|
1000
|
+
return Date.now() <= ts + days * 86400000;
|
|
1001
|
+
}
|
|
1002
|
+
_scheduleAgentMdFetchIfMissing(aid, record, source = '') {
|
|
1003
|
+
const target = String(aid ?? '').trim();
|
|
1004
|
+
if (!target || this._agentMdHasLocalContent(target, record))
|
|
1005
|
+
return;
|
|
1006
|
+
if (this._agentMdFetchInflight.has(target))
|
|
1007
|
+
return;
|
|
1008
|
+
void this.fetchAgentMd(target).catch((err) => {
|
|
1009
|
+
this._saveAgentMdRecord(target, {
|
|
1010
|
+
last_error: err instanceof Error ? err.message : String(err),
|
|
1011
|
+
remote_status: 'found',
|
|
1012
|
+
});
|
|
1013
|
+
this._clientLog.debug(`agent.md auto fetch failed: aid=${target} source=${source || '-'} err=${err instanceof Error ? err.message : String(err)}`);
|
|
1014
|
+
});
|
|
1015
|
+
}
|
|
1016
|
+
_observeAgentMdMeta(aid, etag = '', lastModified = '', source = '') {
|
|
1017
|
+
const target = String(aid ?? '').trim();
|
|
1018
|
+
const remoteEtag = String(etag ?? '').trim();
|
|
1019
|
+
const remoteLastModified = String(lastModified ?? '').trim();
|
|
1020
|
+
if (!target || (!remoteEtag && !remoteLastModified))
|
|
1021
|
+
return;
|
|
1022
|
+
let before = this._agentMdCache.get(target);
|
|
1023
|
+
if (!before || typeof before !== 'object')
|
|
1024
|
+
before = this._loadAgentMdRecord(target) ?? {};
|
|
1025
|
+
const same = (!remoteEtag || String(before.remote_etag ?? '').trim() === remoteEtag) &&
|
|
1026
|
+
(!remoteLastModified || String(before.last_modified ?? '').trim() === remoteLastModified);
|
|
1027
|
+
let record = { ...before };
|
|
1028
|
+
if (!same || Object.keys(before).length === 0) {
|
|
1029
|
+
const fields = {
|
|
1030
|
+
observed_at: Date.now(),
|
|
1031
|
+
remote_status: 'found',
|
|
1032
|
+
};
|
|
1033
|
+
if (remoteEtag)
|
|
1034
|
+
fields.remote_etag = remoteEtag;
|
|
1035
|
+
if (remoteLastModified)
|
|
1036
|
+
fields.last_modified = remoteLastModified;
|
|
1037
|
+
record = this._saveAgentMdRecord(target, fields) || record;
|
|
1038
|
+
}
|
|
1039
|
+
if (target === this._agentMdOwnerAid() && remoteEtag)
|
|
1040
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
1041
|
+
this._scheduleAgentMdFetchIfMissing(target, record, source);
|
|
1042
|
+
this._clientLog.debug(`agent.md meta observed: aid=${target} etag=${remoteEtag || '-'} last_modified=${remoteLastModified || '-'} source=${source || '-'}`);
|
|
1043
|
+
}
|
|
1044
|
+
_observeAgentMdEtag(aid, etag, source = '') {
|
|
1045
|
+
this._observeAgentMdMeta(aid, etag, '', source);
|
|
1046
|
+
}
|
|
1047
|
+
_observeAgentMdFromEnvelope(envelope) {
|
|
1048
|
+
if (!isJsonObject(envelope))
|
|
1049
|
+
return;
|
|
1050
|
+
const env = envelope;
|
|
1051
|
+
if (!isJsonObject(env.agent_md))
|
|
1052
|
+
return;
|
|
1053
|
+
const agentMd = env.agent_md;
|
|
1054
|
+
if (!isJsonObject(agentMd.sender))
|
|
1055
|
+
return;
|
|
1056
|
+
const sender = agentMd.sender;
|
|
1057
|
+
let senderAid = String(sender.aid ?? '').trim();
|
|
1058
|
+
if (!senderAid) {
|
|
1059
|
+
const aad = isJsonObject(env.aad) ? env.aad : {};
|
|
1060
|
+
senderAid = String(aad.from ?? env.from ?? '').trim();
|
|
1061
|
+
}
|
|
1062
|
+
this._observeAgentMdMeta(senderAid, String(sender.etag ?? '').trim(), String(sender.last_modified ?? sender.lastModified ?? '').trim(), 'envelope');
|
|
1063
|
+
}
|
|
1064
|
+
async checkAgentMd(aid, maxUnsyncedDays = 1) {
|
|
1065
|
+
const target = String(aid ?? this._aid ?? '').trim();
|
|
1066
|
+
if (!target)
|
|
1067
|
+
throw new ValidationError('checkAgentMd requires aid (or local AID)');
|
|
1068
|
+
const before = this._loadAgentMdRecord(target) ?? {};
|
|
1069
|
+
const localEtag = String(before.local_etag ?? '').trim();
|
|
1070
|
+
const localFound = !!(Object.keys(before).length > 0 && (String(before.content ?? '') || localEtag));
|
|
1071
|
+
const remoteEtagCached = String(before.remote_etag ?? '').trim();
|
|
1072
|
+
const lastModifiedCached = String(before.last_modified ?? '').trim();
|
|
1073
|
+
const checkedAt = Number(before.checked_at ?? 0);
|
|
1074
|
+
const fetchedAt = Number(before.fetched_at ?? 0);
|
|
1075
|
+
const checkedAtCached = checkedAt > 0 ? checkedAt : fetchedAt;
|
|
1076
|
+
const cachedInSync = !!(localFound && localEtag && remoteEtagCached && localEtag === remoteEtagCached);
|
|
1077
|
+
// max_unsynced_days > 0 且距上次 HEAD 在窗口内 → 直接返回缓存;否则强制 HEAD。
|
|
1078
|
+
if (cachedInSync && this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
|
|
1079
|
+
return {
|
|
1080
|
+
aid: target,
|
|
1081
|
+
local_found: true,
|
|
1082
|
+
remote_found: true,
|
|
1083
|
+
local_etag: localEtag,
|
|
1084
|
+
remote_etag: remoteEtagCached,
|
|
1085
|
+
in_sync: true,
|
|
1086
|
+
last_modified: lastModifiedCached,
|
|
1087
|
+
status: 200,
|
|
1088
|
+
cached: true,
|
|
1089
|
+
verify_status: String(before.verify_status ?? ''),
|
|
1090
|
+
verify_error: String(before.verify_error ?? ''),
|
|
1091
|
+
};
|
|
1092
|
+
}
|
|
1093
|
+
const remoteFoundCached = !!(remoteEtagCached || String(before.remote_status ?? '') === 'found');
|
|
1094
|
+
if (!localFound &&
|
|
1095
|
+
!remoteFoundCached &&
|
|
1096
|
+
String(before.remote_status ?? '') === 'missing' &&
|
|
1097
|
+
this._agentMdCheckedAtFresh(checkedAtCached, maxUnsyncedDays)) {
|
|
1098
|
+
return {
|
|
1099
|
+
aid: target,
|
|
1100
|
+
local_found: false,
|
|
1101
|
+
remote_found: false,
|
|
1102
|
+
local_etag: '',
|
|
1103
|
+
remote_etag: '',
|
|
1104
|
+
in_sync: false,
|
|
1105
|
+
last_modified: '',
|
|
1106
|
+
status: 404,
|
|
1107
|
+
cached: true,
|
|
1108
|
+
verify_status: '',
|
|
1109
|
+
verify_error: '',
|
|
1110
|
+
};
|
|
1111
|
+
}
|
|
1112
|
+
const now = Date.now();
|
|
1113
|
+
let remote;
|
|
1114
|
+
try {
|
|
1115
|
+
remote = await this.auth.headAgentMd(target);
|
|
1116
|
+
}
|
|
1117
|
+
catch (err) {
|
|
1118
|
+
this._saveAgentMdRecord(target, { checked_at: now, remote_status: 'error', last_error: err instanceof Error ? err.message : String(err) });
|
|
1119
|
+
throw err;
|
|
1120
|
+
}
|
|
1121
|
+
const remoteFound = !!remote.found;
|
|
1122
|
+
const remoteEtag = String(remote.etag ?? '').trim();
|
|
1123
|
+
const lastModified = String(remote.last_modified ?? remote.lastModified ?? '').trim();
|
|
1124
|
+
const saved = this._saveAgentMdRecord(target, {
|
|
1125
|
+
remote_etag: remoteFound ? remoteEtag : '',
|
|
1126
|
+
last_modified: lastModified,
|
|
1127
|
+
checked_at: now,
|
|
1128
|
+
remote_status: remoteFound ? 'found' : 'missing',
|
|
1129
|
+
last_error: '',
|
|
1130
|
+
});
|
|
1131
|
+
if (target === this._agentMdOwnerAid() && remoteEtag)
|
|
1132
|
+
this._remoteAgentMdEtag = remoteEtag;
|
|
1133
|
+
const inSync = !!(localFound && remoteFound && localEtag && remoteEtag && localEtag === remoteEtag);
|
|
1134
|
+
return {
|
|
1135
|
+
aid: target,
|
|
1136
|
+
local_found: localFound,
|
|
1137
|
+
remote_found: remoteFound,
|
|
1138
|
+
local_etag: localEtag,
|
|
1139
|
+
remote_etag: remoteEtag,
|
|
1140
|
+
in_sync: inSync,
|
|
1141
|
+
last_modified: lastModified,
|
|
1142
|
+
status: Number(remote.status ?? (remoteFound ? 200 : 404)),
|
|
1143
|
+
cached: false,
|
|
1144
|
+
verify_status: String(saved.verify_status ?? before.verify_status ?? ''),
|
|
1145
|
+
verify_error: String(saved.verify_error ?? before.verify_error ?? ''),
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
561
1148
|
/** transport 的 meta observer:吸收 gateway 注入的 _meta 字段。失败不影响业务。 */
|
|
562
1149
|
_observeRpcMeta(meta) {
|
|
563
1150
|
if (!meta || typeof meta !== 'object')
|
|
@@ -565,6 +1152,17 @@ export class AUNClient {
|
|
|
565
1152
|
const etag = String(meta.agent_md_etag ?? '').trim();
|
|
566
1153
|
if (etag) {
|
|
567
1154
|
this._remoteAgentMdEtag = etag;
|
|
1155
|
+
this._observeAgentMdMeta(this._aid ?? '', etag, '', 'rpc.self');
|
|
1156
|
+
}
|
|
1157
|
+
const etags = meta.agent_md_etags;
|
|
1158
|
+
if (isJsonObject(etags)) {
|
|
1159
|
+
// role key 优先级:requester / peer 是新规范,其余是兼容旧 SDK 的别名。
|
|
1160
|
+
for (const key of ['requester', 'peer', 'receiver', 'target', 'to', 'sender', 'from']) {
|
|
1161
|
+
const item = etags[key];
|
|
1162
|
+
if (!isJsonObject(item))
|
|
1163
|
+
continue;
|
|
1164
|
+
this._observeAgentMdMeta(String(item.aid ?? ''), String(item.etag ?? ''), String(item.last_modified ?? item.lastModified ?? ''), `rpc.${key}`);
|
|
1165
|
+
}
|
|
568
1166
|
}
|
|
569
1167
|
}
|
|
570
1168
|
/** 连接状态 */
|
|
@@ -613,19 +1211,31 @@ export class AUNClient {
|
|
|
613
1211
|
this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 35_000);
|
|
614
1212
|
this._closing = false;
|
|
615
1213
|
this._clientLog.debug(`connect enter: gateway=${String(normalized.gateway ?? '')}, device_id=${this._deviceId}`);
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
1214
|
+
const gateways = this._resolveGateways(normalized);
|
|
1215
|
+
let lastErr = null;
|
|
1216
|
+
for (const gw of gateways) {
|
|
1217
|
+
try {
|
|
1218
|
+
const gwParams = { ...normalized, gateway: gw };
|
|
1219
|
+
await this._connectOnce(gwParams, false);
|
|
1220
|
+
this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms aid=${this._aid ?? ''}, state=${this._state}`);
|
|
1221
|
+
return;
|
|
624
1222
|
}
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
1223
|
+
catch (err) {
|
|
1224
|
+
lastErr = err;
|
|
1225
|
+
if (gateways.length > 1) {
|
|
1226
|
+
this._clientLog.warn(`connect: gateway ${gw} failed, trying next: ${formatCaughtError(err)}`);
|
|
1227
|
+
}
|
|
1228
|
+
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
1229
|
+
this._state = 'connecting';
|
|
1230
|
+
}
|
|
1231
|
+
}
|
|
1232
|
+
}
|
|
1233
|
+
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
1234
|
+
this._state = 'disconnected';
|
|
628
1235
|
}
|
|
1236
|
+
this._clientLog.error(`connect failed: ${formatCaughtError(lastErr)}`, lastErr instanceof Error ? lastErr : undefined);
|
|
1237
|
+
this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
|
|
1238
|
+
throw lastErr;
|
|
629
1239
|
}
|
|
630
1240
|
/** 关闭连接 */
|
|
631
1241
|
async close() {
|
|
@@ -690,11 +1300,17 @@ export class AUNClient {
|
|
|
690
1300
|
}
|
|
691
1301
|
}
|
|
692
1302
|
/**
|
|
693
|
-
*
|
|
1303
|
+
* 列出本地身份摘要。
|
|
1304
|
+
*
|
|
1305
|
+
* @param opts.all=false(默认):仅返回严格校验通过的可用身份——
|
|
1306
|
+
* keypair 完整 + cert 公钥 == keypair 公钥 + cert 时间窗口有效
|
|
1307
|
+
* @param opts.all=true:返回所有 AIDs/ 子目录(不含 _pending/);
|
|
1308
|
+
* 每项含 valid=bool 和 reason=string 字段
|
|
694
1309
|
*/
|
|
695
|
-
listIdentities() {
|
|
1310
|
+
listIdentities(opts) {
|
|
696
1311
|
const tStart = Date.now();
|
|
697
|
-
|
|
1312
|
+
const includeAll = !!opts?.all;
|
|
1313
|
+
this._clientLog.debug(`listIdentities enter all=${includeAll}`);
|
|
698
1314
|
try {
|
|
699
1315
|
const listFn = this._keystore.listIdentities;
|
|
700
1316
|
if (typeof listFn !== 'function') {
|
|
@@ -704,10 +1320,12 @@ export class AUNClient {
|
|
|
704
1320
|
const aids = listFn.call(this._keystore);
|
|
705
1321
|
const summaries = [];
|
|
706
1322
|
for (const aid of [...aids].sort()) {
|
|
707
|
-
const
|
|
708
|
-
if (!
|
|
1323
|
+
const { valid, reason } = this._validateLocalIdentity(aid);
|
|
1324
|
+
if (!includeAll && !valid)
|
|
709
1325
|
continue;
|
|
710
|
-
const summary = { aid };
|
|
1326
|
+
const summary = { aid, valid };
|
|
1327
|
+
if (reason)
|
|
1328
|
+
summary.reason = reason;
|
|
711
1329
|
const loadMetadata = this._keystore.loadMetadata;
|
|
712
1330
|
if (typeof loadMetadata === 'function') {
|
|
713
1331
|
const md = loadMetadata.call(this._keystore, aid);
|
|
@@ -716,7 +1334,7 @@ export class AUNClient {
|
|
|
716
1334
|
}
|
|
717
1335
|
summaries.push(summary);
|
|
718
1336
|
}
|
|
719
|
-
this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms count=${summaries.length}`);
|
|
1337
|
+
this._clientLog.debug(`listIdentities exit: elapsed=${Date.now() - tStart}ms all=${includeAll} count=${summaries.length}`);
|
|
720
1338
|
return summaries;
|
|
721
1339
|
}
|
|
722
1340
|
catch (err) {
|
|
@@ -724,6 +1342,40 @@ export class AUNClient {
|
|
|
724
1342
|
throw err;
|
|
725
1343
|
}
|
|
726
1344
|
}
|
|
1345
|
+
/**
|
|
1346
|
+
* 严格校验本地身份的可用性。返回 {valid, reason}。
|
|
1347
|
+
* 4 项校验:keypair 完整 + cert 存在 + cert 公钥 == keypair 公钥 + cert 时间窗口有效。
|
|
1348
|
+
*/
|
|
1349
|
+
_validateLocalIdentity(aid) {
|
|
1350
|
+
const identity = this._keystore.loadIdentity(aid);
|
|
1351
|
+
if (!identity)
|
|
1352
|
+
return { valid: false, reason: 'no identity record' };
|
|
1353
|
+
const priv = String(identity.private_key_pem ?? '');
|
|
1354
|
+
const pubB64 = String(identity.public_key_der_b64 ?? '');
|
|
1355
|
+
const certPem = String(identity.cert ?? '');
|
|
1356
|
+
if (!priv || !pubB64)
|
|
1357
|
+
return { valid: false, reason: 'missing keypair' };
|
|
1358
|
+
if (!certPem)
|
|
1359
|
+
return { valid: false, reason: 'missing certificate' };
|
|
1360
|
+
try {
|
|
1361
|
+
const crypto = require('node:crypto');
|
|
1362
|
+
const cert = new crypto.X509Certificate(certPem);
|
|
1363
|
+
const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
1364
|
+
const localPubDer = Buffer.from(pubB64, 'base64');
|
|
1365
|
+
if (!certPubDer.equals(localPubDer)) {
|
|
1366
|
+
return { valid: false, reason: 'cert public key does not match keypair' };
|
|
1367
|
+
}
|
|
1368
|
+
const now = Date.now();
|
|
1369
|
+
if (now < new Date(cert.validFrom).getTime())
|
|
1370
|
+
return { valid: false, reason: 'cert not yet valid' };
|
|
1371
|
+
if (now > new Date(cert.validTo).getTime())
|
|
1372
|
+
return { valid: false, reason: 'cert expired' };
|
|
1373
|
+
return { valid: true, reason: '' };
|
|
1374
|
+
}
|
|
1375
|
+
catch (e) {
|
|
1376
|
+
return { valid: false, reason: `cert parse error: ${e instanceof Error ? e.message : String(e)}` };
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
727
1379
|
// ── RPC ───────────────────────────────────────────────────
|
|
728
1380
|
/**
|
|
729
1381
|
* 发送 JSON-RPC 调用。
|
|
@@ -743,6 +1395,16 @@ export class AUNClient {
|
|
|
743
1395
|
throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
|
|
744
1396
|
}
|
|
745
1397
|
const p = { ...(params ?? {}) };
|
|
1398
|
+
const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
|
|
1399
|
+
delete p._rpc_background;
|
|
1400
|
+
const runWithRpcPriority = async (operation) => {
|
|
1401
|
+
if (!rpcBackground)
|
|
1402
|
+
return await operation();
|
|
1403
|
+
return await this._withBackgroundRpc(operation);
|
|
1404
|
+
};
|
|
1405
|
+
if (method === 'message.send' || method === 'group.send') {
|
|
1406
|
+
this._normalizeOutboundMessagePayload(p, method);
|
|
1407
|
+
}
|
|
746
1408
|
this._validateOutboundCall(method, p);
|
|
747
1409
|
this._injectMessageCursorContext(method, p);
|
|
748
1410
|
// group.* 方法的 group_id 归一化为 canonical 格式(兼容老/污染数据)
|
|
@@ -755,23 +1417,39 @@ export class AUNClient {
|
|
|
755
1417
|
p.group_id = normalizedGroupId;
|
|
756
1418
|
}
|
|
757
1419
|
// group.* 方法注入 device_id(服务端用于多设备消息路由)
|
|
758
|
-
if (method.startsWith('group.') &&
|
|
1420
|
+
if (method.startsWith('group.') && p.device_id === undefined) {
|
|
759
1421
|
p.device_id = this._deviceId;
|
|
760
1422
|
}
|
|
761
1423
|
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
762
1424
|
p.slot_id = this._slotId;
|
|
763
1425
|
}
|
|
1426
|
+
const pullGateLocked = Boolean(p._pull_gate_locked);
|
|
1427
|
+
if ('_pull_gate_locked' in p) {
|
|
1428
|
+
delete p._pull_gate_locked;
|
|
1429
|
+
}
|
|
1430
|
+
const pullGateKey = this._pullGateKeyForCall(method, p);
|
|
1431
|
+
if (pullGateKey && this._isPullResponseProcessing(pullGateKey)) {
|
|
1432
|
+
this._clientLog.debug(`pull skipped while processing pull response: method=${method} key=${pullGateKey}`);
|
|
1433
|
+
return this._emptyPullResultForCall(method);
|
|
1434
|
+
}
|
|
1435
|
+
if (pullGateKey && !pullGateLocked) {
|
|
1436
|
+
const lockedParams = { ...p, _pull_gate_locked: true };
|
|
1437
|
+
if (rpcBackground)
|
|
1438
|
+
lockedParams._rpc_background = true;
|
|
1439
|
+
const result = await this._runPullSerialized(pullGateKey, async () => this.call(method, lockedParams));
|
|
1440
|
+
return result;
|
|
1441
|
+
}
|
|
764
1442
|
// 自动加密:message.send 默认加密(encrypt 默认 true)— V2-only
|
|
765
1443
|
if (method === 'message.send') {
|
|
766
1444
|
const encrypt = p.encrypt ?? true;
|
|
767
1445
|
delete p.encrypt;
|
|
768
1446
|
if (encrypt) {
|
|
769
|
-
return await this.sendV2(String(p.to ?? ''), p.payload, {
|
|
1447
|
+
return await runWithRpcPriority(() => this.sendV2(String(p.to ?? ''), p.payload, {
|
|
770
1448
|
messageId: String(p.message_id ?? '') || undefined,
|
|
771
1449
|
timestamp: p.timestamp,
|
|
772
1450
|
protectedHeaders: this._protectedHeadersFromParams(p),
|
|
773
1451
|
context: isJsonObject(p.context) ? p.context : undefined,
|
|
774
|
-
});
|
|
1452
|
+
}));
|
|
775
1453
|
}
|
|
776
1454
|
// encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
|
|
777
1455
|
this._maybeAppendEchoTraceSend(p);
|
|
@@ -781,12 +1459,12 @@ export class AUNClient {
|
|
|
781
1459
|
const encrypt = p.encrypt ?? true;
|
|
782
1460
|
delete p.encrypt;
|
|
783
1461
|
if (encrypt) {
|
|
784
|
-
return await this.sendGroupV2(String(p.group_id ?? ''), p.payload, {
|
|
1462
|
+
return await runWithRpcPriority(() => this.sendGroupV2(String(p.group_id ?? ''), p.payload, {
|
|
785
1463
|
messageId: String(p.message_id ?? '') || undefined,
|
|
786
1464
|
timestamp: p.timestamp,
|
|
787
1465
|
protectedHeaders: this._protectedHeadersFromParams(p),
|
|
788
1466
|
context: isJsonObject(p.context) ? p.context : undefined,
|
|
789
|
-
});
|
|
1467
|
+
}));
|
|
790
1468
|
}
|
|
791
1469
|
this._maybeAppendEchoTraceSend(p);
|
|
792
1470
|
}
|
|
@@ -798,7 +1476,7 @@ export class AUNClient {
|
|
|
798
1476
|
if (!this._v2Session || !String(p.group_id ?? '').trim()) {
|
|
799
1477
|
throw new StateError(v2Error);
|
|
800
1478
|
}
|
|
801
|
-
return await this._putGroupThoughtEncryptedV2(p);
|
|
1479
|
+
return await runWithRpcPriority(() => this._putGroupThoughtEncryptedV2(p));
|
|
802
1480
|
}
|
|
803
1481
|
}
|
|
804
1482
|
if (method === 'message.thought.put') {
|
|
@@ -806,40 +1484,70 @@ export class AUNClient {
|
|
|
806
1484
|
delete p.encrypt;
|
|
807
1485
|
if (encrypt) {
|
|
808
1486
|
await this._ensureV2SessionReady('message.thought.put', 'V2 session not initialized; encrypted message.thought.put requires V2 (V1 E2EE removed)');
|
|
809
|
-
return await this._putMessageThoughtEncryptedV2(p);
|
|
1487
|
+
return await runWithRpcPriority(() => this._putMessageThoughtEncryptedV2(p));
|
|
810
1488
|
}
|
|
811
1489
|
}
|
|
812
|
-
|
|
1490
|
+
// V2-only:兼容入口名只作为 SDK 内部适配层存在,底层绝不能降级发 legacy RPC。
|
|
1491
|
+
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
813
1492
|
await this._ensureV2SessionReady('message.pull');
|
|
814
|
-
const
|
|
1493
|
+
const skipAutoAck = p._skip_auto_ack === true || p.skip_auto_ack === true;
|
|
1494
|
+
const afterSeq = Number(p.after_seq ?? 0) || 0;
|
|
1495
|
+
const limit = Number(p.limit ?? 50) || 50;
|
|
1496
|
+
const messages = skipAutoAck
|
|
1497
|
+
? await runWithRpcPriority(() => this.pullV2(afterSeq, limit, { skipAutoAck: true, gateLocked: true }))
|
|
1498
|
+
: await runWithRpcPriority(() => this.pullV2(afterSeq, limit, { gateLocked: true }));
|
|
815
1499
|
return { messages };
|
|
816
1500
|
}
|
|
817
|
-
if (method === 'message.ack'
|
|
1501
|
+
if (method === 'message.ack' || method === 'message.v2.ack') {
|
|
818
1502
|
await this._ensureV2SessionReady('message.ack');
|
|
819
|
-
return await this.ackV2(Number(p.seq ?? p.up_to_seq ?? 0) || undefined);
|
|
1503
|
+
return await runWithRpcPriority(() => this.ackV2(Number(p.seq ?? p.up_to_seq ?? 0) || undefined));
|
|
820
1504
|
}
|
|
821
|
-
if (method === 'group.pull'
|
|
1505
|
+
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
1506
|
+
if (!String(p.group_id ?? '').trim()) {
|
|
1507
|
+
throw new ValidationError('group.pull requires group_id');
|
|
1508
|
+
}
|
|
822
1509
|
await this._ensureV2SessionReady('group.pull');
|
|
823
|
-
const messages = await this.pullGroupV2(String(p.group_id), Number(p.after_seq ?? p.after_message_seq ?? 0) || 0, Number(p.limit ?? 50) || 50);
|
|
1510
|
+
const messages = await runWithRpcPriority(() => this.pullGroupV2(String(p.group_id), Number(p.after_seq ?? p.after_message_seq ?? 0) || 0, Number(p.limit ?? 50) || 50, { gateLocked: true }));
|
|
824
1511
|
return { messages };
|
|
825
1512
|
}
|
|
826
|
-
if (method === 'group.ack_messages'
|
|
1513
|
+
if (method === 'group.ack_messages' || method === 'group.v2.ack') {
|
|
1514
|
+
if (!String(p.group_id ?? '').trim()) {
|
|
1515
|
+
throw new ValidationError('group.ack_messages requires group_id');
|
|
1516
|
+
}
|
|
827
1517
|
await this._ensureV2SessionReady('group.ack_messages');
|
|
828
|
-
return await this.ackGroupV2(String(p.group_id), Number(p.seq ?? p.msg_seq ?? p.up_to_seq ?? 0) || undefined);
|
|
1518
|
+
return await runWithRpcPriority(() => this.ackGroupV2(String(p.group_id), Number(p.seq ?? p.msg_seq ?? p.up_to_seq ?? 0) || undefined));
|
|
1519
|
+
}
|
|
1520
|
+
if (method === 'message.pull') {
|
|
1521
|
+
delete p._skip_auto_ack;
|
|
1522
|
+
delete p.skip_auto_ack;
|
|
829
1523
|
}
|
|
830
1524
|
// 关键操作自动附加客户端签名
|
|
831
1525
|
if (SIGNED_METHODS.has(method)) {
|
|
832
|
-
this.
|
|
1526
|
+
if (this._shouldSkipClientSignature(method, p)) {
|
|
1527
|
+
delete p.client_signature;
|
|
1528
|
+
}
|
|
1529
|
+
else {
|
|
1530
|
+
this._signClientOperation(method, p);
|
|
1531
|
+
}
|
|
833
1532
|
}
|
|
834
1533
|
// P1-23: 非幂等方法使用更长超时
|
|
835
1534
|
const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT_MS : undefined;
|
|
1535
|
+
if (method === 'group.thought.get' || method === 'message.thought.get') {
|
|
1536
|
+
this._clientLog.debug(`thought.get transport call start: method=${method}, params=${this._debugJson(this._messageEnvelopeFieldsForDebug(p))}`);
|
|
1537
|
+
}
|
|
836
1538
|
let result = callTimeout
|
|
837
|
-
?
|
|
838
|
-
|
|
1539
|
+
? (rpcBackground
|
|
1540
|
+
? await this._transport.call(method, p, callTimeout, undefined, true)
|
|
1541
|
+
: await this._transport.call(method, p, callTimeout))
|
|
1542
|
+
: (rpcBackground
|
|
1543
|
+
? await this._transport.call(method, p, undefined, undefined, true)
|
|
1544
|
+
: await this._transport.call(method, p));
|
|
839
1545
|
if (method === 'group.thought.get' && isJsonObject(result)) {
|
|
1546
|
+
this._clientLog.debug(`group.thought.get transport result: found=${String(result.found ?? '')}, raw_count=${Array.isArray(result.thoughts) ? result.thoughts.length : 0}`);
|
|
840
1547
|
result = await this._decryptGroupThoughts(result);
|
|
841
1548
|
}
|
|
842
1549
|
if (method === 'message.thought.get' && isJsonObject(result)) {
|
|
1550
|
+
this._clientLog.debug(`message.thought.get transport result: found=${String(result.found ?? '')}, raw_count=${Array.isArray(result.thoughts) ? result.thoughts.length : 0}`);
|
|
843
1551
|
result = await this._decryptMessageThoughts(result);
|
|
844
1552
|
}
|
|
845
1553
|
// ── V2-only 群状态编排:成员变更后 propose+confirm state。
|
|
@@ -919,6 +1627,34 @@ export class AUNClient {
|
|
|
919
1627
|
this._clientLog.debug(`on exit: elapsed=${Date.now() - tStart}ms event=${event}`);
|
|
920
1628
|
return result;
|
|
921
1629
|
}
|
|
1630
|
+
async _callRawV2Rpc(method, params) {
|
|
1631
|
+
const p = { ...(params ?? {}) };
|
|
1632
|
+
const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
|
|
1633
|
+
delete p._rpc_background;
|
|
1634
|
+
delete p._pull_gate_locked;
|
|
1635
|
+
delete p._skip_auto_ack;
|
|
1636
|
+
delete p.skip_auto_ack;
|
|
1637
|
+
if (method.startsWith('group.') && p.group_id !== undefined && p.group_id !== null) {
|
|
1638
|
+
p.group_id = normalizeGroupId(String(p.group_id)) || String(p.group_id);
|
|
1639
|
+
}
|
|
1640
|
+
if (method.startsWith('group.') && p.device_id === undefined) {
|
|
1641
|
+
p.device_id = this._deviceId;
|
|
1642
|
+
}
|
|
1643
|
+
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
1644
|
+
p.slot_id = this._slotId;
|
|
1645
|
+
}
|
|
1646
|
+
if (SIGNED_METHODS.has(method)) {
|
|
1647
|
+
if (this._shouldSkipClientSignature(method, p)) {
|
|
1648
|
+
delete p.client_signature;
|
|
1649
|
+
}
|
|
1650
|
+
else {
|
|
1651
|
+
this._signClientOperation(method, p);
|
|
1652
|
+
}
|
|
1653
|
+
}
|
|
1654
|
+
return rpcBackground
|
|
1655
|
+
? await this._transport.call(method, p, undefined, undefined, true)
|
|
1656
|
+
: await this._transport.call(method, p);
|
|
1657
|
+
}
|
|
922
1658
|
/** P2-13: 取消订阅事件(对齐 Python/JS off 方法) */
|
|
923
1659
|
off(event, handler) {
|
|
924
1660
|
const tStart = Date.now();
|
|
@@ -988,6 +1724,7 @@ export class AUNClient {
|
|
|
988
1724
|
async _onRawMessageReceived(data) {
|
|
989
1725
|
const tStart = Date.now();
|
|
990
1726
|
if (isJsonObject(data)) {
|
|
1727
|
+
this._logMessageDebug('server-push', '_raw.message.received', 'message.received', data);
|
|
991
1728
|
this._clientLog.debug(`_onRawMessageReceived enter: from=${String(data.from ?? '')}, message_id=${String(data.message_id ?? '')}, seq=${String(data.seq ?? '')}`);
|
|
992
1729
|
}
|
|
993
1730
|
else {
|
|
@@ -1006,7 +1743,8 @@ export class AUNClient {
|
|
|
1006
1743
|
timestamp: data.timestamp,
|
|
1007
1744
|
_decrypt_error: String(exc),
|
|
1008
1745
|
};
|
|
1009
|
-
this.
|
|
1746
|
+
this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1747
|
+
Promise.resolve(this._publishAppEvent('message.undecryptable', safeEvent)).catch(() => { });
|
|
1010
1748
|
}
|
|
1011
1749
|
});
|
|
1012
1750
|
this._clientLog.debug(`_onRawMessageReceived exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
|
|
@@ -1014,47 +1752,53 @@ export class AUNClient {
|
|
|
1014
1752
|
/** 实际处理推送消息的异步任务 */
|
|
1015
1753
|
async _processAndPublishMessage(data) {
|
|
1016
1754
|
if (!isJsonObject(data)) {
|
|
1017
|
-
await this._publishAppEvent('message.received', data);
|
|
1755
|
+
await this._publishAppEvent('message.received', data, 'push');
|
|
1018
1756
|
return;
|
|
1019
1757
|
}
|
|
1020
1758
|
const msg = { ...data };
|
|
1021
1759
|
if (!this._messageTargetsCurrentInstance(msg)) {
|
|
1760
|
+
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
1761
|
return;
|
|
1023
1762
|
}
|
|
1024
1763
|
// P2P 空洞检测
|
|
1025
1764
|
const seq = msg.seq;
|
|
1026
1765
|
if (seq !== undefined && seq !== null && this._aid) {
|
|
1027
1766
|
const ns = `p2p:${this._aid}`;
|
|
1028
|
-
|
|
1767
|
+
// Push 只先更新 maxSeenSeq;contiguous_seq 是已交付游标,必须等应用层发布返回后再推进。
|
|
1768
|
+
if (seq > 0)
|
|
1769
|
+
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1770
|
+
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1771
|
+
const published = await this._publishOrderedMessage('message.received', ns, seq, msg);
|
|
1772
|
+
const contigAfter = this._seqTracker.getContiguousSeq(ns);
|
|
1773
|
+
const needPull = Number(seq) > contigAfter && !published;
|
|
1029
1774
|
if (needPull) {
|
|
1030
|
-
this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${
|
|
1775
|
+
this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${contigAfter}`);
|
|
1031
1776
|
this._fillP2pGap().catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
1032
1777
|
}
|
|
1033
1778
|
// auto-ack contiguous_seq
|
|
1034
1779
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1035
1780
|
if (contig > 0) {
|
|
1036
|
-
this.
|
|
1037
|
-
|
|
1038
|
-
|
|
1039
|
-
|
|
1040
|
-
|
|
1781
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1782
|
+
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1783
|
+
this._clientLog.debug(`P2P push auto-ack send: ns=${ns}, seq=${ackSeq}, contiguous=${contig}, max_seen=${maxSeen}`);
|
|
1784
|
+
this._withBackgroundRpc(() => this.ackV2(ackSeq))
|
|
1785
|
+
.then(() => { this._clientLog.debug(`P2P push auto-ack ok: ns=${ns}, seq=${ackSeq}`); })
|
|
1786
|
+
.catch((e) => { this._clientLog.debug(`P2P auto-ack failed: ${formatCaughtError(e)}`); });
|
|
1041
1787
|
}
|
|
1042
1788
|
// 即时持久化 cursor,异常断连后不回退
|
|
1043
|
-
|
|
1044
|
-
|
|
1045
|
-
// V2-only:普通 _raw.message.received 只承载明文;V2 密文由 peer.v2.message_received 通知触发 pull。
|
|
1046
|
-
if (seq !== undefined && seq !== null && this._aid) {
|
|
1047
|
-
const ns = `p2p:${this._aid}`;
|
|
1048
|
-
await this._publishOrderedMessage('message.received', ns, seq, msg);
|
|
1789
|
+
if (contigAfter !== contigBefore)
|
|
1790
|
+
this._saveSeqTrackerState();
|
|
1049
1791
|
}
|
|
1050
1792
|
else {
|
|
1051
|
-
|
|
1793
|
+
// V2-only:普通 _raw.message.received 只承载明文;V2 密文由 peer.v2.message_received 通知触发 pull。
|
|
1794
|
+
await this._publishAppEvent('message.received', msg, 'push');
|
|
1052
1795
|
}
|
|
1053
1796
|
}
|
|
1054
1797
|
/** 处理群组消息推送:自动解密后 re-publish */
|
|
1055
1798
|
async _onRawGroupMessageCreated(data) {
|
|
1056
1799
|
const tStart = Date.now();
|
|
1057
1800
|
if (isJsonObject(data)) {
|
|
1801
|
+
this._logMessageDebug('server-push', '_raw.group.message_created', 'group.message_created', data);
|
|
1058
1802
|
this._clientLog.debug(`_onRawGroupMessageCreated enter: group_id=${String(data.group_id ?? '')}, message_id=${String(data.message_id ?? '')}, seq=${String(data.seq ?? '')}`);
|
|
1059
1803
|
}
|
|
1060
1804
|
else {
|
|
@@ -1072,7 +1816,8 @@ export class AUNClient {
|
|
|
1072
1816
|
timestamp: data.timestamp,
|
|
1073
1817
|
_decrypt_error: String(exc),
|
|
1074
1818
|
};
|
|
1075
|
-
this.
|
|
1819
|
+
this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1820
|
+
Promise.resolve(this._publishAppEvent('group.message_undecryptable', safeEvent)).catch(() => { });
|
|
1076
1821
|
}
|
|
1077
1822
|
});
|
|
1078
1823
|
this._clientLog.debug(`_onRawGroupMessageCreated exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
|
|
@@ -1085,7 +1830,7 @@ export class AUNClient {
|
|
|
1085
1830
|
*/
|
|
1086
1831
|
async _processAndPublishGroupMessage(data) {
|
|
1087
1832
|
if (!isJsonObject(data)) {
|
|
1088
|
-
await this._publishAppEvent('group.message_created', data);
|
|
1833
|
+
await this._publishAppEvent('group.message_created', data, 'group-push');
|
|
1089
1834
|
return;
|
|
1090
1835
|
}
|
|
1091
1836
|
const msg = { ...data };
|
|
@@ -1098,125 +1843,88 @@ export class AUNClient {
|
|
|
1098
1843
|
if (payload === undefined || payload === null
|
|
1099
1844
|
|| (typeof payload === 'object' && Object.keys(payload).length === 0)) {
|
|
1100
1845
|
// 不带 payload 的通知不能先推进 seq,否则 auto-pull 会用推进后的 cursor 跳过该消息。
|
|
1101
|
-
|
|
1846
|
+
void this._autoPullGroupMessages(msg).catch((exc) => {
|
|
1847
|
+
this._clientLog.warn(`auto pull group message task failed: ${formatCaughtError(exc)}`);
|
|
1848
|
+
});
|
|
1102
1849
|
return;
|
|
1103
1850
|
}
|
|
1104
1851
|
if (groupId && seq !== undefined && seq !== null) {
|
|
1105
1852
|
const ns = `group:${groupId}`;
|
|
1106
|
-
|
|
1853
|
+
// Push 只先更新 maxSeenSeq;contiguous_seq 是已交付游标,必须等应用层发布返回后再推进。
|
|
1854
|
+
if (seq > 0)
|
|
1855
|
+
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1856
|
+
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1857
|
+
const published = await this._publishOrderedMessage('group.message_created', ns, seq, msg);
|
|
1858
|
+
const contigAfter = this._seqTracker.getContiguousSeq(ns);
|
|
1859
|
+
const needPull = Number(seq) > contigAfter && !published;
|
|
1107
1860
|
if (needPull) {
|
|
1108
|
-
this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${
|
|
1861
|
+
this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${contigAfter}`);
|
|
1109
1862
|
this._fillGroupGap(groupId).catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
1110
1863
|
}
|
|
1111
1864
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1112
1865
|
if (contig > 0) {
|
|
1113
|
-
this.
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
|
|
1117
|
-
|
|
1118
|
-
|
|
1119
|
-
}
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
// V2-only:普通 group.message_created 只承载明文;V2 密文由 group.v2.message_created 通知触发 pull。
|
|
1123
|
-
if (groupId && seq !== undefined && seq !== null) {
|
|
1124
|
-
const nsKey = `group:${groupId}`;
|
|
1125
|
-
await this._publishOrderedMessage('group.message_created', nsKey, seq, msg);
|
|
1866
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1867
|
+
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1868
|
+
this._clientLog.debug(`group push auto-ack send: group=${groupId}, ns=${ns}, seq=${ackSeq}, contiguous=${contig}, max_seen=${maxSeen}`);
|
|
1869
|
+
this._withBackgroundRpc(() => this.ackGroupV2(groupId, ackSeq))
|
|
1870
|
+
.then(() => { this._clientLog.debug(`group push auto-ack ok: group=${groupId}, seq=${ackSeq}`); })
|
|
1871
|
+
.catch((e) => { this._clientLog.debug(`group message auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
1872
|
+
}
|
|
1873
|
+
if (contigAfter !== contigBefore)
|
|
1874
|
+
this._saveSeqTrackerState();
|
|
1126
1875
|
}
|
|
1127
1876
|
else {
|
|
1128
|
-
|
|
1877
|
+
// V2-only:普通 group.message_created 只承载明文;V2 密文由 group.v2.message_created 通知触发 pull。
|
|
1878
|
+
await this._publishAppEvent('group.message_created', msg, 'group-push');
|
|
1129
1879
|
}
|
|
1130
1880
|
}
|
|
1131
1881
|
/** 收到不带 payload 的 group.message_created 通知后,自动 pull 最新消息 */
|
|
1132
1882
|
async _autoPullGroupMessages(notification) {
|
|
1133
|
-
|
|
1883
|
+
let groupId = String(notification.group_id ?? '').trim();
|
|
1134
1884
|
if (!groupId) {
|
|
1135
1885
|
await this._publishAppEvent('group.message_created', notification);
|
|
1136
1886
|
return;
|
|
1137
1887
|
}
|
|
1888
|
+
groupId = normalizeGroupId(groupId) || groupId;
|
|
1138
1889
|
const ns = `group:${groupId}`;
|
|
1139
1890
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
device_id: this._deviceId,
|
|
1150
|
-
limit: 50,
|
|
1151
|
-
});
|
|
1152
|
-
if (isJsonObject(result)) {
|
|
1153
|
-
const messages = result.messages;
|
|
1154
|
-
if (Array.isArray(messages)) {
|
|
1155
|
-
// onPullResult 已在 call() 拦截器中调用,此处不再重复
|
|
1156
|
-
const pushed = this._pushedSeqs.get(ns);
|
|
1157
|
-
for (const msg of messages) {
|
|
1158
|
-
if (isJsonObject(msg)) {
|
|
1159
|
-
const s = msg.seq;
|
|
1160
|
-
if (pushed && s !== undefined && s !== null && pushed.has(s)) {
|
|
1161
|
-
continue; // 已发布到应用层,跳过
|
|
1162
|
-
}
|
|
1163
|
-
if (s !== undefined && s !== null) {
|
|
1164
|
-
await this._publishPulledMessage('group.message_created', ns, s, msg);
|
|
1165
|
-
}
|
|
1166
|
-
else {
|
|
1167
|
-
await this._publishAppEvent('group.message_created', msg);
|
|
1168
|
-
}
|
|
1169
|
-
}
|
|
1170
|
-
}
|
|
1171
|
-
this._prunePushedSeqs(ns);
|
|
1172
|
-
return;
|
|
1173
|
-
}
|
|
1174
|
-
}
|
|
1175
|
-
}
|
|
1176
|
-
catch (exc) {
|
|
1177
|
-
this._clientLog.debug(`auto pull group messages failed: ${formatCaughtError(exc)}`);
|
|
1891
|
+
this._clientLog.debug(`auto pull group messages start: group=${groupId}, after_seq=${afterSeq}, seq=${String(notification.seq ?? '')}`);
|
|
1892
|
+
const started = await this._tryRunBackgroundPull(ns, async () => {
|
|
1893
|
+
const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1894
|
+
const messages = await this.pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
|
|
1895
|
+
this._prunePushedSeqs(ns);
|
|
1896
|
+
return messages.length;
|
|
1897
|
+
}, true);
|
|
1898
|
+
if (!started) {
|
|
1899
|
+
this._clientLog.debug(`auto pull group messages skipped: pull in-flight group=${groupId}`);
|
|
1178
1900
|
}
|
|
1179
|
-
await this._publishAppEvent('group.message_created', notification);
|
|
1180
1901
|
}
|
|
1181
1902
|
/** 后台补齐群消息空洞 */
|
|
1182
1903
|
async _fillGroupGap(groupId) {
|
|
1904
|
+
groupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
1905
|
+
if (!groupId)
|
|
1906
|
+
return;
|
|
1183
1907
|
const ns = `group:${groupId}`;
|
|
1184
1908
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1185
1909
|
// 去重:同一 (group:id:after_seq) 只补一次
|
|
1186
1910
|
const dedupKey = `group_msg:${groupId}:${afterSeq}`;
|
|
1187
1911
|
if (this._gapFillDone.has(dedupKey))
|
|
1188
1912
|
return;
|
|
1913
|
+
const token = this._tryAcquirePullGate(ns);
|
|
1914
|
+
if (token === null) {
|
|
1915
|
+
this._clientLog.debug(`group message gap fill skipped: pull in-flight group=${groupId}`);
|
|
1916
|
+
return;
|
|
1917
|
+
}
|
|
1189
1918
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1190
1919
|
this._clientLog.debug(`group message gap fill start: group=${groupId}, after_seq=${afterSeq}`);
|
|
1920
|
+
let filled = 0;
|
|
1191
1921
|
try {
|
|
1192
|
-
const
|
|
1193
|
-
|
|
1194
|
-
|
|
1195
|
-
|
|
1196
|
-
|
|
1197
|
-
|
|
1198
|
-
let filled = 0;
|
|
1199
|
-
if (isJsonObject(result)) {
|
|
1200
|
-
const messages = result.messages;
|
|
1201
|
-
if (Array.isArray(messages)) {
|
|
1202
|
-
// onPullResult 已在 call() 拦截器中调用,此处不再重复
|
|
1203
|
-
const pushed = this._pushedSeqs.get(ns);
|
|
1204
|
-
for (const msg of messages) {
|
|
1205
|
-
if (isJsonObject(msg)) {
|
|
1206
|
-
const s = msg.seq;
|
|
1207
|
-
if (pushed && s !== undefined && s !== null && pushed.has(s))
|
|
1208
|
-
continue;
|
|
1209
|
-
if (s !== undefined && s !== null) {
|
|
1210
|
-
await this._publishPulledMessage('group.message_created', ns, s, msg);
|
|
1211
|
-
}
|
|
1212
|
-
else {
|
|
1213
|
-
await this._publishAppEvent('group.message_created', msg);
|
|
1214
|
-
}
|
|
1215
|
-
filled += 1;
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
this._prunePushedSeqs(ns);
|
|
1219
|
-
}
|
|
1922
|
+
const messages = await this._withBackgroundRpc(() => this.pullGroupV2(groupId, afterSeq, 50, { gateLocked: true }));
|
|
1923
|
+
filled = messages.length;
|
|
1924
|
+
this._prunePushedSeqs(ns);
|
|
1925
|
+
if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
|
|
1926
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
1927
|
+
this._saveSeqTrackerState();
|
|
1220
1928
|
}
|
|
1221
1929
|
this._clientLog.debug(`group message gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
|
|
1222
1930
|
}
|
|
@@ -1225,6 +1933,10 @@ export class AUNClient {
|
|
|
1225
1933
|
}
|
|
1226
1934
|
finally {
|
|
1227
1935
|
this._gapFillDone.delete(dedupKey);
|
|
1936
|
+
this._releasePullGate(ns, token);
|
|
1937
|
+
if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
|
|
1938
|
+
void this._fillGroupGap(groupId);
|
|
1939
|
+
}
|
|
1228
1940
|
}
|
|
1229
1941
|
}
|
|
1230
1942
|
/** 后台补齐 P2P 消息空洞 */
|
|
@@ -1237,35 +1949,25 @@ export class AUNClient {
|
|
|
1237
1949
|
const dedupKey = `p2p:${afterSeq}`;
|
|
1238
1950
|
if (this._gapFillDone.has(dedupKey))
|
|
1239
1951
|
return;
|
|
1952
|
+
const token = this._tryAcquirePullGate(ns);
|
|
1953
|
+
if (token === null) {
|
|
1954
|
+
this._clientLog.debug(`P2P message gap fill skipped: pull in-flight ns=${ns}`);
|
|
1955
|
+
return;
|
|
1956
|
+
}
|
|
1240
1957
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1241
1958
|
this._clientLog.debug(`P2P message gap fill start: after_seq=${afterSeq}`);
|
|
1959
|
+
let filled = 0;
|
|
1242
1960
|
try {
|
|
1243
|
-
const
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
for (const msg of messages) {
|
|
1254
|
-
if (isJsonObject(msg)) {
|
|
1255
|
-
const s = msg.seq;
|
|
1256
|
-
if (pushed && s !== undefined && s !== null && pushed.has(s))
|
|
1257
|
-
continue;
|
|
1258
|
-
if (s !== undefined && s !== null) {
|
|
1259
|
-
await this._publishPulledMessage('message.received', ns, s, msg);
|
|
1260
|
-
}
|
|
1261
|
-
else {
|
|
1262
|
-
await this._publishAppEvent('message.received', msg);
|
|
1263
|
-
}
|
|
1264
|
-
filled += 1;
|
|
1265
|
-
}
|
|
1266
|
-
}
|
|
1267
|
-
this._prunePushedSeqs(ns);
|
|
1268
|
-
}
|
|
1961
|
+
const messages = await this._withBackgroundRpc(() => this.pullV2(afterSeq, 50, { skipAutoAck: true, gateLocked: true }));
|
|
1962
|
+
filled = messages.length;
|
|
1963
|
+
this._prunePushedSeqs(ns);
|
|
1964
|
+
if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
|
|
1965
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
1966
|
+
this._saveSeqTrackerState();
|
|
1967
|
+
}
|
|
1968
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1969
|
+
if (contig > 0 && contig !== afterSeq) {
|
|
1970
|
+
await this._withBackgroundRpc(() => this.ackV2(contig));
|
|
1269
1971
|
}
|
|
1270
1972
|
this._clientLog.debug(`P2P message gap fill done: after_seq=${afterSeq}, filled=${filled}`);
|
|
1271
1973
|
}
|
|
@@ -1274,6 +1976,10 @@ export class AUNClient {
|
|
|
1274
1976
|
}
|
|
1275
1977
|
finally {
|
|
1276
1978
|
this._gapFillDone.delete(dedupKey);
|
|
1979
|
+
this._releasePullGate(ns, token);
|
|
1980
|
+
if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
|
|
1981
|
+
void this._fillP2pGap();
|
|
1982
|
+
}
|
|
1277
1983
|
}
|
|
1278
1984
|
}
|
|
1279
1985
|
/** 只按硬上限裁剪 published guard,不能按 contiguousSeq 清理。 */
|
|
@@ -1321,10 +2027,10 @@ export class AUNClient {
|
|
|
1321
2027
|
if (!isJsonObject(payload))
|
|
1322
2028
|
return payload;
|
|
1323
2029
|
const result = { ...payload };
|
|
1324
|
-
if (
|
|
2030
|
+
if (!('device_id' in result)) {
|
|
1325
2031
|
result.device_id = this._deviceId;
|
|
1326
2032
|
}
|
|
1327
|
-
if (
|
|
2033
|
+
if (!('slot_id' in result)) {
|
|
1328
2034
|
result.slot_id = this._slotId;
|
|
1329
2035
|
}
|
|
1330
2036
|
return result;
|
|
@@ -1334,10 +2040,11 @@ export class AUNClient {
|
|
|
1334
2040
|
return payload;
|
|
1335
2041
|
return this._attachCurrentInstanceContext(payload);
|
|
1336
2042
|
}
|
|
1337
|
-
|
|
2043
|
+
_publishAppEvent(event, payload, source = 'direct') {
|
|
1338
2044
|
if ((event === 'message.received' || event === 'group.message_created') && isJsonObject(payload)) {
|
|
1339
2045
|
this._maybeAppendEchoTraceReceive(payload);
|
|
1340
2046
|
}
|
|
2047
|
+
this._logAppMessagePublish(event, payload, source);
|
|
1341
2048
|
// 注入本地/远端 agent.md etag,让应用层判断版本一致性;失败不影响业务。
|
|
1342
2049
|
if (isJsonObject(payload)) {
|
|
1343
2050
|
try {
|
|
@@ -1357,7 +2064,7 @@ export class AUNClient {
|
|
|
1357
2064
|
this._clientLog.debug(`agent_md etag inject skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
1358
2065
|
}
|
|
1359
2066
|
}
|
|
1360
|
-
|
|
2067
|
+
return this._dispatcher.publishSyncAware(event, this._normalizePublishedMessagePayload(event, payload));
|
|
1361
2068
|
}
|
|
1362
2069
|
_echoTimestamp() {
|
|
1363
2070
|
const now = new Date();
|
|
@@ -1383,6 +2090,18 @@ export class AUNClient {
|
|
|
1383
2090
|
const trace = `${this._echoTimestamp()} [AUN-SDK.send] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
|
|
1384
2091
|
params.payload = { ...payload, text: payload.text + '\n' + trace };
|
|
1385
2092
|
}
|
|
2093
|
+
_shouldSkipClientSignature(method, params) {
|
|
2094
|
+
if (method !== 'message.send' && method !== 'group.send')
|
|
2095
|
+
return false;
|
|
2096
|
+
if (params.encrypted || params.encrypt)
|
|
2097
|
+
return false;
|
|
2098
|
+
return this._isEchoPayload(params.payload);
|
|
2099
|
+
}
|
|
2100
|
+
_shouldSkipEventSignature(event) {
|
|
2101
|
+
if (event.encrypted || event.encrypt)
|
|
2102
|
+
return false;
|
|
2103
|
+
return this._isEchoPayload(event.payload);
|
|
2104
|
+
}
|
|
1386
2105
|
_maybeAppendEchoTraceReceive(msg) {
|
|
1387
2106
|
if (msg.encrypted)
|
|
1388
2107
|
return;
|
|
@@ -1393,34 +2112,379 @@ export class AUNClient {
|
|
|
1393
2112
|
const trace = `${this._echoTimestamp()} [AUN-SDK.receive] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
|
|
1394
2113
|
msg.payload = { ...payload, text: payload.text + '\n' + trace };
|
|
1395
2114
|
}
|
|
2115
|
+
_debugJson(value) {
|
|
2116
|
+
const seen = new WeakSet();
|
|
2117
|
+
try {
|
|
2118
|
+
return JSON.stringify(value, (_key, item) => {
|
|
2119
|
+
if (typeof item === 'bigint')
|
|
2120
|
+
return item.toString();
|
|
2121
|
+
if (item instanceof Uint8Array) {
|
|
2122
|
+
return {
|
|
2123
|
+
_type: item.constructor.name,
|
|
2124
|
+
len: item.byteLength,
|
|
2125
|
+
base64: Buffer.from(item).toString('base64'),
|
|
2126
|
+
};
|
|
2127
|
+
}
|
|
2128
|
+
if (item && typeof item === 'object') {
|
|
2129
|
+
if (seen.has(item))
|
|
2130
|
+
return '[Circular]';
|
|
2131
|
+
seen.add(item);
|
|
2132
|
+
}
|
|
2133
|
+
return item;
|
|
2134
|
+
});
|
|
2135
|
+
}
|
|
2136
|
+
catch {
|
|
2137
|
+
return String(value);
|
|
2138
|
+
}
|
|
2139
|
+
}
|
|
2140
|
+
_messagePayloadForDebug(message) {
|
|
2141
|
+
if (!isJsonObject(message))
|
|
2142
|
+
return message;
|
|
2143
|
+
const msg = message;
|
|
2144
|
+
if ('payload' in msg)
|
|
2145
|
+
return msg.payload;
|
|
2146
|
+
if ('content' in msg)
|
|
2147
|
+
return msg.content;
|
|
2148
|
+
if (typeof msg.envelope_json === 'string' && msg.envelope_json) {
|
|
2149
|
+
try {
|
|
2150
|
+
return JSON.parse(msg.envelope_json);
|
|
2151
|
+
}
|
|
2152
|
+
catch {
|
|
2153
|
+
return msg.envelope_json;
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
if (isJsonObject(msg.legacy_v1)) {
|
|
2157
|
+
const legacy = msg.legacy_v1;
|
|
2158
|
+
if ('payload' in legacy)
|
|
2159
|
+
return legacy.payload;
|
|
2160
|
+
if ('content' in legacy)
|
|
2161
|
+
return legacy.content;
|
|
2162
|
+
}
|
|
2163
|
+
return null;
|
|
2164
|
+
}
|
|
2165
|
+
_messageEnvelopeFieldsForDebug(message) {
|
|
2166
|
+
if (!isJsonObject(message)) {
|
|
2167
|
+
return { value_type: typeof message };
|
|
2168
|
+
}
|
|
2169
|
+
const msg = message;
|
|
2170
|
+
const keys = [
|
|
2171
|
+
'message_id', 'id', 'from', 'from_aid', 'sender_aid', 'to', 'to_aid',
|
|
2172
|
+
'group_id', 'seq', 'msg_seq', 'type', 'version', 'timestamp', 't_server',
|
|
2173
|
+
'device_id', 'slot_id', 'encrypted', 'dispatch_mode', 'dispatch',
|
|
2174
|
+
'e2ee', 'headers', 'protected_headers', 'context', 'status',
|
|
2175
|
+
'_decrypt_error', '_decrypt_stage',
|
|
2176
|
+
];
|
|
2177
|
+
const out = {};
|
|
2178
|
+
for (const key of keys) {
|
|
2179
|
+
if (Object.prototype.hasOwnProperty.call(msg, key))
|
|
2180
|
+
out[key] = msg[key];
|
|
2181
|
+
}
|
|
2182
|
+
return out;
|
|
2183
|
+
}
|
|
2184
|
+
_logMessageDebug(stage, source, event, message, opts = {}) {
|
|
2185
|
+
// 关键消息链路诊断日志长期保留在代码中;是否输出由 logger 的 debug/level 控制。
|
|
2186
|
+
const record = {
|
|
2187
|
+
stage,
|
|
2188
|
+
source,
|
|
2189
|
+
event,
|
|
2190
|
+
envelope: this._messageEnvelopeFieldsForDebug(message),
|
|
2191
|
+
payload: opts.payloadOverride !== undefined ? opts.payloadOverride : this._messagePayloadForDebug(message),
|
|
2192
|
+
};
|
|
2193
|
+
if (opts.extra)
|
|
2194
|
+
record.extra = opts.extra;
|
|
2195
|
+
this._clientLog.debug(`message.debug ${this._debugJson(record)}`);
|
|
2196
|
+
}
|
|
2197
|
+
_logAppMessagePublish(event, payload, source) {
|
|
2198
|
+
if (!['message.received', 'message.undecryptable', 'group.message_created', 'group.message_undecryptable'].includes(event)) {
|
|
2199
|
+
return;
|
|
2200
|
+
}
|
|
2201
|
+
this._logMessageDebug('publish', source, event, payload);
|
|
2202
|
+
}
|
|
1396
2203
|
_messageTargetsCurrentInstance(message) {
|
|
1397
2204
|
if (!isJsonObject(message))
|
|
1398
2205
|
return true;
|
|
1399
|
-
|
|
1400
|
-
|
|
2206
|
+
if ('device_id' in message) {
|
|
2207
|
+
const targetDeviceId = String(message.device_id ?? '').trim();
|
|
2208
|
+
if (targetDeviceId !== this._deviceId) {
|
|
2209
|
+
return false;
|
|
2210
|
+
}
|
|
2211
|
+
}
|
|
2212
|
+
if ('slot_id' in message) {
|
|
2213
|
+
const targetSlotId = String(message.slot_id ?? '').trim();
|
|
2214
|
+
if (targetSlotId !== this._slotId) {
|
|
2215
|
+
return false;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
return true;
|
|
2219
|
+
}
|
|
2220
|
+
_tryAcquirePullGate(key) {
|
|
2221
|
+
if (!key)
|
|
2222
|
+
return 0;
|
|
2223
|
+
const now = Date.now();
|
|
2224
|
+
const gate = this._pullGates.get(key) ?? { inflight: false, startedAt: 0, token: 0 };
|
|
2225
|
+
if (gate.inflight && now - gate.startedAt <= AUNClient.PULL_GATE_STALE_MS) {
|
|
2226
|
+
return null;
|
|
2227
|
+
}
|
|
2228
|
+
if (gate.inflight) {
|
|
2229
|
+
this._clientLog.warn(`pull in-flight stale reset: key=${key} age=${now - gate.startedAt}ms`);
|
|
2230
|
+
}
|
|
2231
|
+
gate.token += 1;
|
|
2232
|
+
gate.inflight = true;
|
|
2233
|
+
gate.startedAt = now;
|
|
2234
|
+
this._pullGates.set(key, gate);
|
|
2235
|
+
return gate.token;
|
|
2236
|
+
}
|
|
2237
|
+
_releasePullGate(key, token) {
|
|
2238
|
+
if (!key || token == null)
|
|
2239
|
+
return;
|
|
2240
|
+
const gate = this._pullGates.get(key);
|
|
2241
|
+
if (!gate || gate.token !== token)
|
|
2242
|
+
return;
|
|
2243
|
+
gate.inflight = false;
|
|
2244
|
+
gate.startedAt = 0;
|
|
2245
|
+
}
|
|
2246
|
+
_pullGateKeyForCall(method, params) {
|
|
2247
|
+
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
2248
|
+
return this._aid ? `p2p:${this._aid}` : '';
|
|
2249
|
+
}
|
|
2250
|
+
if ((method === 'group.pull' || method === 'group.v2.pull') && String(params.group_id ?? '').trim()) {
|
|
2251
|
+
return `group:${String(params.group_id ?? '').trim()}`;
|
|
2252
|
+
}
|
|
2253
|
+
if (method === 'group.pull_events' && String(params.group_id ?? '').trim()) {
|
|
2254
|
+
return `group_event:${String(params.group_id ?? '').trim()}`;
|
|
2255
|
+
}
|
|
2256
|
+
return '';
|
|
2257
|
+
}
|
|
2258
|
+
_isPullResponseProcessing(key) {
|
|
2259
|
+
if (!key)
|
|
1401
2260
|
return false;
|
|
2261
|
+
return (this._pullResponseKeys.get(key) ?? 0) > 0;
|
|
2262
|
+
}
|
|
2263
|
+
_emptyPullResultForCall(method) {
|
|
2264
|
+
if (method === 'group.pull_events')
|
|
2265
|
+
return { events: [], count: 0 };
|
|
2266
|
+
if (method === 'message.pull' || method === 'message.v2.pull' || method === 'group.pull' || method === 'group.v2.pull') {
|
|
2267
|
+
return { messages: [], count: 0 };
|
|
2268
|
+
}
|
|
2269
|
+
return {};
|
|
2270
|
+
}
|
|
2271
|
+
_withPullResponseProcessing(key, fn) {
|
|
2272
|
+
if (!key)
|
|
2273
|
+
return fn();
|
|
2274
|
+
this._pullResponseKeys.set(key, (this._pullResponseKeys.get(key) ?? 0) + 1);
|
|
2275
|
+
const release = () => {
|
|
2276
|
+
const next = (this._pullResponseKeys.get(key) ?? 1) - 1;
|
|
2277
|
+
if (next <= 0) {
|
|
2278
|
+
this._pullResponseKeys.delete(key);
|
|
2279
|
+
}
|
|
2280
|
+
else {
|
|
2281
|
+
this._pullResponseKeys.set(key, next);
|
|
2282
|
+
}
|
|
2283
|
+
};
|
|
2284
|
+
try {
|
|
2285
|
+
const result = fn();
|
|
2286
|
+
if (isPromiseLike(result)) {
|
|
2287
|
+
return Promise.resolve(result).finally(release);
|
|
2288
|
+
}
|
|
2289
|
+
release();
|
|
2290
|
+
return result;
|
|
2291
|
+
}
|
|
2292
|
+
catch (exc) {
|
|
2293
|
+
release();
|
|
2294
|
+
throw exc;
|
|
2295
|
+
}
|
|
2296
|
+
}
|
|
2297
|
+
_pullResultCount(result) {
|
|
2298
|
+
if (Array.isArray(result))
|
|
2299
|
+
return result.length;
|
|
2300
|
+
if (!isJsonObject(result))
|
|
2301
|
+
return 0;
|
|
2302
|
+
const obj = result;
|
|
2303
|
+
const rawCount = Number(obj.raw_count ?? 0);
|
|
2304
|
+
if (Number.isFinite(rawCount) && rawCount > 0)
|
|
2305
|
+
return rawCount;
|
|
2306
|
+
if (Array.isArray(obj.messages))
|
|
2307
|
+
return obj.messages.length;
|
|
2308
|
+
if (Array.isArray(obj.events))
|
|
2309
|
+
return obj.events.length;
|
|
2310
|
+
return 0;
|
|
2311
|
+
}
|
|
2312
|
+
_nextPullParams(method, params) {
|
|
2313
|
+
const next = { ...params };
|
|
2314
|
+
delete next._pull_gate_locked;
|
|
2315
|
+
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
2316
|
+
if (!this._aid)
|
|
2317
|
+
return null;
|
|
2318
|
+
next.after_seq = this._seqTracker.getContiguousSeq(`p2p:${this._aid}`);
|
|
2319
|
+
return next;
|
|
2320
|
+
}
|
|
2321
|
+
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
2322
|
+
const groupId = normalizeGroupId(String(next.group_id ?? '').trim()) || String(next.group_id ?? '').trim();
|
|
2323
|
+
if (!groupId)
|
|
2324
|
+
return null;
|
|
2325
|
+
next.group_id = groupId;
|
|
2326
|
+
next.after_seq = this._seqTracker.getContiguousSeq(`group:${groupId}`);
|
|
2327
|
+
delete next.after_message_seq;
|
|
2328
|
+
return next;
|
|
2329
|
+
}
|
|
2330
|
+
if (method === 'group.pull_events') {
|
|
2331
|
+
const groupId = normalizeGroupId(String(next.group_id ?? '').trim()) || String(next.group_id ?? '').trim();
|
|
2332
|
+
if (!groupId)
|
|
2333
|
+
return null;
|
|
2334
|
+
next.group_id = groupId;
|
|
2335
|
+
next.after_event_seq = this._seqTracker.getContiguousSeq(`group_event:${groupId}`);
|
|
2336
|
+
return next;
|
|
2337
|
+
}
|
|
2338
|
+
return null;
|
|
2339
|
+
}
|
|
2340
|
+
_pullRequestAfter(method, params) {
|
|
2341
|
+
if (method === 'message.pull' || method === 'message.v2.pull')
|
|
2342
|
+
return Number(params.after_seq ?? 0) || 0;
|
|
2343
|
+
if (method === 'group.pull' || method === 'group.v2.pull')
|
|
2344
|
+
return Number(params.after_seq ?? params.after_message_seq ?? 0) || 0;
|
|
2345
|
+
if (method === 'group.pull_events')
|
|
2346
|
+
return Number(params.after_event_seq ?? 0) || 0;
|
|
2347
|
+
return 0;
|
|
2348
|
+
}
|
|
2349
|
+
_pullRetentionFloor(result, topLevelKey, cursorKey) {
|
|
2350
|
+
const values = [Number(result[topLevelKey] ?? 0)];
|
|
2351
|
+
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
2352
|
+
if (cursor) {
|
|
2353
|
+
values.push(Number(cursor[cursorKey] ?? 0));
|
|
2354
|
+
values.push(Number(cursor.retention_floor_seq ?? 0));
|
|
2355
|
+
}
|
|
2356
|
+
return Math.max(0, ...values.filter((value) => Number.isFinite(value)));
|
|
2357
|
+
}
|
|
2358
|
+
_schedulePullFollowup(method, params, result) {
|
|
2359
|
+
if (method === 'message.pull')
|
|
2360
|
+
method = 'message.v2.pull';
|
|
2361
|
+
else if (method === 'group.pull')
|
|
2362
|
+
method = 'group.v2.pull';
|
|
2363
|
+
if (this._pullResultCount(result) <= 0)
|
|
2364
|
+
return;
|
|
2365
|
+
const next = this._nextPullParams(method, params);
|
|
2366
|
+
if (!next)
|
|
2367
|
+
return;
|
|
2368
|
+
if (this._pullRequestAfter(method, next) <= this._pullRequestAfter(method, params))
|
|
2369
|
+
return;
|
|
2370
|
+
void (async () => {
|
|
2371
|
+
try {
|
|
2372
|
+
await this._withBackgroundRpc(async () => {
|
|
2373
|
+
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
2374
|
+
await this.pullV2(Number(next.after_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
|
|
2375
|
+
return;
|
|
2376
|
+
}
|
|
2377
|
+
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
2378
|
+
const groupId = String(next.group_id ?? '').trim();
|
|
2379
|
+
if (!groupId)
|
|
2380
|
+
return;
|
|
2381
|
+
await this.pullGroupV2(groupId, Number(next.after_seq ?? next.after_message_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
|
|
2382
|
+
return;
|
|
2383
|
+
}
|
|
2384
|
+
await this.call(method, next);
|
|
2385
|
+
});
|
|
2386
|
+
}
|
|
2387
|
+
catch (exc) {
|
|
2388
|
+
this._clientLog.debug(`pull follow-up skipped/failed: method=${method} err=${formatCaughtError(exc)}`);
|
|
2389
|
+
}
|
|
2390
|
+
})();
|
|
2391
|
+
}
|
|
2392
|
+
async _withBackgroundRpc(operation) {
|
|
2393
|
+
this._backgroundRpcDepth += 1;
|
|
2394
|
+
try {
|
|
2395
|
+
return await operation();
|
|
1402
2396
|
}
|
|
1403
|
-
|
|
1404
|
-
|
|
2397
|
+
finally {
|
|
2398
|
+
this._backgroundRpcDepth = Math.max(0, this._backgroundRpcDepth - 1);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
async _runPullSerialized(key, operation) {
|
|
2402
|
+
if (key && this._isPullResponseProcessing(key)) {
|
|
2403
|
+
this._clientLog.debug(`pull skipped while processing pull response: key=${key}`);
|
|
2404
|
+
return [];
|
|
2405
|
+
}
|
|
2406
|
+
let token = this._tryAcquirePullGate(key);
|
|
2407
|
+
if (token === null) {
|
|
2408
|
+
// 显式 pull 可能撞上 push/gap-fill 的后台 pull。这里不并行发第二个 pull,
|
|
2409
|
+
// 也不把后台 in-flight 暴露成业务错误;短等待 gate 释放后再进入连接级 RPC queue。
|
|
2410
|
+
const deadline = Date.now() + AUNClient.PULL_GATE_STALE_MS + 100;
|
|
2411
|
+
while (token === null && Date.now() <= deadline) {
|
|
2412
|
+
await this._sleep(25);
|
|
2413
|
+
token = this._tryAcquirePullGate(key);
|
|
2414
|
+
}
|
|
2415
|
+
if (token === null) {
|
|
2416
|
+
throw new StateError(`pull already in-flight for ${key}`);
|
|
2417
|
+
}
|
|
2418
|
+
}
|
|
2419
|
+
try {
|
|
2420
|
+
return await this._withBackgroundRpc(operation);
|
|
2421
|
+
}
|
|
2422
|
+
finally {
|
|
2423
|
+
this._releasePullGate(key, token);
|
|
2424
|
+
}
|
|
2425
|
+
}
|
|
2426
|
+
async _tryRunBackgroundPull(key, operation, followupOnMessages = false) {
|
|
2427
|
+
if (key && this._isPullResponseProcessing(key))
|
|
2428
|
+
return false;
|
|
2429
|
+
const token = this._tryAcquirePullGate(key);
|
|
2430
|
+
if (token === null)
|
|
1405
2431
|
return false;
|
|
2432
|
+
let count = 0;
|
|
2433
|
+
try {
|
|
2434
|
+
count = await this._withBackgroundRpc(operation);
|
|
2435
|
+
}
|
|
2436
|
+
finally {
|
|
2437
|
+
this._releasePullGate(key, token);
|
|
2438
|
+
}
|
|
2439
|
+
if (followupOnMessages && count > 0) {
|
|
2440
|
+
// 后台续拉是 fire-and-forget;关闭连接时 transport 会拒绝排队 RPC,
|
|
2441
|
+
// 这里必须本地收口,避免测试/宿主进程看到未处理的 Promise rejection。
|
|
2442
|
+
void this._tryRunBackgroundPull(key, operation, true).catch((exc) => {
|
|
2443
|
+
this._clientLog.debug(`background pull follow-up skipped/failed: key=${key} err=${formatCaughtError(exc)}`);
|
|
2444
|
+
});
|
|
1406
2445
|
}
|
|
1407
2446
|
return true;
|
|
1408
2447
|
}
|
|
1409
|
-
async _drainOrderedMessages(ns, beforeSeq) {
|
|
2448
|
+
async _drainOrderedMessages(ns, beforeSeq, pullResponse = false) {
|
|
1410
2449
|
const queue = this._pendingOrderedMsgs.get(ns);
|
|
1411
2450
|
if (!queue || queue.size === 0)
|
|
1412
2451
|
return;
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
2452
|
+
while (true) {
|
|
2453
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2454
|
+
const ready = [...queue.keys()]
|
|
2455
|
+
.filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
|
|
2456
|
+
.sort((a, b) => a - b);
|
|
2457
|
+
let seq = ready[0];
|
|
2458
|
+
if (seq === undefined) {
|
|
2459
|
+
const nextSeq = contig + 1;
|
|
2460
|
+
if (beforeSeq !== undefined && nextSeq >= beforeSeq)
|
|
2461
|
+
break;
|
|
2462
|
+
if (!queue.has(nextSeq))
|
|
2463
|
+
break;
|
|
2464
|
+
seq = nextSeq;
|
|
2465
|
+
}
|
|
1418
2466
|
const item = queue.get(seq);
|
|
1419
2467
|
queue.delete(seq);
|
|
1420
|
-
if (!item
|
|
2468
|
+
if (!item)
|
|
2469
|
+
continue;
|
|
2470
|
+
if (this._pushedSeqs.get(ns)?.has(seq)) {
|
|
2471
|
+
this._clientLog.debug(`publish ordered drain skipped duplicate: ns=${ns}, seq=${seq}, event=${item.event}`);
|
|
2472
|
+
this._markOrderedSeqDelivered(ns, seq);
|
|
1421
2473
|
continue;
|
|
1422
|
-
|
|
2474
|
+
}
|
|
2475
|
+
if (pullResponse) {
|
|
2476
|
+
const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(item.event, item.payload, 'ordered-drain'));
|
|
2477
|
+
if (isPromiseLike(published))
|
|
2478
|
+
await published;
|
|
2479
|
+
}
|
|
2480
|
+
else {
|
|
2481
|
+
const published = this._publishAppEvent(item.event, item.payload, 'ordered-drain');
|
|
2482
|
+
if (isPromiseLike(published))
|
|
2483
|
+
await published;
|
|
2484
|
+
}
|
|
1423
2485
|
this._markPublishedSeq(ns, seq);
|
|
2486
|
+
this._markOrderedSeqDelivered(ns, seq);
|
|
2487
|
+
this._clientLog.debug(`publish ordered drain delivered: ns=${ns}, seq=${seq}, event=${item.event}`);
|
|
1424
2488
|
}
|
|
1425
2489
|
if (queue.size === 0)
|
|
1426
2490
|
this._pendingOrderedMsgs.delete(ns);
|
|
@@ -1428,10 +2492,14 @@ export class AUNClient {
|
|
|
1428
2492
|
async _publishOrderedMessage(event, ns, seq, payload) {
|
|
1429
2493
|
const seqNum = Number(seq);
|
|
1430
2494
|
if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0) {
|
|
1431
|
-
|
|
2495
|
+
this._clientLog.debug(`publish ordered direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
|
|
2496
|
+
const published = this._publishAppEvent(event, payload, 'ordered');
|
|
2497
|
+
if (isPromiseLike(published))
|
|
2498
|
+
await published;
|
|
1432
2499
|
return true;
|
|
1433
2500
|
}
|
|
1434
2501
|
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
2502
|
+
this._clientLog.debug(`publish ordered skipped duplicate: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
1435
2503
|
const queue = this._pendingOrderedMsgs.get(ns);
|
|
1436
2504
|
queue?.delete(seqNum);
|
|
1437
2505
|
if (queue && queue.size === 0)
|
|
@@ -1439,30 +2507,52 @@ export class AUNClient {
|
|
|
1439
2507
|
return false;
|
|
1440
2508
|
}
|
|
1441
2509
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1442
|
-
if (seqNum
|
|
2510
|
+
if (seqNum <= contig) {
|
|
2511
|
+
this._clientLog.debug(`publish ordered stale covered: event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
|
|
2512
|
+
const queue = this._pendingOrderedMsgs.get(ns);
|
|
2513
|
+
queue?.delete(seqNum);
|
|
2514
|
+
if (queue && queue.size === 0)
|
|
2515
|
+
this._pendingOrderedMsgs.delete(ns);
|
|
2516
|
+
return false;
|
|
2517
|
+
}
|
|
2518
|
+
if (seqNum !== contig + 1) {
|
|
2519
|
+
this._clientLog.debug(`publish ordered enqueue(gap): event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
|
|
1443
2520
|
this._enqueueOrderedMessage(ns, event, seqNum, payload);
|
|
1444
2521
|
return false;
|
|
1445
2522
|
}
|
|
1446
2523
|
await this._drainOrderedMessages(ns, seqNum);
|
|
1447
|
-
if (this._pushedSeqs.get(ns)?.has(seqNum))
|
|
2524
|
+
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
2525
|
+
this._clientLog.debug(`publish ordered skipped after-drain duplicate: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
1448
2526
|
return false;
|
|
2527
|
+
}
|
|
1449
2528
|
const queue = this._pendingOrderedMsgs.get(ns);
|
|
1450
2529
|
queue?.delete(seqNum);
|
|
1451
2530
|
if (queue && queue.size === 0)
|
|
1452
2531
|
this._pendingOrderedMsgs.delete(ns);
|
|
1453
|
-
|
|
2532
|
+
const published = this._publishAppEvent(event, payload, 'ordered');
|
|
2533
|
+
if (isPromiseLike(published))
|
|
2534
|
+
await published;
|
|
1454
2535
|
this._markPublishedSeq(ns, seqNum);
|
|
2536
|
+
this._markOrderedSeqDelivered(ns, seqNum);
|
|
2537
|
+
this._clientLog.debug(`publish ordered delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
1455
2538
|
await this._drainOrderedMessages(ns);
|
|
1456
2539
|
return true;
|
|
1457
2540
|
}
|
|
1458
2541
|
async _publishPulledMessage(event, ns, seq, payload) {
|
|
2542
|
+
// Pull/gap-fill 批次是服务端对 after_seq 的可用结果集,可能跨过永久空洞。
|
|
2543
|
+
// 这里只能做 namespace+seq 去重并按返回顺序发布,不能套用 push 路径的
|
|
2544
|
+
// seq == contiguous_seq + 1 门控,否则会把空洞后的可用消息错误卡住。
|
|
1459
2545
|
const seqNum = Number(seq);
|
|
1460
2546
|
if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0 || !ns) {
|
|
1461
|
-
|
|
2547
|
+
this._clientLog.debug(`publish pulled direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
|
|
2548
|
+
const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(event, payload, 'pull'));
|
|
2549
|
+
if (isPromiseLike(published))
|
|
2550
|
+
await published;
|
|
1462
2551
|
return true;
|
|
1463
2552
|
}
|
|
1464
2553
|
const queue = this._pendingOrderedMsgs.get(ns);
|
|
1465
2554
|
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
2555
|
+
this._clientLog.debug(`publish pulled skipped duplicate: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
1466
2556
|
queue?.delete(seqNum);
|
|
1467
2557
|
if (queue && queue.size === 0)
|
|
1468
2558
|
this._pendingOrderedMsgs.delete(ns);
|
|
@@ -1471,70 +2561,126 @@ export class AUNClient {
|
|
|
1471
2561
|
queue?.delete(seqNum);
|
|
1472
2562
|
if (queue && queue.size === 0)
|
|
1473
2563
|
this._pendingOrderedMsgs.delete(ns);
|
|
1474
|
-
|
|
2564
|
+
const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(event, payload, 'pull'));
|
|
2565
|
+
if (isPromiseLike(published))
|
|
2566
|
+
await published;
|
|
1475
2567
|
this._markPublishedSeq(ns, seqNum);
|
|
2568
|
+
this._markPulledSeqDelivered(ns, seqNum);
|
|
2569
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
2570
|
+
this._clientLog.debug(`publish pulled delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
1476
2571
|
return true;
|
|
1477
2572
|
}
|
|
2573
|
+
_markPulledSeqDelivered(ns, seq) {
|
|
2574
|
+
// Pull 批次是 after_seq 之后服务端当前可用的结果集,可能跨过永久空洞。
|
|
2575
|
+
// 这里仅在应用层发布返回后推进已交付游标,不能改成 push 的相邻 seq 门控。
|
|
2576
|
+
const seqNum = Number(seq);
|
|
2577
|
+
if (!ns || !Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0)
|
|
2578
|
+
return false;
|
|
2579
|
+
const before = this._seqTracker.getContiguousSeq(ns);
|
|
2580
|
+
this._seqTracker.forceContiguousSeq(ns, seqNum);
|
|
2581
|
+
return this._seqTracker.getContiguousSeq(ns) !== before;
|
|
2582
|
+
}
|
|
2583
|
+
_markOrderedSeqDelivered(ns, seq) {
|
|
2584
|
+
if (!ns || !Number.isFinite(seq) || !Number.isInteger(seq) || seq <= 0)
|
|
2585
|
+
return false;
|
|
2586
|
+
const before = this._seqTracker.getContiguousSeq(ns);
|
|
2587
|
+
this._seqTracker.onMessageSeq(ns, seq);
|
|
2588
|
+
return this._seqTracker.getContiguousSeq(ns) !== before;
|
|
2589
|
+
}
|
|
1478
2590
|
/** 后台补齐群事件空洞 */
|
|
1479
2591
|
async _fillGroupEventGap(groupId) {
|
|
2592
|
+
groupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
2593
|
+
if (!groupId)
|
|
2594
|
+
return;
|
|
1480
2595
|
const ns = `group_event:${groupId}`;
|
|
1481
2596
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1482
2597
|
// 去重:同一 (group_evt:id:after_seq) 只补一次
|
|
1483
2598
|
const dedupKey = `group_evt:${groupId}:${afterSeq}`;
|
|
1484
2599
|
if (this._gapFillDone.has(dedupKey))
|
|
1485
2600
|
return;
|
|
2601
|
+
const token = this._tryAcquirePullGate(ns);
|
|
2602
|
+
if (token === null) {
|
|
2603
|
+
this._clientLog.debug(`group event gap fill skipped: pull in-flight group=${groupId}`);
|
|
2604
|
+
return;
|
|
2605
|
+
}
|
|
1486
2606
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1487
|
-
|
|
2607
|
+
let filled = 0;
|
|
1488
2608
|
try {
|
|
1489
|
-
|
|
1490
|
-
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
2609
|
+
let nextAfterSeq = afterSeq;
|
|
2610
|
+
const maxPages = 100;
|
|
2611
|
+
let pageCount = 0;
|
|
2612
|
+
while (pageCount < maxPages) {
|
|
2613
|
+
pageCount += 1;
|
|
2614
|
+
this._clientLog.debug(`group event gap fill start: group=${groupId}, after_seq=${nextAfterSeq}`);
|
|
2615
|
+
const result = await this.call('group.pull_events', {
|
|
2616
|
+
group_id: groupId,
|
|
2617
|
+
after_event_seq: nextAfterSeq,
|
|
2618
|
+
device_id: this._deviceId,
|
|
2619
|
+
limit: 50,
|
|
2620
|
+
_pull_gate_locked: true,
|
|
2621
|
+
});
|
|
2622
|
+
if (!isJsonObject(result))
|
|
2623
|
+
return;
|
|
1497
2624
|
const events = result.events;
|
|
1498
|
-
if (Array.isArray(events))
|
|
1499
|
-
|
|
1500
|
-
|
|
1501
|
-
|
|
1502
|
-
|
|
1503
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
}
|
|
1509
|
-
// 持久化 cursor + ack_events(与 Python 对齐)
|
|
1510
|
-
this._saveSeqTrackerState();
|
|
1511
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
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)}`); });
|
|
2625
|
+
if (!Array.isArray(events))
|
|
2626
|
+
return;
|
|
2627
|
+
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
2628
|
+
const eventObjects = events.filter(isJsonObject);
|
|
2629
|
+
const retentionFloor = this._pullRetentionFloor(result, 'retention_floor_event_seq', 'retention_floor_event_seq');
|
|
2630
|
+
if (retentionFloor > 0) {
|
|
2631
|
+
const contigBeforeFloor = this._seqTracker.getContiguousSeq(ns);
|
|
2632
|
+
if (contigBeforeFloor < retentionFloor) {
|
|
2633
|
+
this._clientLog.info(`group.pull_events retention-floor advance: ns=${ns} contiguous=${contigBeforeFloor} -> retention_floor=${retentionFloor}`);
|
|
2634
|
+
this._seqTracker.forceContiguousSeq(ns, retentionFloor);
|
|
1519
2635
|
}
|
|
1520
|
-
|
|
1521
|
-
|
|
1522
|
-
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1526
|
-
|
|
1527
|
-
|
|
1528
|
-
|
|
1529
|
-
|
|
2636
|
+
}
|
|
2637
|
+
const eventSeqs = [];
|
|
2638
|
+
for (const evt of eventObjects) {
|
|
2639
|
+
const eventSeq = Number(evt.event_seq ?? 0);
|
|
2640
|
+
if (Number.isFinite(eventSeq) && eventSeq > 0)
|
|
2641
|
+
eventSeqs.push(eventSeq);
|
|
2642
|
+
evt._from_gap_fill = true;
|
|
2643
|
+
const et = String(evt.event_type ?? '');
|
|
2644
|
+
// 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
|
|
2645
|
+
if (et !== 'group.message_created') {
|
|
2646
|
+
// 验签:有 client_signature 就验(与实时事件路径对齐)
|
|
2647
|
+
const cs = evt.client_signature;
|
|
2648
|
+
if (cs && typeof cs === 'object') {
|
|
2649
|
+
if (this._shouldSkipEventSignature(evt)) {
|
|
2650
|
+
delete evt.client_signature;
|
|
2651
|
+
}
|
|
2652
|
+
else {
|
|
1530
2653
|
evt._verified = await this._verifyEventSignatureAsync(evt, cs);
|
|
1531
2654
|
}
|
|
1532
|
-
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
1533
|
-
await this._dispatcher.publish('group.changed', evt);
|
|
1534
|
-
filled += 1;
|
|
1535
2655
|
}
|
|
2656
|
+
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
2657
|
+
await this._dispatcher.publish('group.changed', evt);
|
|
2658
|
+
}
|
|
2659
|
+
if (Number.isFinite(eventSeq) && eventSeq > 0) {
|
|
2660
|
+
this._markPulledSeqDelivered(ns, eventSeq);
|
|
1536
2661
|
}
|
|
2662
|
+
filled += 1;
|
|
2663
|
+
}
|
|
2664
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2665
|
+
if (contig !== pageContigBefore) {
|
|
2666
|
+
this._saveSeqTrackerState();
|
|
1537
2667
|
}
|
|
2668
|
+
if (eventObjects.length > 0 && contig > 0 && contig !== pageContigBefore) {
|
|
2669
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
2670
|
+
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
2671
|
+
this._transport.call('group.ack_events', {
|
|
2672
|
+
group_id: groupId,
|
|
2673
|
+
event_seq: ackSeq,
|
|
2674
|
+
device_id: this._deviceId,
|
|
2675
|
+
slot_id: this._slotId,
|
|
2676
|
+
}, undefined, undefined, true).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2677
|
+
}
|
|
2678
|
+
// pull_events 与其它 pull 一样:一次后台任务只消费一个批次。
|
|
2679
|
+
// 非空批次返回后由 pull gate 的 fire-and-forget follow-up 重新排队,直到空批停止。
|
|
2680
|
+
break;
|
|
2681
|
+
}
|
|
2682
|
+
if (pageCount >= maxPages) {
|
|
2683
|
+
this._clientLog.warn(`group event gap fill reached max_pages=${maxPages} group=${groupId} after_seq=${nextAfterSeq}`);
|
|
1538
2684
|
}
|
|
1539
2685
|
this._clientLog.debug(`group event gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
|
|
1540
2686
|
}
|
|
@@ -1543,6 +2689,10 @@ export class AUNClient {
|
|
|
1543
2689
|
}
|
|
1544
2690
|
finally {
|
|
1545
2691
|
this._gapFillDone.delete(dedupKey);
|
|
2692
|
+
this._releasePullGate(ns, token);
|
|
2693
|
+
if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
|
|
2694
|
+
void this._fillGroupEventGap(groupId);
|
|
2695
|
+
}
|
|
1546
2696
|
}
|
|
1547
2697
|
}
|
|
1548
2698
|
_extractGroupIdFromResult(result) {
|
|
@@ -1567,28 +2717,45 @@ export class AUNClient {
|
|
|
1567
2717
|
// 验签:有 client_signature 就验,没有默认安全(H20: 严格 boolean)
|
|
1568
2718
|
const cs = d.client_signature;
|
|
1569
2719
|
if (cs && isJsonObject(cs)) {
|
|
1570
|
-
|
|
2720
|
+
if (this._shouldSkipEventSignature(d)) {
|
|
2721
|
+
delete d.client_signature;
|
|
2722
|
+
}
|
|
2723
|
+
else {
|
|
2724
|
+
d._verified = await this._verifyEventSignatureAsync(d, cs);
|
|
2725
|
+
}
|
|
1571
2726
|
}
|
|
1572
2727
|
await this._dispatcher.publish('group.changed', d);
|
|
1573
2728
|
// V2-only:成员/设备变化会影响 group.v2.bootstrap 的设备集与 state commitment。
|
|
1574
2729
|
if (groupId) {
|
|
1575
2730
|
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
1576
2731
|
}
|
|
1577
|
-
|
|
2732
|
+
const membershipActions = new Set([
|
|
2733
|
+
'member_added', 'member_left', 'member_removed', 'role_changed',
|
|
2734
|
+
'owner_transferred', 'joined', 'join_approved', 'invite_code_used',
|
|
2735
|
+
]);
|
|
2736
|
+
if (groupId && this._v2Session && (action === 'upsert' || membershipActions.has(action))) {
|
|
1578
2737
|
this._safeAsync(this._v2AutoProposeState(groupId, { leaderDelay: true }));
|
|
1579
2738
|
}
|
|
1580
2739
|
// Group SPK 编排:成员变更触发注册/轮换
|
|
1581
2740
|
if (this._v2Session && groupId) {
|
|
1582
|
-
|
|
1583
|
-
|
|
1584
|
-
|
|
1585
|
-
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
|
|
1589
|
-
|
|
1590
|
-
|
|
1591
|
-
|
|
2741
|
+
if (membershipActions.has(action)) {
|
|
2742
|
+
const callFn = async (method, params) => this.call(method, params);
|
|
2743
|
+
const joinedAid = String(d.joined_aid ?? d.member_aid ?? d.aid ?? '').trim();
|
|
2744
|
+
const actorAid = String(d.actor_aid ?? '').trim();
|
|
2745
|
+
const selfAid = String(this._aid ?? '').trim();
|
|
2746
|
+
const joinActions = new Set(['member_added', 'joined', 'join_approved', 'invite_code_used']);
|
|
2747
|
+
const isSelfJoin = joinActions.has(action) && !!selfAid && (joinedAid === selfAid ||
|
|
2748
|
+
(!joinedAid && (action === 'joined' || action === 'invite_code_used') && actorAid === selfAid));
|
|
2749
|
+
if (isSelfJoin) {
|
|
2750
|
+
this._v2Session.ensureGroupRegistered?.(groupId, callFn)?.catch(exc => {
|
|
2751
|
+
this._clientLog.debug(`group SPK registration failed (non-fatal): group=${groupId} action=${action} err=${formatCaughtError(exc)}`);
|
|
2752
|
+
});
|
|
2753
|
+
}
|
|
2754
|
+
else {
|
|
2755
|
+
this._v2Session.rotateGroupSPK?.(groupId, callFn)?.catch(exc => {
|
|
2756
|
+
this._clientLog.debug(`group SPK rotation failed (non-fatal): group=${groupId} action=${action} err=${formatCaughtError(exc)}`);
|
|
2757
|
+
});
|
|
2758
|
+
}
|
|
1592
2759
|
}
|
|
1593
2760
|
}
|
|
1594
2761
|
// event_seq 空洞检测:持久化后的 group.changed 会携带 event_seq。
|
|
@@ -1609,7 +2776,7 @@ export class AUNClient {
|
|
|
1609
2776
|
event_seq: contig,
|
|
1610
2777
|
device_id: this._deviceId,
|
|
1611
2778
|
slot_id: this._slotId,
|
|
1612
|
-
}).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2779
|
+
}, undefined, undefined, true).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
1613
2780
|
}
|
|
1614
2781
|
}
|
|
1615
2782
|
// 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
|
|
@@ -1655,12 +2822,17 @@ export class AUNClient {
|
|
|
1655
2822
|
// 提交者签名验证(兼容旧版:无签名时继续)
|
|
1656
2823
|
const cs = d.client_signature;
|
|
1657
2824
|
if (cs && isJsonObject(cs)) {
|
|
1658
|
-
|
|
1659
|
-
|
|
1660
|
-
|
|
1661
|
-
|
|
2825
|
+
if (this._shouldSkipEventSignature(d)) {
|
|
2826
|
+
delete d.client_signature;
|
|
2827
|
+
}
|
|
2828
|
+
else {
|
|
2829
|
+
const verified = await this._verifyEventSignatureAsync(d, cs);
|
|
2830
|
+
if (verified === false) {
|
|
2831
|
+
this._clientLog.warn(`state_committed committer signature verification failed group=${groupId}`);
|
|
2832
|
+
return;
|
|
2833
|
+
}
|
|
2834
|
+
d._verified = verified;
|
|
1662
2835
|
}
|
|
1663
|
-
d._verified = verified;
|
|
1664
2836
|
}
|
|
1665
2837
|
const stateVersion = Number(d.state_version ?? 0);
|
|
1666
2838
|
const stateHash = String(d.state_hash ?? '').trim();
|
|
@@ -1810,12 +2982,86 @@ export class AUNClient {
|
|
|
1810
2982
|
return false;
|
|
1811
2983
|
}
|
|
1812
2984
|
}
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
2985
|
+
async _validateAndCachePeerCert(opts) {
|
|
2986
|
+
const aid = String(opts.aid ?? '').trim();
|
|
2987
|
+
const certPem = String(opts.certPem ?? '').trim();
|
|
2988
|
+
const certFingerprint = String(opts.certFingerprint ?? '').trim() || undefined;
|
|
2989
|
+
if (!aid)
|
|
2990
|
+
throw new ValidationError('peer aid is required for cert validation');
|
|
2991
|
+
if (!certPem)
|
|
2992
|
+
throw new ValidationError(`peer cert is empty for ${aid}`);
|
|
2993
|
+
const gatewayUrl = this._gatewayUrl;
|
|
2994
|
+
if (!gatewayUrl) {
|
|
2995
|
+
throw new ValidationError('gateway url unavailable for e2ee cert validation');
|
|
2996
|
+
}
|
|
2997
|
+
const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
2998
|
+
const x509Cert = new crypto.X509Certificate(certPem);
|
|
2999
|
+
// H7: 严格校验指纹(DER SHA-256 或 SPKI SHA-256 任一匹配即可)
|
|
3000
|
+
if (certFingerprint) {
|
|
3001
|
+
const expectedFP = certFingerprint.toLowerCase();
|
|
3002
|
+
if (!expectedFP.startsWith('sha256:')) {
|
|
3003
|
+
throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
|
|
3004
|
+
}
|
|
3005
|
+
const expectedHex = expectedFP.slice('sha256:'.length);
|
|
3006
|
+
const derHex = x509Cert.fingerprint256.replace(/:/g, '').toLowerCase();
|
|
3007
|
+
let spkiHex = '';
|
|
3008
|
+
try {
|
|
3009
|
+
const spkiDer = x509Cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
3010
|
+
spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
|
|
3011
|
+
}
|
|
3012
|
+
catch {
|
|
3013
|
+
spkiHex = '';
|
|
3014
|
+
}
|
|
3015
|
+
if (expectedHex !== derHex && (!spkiHex || expectedHex !== spkiHex)) {
|
|
3016
|
+
throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
|
|
3017
|
+
}
|
|
3018
|
+
}
|
|
3019
|
+
let cachedBootstrapChain = false;
|
|
3020
|
+
const caChainPems = opts.caChainPems ?? [];
|
|
3021
|
+
if (caChainPems.length > 0) {
|
|
3022
|
+
try {
|
|
3023
|
+
this._auth.cacheGatewayCaChain(peerGatewayUrl, caChainPems, aid);
|
|
3024
|
+
cachedBootstrapChain = true;
|
|
3025
|
+
}
|
|
3026
|
+
catch (exc) {
|
|
3027
|
+
this._clientLog.debug(`bootstrap CA chain cache skipped: peer=${aid}, source=${opts.source ?? 'unknown'}, err=${formatCaughtError(exc)}`);
|
|
3028
|
+
}
|
|
3029
|
+
}
|
|
3030
|
+
try {
|
|
3031
|
+
await this._auth.verifyPeerCertificate(peerGatewayUrl, certPem, aid);
|
|
3032
|
+
}
|
|
3033
|
+
catch (exc) {
|
|
3034
|
+
if (cachedBootstrapChain) {
|
|
3035
|
+
this._auth.discardGatewayCaChain(peerGatewayUrl, aid);
|
|
3036
|
+
}
|
|
3037
|
+
throw new ValidationError(`peer cert verification failed for ${aid}: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
3038
|
+
}
|
|
3039
|
+
const nowSec = Date.now() / 1000;
|
|
3040
|
+
const entry = {
|
|
3041
|
+
certPem,
|
|
3042
|
+
validatedAt: nowSec,
|
|
3043
|
+
refreshAfter: nowSec + PEER_CERT_CACHE_TTL,
|
|
3044
|
+
};
|
|
3045
|
+
const cacheKey = AUNClient._certCacheKey(aid, certFingerprint);
|
|
3046
|
+
this._certCache.set(cacheKey, entry);
|
|
3047
|
+
const bareKey = AUNClient._certCacheKey(aid);
|
|
3048
|
+
if (bareKey !== cacheKey)
|
|
3049
|
+
this._certCache.set(bareKey, entry);
|
|
3050
|
+
if (!certFingerprint) {
|
|
3051
|
+
const actualFp = `sha256:${x509Cert.fingerprint256.replace(/:/g, '').toLowerCase()}`;
|
|
3052
|
+
this._certCache.set(AUNClient._certCacheKey(aid, actualFp), entry);
|
|
3053
|
+
}
|
|
3054
|
+
try {
|
|
3055
|
+
// peer 证书只存版本目录,不覆盖 cert.pem
|
|
3056
|
+
this._keystore.saveCert(aid, certPem, certFingerprint, { makeActive: false });
|
|
3057
|
+
}
|
|
3058
|
+
catch (exc) {
|
|
3059
|
+
this._clientLog.error(`failed to write cert to keystore (aid=${aid}, fp=${certFingerprint ?? ''}): ${formatCaughtError(exc)}`, exc instanceof Error ? exc : undefined);
|
|
3060
|
+
}
|
|
3061
|
+
return certPem;
|
|
3062
|
+
}
|
|
3063
|
+
/** 获取对方证书(带缓存 + 完整 PKI 验证),跨域时自动路由到 peer 所在域。 */
|
|
3064
|
+
async _fetchPeerCert(aid, certFingerprint, timeoutMs = 30_000) {
|
|
1819
3065
|
const tStart = Date.now();
|
|
1820
3066
|
this._clientLog.debug(`_fetchPeerCert enter: aid=${aid}, fp=${certFingerprint ?? ''}`);
|
|
1821
3067
|
try {
|
|
@@ -1830,75 +3076,118 @@ export class AUNClient {
|
|
|
1830
3076
|
if (!gatewayUrl) {
|
|
1831
3077
|
throw new ValidationError('gateway url unavailable for e2ee cert fetch');
|
|
1832
3078
|
}
|
|
1833
|
-
// 跨域时用 peer 所在域的 Gateway URL
|
|
1834
3079
|
const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
1835
3080
|
let certPem;
|
|
1836
3081
|
try {
|
|
1837
|
-
|
|
1838
|
-
certPem = await _httpGetText(certUrl, this._configModel.verifySsl);
|
|
3082
|
+
certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid, certFingerprint), this._configModel.verifySsl, timeoutMs);
|
|
1839
3083
|
}
|
|
1840
3084
|
catch (exc) {
|
|
1841
|
-
if (!certFingerprint)
|
|
3085
|
+
if (!certFingerprint)
|
|
1842
3086
|
throw exc;
|
|
1843
|
-
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
1852
|
-
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
|
|
1856
|
-
|
|
3087
|
+
certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid), this._configModel.verifySsl, timeoutMs);
|
|
3088
|
+
}
|
|
3089
|
+
const validated = await this._validateAndCachePeerCert({
|
|
3090
|
+
aid,
|
|
3091
|
+
certPem,
|
|
3092
|
+
certFingerprint,
|
|
3093
|
+
source: 'fetch',
|
|
3094
|
+
});
|
|
3095
|
+
this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} (fetched)`);
|
|
3096
|
+
return validated;
|
|
3097
|
+
}
|
|
3098
|
+
catch (err) {
|
|
3099
|
+
this._clientLog.debug(`_fetchPeerCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
3100
|
+
throw err;
|
|
3101
|
+
}
|
|
3102
|
+
}
|
|
3103
|
+
_bootstrapCaChain(material) {
|
|
3104
|
+
let raw;
|
|
3105
|
+
for (const key of ['ca_chain', 'ca_chain_pems', 'cert_chain', 'chain']) {
|
|
3106
|
+
if (material[key] !== undefined && material[key] !== null) {
|
|
3107
|
+
raw = material[key];
|
|
3108
|
+
break;
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
if (!Array.isArray(raw))
|
|
3112
|
+
return [];
|
|
3113
|
+
const result = [];
|
|
3114
|
+
for (const item of raw) {
|
|
3115
|
+
let certType = '';
|
|
3116
|
+
let certPem = '';
|
|
3117
|
+
if (isJsonObject(item)) {
|
|
3118
|
+
certType = String(item.cert_type ?? '').trim().toLowerCase();
|
|
3119
|
+
if (certType === 'agent')
|
|
3120
|
+
continue;
|
|
3121
|
+
certPem = String(item.cert_pem ?? item.cert ?? '').trim();
|
|
3122
|
+
}
|
|
3123
|
+
else {
|
|
3124
|
+
certPem = String(item ?? '').trim();
|
|
3125
|
+
}
|
|
3126
|
+
if (!certPem)
|
|
3127
|
+
continue;
|
|
3128
|
+
if (!certType) {
|
|
1857
3129
|
try {
|
|
1858
|
-
|
|
1859
|
-
|
|
3130
|
+
if (!new crypto.X509Certificate(certPem).ca)
|
|
3131
|
+
continue;
|
|
1860
3132
|
}
|
|
1861
3133
|
catch {
|
|
1862
|
-
|
|
1863
|
-
}
|
|
1864
|
-
if (expectedHex !== derHex && (!spkiHex || expectedHex !== spkiHex)) {
|
|
1865
|
-
throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
|
|
3134
|
+
continue;
|
|
1866
3135
|
}
|
|
1867
3136
|
}
|
|
1868
|
-
|
|
1869
|
-
|
|
1870
|
-
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
1875
|
-
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
3137
|
+
result.push(certPem);
|
|
3138
|
+
}
|
|
3139
|
+
return result;
|
|
3140
|
+
}
|
|
3141
|
+
async _primeBootstrapPeerCerts(bootstrap, peerAid) {
|
|
3142
|
+
const certsRaw = bootstrap.certs;
|
|
3143
|
+
if (!isJsonObject(certsRaw))
|
|
3144
|
+
return;
|
|
3145
|
+
const materials = certsRaw;
|
|
3146
|
+
const expected = new Set();
|
|
3147
|
+
const normalizedPeer = String(peerAid ?? '').trim();
|
|
3148
|
+
if (normalizedPeer)
|
|
3149
|
+
expected.add(normalizedPeer);
|
|
3150
|
+
const audit = Array.isArray(bootstrap.audit_recipients) ? bootstrap.audit_recipients : [];
|
|
3151
|
+
for (const dev of audit) {
|
|
3152
|
+
if (!isJsonObject(dev))
|
|
3153
|
+
continue;
|
|
3154
|
+
const aid = String(dev.aid ?? '').trim();
|
|
3155
|
+
if (aid)
|
|
3156
|
+
expected.add(aid);
|
|
3157
|
+
}
|
|
3158
|
+
for (const aid of expected) {
|
|
3159
|
+
if (aid === this._aid)
|
|
3160
|
+
continue;
|
|
3161
|
+
const material = materials[aid];
|
|
3162
|
+
if (!isJsonObject(material))
|
|
3163
|
+
continue;
|
|
3164
|
+
const certPem = String(material.cert_pem ?? material.cert ?? '').trim();
|
|
3165
|
+
if (!certPem)
|
|
3166
|
+
continue;
|
|
3167
|
+
const certFingerprint = String(material.cert_fingerprint ?? material.fingerprint ?? material.fp ?? '').trim() || undefined;
|
|
1881
3168
|
try {
|
|
1882
|
-
|
|
1883
|
-
|
|
3169
|
+
await this._validateAndCachePeerCert({
|
|
3170
|
+
aid,
|
|
3171
|
+
certPem,
|
|
3172
|
+
certFingerprint,
|
|
3173
|
+
caChainPems: this._bootstrapCaChain(material),
|
|
3174
|
+
source: 'bootstrap',
|
|
3175
|
+
});
|
|
1884
3176
|
}
|
|
1885
3177
|
catch (exc) {
|
|
1886
|
-
this._clientLog.
|
|
3178
|
+
this._clientLog.debug(`bootstrap peer cert material ignored: peer=${aid}, err=${formatCaughtError(exc)}`);
|
|
1887
3179
|
}
|
|
1888
|
-
this._clientLog.debug(`_fetchPeerCert exit: elapsed=${Date.now() - tStart}ms aid=${aid} (fetched)`);
|
|
1889
|
-
return certPem;
|
|
1890
|
-
}
|
|
1891
|
-
catch (err) {
|
|
1892
|
-
this._clientLog.debug(`_fetchPeerCert exit (error): elapsed=${Date.now() - tStart}ms aid=${aid} err=${err instanceof Error ? err.message : String(err)}`);
|
|
1893
|
-
throw err;
|
|
1894
3180
|
}
|
|
1895
3181
|
}
|
|
1896
3182
|
async _decryptGroupThoughts(result) {
|
|
3183
|
+
this._clientLog.debug(`group.thought.get decrypt enter: found=${String(result.found ?? '')}, group=${String(result.group_id ?? '')}, sender=${String(result.sender_aid ?? '')}`);
|
|
1897
3184
|
if (!result.found) {
|
|
3185
|
+
this._clientLog.debug('group.thought.get decrypt exit: not found');
|
|
1898
3186
|
return { ...result, thoughts: [] };
|
|
1899
3187
|
}
|
|
1900
3188
|
const items = Array.isArray(result.thoughts) ? result.thoughts.filter(isJsonObject) : [];
|
|
1901
3189
|
if (items.length === 0) {
|
|
3190
|
+
this._clientLog.debug('group.thought.get decrypt exit: empty thoughts');
|
|
1902
3191
|
return { ...result, thoughts: [] };
|
|
1903
3192
|
}
|
|
1904
3193
|
const groupId = String(result.group_id ?? '');
|
|
@@ -1908,26 +3197,32 @@ export class AUNClient {
|
|
|
1908
3197
|
const payload = isJsonObject(item.payload) ? item.payload : null;
|
|
1909
3198
|
const thoughtId = String(item.thought_id ?? item.message_id ?? '');
|
|
1910
3199
|
const fromAid = String(item.from ?? item.sender_aid ?? senderAid);
|
|
3200
|
+
this._logMessageDebug('thought-get-raw', 'group.thought.get', 'group.thought.get', item, {
|
|
3201
|
+
extra: { group_id: groupId, thought_id: thoughtId, from: fromAid },
|
|
3202
|
+
});
|
|
1911
3203
|
let decryptFailed = false;
|
|
1912
3204
|
let decryptedPayload = payload ?? {};
|
|
1913
3205
|
let e2ee;
|
|
1914
3206
|
if (payload?.type === 'e2ee.group_encrypted' && String(payload.version ?? '') === 'v2') {
|
|
3207
|
+
e2ee = this._v2E2eeMeta(payload);
|
|
1915
3208
|
const plain = await this._decryptV2EnvelopeForThought({ envelope: payload, fromAid });
|
|
1916
3209
|
if (plain === null) {
|
|
1917
3210
|
decryptFailed = true;
|
|
3211
|
+
this._clientLog.debug(`group.thought.get decrypt returned null: group=${groupId}, thought_id=${thoughtId}, from=${fromAid}`);
|
|
1918
3212
|
}
|
|
1919
3213
|
else {
|
|
1920
3214
|
decryptedPayload = plain;
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
|
|
1925
|
-
|
|
1926
|
-
};
|
|
3215
|
+
this._logMessageDebug('thought-decrypt-ok', 'group.thought.get', 'group.thought.get', {
|
|
3216
|
+
group_id: groupId,
|
|
3217
|
+
thought_id: thoughtId,
|
|
3218
|
+
from: fromAid,
|
|
3219
|
+
payload: plain,
|
|
3220
|
+
});
|
|
1927
3221
|
}
|
|
1928
3222
|
}
|
|
1929
3223
|
else if (payload?.type === 'e2ee.group_encrypted') {
|
|
1930
3224
|
decryptFailed = true;
|
|
3225
|
+
this._clientLog.debug(`group.thought.get unsupported encrypted payload: group=${groupId}, thought_id=${thoughtId}, type=${String(payload.type ?? '')}, version=${String(payload.version ?? '')}`);
|
|
1931
3226
|
}
|
|
1932
3227
|
const thought = {
|
|
1933
3228
|
thought_id: thoughtId,
|
|
@@ -1935,22 +3230,32 @@ export class AUNClient {
|
|
|
1935
3230
|
payload: decryptFailed ? (payload ?? {}) : decryptedPayload,
|
|
1936
3231
|
created_at: item.created_at,
|
|
1937
3232
|
};
|
|
1938
|
-
if (e2ee !== undefined)
|
|
3233
|
+
if (e2ee !== undefined) {
|
|
1939
3234
|
thought.e2ee = e2ee;
|
|
3235
|
+
if (isJsonObject(e2ee))
|
|
3236
|
+
this._attachV2EnvelopeMetadata(thought, e2ee);
|
|
3237
|
+
}
|
|
1940
3238
|
if (decryptFailed)
|
|
1941
3239
|
thought.decrypt_failed = true;
|
|
1942
3240
|
if ('context' in item)
|
|
1943
3241
|
thought.context = item.context;
|
|
3242
|
+
this._logMessageDebug(decryptFailed ? 'thought-decrypt-fail' : 'thought-result', 'group.thought.get', 'group.thought.get', thought, {
|
|
3243
|
+
extra: { group_id: groupId, thought_id: thoughtId },
|
|
3244
|
+
});
|
|
1944
3245
|
thoughts.push(thought);
|
|
1945
3246
|
}
|
|
3247
|
+
this._clientLog.debug(`group.thought.get decrypt exit: group=${groupId}, total=${items.length}, returned=${thoughts.length}`);
|
|
1946
3248
|
return { ...result, thoughts };
|
|
1947
3249
|
}
|
|
1948
3250
|
async _decryptMessageThoughts(result) {
|
|
3251
|
+
this._clientLog.debug(`message.thought.get decrypt enter: found=${String(result.found ?? '')}, peer=${String(result.peer_aid ?? '')}, sender=${String(result.sender_aid ?? '')}`);
|
|
1949
3252
|
if (!result.found) {
|
|
3253
|
+
this._clientLog.debug('message.thought.get decrypt exit: not found');
|
|
1950
3254
|
return { ...result, thoughts: [] };
|
|
1951
3255
|
}
|
|
1952
3256
|
const items = Array.isArray(result.thoughts) ? result.thoughts.filter(isJsonObject) : [];
|
|
1953
3257
|
if (items.length === 0) {
|
|
3258
|
+
this._clientLog.debug('message.thought.get decrypt exit: empty thoughts');
|
|
1954
3259
|
return { ...result, thoughts: [] };
|
|
1955
3260
|
}
|
|
1956
3261
|
const senderAid = String(result.sender_aid ?? '');
|
|
@@ -1961,26 +3266,32 @@ export class AUNClient {
|
|
|
1961
3266
|
const thoughtId = String(item.thought_id ?? item.message_id ?? '');
|
|
1962
3267
|
const fromAid = String(item.from ?? senderAid);
|
|
1963
3268
|
const toAid = String(item.to ?? peerAid);
|
|
3269
|
+
this._logMessageDebug('thought-get-raw', 'message.thought.get', 'message.thought.get', item, {
|
|
3270
|
+
extra: { thought_id: thoughtId, from: fromAid, to: toAid },
|
|
3271
|
+
});
|
|
1964
3272
|
let decryptFailed = false;
|
|
1965
3273
|
let decryptedPayload = payload ?? {};
|
|
1966
3274
|
let e2ee;
|
|
1967
3275
|
if (payload?.type === 'e2ee.p2p_encrypted' && String(payload.version ?? '') === 'v2') {
|
|
3276
|
+
e2ee = this._v2E2eeMeta(payload);
|
|
1968
3277
|
const plain = await this._decryptV2EnvelopeForThought({ envelope: payload, fromAid });
|
|
1969
3278
|
if (plain === null) {
|
|
1970
3279
|
decryptFailed = true;
|
|
3280
|
+
this._clientLog.debug(`message.thought.get decrypt returned null: thought_id=${thoughtId}, from=${fromAid}, to=${toAid}`);
|
|
1971
3281
|
}
|
|
1972
3282
|
else {
|
|
1973
3283
|
decryptedPayload = plain;
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
};
|
|
3284
|
+
this._logMessageDebug('thought-decrypt-ok', 'message.thought.get', 'message.thought.get', {
|
|
3285
|
+
thought_id: thoughtId,
|
|
3286
|
+
from: fromAid,
|
|
3287
|
+
to: toAid,
|
|
3288
|
+
payload: plain,
|
|
3289
|
+
});
|
|
1980
3290
|
}
|
|
1981
3291
|
}
|
|
1982
3292
|
else if (payload?.type === 'e2ee.encrypted' || payload?.type === 'e2ee.p2p_encrypted') {
|
|
1983
3293
|
decryptFailed = true;
|
|
3294
|
+
this._clientLog.debug(`message.thought.get unsupported encrypted payload: thought_id=${thoughtId}, type=${String(payload.type ?? '')}, version=${String(payload.version ?? '')}`);
|
|
1984
3295
|
}
|
|
1985
3296
|
const thought = {
|
|
1986
3297
|
thought_id: thoughtId,
|
|
@@ -1990,14 +3301,21 @@ export class AUNClient {
|
|
|
1990
3301
|
payload: decryptFailed ? (payload ?? {}) : decryptedPayload,
|
|
1991
3302
|
created_at: item.created_at,
|
|
1992
3303
|
};
|
|
1993
|
-
if (e2ee !== undefined)
|
|
3304
|
+
if (e2ee !== undefined) {
|
|
1994
3305
|
thought.e2ee = e2ee;
|
|
3306
|
+
if (isJsonObject(e2ee))
|
|
3307
|
+
this._attachV2EnvelopeMetadata(thought, e2ee);
|
|
3308
|
+
}
|
|
1995
3309
|
if (decryptFailed)
|
|
1996
3310
|
thought.decrypt_failed = true;
|
|
1997
3311
|
if ('context' in item)
|
|
1998
3312
|
thought.context = item.context;
|
|
3313
|
+
this._logMessageDebug(decryptFailed ? 'thought-decrypt-fail' : 'thought-result', 'message.thought.get', 'message.thought.get', thought, {
|
|
3314
|
+
extra: { thought_id: thoughtId },
|
|
3315
|
+
});
|
|
1999
3316
|
thoughts.push(thought);
|
|
2000
3317
|
}
|
|
3318
|
+
this._clientLog.debug(`message.thought.get decrypt exit: total=${items.length}, returned=${thoughts.length}`);
|
|
2001
3319
|
return { ...result, thoughts };
|
|
2002
3320
|
}
|
|
2003
3321
|
/** 从 keystore 恢复 SeqTracker 状态 */ /** 从 keystore 恢复 SeqTracker 状态 */
|
|
@@ -2102,6 +3420,8 @@ export class AUNClient {
|
|
|
2102
3420
|
this._gapFillDone.clear();
|
|
2103
3421
|
this._pushedSeqs.clear();
|
|
2104
3422
|
this._pendingOrderedMsgs.clear();
|
|
3423
|
+
this._v2SenderIKPending.clear();
|
|
3424
|
+
this._v2SenderIKFetching.clear();
|
|
2105
3425
|
this._groupSynced.clear();
|
|
2106
3426
|
}
|
|
2107
3427
|
_refreshSeqTrackerContext() {
|
|
@@ -2112,6 +3432,8 @@ export class AUNClient {
|
|
|
2112
3432
|
this._gapFillDone.clear();
|
|
2113
3433
|
this._pushedSeqs.clear();
|
|
2114
3434
|
this._pendingOrderedMsgs.clear();
|
|
3435
|
+
this._v2SenderIKPending.clear();
|
|
3436
|
+
this._v2SenderIKFetching.clear();
|
|
2115
3437
|
this._groupSynced.clear();
|
|
2116
3438
|
this._seqTrackerContext = nextContext;
|
|
2117
3439
|
}
|
|
@@ -2152,6 +3474,43 @@ export class AUNClient {
|
|
|
2152
3474
|
}).catch(() => { });
|
|
2153
3475
|
}
|
|
2154
3476
|
}
|
|
3477
|
+
_persistRepairedSeq(ns) {
|
|
3478
|
+
if (!this._aid || !ns)
|
|
3479
|
+
return;
|
|
3480
|
+
const seq = this._seqTracker.getContiguousSeq(ns);
|
|
3481
|
+
try {
|
|
3482
|
+
if (seq > 0 && typeof this._keystore.saveSeq === 'function') {
|
|
3483
|
+
this._keystore.saveSeq(this._aid, this._deviceId, this._slotId, ns, seq);
|
|
3484
|
+
return;
|
|
3485
|
+
}
|
|
3486
|
+
const deleteSeq = this._keystore.deleteSeq;
|
|
3487
|
+
if (seq <= 0 && typeof deleteSeq === 'function') {
|
|
3488
|
+
deleteSeq.call(this._keystore, this._aid, this._deviceId, this._slotId, ns);
|
|
3489
|
+
return;
|
|
3490
|
+
}
|
|
3491
|
+
if (seq > 0) {
|
|
3492
|
+
this._saveSeqTrackerState();
|
|
3493
|
+
}
|
|
3494
|
+
}
|
|
3495
|
+
catch (exc) {
|
|
3496
|
+
this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
|
|
3497
|
+
}
|
|
3498
|
+
}
|
|
3499
|
+
_repairPushContiguousBound(ns, pushSeq, hasPayload, label) {
|
|
3500
|
+
if (!ns || !Number.isFinite(pushSeq) || pushSeq <= 0) {
|
|
3501
|
+
return ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
3502
|
+
}
|
|
3503
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
3504
|
+
const shouldRepair = contig > pushSeq;
|
|
3505
|
+
if (!shouldRepair)
|
|
3506
|
+
return contig;
|
|
3507
|
+
const repairedTo = Math.max(0, pushSeq - 1);
|
|
3508
|
+
this._seqTracker.repairContiguousSeq(ns, repairedTo);
|
|
3509
|
+
const repaired = this._seqTracker.getContiguousSeq(ns);
|
|
3510
|
+
this._persistRepairedSeq(ns);
|
|
3511
|
+
this._clientLog.warn(`${label} push repaired contiguous_seq: ns=${ns} payload=${hasPayload} push_seq=${pushSeq} contiguous=${contig}->${repaired}`);
|
|
3512
|
+
return repaired;
|
|
3513
|
+
}
|
|
2155
3514
|
/** 记录 E2EE 自动编排错误 */
|
|
2156
3515
|
_logE2eeError(stage, groupId, aid, exc) {
|
|
2157
3516
|
try {
|
|
@@ -2276,7 +3635,7 @@ export class AUNClient {
|
|
|
2276
3635
|
catch (exc) {
|
|
2277
3636
|
this._clientLog.warn(`V2 session init failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
2278
3637
|
}
|
|
2279
|
-
// connect/reconnect 成功后自动触发一次 P2P message.pull,补齐离线期间积压
|
|
3638
|
+
// connect/reconnect 成功后自动触发一次 P2P message.v2.pull,补齐离线期间积压
|
|
2280
3639
|
// 群消息按惰性触发,不在此处主动 pull
|
|
2281
3640
|
void this._fillP2pGap().catch((exc) => {
|
|
2282
3641
|
this._clientLog.warn(`schedule post-connect P2P gap fill failed: ${formatCaughtError(exc)}`);
|
|
@@ -2368,45 +3727,245 @@ export class AUNClient {
|
|
|
2368
3727
|
this._v2Session = new V2Session(v2Store, this._deviceId, this._aid, aidPriv, aidPubDer);
|
|
2369
3728
|
await this._v2Session.ensureRegistered(this._v2CallFn());
|
|
2370
3729
|
this._clientLog.debug(`V2 session initialized aid=${this._aid} device=${this._deviceId}`);
|
|
2371
|
-
|
|
3730
|
+
// 群 state proposal 由服务端在 client.online 时定向通知。
|
|
3731
|
+
}
|
|
3732
|
+
async _v2TrustedIKPubDer(aid) {
|
|
3733
|
+
const normalizedAid = String(aid ?? '').trim();
|
|
3734
|
+
if (!normalizedAid)
|
|
3735
|
+
throw new E2EEError('spk_aid_missing');
|
|
3736
|
+
if (this._aid && normalizedAid === this._aid) {
|
|
3737
|
+
if (!this._v2Session)
|
|
3738
|
+
throw new E2EEError('V2 session not initialized');
|
|
3739
|
+
return this._v2Session.currentIkPubDer;
|
|
3740
|
+
}
|
|
3741
|
+
const certPem = await this._fetchPeerCert(normalizedAid);
|
|
3742
|
+
const cert = new crypto.X509Certificate(certPem);
|
|
3743
|
+
const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
3744
|
+
return new Uint8Array(certPubDer);
|
|
3745
|
+
}
|
|
3746
|
+
_v2SPKTimestampText(value, aid, deviceId, spkId) {
|
|
3747
|
+
if (value === null || value === undefined || value === '') {
|
|
3748
|
+
throw new E2EEError(`spk_timestamp_missing: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
3749
|
+
}
|
|
3750
|
+
if (typeof value === 'boolean') {
|
|
3751
|
+
throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
3752
|
+
}
|
|
3753
|
+
if (typeof value === 'number') {
|
|
3754
|
+
if (!Number.isSafeInteger(value)) {
|
|
3755
|
+
throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
3756
|
+
}
|
|
3757
|
+
return String(value);
|
|
3758
|
+
}
|
|
3759
|
+
const text = String(value).trim();
|
|
3760
|
+
if (!/^\d+$/.test(text)) {
|
|
3761
|
+
throw new E2EEError(`spk_timestamp_invalid: aid=${aid} device_id=${deviceId} spk_id=${spkId}`);
|
|
3762
|
+
}
|
|
3763
|
+
return BigInt(text).toString();
|
|
3764
|
+
}
|
|
3765
|
+
async _v2VerifySPKDevice(args) {
|
|
3766
|
+
if (!this._v2Session)
|
|
3767
|
+
throw new E2EEError('V2 session not initialized');
|
|
3768
|
+
const spkId = String(args.dev.spk_id ?? '').trim();
|
|
3769
|
+
if (!spkId)
|
|
3770
|
+
return;
|
|
3771
|
+
if (args.keySource !== 'peer_device_prekey' && args.keySource !== 'group_device_prekey') {
|
|
3772
|
+
throw new E2EEError(`spk_key_source_invalid: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} key_source=${args.keySource}`);
|
|
3773
|
+
}
|
|
3774
|
+
if (!args.spkPkDer || args.spkPkDer.length === 0) {
|
|
3775
|
+
throw new E2EEError(`spk_public_key_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
3776
|
+
}
|
|
3777
|
+
const expectedSpkId = `sha256:${crypto.createHash('sha256').update(Buffer.from(args.spkPkDer)).digest('hex').slice(0, 16)}`;
|
|
3778
|
+
if (spkId !== expectedSpkId) {
|
|
3779
|
+
throw new E2EEError(`spk_id_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId} expected=${expectedSpkId}`);
|
|
3780
|
+
}
|
|
3781
|
+
const trustedIK = await this._v2TrustedIKPubDer(args.aid);
|
|
3782
|
+
if (!_v2BytesEqual(trustedIK, args.ikPkDer)) {
|
|
3783
|
+
throw new E2EEError(`spk_ik_mismatch: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
3784
|
+
}
|
|
3785
|
+
if (_v2BytesEqual(args.spkPkDer, trustedIK)) {
|
|
3786
|
+
this._v2Session.markPeerSPKVerified(args.aid, args.deviceId, spkId);
|
|
3787
|
+
return;
|
|
3788
|
+
}
|
|
3789
|
+
const sigB64 = String(args.dev.spk_signature ?? '').trim();
|
|
3790
|
+
if (!sigB64) {
|
|
3791
|
+
throw new E2EEError(`spk_signature_missing: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
3792
|
+
}
|
|
3793
|
+
let signature;
|
|
3794
|
+
try {
|
|
3795
|
+
signature = _v2B64ToBytesStrict(sigB64);
|
|
3796
|
+
}
|
|
3797
|
+
catch {
|
|
3798
|
+
throw new E2EEError(`spk_signature_invalid_base64: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
3799
|
+
}
|
|
3800
|
+
const tsText = this._v2SPKTimestampText(args.dev.spk_timestamp, args.aid, args.deviceId, spkId);
|
|
3801
|
+
const signData = Buffer.concat([
|
|
3802
|
+
Buffer.from(args.spkPkDer),
|
|
3803
|
+
Buffer.from(spkId, 'utf8'),
|
|
3804
|
+
Buffer.from(tsText, 'utf8'),
|
|
3805
|
+
]);
|
|
3806
|
+
if (!ecdsaVerifyRaw(trustedIK, signature, new Uint8Array(signData))) {
|
|
3807
|
+
throw new E2EEError(`spk_signature_invalid: aid=${args.aid} device_id=${args.deviceId} spk_id=${spkId}`);
|
|
3808
|
+
}
|
|
3809
|
+
this._v2Session.markPeerSPKVerified(args.aid, args.deviceId, spkId);
|
|
3810
|
+
}
|
|
3811
|
+
async _v2BuildTargetFromDevice(args) {
|
|
3812
|
+
const aid = String(args.aid ?? '').trim();
|
|
3813
|
+
const devId = getV2DeviceId(args.dev);
|
|
3814
|
+
const deviceId = devId.present ? devId.value : String(args.deviceId ?? '').trim();
|
|
3815
|
+
const ikPk = String(args.dev.ik_pk ?? '').trim();
|
|
3816
|
+
if (!aid || !devId.present || !ikPk)
|
|
3817
|
+
return null;
|
|
3818
|
+
const ikPkDer = _v2B64ToBytes(ikPk);
|
|
3819
|
+
const spkPkDer = args.dev.spk_pk ? _v2B64ToBytes(String(args.dev.spk_pk)) : undefined;
|
|
3820
|
+
const keySource = String(args.dev.key_source ?? args.defaultKeySource).trim() || args.defaultKeySource;
|
|
3821
|
+
await this._v2VerifySPKDevice({ dev: args.dev, aid, deviceId, ikPkDer, spkPkDer, keySource });
|
|
3822
|
+
this._v2Session?.cachePeerIK(aid, deviceId, ikPkDer);
|
|
3823
|
+
return {
|
|
3824
|
+
aid,
|
|
3825
|
+
deviceId,
|
|
3826
|
+
role: args.role,
|
|
3827
|
+
keySource,
|
|
3828
|
+
ikPkDer,
|
|
3829
|
+
spkPkDer,
|
|
3830
|
+
spkId: String(args.dev.spk_id ?? '').trim(),
|
|
3831
|
+
};
|
|
2372
3832
|
}
|
|
2373
3833
|
async _getV2SenderPubDer(fromAid, senderDeviceId) {
|
|
2374
3834
|
const session = this._v2Session;
|
|
2375
3835
|
if (!session || !fromAid)
|
|
2376
3836
|
return null;
|
|
2377
|
-
|
|
3837
|
+
const senderPubDer = session.getPeerIK(fromAid, senderDeviceId);
|
|
2378
3838
|
if (senderPubDer)
|
|
2379
3839
|
return senderPubDer;
|
|
2380
3840
|
try {
|
|
2381
|
-
const
|
|
2382
|
-
const
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
|
|
2387
|
-
|
|
2388
|
-
|
|
2389
|
-
|
|
2390
|
-
|
|
2391
|
-
|
|
2392
|
-
|
|
3841
|
+
const certPem = await this._fetchPeerCert(fromAid, undefined, 3000);
|
|
3842
|
+
const cert = new crypto.X509Certificate(certPem);
|
|
3843
|
+
const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
3844
|
+
const certPub = new Uint8Array(certPubDer);
|
|
3845
|
+
session.cachePeerIK(fromAid, senderDeviceId, certPub);
|
|
3846
|
+
this._clientLog.debug(`V2 decrypt: sender IK fallback from PKI cert for ${fromAid}`);
|
|
3847
|
+
return certPub;
|
|
3848
|
+
}
|
|
3849
|
+
catch (exc) {
|
|
3850
|
+
this._clientLog.warn(`V2 decrypt: PKI cert sender IK fallback failed for ${fromAid}: ${formatCaughtError(exc)}`);
|
|
3851
|
+
return null;
|
|
3852
|
+
}
|
|
3853
|
+
}
|
|
3854
|
+
_v2PendingSenderIKMessageKey(msg, groupId) {
|
|
3855
|
+
const messageId = String(msg.message_id ?? '').trim();
|
|
3856
|
+
const seq = String(msg.seq ?? '').trim();
|
|
3857
|
+
const prefix = groupId ? `group:${groupId}` : `p2p:${this._aid ?? ''}`;
|
|
3858
|
+
return `${prefix}:${messageId || seq || Math.random().toString(36).slice(2)}`;
|
|
3859
|
+
}
|
|
3860
|
+
_v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId) {
|
|
3861
|
+
return `${fromAid}#${senderDeviceId}#${groupId || ''}`;
|
|
3862
|
+
}
|
|
3863
|
+
_cacheV2PeerIKFromDevice(dev, fallbackAid = '') {
|
|
3864
|
+
const session = this._v2Session;
|
|
3865
|
+
if (!session || !isJsonObject(dev))
|
|
3866
|
+
return;
|
|
3867
|
+
const device = dev;
|
|
3868
|
+
const devId = getV2DeviceId(device);
|
|
3869
|
+
const aid = String(device.aid ?? fallbackAid ?? '').trim();
|
|
3870
|
+
const ikPk = String(device.ik_pk ?? '').trim();
|
|
3871
|
+
if (!devId.present || !aid || !ikPk)
|
|
3872
|
+
return;
|
|
3873
|
+
try {
|
|
3874
|
+
session.cachePeerIK(aid, devId.value, _v2B64ToBytes(ikPk));
|
|
2393
3875
|
}
|
|
2394
3876
|
catch (exc) {
|
|
2395
|
-
this._clientLog.
|
|
3877
|
+
this._clientLog.debug(`V2 sender IK cache from bootstrap skipped aid=${aid} dev=${devId.value}: ${formatCaughtError(exc)}`);
|
|
2396
3878
|
}
|
|
3879
|
+
}
|
|
3880
|
+
_scheduleV2SenderIKPending(args) {
|
|
3881
|
+
const fromAid = String(args.fromAid ?? '').trim();
|
|
3882
|
+
if (!fromAid)
|
|
3883
|
+
return;
|
|
3884
|
+
const senderDeviceId = String(args.senderDeviceId ?? '');
|
|
3885
|
+
const groupId = String(args.groupId ?? '').trim();
|
|
3886
|
+
const messageKey = this._v2PendingSenderIKMessageKey(args.msg, groupId);
|
|
3887
|
+
this._v2SenderIKPending.set(messageKey, {
|
|
3888
|
+
msg: { ...args.msg },
|
|
3889
|
+
fromAid,
|
|
3890
|
+
senderDeviceId,
|
|
3891
|
+
groupId,
|
|
3892
|
+
createdAt: Date.now(),
|
|
3893
|
+
});
|
|
3894
|
+
this._clientLog.debug(`V2 decrypt pending sender IK: key=${messageKey} from=${fromAid} device=${senderDeviceId || '-'} group=${groupId || '<p2p>'} pending=${this._v2SenderIKPending.size}`);
|
|
3895
|
+
this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId);
|
|
3896
|
+
}
|
|
3897
|
+
_scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId) {
|
|
3898
|
+
const fetchKey = this._v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId);
|
|
3899
|
+
if (!fromAid || this._v2SenderIKFetching.has(fetchKey))
|
|
3900
|
+
return;
|
|
3901
|
+
this._v2SenderIKFetching.add(fetchKey);
|
|
3902
|
+
this._safeAsync(this._resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey));
|
|
3903
|
+
}
|
|
3904
|
+
async _resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey) {
|
|
2397
3905
|
try {
|
|
2398
|
-
const
|
|
2399
|
-
|
|
2400
|
-
|
|
2401
|
-
|
|
2402
|
-
|
|
2403
|
-
|
|
3906
|
+
const session = this._v2Session;
|
|
3907
|
+
if (session && fromAid) {
|
|
3908
|
+
try {
|
|
3909
|
+
const bs = await this.call('message.v2.bootstrap', {
|
|
3910
|
+
peer_aid: fromAid,
|
|
3911
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
3912
|
+
});
|
|
3913
|
+
await this._primeBootstrapPeerCerts(bs, fromAid);
|
|
3914
|
+
const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
3915
|
+
for (const dev of peers)
|
|
3916
|
+
this._cacheV2PeerIKFromDevice(dev, fromAid);
|
|
3917
|
+
}
|
|
3918
|
+
catch (exc) {
|
|
3919
|
+
this._clientLog.warn(`V2 sender IK pending bootstrap failed peer=${fromAid}: ${formatCaughtError(exc)}`);
|
|
3920
|
+
}
|
|
3921
|
+
if (groupId) {
|
|
3922
|
+
try {
|
|
3923
|
+
const gbs = await this.call('group.v2.bootstrap', {
|
|
3924
|
+
group_id: groupId,
|
|
3925
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
3926
|
+
});
|
|
3927
|
+
const devices = (Array.isArray(gbs?.devices) ? gbs.devices : []);
|
|
3928
|
+
const audit = (Array.isArray(gbs?.audit_recipients) ? gbs.audit_recipients : []);
|
|
3929
|
+
for (const dev of devices)
|
|
3930
|
+
this._cacheV2PeerIKFromDevice(dev);
|
|
3931
|
+
for (const dev of audit)
|
|
3932
|
+
this._cacheV2PeerIKFromDevice(dev);
|
|
3933
|
+
}
|
|
3934
|
+
catch (exc) {
|
|
3935
|
+
this._clientLog.warn(`V2 sender IK pending group bootstrap failed group=${groupId}: ${formatCaughtError(exc)}`);
|
|
3936
|
+
}
|
|
3937
|
+
}
|
|
3938
|
+
if (!session.getPeerIK(fromAid, senderDeviceId)) {
|
|
3939
|
+
await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
3940
|
+
}
|
|
3941
|
+
}
|
|
3942
|
+
const pendingItems = [...this._v2SenderIKPending.entries()].filter(([, entry]) => entry.fromAid === fromAid && entry.senderDeviceId === senderDeviceId && entry.groupId === groupId);
|
|
3943
|
+
for (const [key, entry] of pendingItems) {
|
|
3944
|
+
let plaintext = null;
|
|
3945
|
+
try {
|
|
3946
|
+
plaintext = await this._decryptV2Message(entry.msg, false);
|
|
3947
|
+
}
|
|
3948
|
+
catch (exc) {
|
|
3949
|
+
this._clientLog.warn(`V2 sender IK pending retry raised: key=${key} err=${formatCaughtError(exc)}`);
|
|
3950
|
+
}
|
|
3951
|
+
this._v2SenderIKPending.delete(key);
|
|
3952
|
+
if (plaintext === null) {
|
|
3953
|
+
this._clientLog.debug(`V2 sender IK pending retry failed: key=${key}`);
|
|
3954
|
+
continue;
|
|
3955
|
+
}
|
|
3956
|
+
const seq = Number(entry.msg.seq ?? 0);
|
|
3957
|
+
if (entry.groupId) {
|
|
3958
|
+
plaintext.group_id = entry.groupId;
|
|
3959
|
+
await this._publishPulledMessage('group.message_created', `group:${entry.groupId}`, seq, plaintext);
|
|
3960
|
+
}
|
|
3961
|
+
else {
|
|
3962
|
+
await this._publishPulledMessage('message.received', `p2p:${this._aid ?? ''}`, seq, plaintext);
|
|
3963
|
+
}
|
|
3964
|
+
this._clientLog.debug(`V2 sender IK pending retry delivered: key=${key}`);
|
|
2404
3965
|
}
|
|
2405
|
-
return senderPubDer;
|
|
2406
3966
|
}
|
|
2407
|
-
|
|
2408
|
-
this.
|
|
2409
|
-
return null;
|
|
3967
|
+
finally {
|
|
3968
|
+
this._v2SenderIKFetching.delete(fetchKey);
|
|
2410
3969
|
}
|
|
2411
3970
|
}
|
|
2412
3971
|
/**
|
|
@@ -2423,20 +3982,30 @@ export class AUNClient {
|
|
|
2423
3982
|
const useCache = opts.useCache !== false;
|
|
2424
3983
|
let peerDevices = [];
|
|
2425
3984
|
let auditRaw = [];
|
|
3985
|
+
let wrapPolicy = normalizeV2WrapPolicy(undefined);
|
|
2426
3986
|
const cached = useCache ? this._v2BootstrapCache.get(to) : undefined;
|
|
2427
3987
|
if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
2428
3988
|
peerDevices = cached.devices;
|
|
2429
3989
|
auditRaw = cached.auditRecipients;
|
|
3990
|
+
wrapPolicy = cached.wrapPolicy ?? wrapPolicy;
|
|
3991
|
+
this._clientLog.debug(`message.v2.bootstrap cache hit: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
|
|
2430
3992
|
}
|
|
2431
3993
|
else {
|
|
2432
|
-
const bs = await this.call('message.v2.bootstrap', {
|
|
3994
|
+
const bs = await this.call('message.v2.bootstrap', {
|
|
3995
|
+
peer_aid: to,
|
|
3996
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
3997
|
+
});
|
|
3998
|
+
await this._primeBootstrapPeerCerts(bs, to);
|
|
3999
|
+
wrapPolicy = normalizeV2WrapPolicy(bs.e2ee_wrap_policy);
|
|
2433
4000
|
peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
2434
4001
|
auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
|
|
4002
|
+
this._clientLog.debug(`message.v2.bootstrap fetched: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
|
|
2435
4003
|
if (peerDevices.length > 0) {
|
|
2436
4004
|
this._v2BootstrapCache.set(to, {
|
|
2437
4005
|
devices: peerDevices,
|
|
2438
4006
|
auditRecipients: auditRaw,
|
|
2439
4007
|
cachedAt: Date.now(),
|
|
4008
|
+
wrapPolicy,
|
|
2440
4009
|
});
|
|
2441
4010
|
}
|
|
2442
4011
|
}
|
|
@@ -2445,37 +4014,28 @@ export class AUNClient {
|
|
|
2445
4014
|
}
|
|
2446
4015
|
const targets = [];
|
|
2447
4016
|
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({
|
|
4017
|
+
const devId = getV2DeviceId(dev);
|
|
4018
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
4019
|
+
dev,
|
|
2456
4020
|
aid: to,
|
|
2457
|
-
deviceId: devId,
|
|
4021
|
+
deviceId: devId.value,
|
|
2458
4022
|
role: 'peer',
|
|
2459
|
-
|
|
2460
|
-
ikPkDer: ikDer,
|
|
2461
|
-
spkPkDer: spkDer,
|
|
2462
|
-
spkId: String(dev.spk_id ?? ''),
|
|
4023
|
+
defaultKeySource: 'peer_device_prekey',
|
|
2463
4024
|
});
|
|
4025
|
+
if (target)
|
|
4026
|
+
targets.push(target);
|
|
2464
4027
|
}
|
|
2465
4028
|
const auditTargets = [];
|
|
2466
4029
|
for (const dev of auditRaw) {
|
|
2467
|
-
const
|
|
2468
|
-
|
|
2469
|
-
continue;
|
|
2470
|
-
auditTargets.push({
|
|
4030
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
4031
|
+
dev,
|
|
2471
4032
|
aid: String(dev.aid ?? ''),
|
|
2472
4033
|
deviceId: String(dev.device_id ?? ''),
|
|
2473
4034
|
role: 'audit',
|
|
2474
|
-
|
|
2475
|
-
ikPkDer: _v2B64ToBytes(ikPk),
|
|
2476
|
-
spkPkDer: dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined,
|
|
2477
|
-
spkId: String(dev.spk_id ?? ''),
|
|
4035
|
+
defaultKeySource: 'peer_device_prekey',
|
|
2478
4036
|
});
|
|
4037
|
+
if (target)
|
|
4038
|
+
auditTargets.push(target);
|
|
2479
4039
|
}
|
|
2480
4040
|
// self-sync:给同 AID 其它在线/注册设备也 wrap 一份。
|
|
2481
4041
|
if (this._aid && this._aid !== to) {
|
|
@@ -2486,30 +4046,35 @@ export class AUNClient {
|
|
|
2486
4046
|
selfDevices = selfCached.devices;
|
|
2487
4047
|
}
|
|
2488
4048
|
else {
|
|
2489
|
-
const selfBs = await this.call('message.v2.bootstrap', {
|
|
4049
|
+
const selfBs = await this.call('message.v2.bootstrap', {
|
|
4050
|
+
peer_aid: this._aid,
|
|
4051
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
4052
|
+
});
|
|
4053
|
+
await this._primeBootstrapPeerCerts(selfBs, this._aid);
|
|
2490
4054
|
selfDevices = (Array.isArray(selfBs?.peer_devices) ? selfBs.peer_devices : []);
|
|
4055
|
+
const selfWrapPolicy = normalizeV2WrapPolicy(selfBs.e2ee_wrap_policy);
|
|
2491
4056
|
if (selfDevices.length > 0) {
|
|
2492
4057
|
this._v2BootstrapCache.set(this._aid, {
|
|
2493
4058
|
devices: selfDevices,
|
|
2494
4059
|
auditRecipients: [],
|
|
2495
4060
|
cachedAt: Date.now(),
|
|
4061
|
+
wrapPolicy: selfWrapPolicy,
|
|
2496
4062
|
});
|
|
2497
4063
|
}
|
|
2498
4064
|
}
|
|
2499
4065
|
for (const dev of selfDevices) {
|
|
2500
|
-
const devId =
|
|
2501
|
-
|
|
2502
|
-
if (!devId || devId === this._deviceId || !ikPk)
|
|
4066
|
+
const devId = getV2DeviceId(dev);
|
|
4067
|
+
if (!devId.present || devId.value === this._deviceId)
|
|
2503
4068
|
continue;
|
|
2504
|
-
|
|
4069
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
4070
|
+
dev,
|
|
2505
4071
|
aid: this._aid,
|
|
2506
|
-
deviceId: devId,
|
|
4072
|
+
deviceId: devId.value,
|
|
2507
4073
|
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 ?? ''),
|
|
4074
|
+
defaultKeySource: 'peer_device_prekey',
|
|
2512
4075
|
});
|
|
4076
|
+
if (target)
|
|
4077
|
+
targets.push(target);
|
|
2513
4078
|
}
|
|
2514
4079
|
}
|
|
2515
4080
|
catch (exc) {
|
|
@@ -2519,12 +4084,32 @@ export class AUNClient {
|
|
|
2519
4084
|
if (targets.length === 0) {
|
|
2520
4085
|
throw new E2EEError(`V2 bootstrap: no usable devices found for ${to}`);
|
|
2521
4086
|
}
|
|
2522
|
-
|
|
4087
|
+
const envelope = encryptP2PMessage(session.getSenderIdentity(), {
|
|
4088
|
+
targets: applyV2WrapPolicyToTargets(targets, wrapPolicy),
|
|
4089
|
+
auditRecipients: applyV2WrapPolicyToTargets(auditTargets, wrapPolicy),
|
|
4090
|
+
}, opts.payload, {
|
|
2523
4091
|
messageId: opts.messageId,
|
|
2524
4092
|
timestamp: opts.timestamp,
|
|
2525
4093
|
protectedHeaders: opts.protectedHeaders,
|
|
2526
4094
|
context: opts.context,
|
|
2527
4095
|
});
|
|
4096
|
+
this._logMessageDebug('send-envelope', 'message.send.v2', 'message.send', {
|
|
4097
|
+
message_id: envelope.message_id,
|
|
4098
|
+
to,
|
|
4099
|
+
type: envelope.type,
|
|
4100
|
+
version: envelope.version,
|
|
4101
|
+
protected_headers: envelope.protected_headers,
|
|
4102
|
+
context: envelope.context,
|
|
4103
|
+
}, {
|
|
4104
|
+
payloadOverride: envelope,
|
|
4105
|
+
extra: {
|
|
4106
|
+
plaintext_payload: opts.payload,
|
|
4107
|
+
target_count: targets.length,
|
|
4108
|
+
audit_count: auditTargets.length,
|
|
4109
|
+
use_cache: useCache,
|
|
4110
|
+
},
|
|
4111
|
+
});
|
|
4112
|
+
return envelope;
|
|
2528
4113
|
}
|
|
2529
4114
|
/** V2 P2P 加密发送,推测性缓存失败后刷新 bootstrap 重试一次。 */
|
|
2530
4115
|
async sendV2(to, payload, opts) {
|
|
@@ -2534,7 +4119,13 @@ export class AUNClient {
|
|
|
2534
4119
|
throw new ValidationError("message.send requires 'to'");
|
|
2535
4120
|
if (!isJsonObject(payload))
|
|
2536
4121
|
throw new ValidationError('message.send payload must be a dict for V2 encryption');
|
|
4122
|
+
this._logMessageDebug('send-plaintext', 'message.send.v2', 'message.send', {
|
|
4123
|
+
to: toAid,
|
|
4124
|
+
message_id: opts?.messageId ?? '',
|
|
4125
|
+
payload,
|
|
4126
|
+
}, { payloadOverride: payload });
|
|
2537
4127
|
const attempt = async (useCache) => {
|
|
4128
|
+
this._clientLog.debug(`message.v2.send attempt: to=${toAid}, use_cache=${useCache}`);
|
|
2538
4129
|
const envelope = await this._buildV2P2PEnvelope({
|
|
2539
4130
|
to: toAid,
|
|
2540
4131
|
payload,
|
|
@@ -2544,11 +4135,13 @@ export class AUNClient {
|
|
|
2544
4135
|
context: opts?.context,
|
|
2545
4136
|
useCache,
|
|
2546
4137
|
});
|
|
2547
|
-
|
|
4138
|
+
const result = await this.call('message.send', {
|
|
2548
4139
|
to: toAid,
|
|
2549
4140
|
payload: envelope,
|
|
2550
4141
|
encrypt: false,
|
|
2551
4142
|
});
|
|
4143
|
+
this._clientLog.debug(`message.v2.send ok: to=${toAid}, use_cache=${useCache}, seq=${String((isJsonObject(result) ? result.seq : '') ?? '')}`);
|
|
4144
|
+
return result;
|
|
2552
4145
|
};
|
|
2553
4146
|
try {
|
|
2554
4147
|
return await attempt(true);
|
|
@@ -2564,94 +4157,153 @@ export class AUNClient {
|
|
|
2564
4157
|
}
|
|
2565
4158
|
}
|
|
2566
4159
|
/** V2 P2P 拉取并解密;直接方法返回消息数组,call("message.pull") 会包装为 {messages}. */
|
|
2567
|
-
async pullV2(afterSeq = 0, limit = 50) {
|
|
4160
|
+
async pullV2(afterSeq = 0, limit = 50, opts) {
|
|
2568
4161
|
await this._ensureV2SessionReady('message.pull');
|
|
2569
4162
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
2570
|
-
|
|
2571
|
-
|
|
2572
|
-
|
|
4163
|
+
if (ns && !opts?.gateLocked) {
|
|
4164
|
+
return await this._runPullSerialized(ns, async () => this.pullV2(afterSeq, limit, {
|
|
4165
|
+
...(opts ?? {}),
|
|
4166
|
+
gateLocked: true,
|
|
4167
|
+
scheduleFollowup: true,
|
|
4168
|
+
}));
|
|
4169
|
+
}
|
|
2573
4170
|
const decrypted = [];
|
|
2574
|
-
|
|
2575
|
-
|
|
2576
|
-
|
|
2577
|
-
const
|
|
2578
|
-
|
|
2579
|
-
|
|
2580
|
-
|
|
2581
|
-
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
const
|
|
2586
|
-
|
|
2587
|
-
|
|
2588
|
-
|
|
2589
|
-
|
|
2590
|
-
|
|
2591
|
-
|
|
2592
|
-
|
|
2593
|
-
|
|
2594
|
-
|
|
2595
|
-
|
|
2596
|
-
|
|
2597
|
-
|
|
2598
|
-
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
|
|
2602
|
-
|
|
2603
|
-
|
|
2604
|
-
|
|
2605
|
-
|
|
2606
|
-
|
|
2607
|
-
|
|
4171
|
+
let totalRawCount = 0;
|
|
4172
|
+
let nextAfterSeq = afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0);
|
|
4173
|
+
let pageCount = 0;
|
|
4174
|
+
const maxPages = 100;
|
|
4175
|
+
while (pageCount < maxPages) {
|
|
4176
|
+
pageCount += 1;
|
|
4177
|
+
this._clientLog.debug(`message.v2.pull page request: page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ns=${ns || '<none>'}`);
|
|
4178
|
+
const result = await this._callRawV2Rpc('message.v2.pull', {
|
|
4179
|
+
after_seq: nextAfterSeq,
|
|
4180
|
+
limit,
|
|
4181
|
+
});
|
|
4182
|
+
const messages = (Array.isArray(result?.messages) ? result.messages : []);
|
|
4183
|
+
totalRawCount += messages.length;
|
|
4184
|
+
this._clientLog.debug(`message.v2.pull page response: page=${pageCount}, raw_count=${messages.length}, has_more=${String(result.has_more ?? '')}, server_ack_seq=${String(result.server_ack_seq ?? '')}`);
|
|
4185
|
+
for (const msg of messages) {
|
|
4186
|
+
this._logMessageDebug('pull-raw', 'message.v2.pull', 'message.received', msg);
|
|
4187
|
+
}
|
|
4188
|
+
const seqs = messages
|
|
4189
|
+
.map((msg) => Number(msg.seq ?? 0))
|
|
4190
|
+
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
4191
|
+
const pageContigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
4192
|
+
let pageMaxSeq = nextAfterSeq;
|
|
4193
|
+
if (seqs.length > 0) {
|
|
4194
|
+
pageMaxSeq = Math.max(...seqs);
|
|
4195
|
+
if (ns) {
|
|
4196
|
+
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
4197
|
+
this._clientLog.debug(`message.v2.pull force contiguous: ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
|
|
4198
|
+
}
|
|
4199
|
+
}
|
|
4200
|
+
for (const msg of messages) {
|
|
4201
|
+
const seq = Number(msg.seq ?? 0);
|
|
4202
|
+
if (!Number.isFinite(seq) || seq <= 0)
|
|
4203
|
+
continue;
|
|
4204
|
+
const version = String(msg.version ?? 'v2');
|
|
4205
|
+
if (version === 'v1') {
|
|
4206
|
+
const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
|
|
4207
|
+
const legacyPayload = legacy.payload;
|
|
4208
|
+
const payloadType = isJsonObject(legacyPayload)
|
|
4209
|
+
? String(legacyPayload.type ?? '').trim()
|
|
4210
|
+
: '';
|
|
4211
|
+
if (legacyPayload !== undefined && legacyPayload !== null && payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
|
|
4212
|
+
const v1Msg = {
|
|
4213
|
+
message_id: String(msg.message_id ?? ''),
|
|
4214
|
+
from: String(msg.from_aid ?? ''),
|
|
4215
|
+
to: String(legacy.to ?? this._aid ?? ''),
|
|
4216
|
+
seq: msg.seq,
|
|
4217
|
+
type: String(msg.type ?? ''),
|
|
4218
|
+
timestamp: msg.t_server,
|
|
4219
|
+
payload: legacyPayload,
|
|
4220
|
+
encrypted: false,
|
|
4221
|
+
};
|
|
4222
|
+
if (ns) {
|
|
4223
|
+
await this._publishPulledMessage('message.received', ns, seq, v1Msg);
|
|
4224
|
+
}
|
|
4225
|
+
else {
|
|
4226
|
+
await this._publishAppEvent('message.received', v1Msg, 'pull');
|
|
4227
|
+
}
|
|
4228
|
+
decrypted.push(v1Msg);
|
|
4229
|
+
this._clientLog.debug(`message.v2.pull plaintext V1 delivered: seq=${seq}, ns=${ns || '<none>'}`);
|
|
4230
|
+
}
|
|
4231
|
+
else {
|
|
4232
|
+
this._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
|
|
4233
|
+
}
|
|
4234
|
+
continue;
|
|
4235
|
+
}
|
|
4236
|
+
if (version !== 'v2') {
|
|
4237
|
+
this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
|
|
4238
|
+
continue;
|
|
4239
|
+
}
|
|
4240
|
+
const spkId = String(msg.spk_id ?? '');
|
|
4241
|
+
if (spkId && this._v2Session && !this._v2Session.isCurrentSPK(spkId)) {
|
|
4242
|
+
this._v2Session.trackOldSPKMaxSeq(spkId, seq);
|
|
4243
|
+
}
|
|
4244
|
+
const plaintext = await this._decryptV2Message(msg);
|
|
4245
|
+
if (plaintext === null) {
|
|
4246
|
+
this._clientLog.debug(`message.v2.pull decrypt returned null: seq=${seq}, ns=${ns || '<none>'}`);
|
|
4247
|
+
continue;
|
|
4248
|
+
}
|
|
4249
|
+
if (ns) {
|
|
4250
|
+
await this._publishPulledMessage('message.received', ns, seq, plaintext);
|
|
2608
4251
|
}
|
|
2609
4252
|
else {
|
|
2610
|
-
this.
|
|
4253
|
+
await this._publishAppEvent('message.received', plaintext, 'pull');
|
|
4254
|
+
}
|
|
4255
|
+
decrypted.push(plaintext);
|
|
4256
|
+
this._logMessageDebug('decrypt-ok', 'message.v2.pull', 'message.received', plaintext);
|
|
4257
|
+
}
|
|
4258
|
+
const serverAckSeq = Number(result.server_ack_seq ?? 0);
|
|
4259
|
+
if (ns && Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
|
|
4260
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
4261
|
+
if (contig < serverAckSeq) {
|
|
4262
|
+
this._clientLog.info(`message.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAckSeq}`);
|
|
4263
|
+
this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
|
|
2611
4264
|
}
|
|
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
4265
|
}
|
|
2642
|
-
if (
|
|
2643
|
-
this.
|
|
4266
|
+
if (ns) {
|
|
4267
|
+
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
4268
|
+
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
4269
|
+
if (contigAdvanced) {
|
|
4270
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
4271
|
+
this._saveSeqTrackerState();
|
|
4272
|
+
}
|
|
4273
|
+
if (messages.length > 0 && contigAdvanced && ackSeq > 0 && !opts?.skipAutoAck) {
|
|
4274
|
+
this._clientLog.debug(`message.v2.pull scheduling auto-ack: ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
|
|
4275
|
+
this._safeAsync(this.ackV2(ackSeq).then(() => undefined));
|
|
4276
|
+
}
|
|
2644
4277
|
}
|
|
4278
|
+
const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
|
|
4279
|
+
if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
4280
|
+
break;
|
|
4281
|
+
nextAfterSeq = nextAfter;
|
|
4282
|
+
}
|
|
4283
|
+
if (pageCount >= maxPages) {
|
|
4284
|
+
this._clientLog.warn(`message.v2.pull reached max_pages=${maxPages} after_seq=${nextAfterSeq}`);
|
|
2645
4285
|
}
|
|
4286
|
+
this._clientLog.debug(`message.v2.pull done: requested_after_seq=${afterSeq}, pages=${pageCount}, decrypted=${decrypted.length}, ns=${ns || '<none>'}`);
|
|
2646
4287
|
return decrypted;
|
|
2647
4288
|
}
|
|
2648
4289
|
/** V2 P2P ack,并触发旧 SPK 销毁自检。 */
|
|
2649
4290
|
async ackV2(upToSeq) {
|
|
2650
4291
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
2651
|
-
|
|
2652
|
-
if (!Number.isFinite(seq) || seq <= 0)
|
|
4292
|
+
let seq = Number(upToSeq ?? (ns ? this._seqTracker.getContiguousSeq(ns) : 0));
|
|
4293
|
+
if (!Number.isFinite(seq) || seq <= 0) {
|
|
4294
|
+
this._clientLog.debug(`message.v2.ack skipped: ns=${ns || '<none>'}, up_to_seq=${String(upToSeq ?? '')}`);
|
|
2653
4295
|
return { acked: 0 };
|
|
2654
|
-
|
|
4296
|
+
}
|
|
4297
|
+
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
4298
|
+
if (ns) {
|
|
4299
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
4300
|
+
if (maxSeen > 0 && seq > maxSeen) {
|
|
4301
|
+
this._clientLog.warn(`ackV2 clamp: up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
|
|
4302
|
+
seq = maxSeen;
|
|
4303
|
+
}
|
|
4304
|
+
}
|
|
4305
|
+
this._clientLog.debug(`message.v2.ack send: ns=${ns || '<none>'}, up_to_seq=${seq}`);
|
|
4306
|
+
const raw = await this._callRawV2Rpc('message.v2.ack', { up_to_seq: seq });
|
|
2655
4307
|
const result = isJsonObject(raw)
|
|
2656
4308
|
? { ...raw }
|
|
2657
4309
|
: { result: raw };
|
|
@@ -2679,6 +4331,7 @@ export class AUNClient {
|
|
|
2679
4331
|
this._clientLog.debug(`V2 SPK destroy failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
2680
4332
|
}
|
|
2681
4333
|
}
|
|
4334
|
+
this._clientLog.debug(`message.v2.ack ok: ns=${ns || '<none>'}, requested=${seq}, effective=${actualAckSeq}, acked=${String(result.acked ?? '')}`);
|
|
2682
4335
|
return result;
|
|
2683
4336
|
}
|
|
2684
4337
|
/** V2 Group 加密发送,推测性缓存失败后刷新 bootstrap 重试一次。 */
|
|
@@ -2689,7 +4342,13 @@ export class AUNClient {
|
|
|
2689
4342
|
throw new ValidationError("group.send requires 'group_id'");
|
|
2690
4343
|
if (!isJsonObject(payload))
|
|
2691
4344
|
throw new ValidationError('group.send payload must be a dict for V2 encryption');
|
|
4345
|
+
this._logMessageDebug('send-plaintext', 'group.send.v2', 'group.send', {
|
|
4346
|
+
group_id: gid,
|
|
4347
|
+
message_id: opts?.messageId ?? '',
|
|
4348
|
+
payload,
|
|
4349
|
+
}, { payloadOverride: payload });
|
|
2692
4350
|
const attempt = async (useCache) => {
|
|
4351
|
+
this._clientLog.debug(`group.v2.send attempt: group=${gid}, use_cache=${useCache}`);
|
|
2693
4352
|
const envelope = await this._buildV2GroupEnvelope({
|
|
2694
4353
|
groupId: gid,
|
|
2695
4354
|
payload,
|
|
@@ -2699,10 +4358,12 @@ export class AUNClient {
|
|
|
2699
4358
|
context: opts?.context,
|
|
2700
4359
|
useCache,
|
|
2701
4360
|
});
|
|
2702
|
-
|
|
4361
|
+
const result = await this.call('group.v2.send', {
|
|
2703
4362
|
group_id: gid,
|
|
2704
4363
|
envelope: envelope,
|
|
2705
4364
|
});
|
|
4365
|
+
this._clientLog.debug(`group.v2.send ok: group=${gid}, use_cache=${useCache}, seq=${String((isJsonObject(result) ? result.seq : '') ?? '')}`);
|
|
4366
|
+
return result;
|
|
2706
4367
|
};
|
|
2707
4368
|
const markSentSeq = (result) => {
|
|
2708
4369
|
if (!isJsonObject(result))
|
|
@@ -2715,6 +4376,7 @@ export class AUNClient {
|
|
|
2715
4376
|
this._seqTracker.onMessageSeq(ns, seq);
|
|
2716
4377
|
this._markPublishedSeq(ns, seq);
|
|
2717
4378
|
this._saveSeqTrackerState();
|
|
4379
|
+
this._clientLog.debug(`group.v2.send marked own seq: group=${gid}, ns=${ns}, seq=${seq}`);
|
|
2718
4380
|
};
|
|
2719
4381
|
try {
|
|
2720
4382
|
const result = await attempt(true);
|
|
@@ -2747,18 +4409,26 @@ export class AUNClient {
|
|
|
2747
4409
|
let auditRecipientsRaw = [];
|
|
2748
4410
|
let epoch = 0;
|
|
2749
4411
|
let stateCommitment = { state_version: 0, state_hash: '', state_chain: '' };
|
|
4412
|
+
let wrapPolicy = normalizeV2WrapPolicy(undefined);
|
|
2750
4413
|
const cached = useCache ? this._v2BootstrapCache.get(cacheKey) : undefined;
|
|
2751
4414
|
if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
2752
4415
|
allDevices = cached.devices;
|
|
2753
4416
|
auditRecipientsRaw = cached.auditRecipients;
|
|
2754
4417
|
epoch = cached.epoch ?? 0;
|
|
2755
4418
|
stateCommitment = cached.stateCommitment ?? stateCommitment;
|
|
4419
|
+
wrapPolicy = cached.wrapPolicy ?? wrapPolicy;
|
|
4420
|
+
this._clientLog.debug(`group.v2.bootstrap cache hit: group=${groupId}, devices=${allDevices.length}, audit=${auditRecipientsRaw.length}, epoch=${epoch}, state_version=${stateCommitment.state_version}`);
|
|
2756
4421
|
}
|
|
2757
4422
|
else {
|
|
2758
|
-
const bs = await this.call('group.v2.bootstrap', {
|
|
4423
|
+
const bs = await this.call('group.v2.bootstrap', {
|
|
4424
|
+
group_id: groupId,
|
|
4425
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
4426
|
+
});
|
|
2759
4427
|
allDevices = (Array.isArray(bs.devices) ? bs.devices : []);
|
|
2760
4428
|
auditRecipientsRaw = (Array.isArray(bs.audit_recipients) ? bs.audit_recipients : []);
|
|
2761
4429
|
epoch = Number(bs.epoch ?? 0) || 0;
|
|
4430
|
+
wrapPolicy = normalizeV2WrapPolicy(bs.e2ee_wrap_policy);
|
|
4431
|
+
this._clientLog.debug(`group.v2.bootstrap fetched: group=${groupId}, devices=${allDevices.length}, audit=${auditRecipientsRaw.length}, epoch=${epoch}, members=${Array.isArray(bs.member_aids) ? bs.member_aids.length : 0}`);
|
|
2762
4432
|
const stateChain = String(bs.state_chain ?? '');
|
|
2763
4433
|
await this._v2CheckFork(groupId, stateChain);
|
|
2764
4434
|
await this._v2VerifyStateSignature(groupId, bs);
|
|
@@ -2775,6 +4445,7 @@ export class AUNClient {
|
|
|
2775
4445
|
cachedAt: Date.now(),
|
|
2776
4446
|
epoch,
|
|
2777
4447
|
stateCommitment,
|
|
4448
|
+
wrapPolicy,
|
|
2778
4449
|
});
|
|
2779
4450
|
}
|
|
2780
4451
|
// lazy sync 触发:发现 pending members 时异步发起提案
|
|
@@ -2789,83 +4460,135 @@ export class AUNClient {
|
|
|
2789
4460
|
const targets = [];
|
|
2790
4461
|
for (const dev of allDevices) {
|
|
2791
4462
|
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)
|
|
4463
|
+
const devId = getV2DeviceId(dev);
|
|
4464
|
+
if (devAid === this._aid && devId.present && devId.value === this._deviceId)
|
|
2797
4465
|
continue;
|
|
2798
4466
|
const role = devAid === this._aid ? 'self_sync' : 'member';
|
|
2799
|
-
|
|
4467
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
4468
|
+
dev,
|
|
2800
4469
|
aid: devAid,
|
|
2801
|
-
deviceId: devId,
|
|
4470
|
+
deviceId: devId.value,
|
|
2802
4471
|
role,
|
|
2803
|
-
|
|
2804
|
-
ikPkDer: _v2B64ToBytes(ikPk),
|
|
2805
|
-
spkPkDer: dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined,
|
|
2806
|
-
spkId: String(dev.spk_id ?? ''),
|
|
4472
|
+
defaultKeySource: 'peer_device_prekey',
|
|
2807
4473
|
});
|
|
4474
|
+
if (target)
|
|
4475
|
+
targets.push(target);
|
|
2808
4476
|
}
|
|
2809
4477
|
if (targets.length === 0) {
|
|
2810
4478
|
throw new E2EEError(`V2 group: no target devices for group ${groupId}`);
|
|
2811
4479
|
}
|
|
2812
4480
|
for (const dev of auditRecipientsRaw) {
|
|
2813
|
-
const
|
|
2814
|
-
|
|
2815
|
-
continue;
|
|
2816
|
-
targets.push({
|
|
4481
|
+
const target = await this._v2BuildTargetFromDevice({
|
|
4482
|
+
dev,
|
|
2817
4483
|
aid: String(dev.aid ?? ''),
|
|
2818
4484
|
deviceId: String(dev.device_id ?? ''),
|
|
2819
4485
|
role: 'audit',
|
|
2820
|
-
|
|
2821
|
-
ikPkDer: _v2B64ToBytes(ikPk),
|
|
2822
|
-
spkPkDer: dev.spk_pk ? _v2B64ToBytes(String(dev.spk_pk)) : undefined,
|
|
2823
|
-
spkId: String(dev.spk_id ?? ''),
|
|
4486
|
+
defaultKeySource: 'peer_device_prekey',
|
|
2824
4487
|
});
|
|
4488
|
+
if (target)
|
|
4489
|
+
targets.push(target);
|
|
2825
4490
|
}
|
|
2826
|
-
|
|
4491
|
+
const envelope = encryptGroupMessage(session.getSenderIdentity(), groupId, epoch, applyV2WrapPolicyToTargets(targets, wrapPolicy), opts.payload, {
|
|
2827
4492
|
messageId: opts.messageId,
|
|
2828
4493
|
timestamp: opts.timestamp,
|
|
2829
4494
|
protectedHeaders: opts.protectedHeaders,
|
|
2830
4495
|
context: opts.context,
|
|
2831
4496
|
}, stateCommitment);
|
|
4497
|
+
this._logMessageDebug('send-envelope', 'group.send.v2', 'group.send', {
|
|
4498
|
+
group_id: groupId,
|
|
4499
|
+
message_id: envelope.message_id,
|
|
4500
|
+
type: envelope.type,
|
|
4501
|
+
version: envelope.version,
|
|
4502
|
+
protected_headers: envelope.protected_headers,
|
|
4503
|
+
context: envelope.context,
|
|
4504
|
+
}, {
|
|
4505
|
+
payloadOverride: envelope,
|
|
4506
|
+
extra: {
|
|
4507
|
+
plaintext_payload: opts.payload,
|
|
4508
|
+
epoch,
|
|
4509
|
+
target_count: targets.length,
|
|
4510
|
+
audit_count: auditRecipientsRaw.length,
|
|
4511
|
+
state_version: stateCommitment.state_version,
|
|
4512
|
+
use_cache: useCache,
|
|
4513
|
+
},
|
|
4514
|
+
});
|
|
4515
|
+
return envelope;
|
|
2832
4516
|
}
|
|
2833
4517
|
async _pullGroupV2Internal(params) {
|
|
2834
|
-
await this.pullGroupV2(params.group_id, params.after_seq, params.limit);
|
|
4518
|
+
await this.pullGroupV2(params.group_id, params.after_seq, params.limit, { gateLocked: true });
|
|
2835
4519
|
}
|
|
2836
4520
|
/** V2 Group 拉取并解密;直接方法返回消息数组,call("group.pull") 会包装为 {messages}. */
|
|
2837
|
-
async pullGroupV2(groupId, afterSeq = 0, limit = 50) {
|
|
4521
|
+
async pullGroupV2(groupId, afterSeq = 0, limit = 50, opts) {
|
|
2838
4522
|
await this._ensureV2SessionReady('group.pull');
|
|
2839
4523
|
const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
2840
4524
|
if (!gid)
|
|
2841
4525
|
throw new ValidationError('group.pull requires group_id');
|
|
2842
4526
|
const ns = `group:${gid}`;
|
|
2843
|
-
|
|
2844
|
-
|
|
2845
|
-
|
|
2846
|
-
|
|
2847
|
-
|
|
2848
|
-
|
|
2849
|
-
const messages = (Array.isArray(result.messages) ? result.messages : []);
|
|
2850
|
-
const decrypted = [];
|
|
2851
|
-
const seqs = messages
|
|
2852
|
-
.map((msg) => Number(msg.seq ?? 0))
|
|
2853
|
-
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
2854
|
-
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
2855
|
-
if (seqs.length > 0 && seqs[0] > contigBefore) {
|
|
2856
|
-
this._seqTracker.forceContiguousSeq(ns, seqs[0]);
|
|
4527
|
+
if (!opts?.gateLocked) {
|
|
4528
|
+
return await this._runPullSerialized(ns, async () => this.pullGroupV2(gid, afterSeq, limit, {
|
|
4529
|
+
...(opts ?? {}),
|
|
4530
|
+
gateLocked: true,
|
|
4531
|
+
scheduleFollowup: true,
|
|
4532
|
+
}));
|
|
2857
4533
|
}
|
|
2858
|
-
|
|
2859
|
-
|
|
2860
|
-
|
|
2861
|
-
|
|
2862
|
-
|
|
2863
|
-
|
|
2864
|
-
|
|
2865
|
-
|
|
2866
|
-
|
|
2867
|
-
|
|
2868
|
-
|
|
4534
|
+
const decrypted = [];
|
|
4535
|
+
let totalRawCount = 0;
|
|
4536
|
+
let nextAfterSeq = afterSeq || this._seqTracker.getContiguousSeq(ns);
|
|
4537
|
+
let pageCount = 0;
|
|
4538
|
+
const maxPages = 100;
|
|
4539
|
+
while (pageCount < maxPages) {
|
|
4540
|
+
pageCount += 1;
|
|
4541
|
+
this._clientLog.debug(`group.v2.pull page request: group=${gid}, page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ns=${ns}`);
|
|
4542
|
+
const result = await this._callRawV2Rpc('group.v2.pull', {
|
|
4543
|
+
group_id: gid,
|
|
4544
|
+
after_seq: nextAfterSeq,
|
|
4545
|
+
limit,
|
|
4546
|
+
});
|
|
4547
|
+
const messages = (Array.isArray(result.messages) ? result.messages : []);
|
|
4548
|
+
totalRawCount += messages.length;
|
|
4549
|
+
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
4550
|
+
this._clientLog.debug(`group.v2.pull page response: group=${gid}, page=${pageCount}, raw_count=${messages.length}, has_more=${String(result.has_more ?? '')}, cursor_current=${String(cursor?.current_seq ?? '')}`);
|
|
4551
|
+
for (const msg of messages) {
|
|
4552
|
+
this._logMessageDebug('pull-raw', 'group.v2.pull', 'group.message_created', msg);
|
|
4553
|
+
}
|
|
4554
|
+
const seqs = messages
|
|
4555
|
+
.map((msg) => Number(msg.seq ?? 0))
|
|
4556
|
+
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
4557
|
+
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
4558
|
+
let pageMaxSeq = nextAfterSeq;
|
|
4559
|
+
if (seqs.length > 0) {
|
|
4560
|
+
pageMaxSeq = Math.max(...seqs);
|
|
4561
|
+
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
4562
|
+
this._clientLog.debug(`group.v2.pull force contiguous: group=${gid}, ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
|
|
4563
|
+
}
|
|
4564
|
+
for (const msg of messages) {
|
|
4565
|
+
const seq = Number(msg.seq ?? 0);
|
|
4566
|
+
if (!Number.isFinite(seq) || seq <= 0)
|
|
4567
|
+
continue;
|
|
4568
|
+
const version = String(msg.version ?? 'v2');
|
|
4569
|
+
if (version === 'v1') {
|
|
4570
|
+
const payload = msg.payload;
|
|
4571
|
+
const payloadObj = isJsonObject(payload) ? payload : null;
|
|
4572
|
+
if (payloadObj) {
|
|
4573
|
+
const payloadType = String(payloadObj.type ?? '').trim();
|
|
4574
|
+
if (payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
|
|
4575
|
+
const v1Msg = {
|
|
4576
|
+
message_id: String(msg.message_id ?? ''),
|
|
4577
|
+
from: String(msg.from_aid ?? ''),
|
|
4578
|
+
group_id: gid,
|
|
4579
|
+
seq: msg.seq,
|
|
4580
|
+
type: String(msg.type ?? ''),
|
|
4581
|
+
timestamp: msg.t_server,
|
|
4582
|
+
payload,
|
|
4583
|
+
encrypted: false,
|
|
4584
|
+
};
|
|
4585
|
+
await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
|
|
4586
|
+
decrypted.push(v1Msg);
|
|
4587
|
+
this._clientLog.debug(`group.v2.pull plaintext V1 delivered: group=${gid}, seq=${seq}`);
|
|
4588
|
+
continue;
|
|
4589
|
+
}
|
|
4590
|
+
}
|
|
4591
|
+
else if (payload !== undefined && payload !== null) {
|
|
2869
4592
|
const v1Msg = {
|
|
2870
4593
|
message_id: String(msg.message_id ?? ''),
|
|
2871
4594
|
from: String(msg.from_aid ?? ''),
|
|
@@ -2878,53 +4601,53 @@ export class AUNClient {
|
|
|
2878
4601
|
};
|
|
2879
4602
|
await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
|
|
2880
4603
|
decrypted.push(v1Msg);
|
|
4604
|
+
this._clientLog.debug(`group.v2.pull plaintext V1 delivered: group=${gid}, seq=${seq}`);
|
|
2881
4605
|
continue;
|
|
2882
4606
|
}
|
|
4607
|
+
this._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
|
|
4608
|
+
continue;
|
|
2883
4609
|
}
|
|
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);
|
|
4610
|
+
if (version !== 'v2') {
|
|
4611
|
+
this._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
|
|
2897
4612
|
continue;
|
|
2898
4613
|
}
|
|
2899
|
-
|
|
2900
|
-
|
|
4614
|
+
const plaintext = await this._decryptV2Message(msg);
|
|
4615
|
+
if (plaintext === null) {
|
|
4616
|
+
this._clientLog.debug(`group.v2.pull decrypt returned null: group=${gid}, seq=${seq}`);
|
|
4617
|
+
continue;
|
|
4618
|
+
}
|
|
4619
|
+
plaintext.group_id = gid;
|
|
4620
|
+
await this._publishPulledMessage('group.message_created', ns, seq, plaintext);
|
|
4621
|
+
decrypted.push(plaintext);
|
|
4622
|
+
this._logMessageDebug('decrypt-ok', 'group.v2.pull', 'group.message_created', plaintext);
|
|
4623
|
+
}
|
|
4624
|
+
const retentionFloor = this._pullRetentionFloor(result, 'retention_floor_message_seq', 'retention_floor_message_seq');
|
|
4625
|
+
if (retentionFloor > 0) {
|
|
4626
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
4627
|
+
if (contig < retentionFloor) {
|
|
4628
|
+
this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> retention_floor=${retentionFloor}`);
|
|
4629
|
+
this._seqTracker.forceContiguousSeq(ns, retentionFloor);
|
|
4630
|
+
}
|
|
2901
4631
|
}
|
|
2902
|
-
|
|
2903
|
-
|
|
2904
|
-
|
|
4632
|
+
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
4633
|
+
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
4634
|
+
if (contigAdvanced) {
|
|
4635
|
+
await this._drainOrderedMessages(ns, undefined, true);
|
|
4636
|
+
this._saveSeqTrackerState();
|
|
2905
4637
|
}
|
|
2906
|
-
|
|
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);
|
|
2918
|
-
await this._drainOrderedMessages(ns);
|
|
4638
|
+
if (messages.length > 0 && contigAdvanced && ackSeq > 0) {
|
|
4639
|
+
this._clientLog.debug(`group.v2.pull scheduling auto-ack: group=${gid}, ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
|
|
4640
|
+
this._safeAsync(this.ackGroupV2(gid, ackSeq).then(() => undefined));
|
|
2919
4641
|
}
|
|
4642
|
+
const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
|
|
4643
|
+
if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
4644
|
+
break;
|
|
4645
|
+
nextAfterSeq = nextAfter;
|
|
2920
4646
|
}
|
|
2921
|
-
|
|
2922
|
-
|
|
2923
|
-
this._saveSeqTrackerState();
|
|
2924
|
-
}
|
|
2925
|
-
if (ackSeq > 0 && ackSeq !== contigBefore) {
|
|
2926
|
-
this._safeAsync(this.ackGroupV2(gid, ackSeq).then(() => undefined));
|
|
4647
|
+
if (pageCount >= maxPages) {
|
|
4648
|
+
this._clientLog.warn(`group.v2.pull reached max_pages=${maxPages} group=${gid} after_seq=${nextAfterSeq}`);
|
|
2927
4649
|
}
|
|
4650
|
+
this._clientLog.debug(`group.v2.pull done: group=${gid}, requested_after_seq=${afterSeq}, pages=${pageCount}, decrypted=${decrypted.length}, ns=${ns}`);
|
|
2928
4651
|
return decrypted;
|
|
2929
4652
|
}
|
|
2930
4653
|
/** V2 Group ack。 */
|
|
@@ -2933,13 +4656,24 @@ export class AUNClient {
|
|
|
2933
4656
|
if (!gid)
|
|
2934
4657
|
throw new ValidationError('group.ack_messages requires group_id');
|
|
2935
4658
|
const ns = `group:${gid}`;
|
|
2936
|
-
|
|
2937
|
-
if (!Number.isFinite(seq) || seq <= 0)
|
|
4659
|
+
let seq = Number(upToSeq ?? this._seqTracker.getContiguousSeq(ns));
|
|
4660
|
+
if (!Number.isFinite(seq) || seq <= 0) {
|
|
4661
|
+
this._clientLog.debug(`group.v2.ack skipped: group=${gid}, ns=${ns}, up_to_seq=${String(upToSeq ?? '')}`);
|
|
2938
4662
|
return { acked: 0 };
|
|
2939
|
-
|
|
4663
|
+
}
|
|
4664
|
+
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
4665
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
4666
|
+
if (maxSeen > 0 && seq > maxSeen) {
|
|
4667
|
+
this._clientLog.warn(`ackGroupV2 clamp: group=${gid} up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
|
|
4668
|
+
seq = maxSeen;
|
|
4669
|
+
}
|
|
4670
|
+
this._clientLog.debug(`group.v2.ack send: group=${gid}, ns=${ns}, up_to_seq=${seq}`);
|
|
4671
|
+
const result = await this._callRawV2Rpc('group.v2.ack', { group_id: gid, up_to_seq: seq });
|
|
4672
|
+
this._clientLog.debug(`group.v2.ack ok: group=${gid}, ns=${ns}, requested=${seq}, result=${this._debugJson(result)}`);
|
|
4673
|
+
return result;
|
|
2940
4674
|
}
|
|
2941
|
-
/** 解密单条 V2 pull
|
|
2942
|
-
async _decryptV2Message(msg) {
|
|
4675
|
+
/** 解密单条 V2 pull 消息。缺 sender IK 时先入 pending,后台补齐后重试。 */
|
|
4676
|
+
async _decryptV2Message(msg, allowPending = true) {
|
|
2943
4677
|
const session = this._v2Session;
|
|
2944
4678
|
if (!session)
|
|
2945
4679
|
return null;
|
|
@@ -2954,6 +4688,8 @@ export class AUNClient {
|
|
|
2954
4688
|
this._clientLog.warn(`V2 decrypt: invalid envelope_json for msg seq=${String(msg.seq)}`);
|
|
2955
4689
|
return null;
|
|
2956
4690
|
}
|
|
4691
|
+
const e2eeMeta = this._v2E2eeMeta(envelope);
|
|
4692
|
+
this._observeAgentMdFromEnvelope(envelope);
|
|
2957
4693
|
let spkId = '';
|
|
2958
4694
|
let recipientKeySource = '';
|
|
2959
4695
|
if (isJsonObject(envelope.recipient)) {
|
|
@@ -2968,7 +4704,7 @@ export class AUNClient {
|
|
|
2968
4704
|
for (const row of recipients) {
|
|
2969
4705
|
if (Array.isArray(row) && row.length >= 6
|
|
2970
4706
|
&& String(row[0] ?? '') === this._aid
|
|
2971
|
-
&& String(row[1] ?? '') === this._deviceId) {
|
|
4707
|
+
&& (String(row[1] ?? '') === this._deviceId || String(row[1] ?? '') === '')) {
|
|
2972
4708
|
if (!spkId)
|
|
2973
4709
|
spkId = String(row[5] ?? '');
|
|
2974
4710
|
if (row.length > 3)
|
|
@@ -2977,31 +4713,73 @@ export class AUNClient {
|
|
|
2977
4713
|
}
|
|
2978
4714
|
}
|
|
2979
4715
|
}
|
|
2980
|
-
//
|
|
4716
|
+
// group_id 只表示群上下文;getGroupDecryptKeys 内部必须按 group SPK -> P2P device SPK -> IK fallback 查找。
|
|
2981
4717
|
const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
|
|
2982
4718
|
const groupIdForKeys = String(aad.group_id ?? msg.group_id ?? '').trim();
|
|
4719
|
+
const undecryptableEvent = groupIdForKeys ? 'group.message_undecryptable' : 'message.undecryptable';
|
|
4720
|
+
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
4721
|
let ikPriv;
|
|
2984
4722
|
let spkPriv;
|
|
2985
|
-
|
|
2986
|
-
|
|
2987
|
-
|
|
2988
|
-
|
|
4723
|
+
try {
|
|
4724
|
+
if (groupIdForKeys) {
|
|
4725
|
+
const keys = session.getGroupDecryptKeys(groupIdForKeys, spkId);
|
|
4726
|
+
ikPriv = keys.ikPriv;
|
|
4727
|
+
spkPriv = keys.spkPriv ?? undefined;
|
|
4728
|
+
}
|
|
4729
|
+
else {
|
|
4730
|
+
const keys = session.getDecryptKeys(spkId);
|
|
4731
|
+
ikPriv = keys.ikPriv;
|
|
4732
|
+
spkPriv = keys.spkPriv;
|
|
4733
|
+
}
|
|
2989
4734
|
}
|
|
2990
|
-
|
|
2991
|
-
|
|
2992
|
-
|
|
2993
|
-
|
|
4735
|
+
catch (exc) {
|
|
4736
|
+
this._clientLog.warn(`V2 decrypt: SPK lookup failed seq=${String(msg.seq)} spk_id=${spkId}: ${formatCaughtError(exc)}`);
|
|
4737
|
+
const event = {
|
|
4738
|
+
message_id: String(msg.message_id ?? ''),
|
|
4739
|
+
from: String(msg.from_aid ?? ''),
|
|
4740
|
+
to: String(msg.to ?? ''),
|
|
4741
|
+
seq: msg.seq,
|
|
4742
|
+
timestamp: (msg.t_server ?? msg.timestamp),
|
|
4743
|
+
device_id: String(msg.device_id ?? ''),
|
|
4744
|
+
slot_id: String(msg.slot_id ?? ''),
|
|
4745
|
+
_decrypt_error: String(formatCaughtError(exc)),
|
|
4746
|
+
_decrypt_stage: 'spk_lookup',
|
|
4747
|
+
_envelope_type: String(envelope.type ?? ''),
|
|
4748
|
+
_suite: String(envelope.suite ?? ''),
|
|
4749
|
+
_spk_id: spkId,
|
|
4750
|
+
};
|
|
4751
|
+
this._attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4752
|
+
this._logMessageDebug('decrypt-fail', 'v2.decrypt', undecryptableEvent, event);
|
|
4753
|
+
await this._dispatcher.publish(undecryptableEvent, event);
|
|
4754
|
+
return null;
|
|
2994
4755
|
}
|
|
4756
|
+
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
4757
|
const fromAid = String(msg.from_aid ?? '');
|
|
2996
4758
|
const senderDeviceId = String(aad.from_device ?? '');
|
|
2997
4759
|
const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
2998
4760
|
if (!senderPubDer) {
|
|
2999
|
-
|
|
4761
|
+
this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
|
|
4762
|
+
if (allowPending) {
|
|
4763
|
+
this._scheduleV2SenderIKPending({ msg, fromAid, senderDeviceId, groupId: groupIdForKeys });
|
|
4764
|
+
return null;
|
|
4765
|
+
}
|
|
4766
|
+
const event = {
|
|
3000
4767
|
message_id: String(msg.message_id ?? ''),
|
|
3001
4768
|
from: fromAid,
|
|
4769
|
+
to: String(msg.to ?? ''),
|
|
3002
4770
|
seq: msg.seq,
|
|
4771
|
+
timestamp: (msg.t_server ?? msg.timestamp),
|
|
4772
|
+
device_id: String(msg.device_id ?? ''),
|
|
4773
|
+
slot_id: String(msg.slot_id ?? ''),
|
|
3003
4774
|
_decrypt_error: 'sender_ik_not_found',
|
|
3004
|
-
|
|
4775
|
+
_decrypt_stage: 'sender_ik',
|
|
4776
|
+
_envelope_type: String(envelope.type ?? ''),
|
|
4777
|
+
_suite: String(envelope.suite ?? ''),
|
|
4778
|
+
_sender_device_id: String(aad.from_device ?? ''),
|
|
4779
|
+
};
|
|
4780
|
+
this._attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4781
|
+
this._logMessageDebug('decrypt-fail', 'v2.decrypt', undecryptableEvent, event);
|
|
4782
|
+
await this._dispatcher.publish(undecryptableEvent, event);
|
|
3005
4783
|
return null;
|
|
3006
4784
|
}
|
|
3007
4785
|
let plaintext;
|
|
@@ -3010,16 +4788,29 @@ export class AUNClient {
|
|
|
3010
4788
|
}
|
|
3011
4789
|
catch (exc) {
|
|
3012
4790
|
this._clientLog.warn(`V2 decrypt failed for msg seq=${String(msg.seq)}: ${formatCaughtError(exc)}`);
|
|
3013
|
-
|
|
4791
|
+
const event = {
|
|
3014
4792
|
message_id: String(msg.message_id ?? ''),
|
|
3015
4793
|
from: fromAid,
|
|
4794
|
+
to: String(msg.to ?? ''),
|
|
3016
4795
|
seq: msg.seq,
|
|
4796
|
+
timestamp: (msg.t_server ?? msg.timestamp),
|
|
4797
|
+
device_id: String(msg.device_id ?? ''),
|
|
4798
|
+
slot_id: String(msg.slot_id ?? ''),
|
|
3017
4799
|
_decrypt_error: String(formatCaughtError(exc)),
|
|
3018
|
-
|
|
4800
|
+
_decrypt_stage: 'decrypt',
|
|
4801
|
+
_envelope_type: String(envelope.type ?? ''),
|
|
4802
|
+
_suite: String(envelope.suite ?? ''),
|
|
4803
|
+
_sender_device_id: String(aad.from_device ?? ''),
|
|
4804
|
+
};
|
|
4805
|
+
this._attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4806
|
+
this._logMessageDebug('decrypt-fail', 'v2.decrypt', undecryptableEvent, event);
|
|
4807
|
+
await this._dispatcher.publish(undecryptableEvent, event);
|
|
3019
4808
|
return null;
|
|
3020
4809
|
}
|
|
3021
|
-
if (plaintext === null)
|
|
4810
|
+
if (plaintext === null) {
|
|
4811
|
+
this._clientLog.debug(`V2 decrypt returned null plaintext: seq=${String(msg.seq ?? '')}, group=${groupIdForKeys || '<p2p>'}`);
|
|
3022
4812
|
return null;
|
|
4813
|
+
}
|
|
3023
4814
|
// 消费触发 SPK 轮换
|
|
3024
4815
|
if (groupIdForKeys && recipientKeySource === 'group_device_prekey' && session.isLastUploadedGroupSPK(groupIdForKeys, spkId)) {
|
|
3025
4816
|
// Group SPK 消费触发轮换
|
|
@@ -3042,8 +4833,8 @@ export class AUNClient {
|
|
|
3042
4833
|
this._clientLog.debug(`V2 SPK rotation failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
3043
4834
|
});
|
|
3044
4835
|
}
|
|
3045
|
-
const
|
|
3046
|
-
|
|
4836
|
+
const e2ee = this._v2E2eeMeta(envelope);
|
|
4837
|
+
const result = {
|
|
3047
4838
|
message_id: String(msg.message_id ?? ''),
|
|
3048
4839
|
from: fromAid,
|
|
3049
4840
|
to: this._aid ?? '',
|
|
@@ -3051,13 +4842,84 @@ export class AUNClient {
|
|
|
3051
4842
|
t_server: msg.t_server,
|
|
3052
4843
|
payload: plaintext,
|
|
3053
4844
|
encrypted: true,
|
|
3054
|
-
e2ee
|
|
3055
|
-
|
|
3056
|
-
|
|
3057
|
-
|
|
3058
|
-
|
|
3059
|
-
|
|
4845
|
+
e2ee,
|
|
4846
|
+
};
|
|
4847
|
+
this._attachV2EnvelopeMetadata(result, e2ee);
|
|
4848
|
+
this._logMessageDebug('decrypt-ok', 'v2.decrypt', groupIdForKeys ? 'group.message_created' : 'message.received', result);
|
|
4849
|
+
return result;
|
|
4850
|
+
}
|
|
4851
|
+
_v2E2eeMeta(envelope) {
|
|
4852
|
+
const suite = String(envelope.suite ?? '');
|
|
4853
|
+
const meta = {
|
|
4854
|
+
version: 'v2',
|
|
4855
|
+
suite,
|
|
4856
|
+
encryption_mode: `v2_${suite || 'unknown'}`,
|
|
4857
|
+
forward_secrecy: true,
|
|
3060
4858
|
};
|
|
4859
|
+
const protectedHeaders = this._metadataWithoutAuth(envelope.protected_headers);
|
|
4860
|
+
if (protectedHeaders && Object.keys(protectedHeaders).length > 0) {
|
|
4861
|
+
meta.protected_headers = protectedHeaders;
|
|
4862
|
+
}
|
|
4863
|
+
const payloadType = String(envelope.payload_type ?? protectedHeaders?.payload_type ?? '').trim();
|
|
4864
|
+
if (payloadType) {
|
|
4865
|
+
meta.payload_type = payloadType;
|
|
4866
|
+
}
|
|
4867
|
+
const context = this._metadataWithoutAuth(envelope.context);
|
|
4868
|
+
if (context && Object.keys(context).length > 0) {
|
|
4869
|
+
meta.context = context;
|
|
4870
|
+
}
|
|
4871
|
+
const agentMd = this._metadataWithoutAuth(envelope.agent_md);
|
|
4872
|
+
if (agentMd && Object.keys(agentMd).length > 0) {
|
|
4873
|
+
meta.agent_md = agentMd;
|
|
4874
|
+
}
|
|
4875
|
+
return meta;
|
|
4876
|
+
}
|
|
4877
|
+
_attachV2EnvelopeMetadata(message, meta) {
|
|
4878
|
+
const payloadType = typeof meta.payload_type === 'string' ? meta.payload_type.trim() : '';
|
|
4879
|
+
if (payloadType)
|
|
4880
|
+
message.payload_type = payloadType;
|
|
4881
|
+
if (isJsonObject(meta.protected_headers)) {
|
|
4882
|
+
message.protected_headers = { ...meta.protected_headers };
|
|
4883
|
+
}
|
|
4884
|
+
if (isJsonObject(meta.agent_md)) {
|
|
4885
|
+
message.agent_md = { ...meta.agent_md };
|
|
4886
|
+
}
|
|
4887
|
+
}
|
|
4888
|
+
_attachV2EnvelopeMetadataFromSource(message, source) {
|
|
4889
|
+
const envelope = this._extractV2EnvelopeFromSource(source);
|
|
4890
|
+
if (envelope) {
|
|
4891
|
+
this._observeAgentMdFromEnvelope(envelope);
|
|
4892
|
+
this._attachV2EnvelopeMetadata(message, this._v2E2eeMeta(envelope));
|
|
4893
|
+
}
|
|
4894
|
+
}
|
|
4895
|
+
_extractV2EnvelopeFromSource(source) {
|
|
4896
|
+
const candidate = source;
|
|
4897
|
+
if (!isJsonObject(candidate))
|
|
4898
|
+
return null;
|
|
4899
|
+
if (isJsonObject(candidate.payload))
|
|
4900
|
+
return candidate.payload;
|
|
4901
|
+
if (typeof candidate.envelope_json === 'string' && candidate.envelope_json) {
|
|
4902
|
+
try {
|
|
4903
|
+
const parsed = JSON.parse(candidate.envelope_json);
|
|
4904
|
+
if (isJsonObject(parsed))
|
|
4905
|
+
return parsed;
|
|
4906
|
+
}
|
|
4907
|
+
catch {
|
|
4908
|
+
return null;
|
|
4909
|
+
}
|
|
4910
|
+
}
|
|
4911
|
+
return null;
|
|
4912
|
+
}
|
|
4913
|
+
_metadataWithoutAuth(value) {
|
|
4914
|
+
const candidate = value;
|
|
4915
|
+
if (!isJsonObject(candidate))
|
|
4916
|
+
return null;
|
|
4917
|
+
const body = {};
|
|
4918
|
+
for (const [key, item] of Object.entries(candidate)) {
|
|
4919
|
+
if (key !== '_auth')
|
|
4920
|
+
body[key] = item;
|
|
4921
|
+
}
|
|
4922
|
+
return body;
|
|
3061
4923
|
}
|
|
3062
4924
|
async _putMessageThoughtEncryptedV2(params) {
|
|
3063
4925
|
const toAid = String(params.to ?? '').trim();
|
|
@@ -3070,7 +4932,14 @@ export class AUNClient {
|
|
|
3070
4932
|
const thoughtId = String(params.thought_id ?? '').trim() || `mt-${crypto.randomUUID()}`;
|
|
3071
4933
|
const timestamp = Number(params.timestamp ?? Date.now());
|
|
3072
4934
|
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
4935
|
+
this._logMessageDebug('thought-send-plaintext', 'message.thought.put.v2', 'message.thought.put', {
|
|
4936
|
+
to: toAid,
|
|
4937
|
+
thought_id: thoughtId,
|
|
4938
|
+
timestamp,
|
|
4939
|
+
payload,
|
|
4940
|
+
}, { payloadOverride: payload });
|
|
3073
4941
|
const attempt = async (useCache) => {
|
|
4942
|
+
this._clientLog.debug(`message.thought.put attempt: to=${toAid}, thought_id=${thoughtId}, use_cache=${useCache}`);
|
|
3074
4943
|
const context = isJsonObject(params.context) ? params.context : undefined;
|
|
3075
4944
|
const envelope = await this._buildV2P2PEnvelope({
|
|
3076
4945
|
to: toAid,
|
|
@@ -3091,7 +4960,13 @@ export class AUNClient {
|
|
|
3091
4960
|
if ('context' in params)
|
|
3092
4961
|
sendParams.context = params.context;
|
|
3093
4962
|
this._signClientOperation('message.thought.put', sendParams);
|
|
3094
|
-
|
|
4963
|
+
this._logMessageDebug('thought-send-envelope', 'message.thought.put.v2', 'message.thought.put', sendParams, {
|
|
4964
|
+
payloadOverride: envelope,
|
|
4965
|
+
extra: { to: toAid, thought_id: thoughtId, use_cache: useCache },
|
|
4966
|
+
});
|
|
4967
|
+
const result = await this._transport.call('message.thought.put', sendParams);
|
|
4968
|
+
this._clientLog.debug(`message.thought.put ok: to=${toAid}, thought_id=${thoughtId}, use_cache=${useCache}`);
|
|
4969
|
+
return result;
|
|
3095
4970
|
};
|
|
3096
4971
|
try {
|
|
3097
4972
|
return await attempt(true);
|
|
@@ -3116,7 +4991,14 @@ export class AUNClient {
|
|
|
3116
4991
|
const thoughtId = String(params.thought_id ?? '').trim() || `gt-${crypto.randomUUID()}`;
|
|
3117
4992
|
const timestamp = Number(params.timestamp ?? Date.now());
|
|
3118
4993
|
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
4994
|
+
this._logMessageDebug('thought-send-plaintext', 'group.thought.put.v2', 'group.thought.put', {
|
|
4995
|
+
group_id: groupId,
|
|
4996
|
+
thought_id: thoughtId,
|
|
4997
|
+
timestamp,
|
|
4998
|
+
payload,
|
|
4999
|
+
}, { payloadOverride: payload });
|
|
3119
5000
|
const attempt = async (useCache) => {
|
|
5001
|
+
this._clientLog.debug(`group.thought.put attempt: group=${groupId}, thought_id=${thoughtId}, use_cache=${useCache}`);
|
|
3120
5002
|
const context = isJsonObject(params.context) ? params.context : undefined;
|
|
3121
5003
|
const envelope = await this._buildV2GroupEnvelope({
|
|
3122
5004
|
groupId,
|
|
@@ -3137,7 +5019,13 @@ export class AUNClient {
|
|
|
3137
5019
|
if ('context' in params)
|
|
3138
5020
|
sendParams.context = params.context;
|
|
3139
5021
|
this._signClientOperation('group.thought.put', sendParams);
|
|
3140
|
-
|
|
5022
|
+
this._logMessageDebug('thought-send-envelope', 'group.thought.put.v2', 'group.thought.put', sendParams, {
|
|
5023
|
+
payloadOverride: envelope,
|
|
5024
|
+
extra: { group_id: groupId, thought_id: thoughtId, use_cache: useCache },
|
|
5025
|
+
});
|
|
5026
|
+
const result = await this._transport.call('group.thought.put', sendParams);
|
|
5027
|
+
this._clientLog.debug(`group.thought.put ok: group=${groupId}, thought_id=${thoughtId}, use_cache=${useCache}`);
|
|
5028
|
+
return result;
|
|
3141
5029
|
};
|
|
3142
5030
|
try {
|
|
3143
5031
|
return await attempt(true);
|
|
@@ -3159,30 +5047,58 @@ export class AUNClient {
|
|
|
3159
5047
|
return null;
|
|
3160
5048
|
const envelope = opts.envelope;
|
|
3161
5049
|
let spkId = '';
|
|
5050
|
+
let recipientKeySource = '';
|
|
3162
5051
|
if (Array.isArray(envelope.recipients)) {
|
|
3163
5052
|
for (const row of envelope.recipients) {
|
|
3164
|
-
if (!Array.isArray(row) || row.length <
|
|
5053
|
+
if (!Array.isArray(row) || row.length < 6)
|
|
3165
5054
|
continue;
|
|
3166
|
-
if (String(row[0] ?? '') === this._aid
|
|
5055
|
+
if (String(row[0] ?? '') === this._aid
|
|
5056
|
+
&& (String(row[1] ?? '') === this._deviceId || String(row[1] ?? '') === '')) {
|
|
3167
5057
|
spkId = String(row[5] ?? '');
|
|
5058
|
+
recipientKeySource = String(row[3] ?? '');
|
|
3168
5059
|
break;
|
|
3169
5060
|
}
|
|
3170
5061
|
}
|
|
3171
5062
|
}
|
|
3172
5063
|
else if (isJsonObject(envelope.recipient)) {
|
|
3173
|
-
|
|
5064
|
+
const recipient = envelope.recipient;
|
|
5065
|
+
spkId = String(recipient.spk_id ?? '');
|
|
5066
|
+
recipientKeySource = String(recipient.key_source ?? '');
|
|
3174
5067
|
}
|
|
3175
|
-
const { ikPriv, spkPriv } = session.getDecryptKeys(spkId);
|
|
3176
5068
|
const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
|
|
5069
|
+
const groupIdForKeys = String(aad.group_id ?? envelope.group_id ?? '').trim();
|
|
3177
5070
|
const fromAid = String(opts.fromAid || aad.from || '').trim();
|
|
3178
5071
|
const senderDeviceId = String(aad.from_device ?? '');
|
|
5072
|
+
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 ?? '')}`);
|
|
5073
|
+
// group_id 只表示群上下文;group lookup 内部按 group SPK -> P2P device SPK -> IK fallback。
|
|
5074
|
+
let ikPriv;
|
|
5075
|
+
let spkPriv;
|
|
5076
|
+
try {
|
|
5077
|
+
if (groupIdForKeys) {
|
|
5078
|
+
const keys = session.getGroupDecryptKeys(groupIdForKeys, spkId);
|
|
5079
|
+
ikPriv = keys.ikPriv;
|
|
5080
|
+
spkPriv = keys.spkPriv ?? undefined;
|
|
5081
|
+
}
|
|
5082
|
+
else {
|
|
5083
|
+
const keys = session.getDecryptKeys(spkId);
|
|
5084
|
+
ikPriv = keys.ikPriv;
|
|
5085
|
+
spkPriv = keys.spkPriv;
|
|
5086
|
+
}
|
|
5087
|
+
}
|
|
5088
|
+
catch (exc) {
|
|
5089
|
+
this._clientLog.warn(`V2 thought decrypt: SPK lookup failed from=${fromAid}, group=${groupIdForKeys || '<p2p>'}, spk_id=${spkId || '<empty>'}: ${formatCaughtError(exc)}`);
|
|
5090
|
+
return null;
|
|
5091
|
+
}
|
|
3179
5092
|
const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
3180
5093
|
if (!senderPubDer) {
|
|
3181
5094
|
this._clientLog.warn(`V2 thought decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
|
|
5095
|
+
this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupIdForKeys);
|
|
3182
5096
|
return null;
|
|
3183
5097
|
}
|
|
3184
5098
|
try {
|
|
3185
|
-
|
|
5099
|
+
const plain = decryptMessage(envelope, this._aid ?? '', this._deviceId, ikPriv, spkPriv, senderPubDer);
|
|
5100
|
+
this._clientLog.debug(`V2 thought decrypt ok: from=${fromAid}, sender_device=${senderDeviceId}, group=${groupIdForKeys || '<p2p>'}`);
|
|
5101
|
+
return plain;
|
|
3186
5102
|
}
|
|
3187
5103
|
catch (exc) {
|
|
3188
5104
|
this._clientLog.warn(`V2 thought decrypt failed from=${fromAid}: ${formatCaughtError(exc)}`);
|
|
@@ -3219,11 +5135,7 @@ export class AUNClient {
|
|
|
3219
5135
|
});
|
|
3220
5136
|
const sigBytes = Buffer.from(stateSignature, 'base64');
|
|
3221
5137
|
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)
|
|
5138
|
+
.update(lengthPrefixedBytesKey(Buffer.from(actorAid, 'utf-8'), Buffer.from(signPayload, 'utf-8'), sigBytes))
|
|
3227
5139
|
.digest('hex');
|
|
3228
5140
|
const now = Date.now();
|
|
3229
5141
|
const cachedExp = this._v2SigCache.get(cacheKey);
|
|
@@ -3425,15 +5337,19 @@ export class AUNClient {
|
|
|
3425
5337
|
}
|
|
3426
5338
|
if (myRole !== 'owner' && myRole !== 'admin')
|
|
3427
5339
|
return false;
|
|
3428
|
-
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5340
|
+
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5341
|
+
group_id: groupId,
|
|
5342
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5343
|
+
});
|
|
3429
5344
|
const devices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
|
|
3430
5345
|
? bootstrapResp.devices.filter(isJsonObject)
|
|
3431
5346
|
: [];
|
|
3432
5347
|
const candidates = [];
|
|
3433
5348
|
for (const dev of devices) {
|
|
3434
5349
|
const aid = String(dev.aid ?? '').trim();
|
|
5350
|
+
const hasDeviceId = 'device_id' in dev;
|
|
3435
5351
|
const deviceId = String(dev.device_id ?? '').trim();
|
|
3436
|
-
if (aid &&
|
|
5352
|
+
if (aid && hasDeviceId && onlineAdminAids.has(aid)) {
|
|
3437
5353
|
candidates.push(`${aid}\x1f${deviceId}`);
|
|
3438
5354
|
}
|
|
3439
5355
|
}
|
|
@@ -3449,7 +5365,7 @@ export class AUNClient {
|
|
|
3449
5365
|
this._clientLog.debug(`V2 auto propose leader elected: group=${groupId} leader=${leader}`);
|
|
3450
5366
|
return true;
|
|
3451
5367
|
}
|
|
3452
|
-
const delayMs = this._v2LeaderDelayMs(
|
|
5368
|
+
const delayMs = this._v2LeaderDelayMs(lengthPrefixedTextKey(groupId, myKey));
|
|
3453
5369
|
this._clientLog.debug(`V2 auto propose non-leader delay: group=${groupId} leader=${leader} self=${myKey} delay_ms=${delayMs}`);
|
|
3454
5370
|
await this._sleep(delayMs);
|
|
3455
5371
|
return true;
|
|
@@ -3533,7 +5449,10 @@ export class AUNClient {
|
|
|
3533
5449
|
}
|
|
3534
5450
|
}
|
|
3535
5451
|
}
|
|
3536
|
-
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5452
|
+
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5453
|
+
group_id: groupId,
|
|
5454
|
+
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5455
|
+
});
|
|
3537
5456
|
const allDevices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
|
|
3538
5457
|
? bootstrapResp.devices.filter(isJsonObject)
|
|
3539
5458
|
: [];
|
|
@@ -3719,30 +5638,45 @@ export class AUNClient {
|
|
|
3719
5638
|
const pushFrom = isJsonObject(data) ? String(data.from_aid ?? '') : '';
|
|
3720
5639
|
const pushMsgId = isJsonObject(data) ? String(data.message_id ?? '') : '';
|
|
3721
5640
|
const envelopeJson = isJsonObject(data) ? data.envelope_json : undefined;
|
|
5641
|
+
const hasPayload = !!envelopeJson;
|
|
3722
5642
|
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
3723
5643
|
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=${
|
|
5644
|
+
this._clientLog.debug(`_onV2PushNotification: push_seq=${pushSeq || 'null'} push_from=${pushFrom} push_msg_id=${pushMsgId} has_payload=${hasPayload} contiguous_seq=${contigBefore}`);
|
|
5645
|
+
// ── Push 修上界:只更新 maxSeenSeq,不动 contiguousSeq ──
|
|
5646
|
+
// 即使 pushSeq 是脏数据(如服务端 bug 导致的 99999),也只影响"已知上界",
|
|
5647
|
+
// 不会污染下界 contiguousSeq,更不会导致 SDK 把脏数据 ack 回服务端。
|
|
5648
|
+
if (pushSeq > 0 && ns) {
|
|
5649
|
+
this._seqTracker.updateMaxSeen(ns, pushSeq);
|
|
5650
|
+
if (contigBefore === pushSeq) {
|
|
5651
|
+
this._clientLog.debug(`_onV2PushNotification: push seq=${pushSeq} already covered by contiguous_seq=${contigBefore}, ignore duplicate push`);
|
|
5652
|
+
return;
|
|
5653
|
+
}
|
|
5654
|
+
contigBefore = this._repairPushContiguousBound(ns, pushSeq, hasPayload, '_raw.peer.v2.message_received');
|
|
5655
|
+
}
|
|
3725
5656
|
// ── 带 payload 的 push:尝试就地解密 ──
|
|
3726
|
-
if (
|
|
5657
|
+
if (hasPayload && pushSeq > 0 && ns) {
|
|
3727
5658
|
try {
|
|
3728
5659
|
const decrypted = await this._decryptV2PushMessage(data);
|
|
3729
5660
|
if (decrypted) {
|
|
3730
|
-
//
|
|
3731
|
-
this.
|
|
3732
|
-
if (pushSeq === contigBefore + 1) {
|
|
3733
|
-
this._seqTracker.forceContiguousSeq(ns, pushSeq);
|
|
3734
|
-
}
|
|
3735
|
-
await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
|
|
5661
|
+
// 解密成功也不能先推进 contiguousSeq;必须等应用层发布返回后再推进和 ACK。
|
|
5662
|
+
const published = await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
|
|
3736
5663
|
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5664
|
+
const needPull = pushSeq > newContig && !published;
|
|
3737
5665
|
if (newContig !== contigBefore) {
|
|
3738
5666
|
this._saveSeqTrackerState();
|
|
3739
5667
|
}
|
|
3740
5668
|
if (newContig > 0 && newContig !== contigBefore) {
|
|
3741
|
-
|
|
5669
|
+
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
5670
|
+
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
5671
|
+
const ackSeq = maxSeen > 0 ? Math.min(newContig, maxSeen) : newContig;
|
|
5672
|
+
this.call('message.v2.ack', { up_to_seq: ackSeq, _rpc_background: true })
|
|
3742
5673
|
.catch(e => this._clientLog.debug(`V2 P2P push-ack failed: ${formatCaughtError(e)}`));
|
|
3743
5674
|
}
|
|
3744
5675
|
this._clientLog.debug(`_onV2PushNotification: push 带 payload 解密成功, contiguous_seq=${contigBefore}->${newContig} push_seq=${pushSeq}`);
|
|
3745
|
-
|
|
5676
|
+
if (!needPull && (published || newContig >= pushSeq || pushSeq <= contigBefore)) {
|
|
5677
|
+
return;
|
|
5678
|
+
}
|
|
5679
|
+
this._clientLog.debug(`_onV2PushNotification: payload push seq=${pushSeq} 因空洞挂起,继续 pull 补齐 after_seq=${newContig}`);
|
|
3746
5680
|
}
|
|
3747
5681
|
}
|
|
3748
5682
|
catch (exc) {
|
|
@@ -3753,38 +5687,37 @@ export class AUNClient {
|
|
|
3753
5687
|
// 纯通知只表示服务端已有 pushSeq 这条消息,内容还没有进入本地,不能先推进 contiguousSeq。
|
|
3754
5688
|
// 后续 pull 必须从当前 contiguousSeq 开始,否则会跳过 pushSeq 本身。
|
|
3755
5689
|
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
5690
|
this._clientLog.debug(`_onV2PushNotification: 纯通知 push_seq=${pushSeq} > contiguous_seq=${contigBefore}, 触发 pull(after_seq=${contigBefore})`);
|
|
3762
5691
|
}
|
|
3763
|
-
if (
|
|
3764
|
-
this._v2PullPending = true;
|
|
5692
|
+
if (!ns)
|
|
3765
5693
|
return;
|
|
3766
|
-
|
|
3767
|
-
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
5694
|
+
void this._tryRunBackgroundPull(ns, async () => {
|
|
5695
|
+
const operationBefore = this._seqTracker.getContiguousSeq(ns);
|
|
5696
|
+
const dedupKey = `p2p_pull:${ns}`;
|
|
5697
|
+
if (this._gapFillDone.has(dedupKey))
|
|
5698
|
+
return 0;
|
|
5699
|
+
this._gapFillDone.set(dedupKey, Date.now());
|
|
5700
|
+
try {
|
|
5701
|
+
const pulled = await this.pullV2(0, 50, { gateLocked: true });
|
|
5702
|
+
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
3773
5703
|
this._clientLog.debug(`_onV2PushNotification pull done: contiguous_seq=${contigBefore}->${newContig} (push_seq=${pushSeq || 'null'})`);
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
5704
|
+
if (newContig <= operationBefore)
|
|
5705
|
+
return 0;
|
|
5706
|
+
return pulled.length;
|
|
5707
|
+
}
|
|
5708
|
+
finally {
|
|
5709
|
+
this._gapFillDone.delete(dedupKey);
|
|
5710
|
+
}
|
|
5711
|
+
}, true).catch((exc) => {
|
|
5712
|
+
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
3778
5713
|
this._clientLog.warn(`V2 push auto-pull failed: contiguous_seq=${contigBefore}->${newContig} err=${formatCaughtError(exc)}`);
|
|
3779
|
-
}
|
|
3780
|
-
finally {
|
|
3781
|
-
this._v2PullInflight = false;
|
|
3782
|
-
}
|
|
5714
|
+
});
|
|
3783
5715
|
}
|
|
3784
5716
|
async _onV2StateProposed(data) {
|
|
3785
5717
|
if (!isJsonObject(data) || !this._v2Session)
|
|
3786
5718
|
return;
|
|
3787
|
-
const
|
|
5719
|
+
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5720
|
+
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
3788
5721
|
if (!groupId)
|
|
3789
5722
|
return;
|
|
3790
5723
|
await this._dispatcher.publish('group.v2.state_proposed', data);
|
|
@@ -3798,7 +5731,8 @@ export class AUNClient {
|
|
|
3798
5731
|
async _onV2StateRetryNeeded(data) {
|
|
3799
5732
|
if (!isJsonObject(data) || !this._v2Session)
|
|
3800
5733
|
return;
|
|
3801
|
-
const
|
|
5734
|
+
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5735
|
+
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
3802
5736
|
if (!groupId)
|
|
3803
5737
|
return;
|
|
3804
5738
|
await this._dispatcher.publish('group.v2.state_retry_needed', data);
|
|
@@ -3812,7 +5746,8 @@ export class AUNClient {
|
|
|
3812
5746
|
async _onV2StateConfirmed(data) {
|
|
3813
5747
|
if (!isJsonObject(data))
|
|
3814
5748
|
return;
|
|
3815
|
-
const
|
|
5749
|
+
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5750
|
+
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
3816
5751
|
if (groupId) {
|
|
3817
5752
|
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
3818
5753
|
this._v2AutoProposeLastSnapshot.delete(groupId);
|
|
@@ -3820,29 +5755,51 @@ export class AUNClient {
|
|
|
3820
5755
|
await this._dispatcher.publish('group.v2.state_confirmed', data);
|
|
3821
5756
|
}
|
|
3822
5757
|
async _onRawGroupV2MessageCreated(data) {
|
|
3823
|
-
if (!isJsonObject(data) || !this._v2Session)
|
|
5758
|
+
if (!isJsonObject(data) || !this._v2Session) {
|
|
5759
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: is_object=${String(isJsonObject(data))}, has_v2_session=${String(!!this._v2Session)}`);
|
|
3824
5760
|
return;
|
|
3825
|
-
|
|
5761
|
+
}
|
|
5762
|
+
this._logMessageDebug('server-push', '_raw.group.v2.message_created', 'group.message_created', data);
|
|
5763
|
+
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5764
|
+
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
3826
5765
|
const seq = Number(data.seq ?? 0);
|
|
3827
|
-
if (!groupId || !Number.isFinite(seq) || seq <= 0)
|
|
5766
|
+
if (!groupId || !Number.isFinite(seq) || seq <= 0) {
|
|
5767
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: group=${groupId || '<empty>'}, seq=${String(data.seq ?? '')}`);
|
|
3828
5768
|
return;
|
|
5769
|
+
}
|
|
3829
5770
|
const ns = `group:${groupId}`;
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
const
|
|
3833
|
-
|
|
3834
|
-
if (
|
|
5771
|
+
// Push 修上界:先更新 maxSeenSeq
|
|
5772
|
+
this._seqTracker.updateMaxSeen(ns, seq);
|
|
5773
|
+
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
5774
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated enter: group=${groupId}, seq=${seq}, contiguous=${contigBefore}, max_seen=${this._seqTracker.getMaxSeenSeq(ns)}`);
|
|
5775
|
+
if (contigBefore === seq) {
|
|
5776
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated duplicate push already covered: group=${groupId} seq=${seq}`);
|
|
3835
5777
|
return;
|
|
3836
|
-
this._gapFillDone.set(dedupKey, Date.now());
|
|
3837
|
-
try {
|
|
3838
|
-
await this.pullGroupV2(groupId, afterSeq, 50);
|
|
3839
5778
|
}
|
|
3840
|
-
|
|
5779
|
+
const afterSeq = this._repairPushContiguousBound(ns, seq, false, '_raw.group.v2.message_created');
|
|
5780
|
+
const dedupKey = `v2_group_push:${groupId}:${afterSeq}`;
|
|
5781
|
+
void this._tryRunBackgroundPull(ns, async () => {
|
|
5782
|
+
const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
5783
|
+
if (this._gapFillDone.has(dedupKey)) {
|
|
5784
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped duplicate in-flight pull: group=${groupId}, dedup=${dedupKey}`);
|
|
5785
|
+
return 0;
|
|
5786
|
+
}
|
|
5787
|
+
this._gapFillDone.set(dedupKey, Date.now());
|
|
5788
|
+
try {
|
|
5789
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull start: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}`);
|
|
5790
|
+
const pulled = await this.pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
|
|
5791
|
+
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5792
|
+
this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull done: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}, contiguous=${newContig}`);
|
|
5793
|
+
if (newContig <= pullAfterSeq)
|
|
5794
|
+
return 0;
|
|
5795
|
+
return pulled.length;
|
|
5796
|
+
}
|
|
5797
|
+
finally {
|
|
5798
|
+
this._gapFillDone.delete(dedupKey);
|
|
5799
|
+
}
|
|
5800
|
+
}, true).catch((exc) => {
|
|
3841
5801
|
this._clientLog.warn(`V2 group push auto-pull failed: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
3842
|
-
}
|
|
3843
|
-
finally {
|
|
3844
|
-
this._gapFillDone.delete(dedupKey);
|
|
3845
|
-
}
|
|
5802
|
+
});
|
|
3846
5803
|
}
|
|
3847
5804
|
/** Push 通知带 payload 时的就地解密(复用 _decryptV2Message) */
|
|
3848
5805
|
async _decryptV2PushMessage(data) {
|
|
@@ -3869,6 +5826,11 @@ export class AUNClient {
|
|
|
3869
5826
|
}
|
|
3870
5827
|
/** 从参数中解析 Gateway URL */
|
|
3871
5828
|
_resolveGateway(params) {
|
|
5829
|
+
const gateways = this._resolveGateways(params);
|
|
5830
|
+
return gateways[0];
|
|
5831
|
+
}
|
|
5832
|
+
/** 从参数中解析所有 Gateway URL(支持 string 或 string[]) */
|
|
5833
|
+
_resolveGateways(params) {
|
|
3872
5834
|
const topology = params.topology;
|
|
3873
5835
|
if (isJsonObject(topology)) {
|
|
3874
5836
|
const topo = topology;
|
|
@@ -3880,11 +5842,16 @@ export class AUNClient {
|
|
|
3880
5842
|
throw new ValidationError('relay topology is not implemented in the TypeScript SDK');
|
|
3881
5843
|
}
|
|
3882
5844
|
}
|
|
3883
|
-
const
|
|
3884
|
-
if (
|
|
3885
|
-
|
|
5845
|
+
const gw = params.gateway ?? params.gateways;
|
|
5846
|
+
if (Array.isArray(gw)) {
|
|
5847
|
+
const urls = gw.map(g => String(g ?? '')).filter(u => u.length > 0);
|
|
5848
|
+
if (urls.length > 0)
|
|
5849
|
+
return urls;
|
|
5850
|
+
}
|
|
5851
|
+
if (typeof gw === 'string' && gw) {
|
|
5852
|
+
return [gw];
|
|
3886
5853
|
}
|
|
3887
|
-
|
|
5854
|
+
throw new StateError('missing gateway in connect params');
|
|
3888
5855
|
}
|
|
3889
5856
|
/** 连接后同步身份信息 */
|
|
3890
5857
|
_syncIdentityAfterConnect(accessToken) {
|
|
@@ -4138,6 +6105,16 @@ export class AUNClient {
|
|
|
4138
6105
|
};
|
|
4139
6106
|
scheduleNext(0);
|
|
4140
6107
|
}
|
|
6108
|
+
_normalizeOutboundMessagePayload(params, method = '') {
|
|
6109
|
+
if (!Object.prototype.hasOwnProperty.call(params, 'payload') && Object.prototype.hasOwnProperty.call(params, 'content')) {
|
|
6110
|
+
params.payload = params.content;
|
|
6111
|
+
delete params.content;
|
|
6112
|
+
}
|
|
6113
|
+
const payload = params.payload;
|
|
6114
|
+
if (isJsonObject(payload) && !Object.prototype.hasOwnProperty.call(payload, 'type') && typeof payload.text === 'string') {
|
|
6115
|
+
params.payload = { type: 'text', ...payload };
|
|
6116
|
+
}
|
|
6117
|
+
}
|
|
4141
6118
|
_validateMessageRecipient(toAid) {
|
|
4142
6119
|
if (isGroupServiceAid(toAid)) {
|
|
4143
6120
|
throw new ValidationError('message.send receiver cannot be group.{issuer}; use group.send instead');
|