@agentunion/fastaun 0.4.6 → 0.4.8
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 +38 -0
- package/_packed_docs/CHANGELOG.md +38 -0
- package/_packed_docs/INDEX.md +46 -22
- package/_packed_docs/KITE_DOCS_GUIDE.md +16 -12
- package/_packed_docs/design/AUNClient/346/213/206/345/210/206/351/207/215/346/236/204/346/211/247/350/241/214/346/226/271/346/241/210.md +859 -0
- package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +5 -3
- package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +5 -5
- package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +4 -2
- package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +1 -1
- package/dist/agent-md.d.ts +1 -1
- package/dist/agent-md.js +12 -5
- package/dist/agent-md.js.map +1 -1
- package/dist/aid-store.d.ts +6 -1
- package/dist/aid-store.js +93 -14
- package/dist/aid-store.js.map +1 -1
- package/dist/aid.d.ts +1 -0
- package/dist/aid.js +8 -3
- package/dist/aid.js.map +1 -1
- package/dist/cert-utils.d.ts +5 -1
- package/dist/cert-utils.js +47 -9
- package/dist/cert-utils.js.map +1 -1
- package/dist/client/delivery.d.ts +50 -0
- package/dist/client/delivery.js +1147 -0
- package/dist/client/delivery.js.map +1 -0
- package/dist/client/group-state.d.ts +31 -0
- package/dist/client/group-state.js +853 -0
- package/dist/client/group-state.js.map +1 -0
- package/dist/client/identity.d.ts +7 -0
- package/dist/client/identity.js +35 -0
- package/dist/client/identity.js.map +1 -0
- package/dist/client/lifecycle.d.ts +9 -0
- package/dist/client/lifecycle.js +226 -0
- package/dist/client/lifecycle.js.map +1 -0
- package/dist/client/peers.d.ts +10 -0
- package/dist/client/peers.js +42 -0
- package/dist/client/peers.js.map +1 -0
- package/dist/client/rpc-pipeline.d.ts +36 -0
- package/dist/client/rpc-pipeline.js +461 -0
- package/dist/client/rpc-pipeline.js.map +1 -0
- package/dist/client/runtime.d.ts +5 -0
- package/dist/client/runtime.js +7 -0
- package/dist/client/runtime.js.map +1 -0
- package/dist/client/v2-e2ee.d.ts +109 -0
- package/dist/client/v2-e2ee.js +1640 -0
- package/dist/client/v2-e2ee.js.map +1 -0
- package/dist/client.d.ts +21 -57
- package/dist/client.js +303 -3859
- package/dist/client.js.map +1 -1
- package/dist/config.js +9 -8
- package/dist/config.js.map +1 -1
- package/dist/discovery.js +56 -6
- package/dist/discovery.js.map +1 -1
- package/dist/keystore/aid-db.d.ts +2 -0
- package/dist/keystore/aid-db.js +12 -2
- package/dist/keystore/aid-db.js.map +1 -1
- package/dist/tools/cross-sdk-agent.js +2 -2
- package/dist/tools/cross-sdk-agent.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +3 -3
package/dist/client.js
CHANGED
|
@@ -14,7 +14,7 @@ import * as crypto from 'node:crypto';
|
|
|
14
14
|
import * as http from 'node:http';
|
|
15
15
|
import * as https from 'node:https';
|
|
16
16
|
import { URL } from 'node:url';
|
|
17
|
-
import { configFromMap, getDeviceId, normalizeSlotId
|
|
17
|
+
import { configFromMap, getDeviceId, normalizeSlotId } from './config.js';
|
|
18
18
|
import { CryptoProvider } from './crypto.js';
|
|
19
19
|
import { GatewayDiscovery } from './discovery.js';
|
|
20
20
|
import { DnsResilientNet } from './net.js';
|
|
@@ -27,12 +27,19 @@ import { RPCTransport } from './transport.js';
|
|
|
27
27
|
import { AuthFlow } from './auth.js';
|
|
28
28
|
import { AgentMdManager } from './agent-md.js';
|
|
29
29
|
import { SeqTracker } from './seq-tracker.js';
|
|
30
|
-
import {
|
|
31
|
-
import {
|
|
30
|
+
import { ClientRuntime } from './client/runtime.js';
|
|
31
|
+
import { MessageDeliveryEngine } from './client/delivery.js';
|
|
32
|
+
import { IdentityRuntimeManager } from './client/identity.js';
|
|
33
|
+
import { LifecycleController } from './client/lifecycle.js';
|
|
34
|
+
import { PeerDirectory } from './client/peers.js';
|
|
35
|
+
import { RpcPipeline } from './client/rpc-pipeline.js';
|
|
36
|
+
import { V2E2EECoordinator } from './client/v2-e2ee.js';
|
|
37
|
+
import { GroupStateCoordinator } from './client/group-state.js';
|
|
38
|
+
import { decryptMessage, } from './v2/e2ee/index.js';
|
|
32
39
|
import { ecdsaVerifyRaw } from './v2/crypto/ecdsa.js';
|
|
33
|
-
import { computeStateCommitment } from './v2/state/index.js';
|
|
34
40
|
import { isJsonObject, ConnectionState, STATE_TO_PUBLIC, } from './types.js';
|
|
35
41
|
import { AID } from './aid.js';
|
|
42
|
+
import { certMatchesFingerprint, normalizeFingerprintHex } from './cert-utils.js';
|
|
36
43
|
function isPromiseLike(value) {
|
|
37
44
|
return Boolean(value && typeof value.then === 'function');
|
|
38
45
|
}
|
|
@@ -69,6 +76,16 @@ export function stableStringify(obj) {
|
|
|
69
76
|
}
|
|
70
77
|
return JSON.stringify(obj);
|
|
71
78
|
}
|
|
79
|
+
function attachGatewayProximity(message, source) {
|
|
80
|
+
if (isJsonObject(source.proximity)) {
|
|
81
|
+
message.proximity = { ...source.proximity };
|
|
82
|
+
}
|
|
83
|
+
for (const key of ['same_device', 'same_network', 'same_egress_ip']) {
|
|
84
|
+
if (Object.prototype.hasOwnProperty.call(source, key)) {
|
|
85
|
+
message[key] = source[key];
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
72
89
|
function getV2DeviceId(dev) {
|
|
73
90
|
if (Object.prototype.hasOwnProperty.call(dev, 'device_id')) {
|
|
74
91
|
return { present: true, value: String(dev.device_id ?? '').trim() };
|
|
@@ -78,47 +95,6 @@ function getV2DeviceId(dev) {
|
|
|
78
95
|
}
|
|
79
96
|
return { present: false, value: '' };
|
|
80
97
|
}
|
|
81
|
-
function computeStateHash(params) {
|
|
82
|
-
const sortedMembers = [...params.members].sort((a, b) => a.aid.localeCompare(b.aid));
|
|
83
|
-
const membershipBlock = sortedMembers.map(m => `${m.aid}:${m.role}`).join('|');
|
|
84
|
-
const sortedPolicy = {};
|
|
85
|
-
for (const key of Object.keys(params.policy).sort()) {
|
|
86
|
-
sortedPolicy[key] = params.policy[key];
|
|
87
|
-
}
|
|
88
|
-
const policyBlock = Object.keys(params.policy).length > 0 ? JSON.stringify(sortedPolicy) : '';
|
|
89
|
-
const prevBytes = params.prevStateHash ? Buffer.from(params.prevStateHash, 'hex') : Buffer.alloc(32);
|
|
90
|
-
const svBuf = Buffer.alloc(8);
|
|
91
|
-
svBuf.writeBigUInt64BE(BigInt(params.stateVersion));
|
|
92
|
-
const keBuf = Buffer.alloc(8);
|
|
93
|
-
keBuf.writeBigUInt64BE(BigInt(params.keyEpoch));
|
|
94
|
-
const data = Buffer.concat([
|
|
95
|
-
Buffer.from(params.groupId, 'utf-8'), Buffer.from([0x00]),
|
|
96
|
-
svBuf, Buffer.from([0x00]),
|
|
97
|
-
keBuf, Buffer.from([0x00]),
|
|
98
|
-
Buffer.from(membershipBlock, 'utf-8'), Buffer.from([0x00]),
|
|
99
|
-
Buffer.from(policyBlock, 'utf-8'), Buffer.from([0x00]),
|
|
100
|
-
prevBytes,
|
|
101
|
-
]);
|
|
102
|
-
return crypto.createHash('sha256').update(data).digest('hex');
|
|
103
|
-
}
|
|
104
|
-
// ── 常量 ──────────────────────────────────────────────────────
|
|
105
|
-
/** 内部专用方法,禁止外部直接调用 */
|
|
106
|
-
const INTERNAL_ONLY_METHODS = new Set([
|
|
107
|
-
'auth.login1',
|
|
108
|
-
'auth.aid_login1',
|
|
109
|
-
'auth.login2',
|
|
110
|
-
'auth.aid_login2',
|
|
111
|
-
'auth.connect',
|
|
112
|
-
'auth.refresh_token',
|
|
113
|
-
'initialize',
|
|
114
|
-
]);
|
|
115
|
-
/** 已移除的旧版 E2EE RPC。 */
|
|
116
|
-
const REMOVED_E2EE_METHODS = new Set([
|
|
117
|
-
'group.rotate_epoch',
|
|
118
|
-
'group.e2ee.begin_rotation',
|
|
119
|
-
'group.e2ee.commit_rotation',
|
|
120
|
-
'group.e2ee.abort_rotation',
|
|
121
|
-
]);
|
|
122
98
|
const DEFAULT_SESSION_OPTIONS = {
|
|
123
99
|
auto_reconnect: true,
|
|
124
100
|
heartbeat_interval: 30.0,
|
|
@@ -135,31 +111,9 @@ const DEFAULT_SESSION_OPTIONS = {
|
|
|
135
111
|
http: 30.0,
|
|
136
112
|
},
|
|
137
113
|
};
|
|
138
|
-
const PUBLIC_CONNECTION_OPTION_KEYS = new Set([
|
|
139
|
-
'auto_reconnect',
|
|
140
|
-
'connect_timeout',
|
|
141
|
-
'retry_initial_delay',
|
|
142
|
-
'retry_max_delay',
|
|
143
|
-
'retry_max_attempts',
|
|
144
|
-
'heartbeat_interval',
|
|
145
|
-
'call_timeout',
|
|
146
|
-
'connection_kind',
|
|
147
|
-
'short_ttl_ms',
|
|
148
|
-
'delivery_mode',
|
|
149
|
-
'extra_info',
|
|
150
|
-
'background_sync',
|
|
151
|
-
]);
|
|
152
|
-
const PROTECTED_HEADERS_METHODS = new Set([
|
|
153
|
-
'message.send',
|
|
154
|
-
'group.send',
|
|
155
|
-
'message.thought.put',
|
|
156
|
-
'group.thought.put',
|
|
157
|
-
]);
|
|
158
114
|
const RECONNECT_MIN_BASE_DELAY_MS = 1_000;
|
|
159
115
|
const RECONNECT_MAX_BASE_DELAY_MS = 64_000;
|
|
160
116
|
const TOKEN_REFRESH_CHECK_INTERVAL_MS = 30_000;
|
|
161
|
-
const PUSHED_SEQS_LIMIT = 50_000;
|
|
162
|
-
const PENDING_ORDERED_LIMIT = 50_000;
|
|
163
117
|
// 心跳间隔下/上限(秒)。0 = 关闭心跳;负值视为 0;其余值 clamp 到 [10, 600]。
|
|
164
118
|
// 服务端通过 hello.heartbeat_interval 与 meta.ping pong 中的同名字段下发。
|
|
165
119
|
const HEARTBEAT_MIN_INTERVAL_SECONDS = 10;
|
|
@@ -186,15 +140,7 @@ const NON_IDEMPOTENT_METHODS = new Set([
|
|
|
186
140
|
'message.thought.put', 'group.thought.put',
|
|
187
141
|
'group.add_member',
|
|
188
142
|
]);
|
|
189
|
-
|
|
190
|
-
const parsed = Number(value);
|
|
191
|
-
const ms = Number.isFinite(parsed) ? parsed : fallback;
|
|
192
|
-
return Math.min(Math.max(ms, RECONNECT_MIN_BASE_DELAY_MS), upper);
|
|
193
|
-
}
|
|
194
|
-
function reconnectSleepDelayMs(baseDelay, maxBaseDelay) {
|
|
195
|
-
return baseDelay + Math.random() * maxBaseDelay;
|
|
196
|
-
}
|
|
197
|
-
/** 需要客户端签名的关键方法 */
|
|
143
|
+
/** 需要客户端签名的关键方法。运行时逻辑已迁入 RpcPipeline,此处保留给源码审计测试。 */
|
|
198
144
|
const SIGNED_METHODS = new Set([
|
|
199
145
|
'message.send',
|
|
200
146
|
'message.v2.put_peer_pk', 'message.v2.bootstrap',
|
|
@@ -223,70 +169,16 @@ const SIGNED_METHODS = new Set([
|
|
|
223
169
|
'group.ban', 'group.unban',
|
|
224
170
|
'group.dissolve', 'group.suspend', 'group.resume',
|
|
225
171
|
]);
|
|
226
|
-
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
return { explicit: false, version: '', protocol: '', scope: 'device' };
|
|
231
|
-
}
|
|
232
|
-
const obj = raw;
|
|
233
|
-
let protocol = String(obj.protocol ?? '').trim().toUpperCase();
|
|
234
|
-
if (protocol !== '1DH' && protocol !== '3DH')
|
|
235
|
-
protocol = '';
|
|
236
|
-
let scope = String(obj.scope ?? '').trim().toLowerCase();
|
|
237
|
-
if (scope !== 'aid' && scope !== 'device') {
|
|
238
|
-
scope = obj.per_aid_wrap === true ? 'aid' : 'device';
|
|
239
|
-
}
|
|
240
|
-
if (scope === 'aid')
|
|
241
|
-
protocol = '1DH';
|
|
242
|
-
return {
|
|
243
|
-
explicit: true,
|
|
244
|
-
version: String(obj.version ?? ''),
|
|
245
|
-
protocol,
|
|
246
|
-
scope: scope,
|
|
247
|
-
};
|
|
248
|
-
}
|
|
249
|
-
function v2WrapCapabilities() {
|
|
250
|
-
return {
|
|
251
|
-
version: 'v2.1',
|
|
252
|
-
protocols: ['1DH', '3DH'],
|
|
253
|
-
scopes: ['aid', 'device'],
|
|
254
|
-
per_aid_wrap: true,
|
|
255
|
-
per_device_wrap: true,
|
|
256
|
-
};
|
|
257
|
-
}
|
|
258
|
-
function applyV2WrapPolicyToTargets(targets, policy) {
|
|
259
|
-
if (!policy.explicit)
|
|
260
|
-
return targets;
|
|
261
|
-
const out = [];
|
|
262
|
-
const seen = new Set();
|
|
263
|
-
for (const target of targets) {
|
|
264
|
-
const row = { ...target };
|
|
265
|
-
if (policy.protocol === '1DH') {
|
|
266
|
-
row.keySource = 'aid_master';
|
|
267
|
-
row.spkPkDer = undefined;
|
|
268
|
-
row.spkId = '';
|
|
269
|
-
}
|
|
270
|
-
if (policy.scope === 'aid') {
|
|
271
|
-
const key = `${row.aid}\x1f${row.role}`;
|
|
272
|
-
if (seen.has(key))
|
|
273
|
-
continue;
|
|
274
|
-
seen.add(key);
|
|
275
|
-
row.deviceId = '';
|
|
276
|
-
}
|
|
277
|
-
out.push(row);
|
|
278
|
-
}
|
|
279
|
-
return out;
|
|
172
|
+
function clampReconnectDelayMs(value, fallback, upper = RECONNECT_MAX_BASE_DELAY_MS) {
|
|
173
|
+
const parsed = Number(value);
|
|
174
|
+
const ms = Number.isFinite(parsed) ? parsed : fallback;
|
|
175
|
+
return Math.min(Math.max(ms, RECONNECT_MIN_BASE_DELAY_MS), upper);
|
|
280
176
|
}
|
|
281
|
-
function
|
|
282
|
-
|
|
283
|
-
return b;
|
|
284
|
-
if (b.length > 32)
|
|
285
|
-
return b.subarray(b.length - 32);
|
|
286
|
-
const out = new Uint8Array(32);
|
|
287
|
-
out.set(b, 32 - b.length);
|
|
288
|
-
return out;
|
|
177
|
+
function reconnectSleepDelayMs(baseDelay, maxBaseDelay) {
|
|
178
|
+
return baseDelay + Math.random() * maxBaseDelay;
|
|
289
179
|
}
|
|
180
|
+
/** peer 证书缓存 TTL(1 小时) */
|
|
181
|
+
const PEER_CERT_CACHE_TTL = 3600;
|
|
290
182
|
function _v2B64ToBytes(s) {
|
|
291
183
|
const buf = Buffer.from(String(s ?? '').trim(), 'base64');
|
|
292
184
|
return new Uint8Array(buf.buffer, buf.byteOffset, buf.byteLength);
|
|
@@ -306,18 +198,6 @@ function _v2BytesEqual(a, b) {
|
|
|
306
198
|
diff |= a[i] ^ b[i];
|
|
307
199
|
return diff === 0;
|
|
308
200
|
}
|
|
309
|
-
function _v2B64uToBytes(s) {
|
|
310
|
-
const std = String(s ?? '').replace(/-/g, '+').replace(/_/g, '/');
|
|
311
|
-
const pad = std.length % 4 === 0 ? '' : '='.repeat(4 - (std.length % 4));
|
|
312
|
-
return _v2B64ToBytes(std + pad);
|
|
313
|
-
}
|
|
314
|
-
function isGroupServiceAid(value) {
|
|
315
|
-
const text = String(value ?? '').trim();
|
|
316
|
-
if (!text.includes('.'))
|
|
317
|
-
return false;
|
|
318
|
-
const [name, ...issuerParts] = text.split('.');
|
|
319
|
-
return name === 'group' && issuerParts.join('.').length > 0;
|
|
320
|
-
}
|
|
321
201
|
function formatCaughtError(error) {
|
|
322
202
|
return error instanceof Error ? error : String(error);
|
|
323
203
|
}
|
|
@@ -391,17 +271,6 @@ function _httpGetText(url, verifySsl, timeoutMs = 30_000) {
|
|
|
391
271
|
/**
|
|
392
272
|
* AUN Core SDK 主客户端
|
|
393
273
|
*/
|
|
394
|
-
function lengthPrefixedTextKey(...parts) {
|
|
395
|
-
return parts.map((part) => `${Buffer.byteLength(part, 'utf8')}:${part};`).join('');
|
|
396
|
-
}
|
|
397
|
-
function lengthPrefixedBytesKey(...parts) {
|
|
398
|
-
const chunks = [];
|
|
399
|
-
for (const part of parts) {
|
|
400
|
-
const bytes = Buffer.from(part.buffer, part.byteOffset, part.byteLength);
|
|
401
|
-
chunks.push(Buffer.from(`${bytes.length}:`, 'ascii'), bytes, Buffer.from(';', 'ascii'));
|
|
402
|
-
}
|
|
403
|
-
return Buffer.concat(chunks);
|
|
404
|
-
}
|
|
405
274
|
function createAgentMdManagerForRuntime(opts) {
|
|
406
275
|
return new AgentMdManager({
|
|
407
276
|
aunPath: opts.config().aunPath,
|
|
@@ -456,27 +325,22 @@ function createAgentMdManagerForRuntime(opts) {
|
|
|
456
325
|
opts.identity.set(identity);
|
|
457
326
|
return token;
|
|
458
327
|
},
|
|
459
|
-
peerResolver: async (aid) => {
|
|
328
|
+
peerResolver: async (aid, certFingerprint) => {
|
|
460
329
|
const target = String(aid ?? '').trim();
|
|
330
|
+
const expectedFp = String(certFingerprint ?? '').trim().toLowerCase();
|
|
461
331
|
const current = opts.currentAid();
|
|
462
|
-
if (current?.aid === target)
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
certPem = String(opts.tokenStore().loadCert(target) ?? '').trim();
|
|
332
|
+
if (current?.aid === target) {
|
|
333
|
+
if (!expectedFp || certMatchesFingerprint(current.certPem, expectedFp))
|
|
334
|
+
return current;
|
|
335
|
+
throw new StateError(`current AID certificate fingerprint mismatch for ${target}`);
|
|
467
336
|
}
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
if (!certPem) {
|
|
472
|
-
if (!opts.gateway.get()) {
|
|
473
|
-
try {
|
|
474
|
-
opts.gateway.set(await opts.gateway.resolve(target));
|
|
475
|
-
}
|
|
476
|
-
catch { /* best effort before cert fetch */ }
|
|
337
|
+
if (!opts.gateway.get()) {
|
|
338
|
+
try {
|
|
339
|
+
opts.gateway.set(await opts.gateway.resolve(target));
|
|
477
340
|
}
|
|
478
|
-
|
|
341
|
+
catch { /* best effort before cert fetch */ }
|
|
479
342
|
}
|
|
343
|
+
const certPem = String(await opts.fetchPeerCert(target, expectedFp || undefined) ?? '').trim();
|
|
480
344
|
if (!certPem)
|
|
481
345
|
throw new NotFoundError(`certificate not found for aid: ${target}`);
|
|
482
346
|
return AID._create({
|
|
@@ -556,6 +420,12 @@ export class AUNClient {
|
|
|
556
420
|
_pendingOrderedMsgs = new Map();
|
|
557
421
|
/** P2P pull 进行中到达的纯通知 push 上界;pull gate 释放后需要补拉一次。 */
|
|
558
422
|
_pendingP2pPullUpper = new Map();
|
|
423
|
+
/** 在线未读 hint 队列:同一 group 只保留最后一条,延迟 drain 降低登录瞬时拉取压力。 */
|
|
424
|
+
_onlineUnreadHintQueue = new Map();
|
|
425
|
+
_onlineUnreadHintTimer = null;
|
|
426
|
+
_onlineUnreadHintDrainActive = false;
|
|
427
|
+
_onlineUnreadHintInitialDelayMs = 750;
|
|
428
|
+
_onlineUnreadHintIntervalMs = 50;
|
|
559
429
|
/** 缺 sender IK 时暂存原始 V2 消息,后台补齐 IK 后重试解密。 */
|
|
560
430
|
_v2SenderIKPending = new Map();
|
|
561
431
|
/** sender IK 后台补齐任务去重。 */
|
|
@@ -569,9 +439,9 @@ export class AUNClient {
|
|
|
569
439
|
_v2Session;
|
|
570
440
|
_v2KeyStore;
|
|
571
441
|
_v2SessionInitInFlight = null;
|
|
442
|
+
_v2RuntimeGeneration = 0;
|
|
572
443
|
/** V2 bootstrap 缓存:aid/group:id → 设备列表 + 时间戳 */
|
|
573
444
|
_v2BootstrapCache = new Map();
|
|
574
|
-
_connectCapabilities = null;
|
|
575
445
|
_v2SigCache = new Map();
|
|
576
446
|
_v2StateChains = new Map();
|
|
577
447
|
_v2GroupSecurityLevels = new Map();
|
|
@@ -583,12 +453,8 @@ export class AUNClient {
|
|
|
583
453
|
_v2AutoProposeLastSnapshot = new Map();
|
|
584
454
|
_v2LazyProposeTriggered = new Map();
|
|
585
455
|
static V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
|
|
586
|
-
static V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
|
|
587
|
-
static PULL_GATE_STALE_MS = 3000;
|
|
588
456
|
/** 对端 AID 缓存(aid string → AID 对象) */
|
|
589
457
|
_peerCache = new Map();
|
|
590
|
-
static V2_SIG_CACHE_TTL_MS = 60 * 60 * 1000;
|
|
591
|
-
static V2_SIG_CACHE_MAX = 16_384;
|
|
592
458
|
_reconnectActive = false;
|
|
593
459
|
_reconnectAbort = null;
|
|
594
460
|
_serverKicked = false;
|
|
@@ -596,6 +462,14 @@ export class AUNClient {
|
|
|
596
462
|
_lastDisconnectInfo = null;
|
|
597
463
|
_logger;
|
|
598
464
|
_clientLog;
|
|
465
|
+
_runtime;
|
|
466
|
+
_identityRuntime;
|
|
467
|
+
_peerDirectory;
|
|
468
|
+
_lifecycle;
|
|
469
|
+
_rpcPipeline;
|
|
470
|
+
_delivery;
|
|
471
|
+
_v2E2EE;
|
|
472
|
+
_groupState;
|
|
599
473
|
constructor(aid) {
|
|
600
474
|
if (aid !== null && aid !== undefined && !isAIDObject(aid)) {
|
|
601
475
|
throw new ValidationError('AUNClient only accepts an AID object or no argument');
|
|
@@ -669,7 +543,7 @@ export class AUNClient {
|
|
|
669
543
|
},
|
|
670
544
|
auth: () => this._auth,
|
|
671
545
|
tokenStore: () => this._tokenStore,
|
|
672
|
-
fetchPeerCert: (target) => this._fetchPeerCert(target),
|
|
546
|
+
fetchPeerCert: (target, certFingerprint) => this._fetchPeerCert(target, certFingerprint || undefined),
|
|
673
547
|
});
|
|
674
548
|
this._transport = new RPCTransport({
|
|
675
549
|
eventDispatcher: this._dispatcher,
|
|
@@ -680,6 +554,14 @@ export class AUNClient {
|
|
|
680
554
|
dnsNet,
|
|
681
555
|
});
|
|
682
556
|
this._transport.setMetaObserver((meta) => this._observeRpcMeta(meta));
|
|
557
|
+
this._runtime = new ClientRuntime(this);
|
|
558
|
+
this._identityRuntime = new IdentityRuntimeManager(this._runtime);
|
|
559
|
+
this._peerDirectory = new PeerDirectory(this._runtime);
|
|
560
|
+
this._lifecycle = new LifecycleController(this._runtime);
|
|
561
|
+
this._rpcPipeline = new RpcPipeline(this._runtime);
|
|
562
|
+
this._delivery = new MessageDeliveryEngine(this._runtime);
|
|
563
|
+
this._v2E2EE = new V2E2EECoordinator(this._runtime);
|
|
564
|
+
this._groupState = new GroupStateCoordinator(this._runtime);
|
|
683
565
|
if (inputAid) {
|
|
684
566
|
// 与 Python 对齐:私钥无效时只用 aunPath 配置,state 保持 no_identity
|
|
685
567
|
if (inputAid.isPrivateKeyValid()) {
|
|
@@ -770,7 +652,51 @@ export class AUNClient {
|
|
|
770
652
|
get lastErrorCode() {
|
|
771
653
|
return this._lastErrorCode;
|
|
772
654
|
}
|
|
655
|
+
_v2SessionMatchesIdentity() {
|
|
656
|
+
if (!this._v2Session)
|
|
657
|
+
return false;
|
|
658
|
+
const session = this._v2Session;
|
|
659
|
+
if (session.currentIkPubDer === undefined)
|
|
660
|
+
return true;
|
|
661
|
+
return session.aid === this._aid && session.deviceId === this._deviceId;
|
|
662
|
+
}
|
|
663
|
+
_resetV2IdentityRuntime() {
|
|
664
|
+
this._v2RuntimeGeneration += 1;
|
|
665
|
+
const keyStore = this._v2KeyStore;
|
|
666
|
+
try {
|
|
667
|
+
keyStore?.close?.();
|
|
668
|
+
}
|
|
669
|
+
catch (exc) {
|
|
670
|
+
this._clientLog?.debug?.(`V2 keystore cleanup skipped: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
671
|
+
}
|
|
672
|
+
this._v2Session = undefined;
|
|
673
|
+
this._v2KeyStore = undefined;
|
|
674
|
+
this._v2SessionInitInFlight = null;
|
|
675
|
+
this._v2BootstrapCache.clear();
|
|
676
|
+
this._v2SenderIKPending.clear();
|
|
677
|
+
this._v2SenderIKFetching.clear();
|
|
678
|
+
this._v2SigCache.clear();
|
|
679
|
+
this._v2StateChains.clear();
|
|
680
|
+
this._v2GroupSecurityLevels.clear();
|
|
681
|
+
this._v2AutoProposeInflight.clear();
|
|
682
|
+
this._v2AutoProposePending.clear();
|
|
683
|
+
this._v2AutoProposeLastSnapshot.clear();
|
|
684
|
+
this._v2LazyProposeTriggered.clear();
|
|
685
|
+
}
|
|
773
686
|
_applyAidRuntimeContext(aid) {
|
|
687
|
+
const oldTransport = this._transport;
|
|
688
|
+
try {
|
|
689
|
+
const closeResult = oldTransport?.close?.();
|
|
690
|
+
if (closeResult && typeof closeResult.catch === 'function') {
|
|
691
|
+
void closeResult.catch((exc) => {
|
|
692
|
+
this._clientLog.debug(`old transport cleanup skipped: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
catch (exc) {
|
|
697
|
+
this._clientLog.debug(`old transport cleanup skipped: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
698
|
+
}
|
|
699
|
+
this._resetV2IdentityRuntime();
|
|
774
700
|
const rawConfig = {
|
|
775
701
|
aun_path: aid.aunPath,
|
|
776
702
|
verify_ssl: aid.verifySsl,
|
|
@@ -836,7 +762,7 @@ export class AUNClient {
|
|
|
836
762
|
},
|
|
837
763
|
auth: () => this._auth,
|
|
838
764
|
tokenStore: () => this._tokenStore,
|
|
839
|
-
fetchPeerCert: (target) => this._fetchPeerCert(target),
|
|
765
|
+
fetchPeerCert: (target, certFingerprint) => this._fetchPeerCert(target, certFingerprint || undefined),
|
|
840
766
|
});
|
|
841
767
|
this._transport = new RPCTransport({
|
|
842
768
|
eventDispatcher: this._dispatcher,
|
|
@@ -849,30 +775,7 @@ export class AUNClient {
|
|
|
849
775
|
this._transport.setMetaObserver((meta) => this._observeRpcMeta(meta));
|
|
850
776
|
}
|
|
851
777
|
loadIdentity(aid) {
|
|
852
|
-
|
|
853
|
-
throw new StateError('loadIdentity requires an AID with a valid private key');
|
|
854
|
-
}
|
|
855
|
-
const publicState = this.state;
|
|
856
|
-
if (publicState !== ConnectionState.NO_IDENTITY && publicState !== ConnectionState.CLOSED) {
|
|
857
|
-
throw new StateError(`loadIdentity not allowed in state ${publicState}`);
|
|
858
|
-
}
|
|
859
|
-
this._applyAidRuntimeContext(aid);
|
|
860
|
-
this._currentAid = aid;
|
|
861
|
-
this._aid = aid.aid;
|
|
862
|
-
this._identity = {
|
|
863
|
-
aid: aid.aid,
|
|
864
|
-
private_key_pem: aid.privateKeyPem,
|
|
865
|
-
public_key_der_b64: aid.publicKey,
|
|
866
|
-
cert: aid.certPem,
|
|
867
|
-
};
|
|
868
|
-
// 注入内存私钥到 AuthFlow,禁止 AuthFlow 内部再走 keystore 解密
|
|
869
|
-
this._auth.setIdentity(this._identity);
|
|
870
|
-
this._state = 'standby';
|
|
871
|
-
this._closing = false;
|
|
872
|
-
this._lastError = null;
|
|
873
|
-
this._lastErrorCode = null;
|
|
874
|
-
this._retryAttempt = 0;
|
|
875
|
-
this._nextRetryAt = null;
|
|
778
|
+
this._identityRuntime.loadIdentity(aid);
|
|
876
779
|
}
|
|
877
780
|
setProtectedHeaders(headers) {
|
|
878
781
|
if (!headers) {
|
|
@@ -896,36 +799,16 @@ export class AUNClient {
|
|
|
896
799
|
return this._instanceProtectedHeaders ? { ...this._instanceProtectedHeaders } : null;
|
|
897
800
|
}
|
|
898
801
|
cachePeer(aid) {
|
|
899
|
-
|
|
900
|
-
throw new StateError('cachePeer requires a loaded identity');
|
|
901
|
-
if (!aid.isCertValid())
|
|
902
|
-
throw new ValidationError('cachePeer requires an AID with a valid certificate');
|
|
903
|
-
this._peerCache.set(aid.aid, aid);
|
|
904
|
-
return aid;
|
|
802
|
+
return this._peerDirectory.cachePeer(aid);
|
|
905
803
|
}
|
|
906
804
|
getPeer(aid) {
|
|
907
|
-
|
|
908
|
-
throw new StateError('getPeer requires a loaded identity');
|
|
909
|
-
return this._peerCache.get(String(aid ?? '').trim()) ?? null;
|
|
805
|
+
return this._peerDirectory.getPeer(aid);
|
|
910
806
|
}
|
|
911
807
|
async lookupPeer(aid) {
|
|
912
|
-
|
|
913
|
-
throw new StateError('lookupPeer requires a loaded identity');
|
|
914
|
-
const target = String(aid ?? '').trim();
|
|
915
|
-
if (!target)
|
|
916
|
-
throw new ValidationError('lookupPeer requires non-empty aid');
|
|
917
|
-
const cached = this._peerCache.get(target);
|
|
918
|
-
if (cached)
|
|
919
|
-
return cached;
|
|
920
|
-
throw new NotFoundError(`peer not found in cache: ${target}`);
|
|
808
|
+
return this._peerDirectory.lookupPeer(aid);
|
|
921
809
|
}
|
|
922
810
|
peers() {
|
|
923
|
-
|
|
924
|
-
throw new StateError('peers requires a loaded identity');
|
|
925
|
-
return [...this._peerCache.entries()].sort(([a], [b]) => a.localeCompare(b)).map(([, v]) => v);
|
|
926
|
-
}
|
|
927
|
-
async uploadAgentMd(content) {
|
|
928
|
-
return await this._agentMdManager.upload(content);
|
|
811
|
+
return this._peerDirectory.peers();
|
|
929
812
|
}
|
|
930
813
|
/** transport 的 meta observer:吸收 gateway 注入的 _meta 字段。失败不影响业务。 */
|
|
931
814
|
_observeRpcMeta(meta) {
|
|
@@ -945,137 +828,11 @@ export class AUNClient {
|
|
|
945
828
|
// ── 生命周期 ──────────────────────────────────────────────
|
|
946
829
|
/** 仅认证当前身份,获取/刷新 token,但不建立长连接。 */
|
|
947
830
|
async authenticate(options = {}) {
|
|
948
|
-
|
|
949
|
-
const target = this._currentAid?.aid ?? this._aid ?? '';
|
|
950
|
-
if (!target || !this._currentAid?.isPrivateKeyValid()) {
|
|
951
|
-
throw new StateError('authenticate requires a loaded AID with a valid private key');
|
|
952
|
-
}
|
|
953
|
-
const publicState = this.state;
|
|
954
|
-
if (publicState !== ConnectionState.STANDBY) {
|
|
955
|
-
throw new StateError(`authenticate not allowed in state ${publicState}`);
|
|
956
|
-
}
|
|
957
|
-
if ('aid' in options || 'access_token' in options || 'token' in options || 'kite_token' in options) {
|
|
958
|
-
throw new ValidationError('authenticate options must not include aid or token fields; load an AID object first');
|
|
959
|
-
}
|
|
960
|
-
this._state = 'connecting';
|
|
961
|
-
try {
|
|
962
|
-
const gateway = String(options.gateway ?? this._gatewayUrl ?? await this._resolveGatewayForAid(target)).trim();
|
|
963
|
-
const result = await this._auth.authenticate(gateway, { aid: target });
|
|
964
|
-
this._gatewayUrl = String(result.gateway ?? gateway);
|
|
965
|
-
this._identity = this._auth.loadIdentityOrNone(target);
|
|
966
|
-
this._state = 'authenticated';
|
|
967
|
-
this._lastError = null;
|
|
968
|
-
this._lastErrorCode = null;
|
|
969
|
-
this._clientLog.debug(`authenticate exit: elapsed=${Date.now() - tStart}ms aid=${target}`);
|
|
970
|
-
return result;
|
|
971
|
-
}
|
|
972
|
-
catch (err) {
|
|
973
|
-
this._state = 'standby';
|
|
974
|
-
this._lastError = err instanceof Error ? err : new Error(String(err));
|
|
975
|
-
this._lastErrorCode = 'AUTHENTICATE_FAILED';
|
|
976
|
-
this._clientLog.debug(`authenticate exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
977
|
-
throw err;
|
|
978
|
-
}
|
|
831
|
+
return this._lifecycle.authenticate(options);
|
|
979
832
|
}
|
|
980
833
|
/** 连接到 Gateway;身份来自构造函数或 loadIdentity(aid),认证由 SDK 内部自动完成。 */
|
|
981
834
|
async connect(opts) {
|
|
982
|
-
|
|
983
|
-
// 先校验非法参数(ValidationError),再检查身份(StateError)
|
|
984
|
-
if (opts !== undefined && opts !== null && typeof opts === 'object') {
|
|
985
|
-
const raw = opts;
|
|
986
|
-
const invalid = Object.keys(raw).filter((key) => !PUBLIC_CONNECTION_OPTION_KEYS.has(key)).sort();
|
|
987
|
-
if (invalid.length > 0) {
|
|
988
|
-
throw new ValidationError(`connect options contain unsupported field(s): ${invalid.join(', ')}`);
|
|
989
|
-
}
|
|
990
|
-
}
|
|
991
|
-
const target = this._currentAid?.aid ?? this._aid ?? '';
|
|
992
|
-
if (!target || !this._currentAid?.isPrivateKeyValid()) {
|
|
993
|
-
throw new StateError('connect requires a loaded AID with a valid private key');
|
|
994
|
-
}
|
|
995
|
-
const options = {};
|
|
996
|
-
if (opts?.auto_reconnect !== undefined)
|
|
997
|
-
options.auto_reconnect = opts.auto_reconnect;
|
|
998
|
-
if (opts?.heartbeat_interval !== undefined)
|
|
999
|
-
options.heartbeat_interval = opts.heartbeat_interval;
|
|
1000
|
-
if (opts?.connect_timeout !== undefined || opts?.call_timeout !== undefined) {
|
|
1001
|
-
options.timeouts = {
|
|
1002
|
-
...(opts.connect_timeout !== undefined ? { connect: opts.connect_timeout } : {}),
|
|
1003
|
-
...(opts.call_timeout !== undefined ? { call: opts.call_timeout } : {}),
|
|
1004
|
-
};
|
|
1005
|
-
}
|
|
1006
|
-
if (opts?.retry_initial_delay !== undefined || opts?.retry_max_delay !== undefined || opts?.retry_max_attempts !== undefined) {
|
|
1007
|
-
options.retry = {
|
|
1008
|
-
initial_delay: opts.retry_initial_delay ?? 1,
|
|
1009
|
-
max_delay: opts.retry_max_delay ?? 64,
|
|
1010
|
-
max_attempts: opts.retry_max_attempts ?? 0,
|
|
1011
|
-
};
|
|
1012
|
-
}
|
|
1013
|
-
if (opts?.connection_kind !== undefined)
|
|
1014
|
-
options.connection_kind = opts.connection_kind;
|
|
1015
|
-
if (opts?.short_ttl_ms !== undefined)
|
|
1016
|
-
options.short_ttl_ms = opts.short_ttl_ms;
|
|
1017
|
-
if (opts?.delivery_mode !== undefined)
|
|
1018
|
-
options.delivery_mode = opts.delivery_mode;
|
|
1019
|
-
if (opts?.extra_info !== undefined)
|
|
1020
|
-
options.extra_info = opts.extra_info;
|
|
1021
|
-
if (opts?.background_sync !== undefined)
|
|
1022
|
-
options.background_sync = opts.background_sync;
|
|
1023
|
-
const publicState = this.state;
|
|
1024
|
-
const allowed = new Set([
|
|
1025
|
-
ConnectionState.STANDBY,
|
|
1026
|
-
ConnectionState.AUTHENTICATED,
|
|
1027
|
-
ConnectionState.RETRY_BACKOFF,
|
|
1028
|
-
ConnectionState.CONNECTION_FAILED,
|
|
1029
|
-
]);
|
|
1030
|
-
if (!allowed.has(publicState)) {
|
|
1031
|
-
throw new StateError(`connect not allowed in state ${publicState}`);
|
|
1032
|
-
}
|
|
1033
|
-
if (publicState === ConnectionState.RETRY_BACKOFF) {
|
|
1034
|
-
this._stopReconnect();
|
|
1035
|
-
}
|
|
1036
|
-
// gateway 来自 authenticate() 缓存的 this._gatewayUrl;未认证则自动 authenticate()
|
|
1037
|
-
if (!this._gatewayUrl) {
|
|
1038
|
-
await this.authenticate();
|
|
1039
|
-
}
|
|
1040
|
-
this._state = 'connecting';
|
|
1041
|
-
const gateway = String(this._gatewayUrl ?? '').trim();
|
|
1042
|
-
const params = { ...options, gateway };
|
|
1043
|
-
const normalized = this._normalizeConnectParams(params);
|
|
1044
|
-
this._captureCapabilitiesFromConnect(normalized);
|
|
1045
|
-
this._sessionParams = normalized;
|
|
1046
|
-
this._sessionOptions = this._buildSessionOptions(normalized);
|
|
1047
|
-
const callTimeoutSec = this._sessionOptions.timeouts.call;
|
|
1048
|
-
this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 35_000);
|
|
1049
|
-
this._closing = false;
|
|
1050
|
-
this._clientLog.debug(`connect enter: gateway=${String(normalized.gateway ?? '')}, device_id=${this._deviceId}`);
|
|
1051
|
-
const gateways = this._resolveGateways(normalized);
|
|
1052
|
-
let lastErr = null;
|
|
1053
|
-
for (const gw of gateways) {
|
|
1054
|
-
try {
|
|
1055
|
-
const gwParams = { ...normalized, gateway: gw };
|
|
1056
|
-
await this._connectOnce(gwParams, true);
|
|
1057
|
-
this._lastError = null;
|
|
1058
|
-
this._lastErrorCode = null;
|
|
1059
|
-
this._clientLog.debug(`connect exit: elapsed=${Date.now() - tStart}ms aid=${this._aid ?? ''}, state=${this._state}`);
|
|
1060
|
-
return;
|
|
1061
|
-
}
|
|
1062
|
-
catch (err) {
|
|
1063
|
-
lastErr = err;
|
|
1064
|
-
if (gateways.length > 1) {
|
|
1065
|
-
this._clientLog.warn(`connect: gateway ${gw} failed, trying next: ${formatCaughtError(err)}`);
|
|
1066
|
-
}
|
|
1067
|
-
if (this._state !== 'closed')
|
|
1068
|
-
this._state = 'connecting';
|
|
1069
|
-
}
|
|
1070
|
-
}
|
|
1071
|
-
if (this._state === 'connecting' || this._state === 'authenticating') {
|
|
1072
|
-
this._state = 'connection_failed';
|
|
1073
|
-
}
|
|
1074
|
-
this._lastError = lastErr instanceof Error ? lastErr : new Error(String(lastErr));
|
|
1075
|
-
this._lastErrorCode = 'CONNECT_FAILED';
|
|
1076
|
-
this._clientLog.error(`connect failed: ${formatCaughtError(lastErr)}`, lastErr instanceof Error ? lastErr : undefined);
|
|
1077
|
-
this._clientLog.debug(`connect exit (error): elapsed=${Date.now() - tStart}ms err=${lastErr instanceof Error ? lastErr.message : String(lastErr)}`);
|
|
1078
|
-
throw lastErr;
|
|
835
|
+
return this._lifecycle.connect(opts);
|
|
1079
836
|
}
|
|
1080
837
|
/** 关闭连接 */
|
|
1081
838
|
async close() {
|
|
@@ -1155,56 +912,14 @@ export class AUNClient {
|
|
|
1155
912
|
const tStart = Date.now();
|
|
1156
913
|
this._clientLog.debug(`call enter: method=${method}`);
|
|
1157
914
|
try {
|
|
1158
|
-
|
|
1159
|
-
|
|
1160
|
-
|
|
1161
|
-
if (INTERNAL_ONLY_METHODS.has(method)) {
|
|
1162
|
-
throw new PermissionError(`method is internal_only: ${method}`);
|
|
1163
|
-
}
|
|
1164
|
-
if (method.startsWith('message.e2ee.') || method.startsWith('group.e2ee.') || REMOVED_E2EE_METHODS.has(method)) {
|
|
1165
|
-
throw new PermissionError(`legacy E2EE method is removed in this SDK: ${method}`);
|
|
1166
|
-
}
|
|
1167
|
-
const p = { ...(params ?? {}) };
|
|
1168
|
-
if (this._instanceProtectedHeaders && PROTECTED_HEADERS_METHODS.has(method)) {
|
|
1169
|
-
const existing = isJsonObject(p.protected_headers) ? p.protected_headers : {};
|
|
1170
|
-
p.protected_headers = { ...this._instanceProtectedHeaders, ...existing };
|
|
1171
|
-
}
|
|
1172
|
-
const rpcBackground = Boolean(p._rpc_background) || this._backgroundRpcDepth > 0;
|
|
1173
|
-
delete p._rpc_background;
|
|
915
|
+
const preflight = this._rpcPipeline.preflight(method, params);
|
|
916
|
+
const p = preflight.params;
|
|
917
|
+
const rpcBackground = preflight.rpcBackground;
|
|
1174
918
|
const runWithRpcPriority = async (operation) => {
|
|
1175
919
|
if (!rpcBackground)
|
|
1176
920
|
return await operation();
|
|
1177
921
|
return await this._withBackgroundRpc(operation);
|
|
1178
922
|
};
|
|
1179
|
-
if (method === 'message.send' || method === 'group.send') {
|
|
1180
|
-
this._normalizeOutboundMessagePayload(p, method);
|
|
1181
|
-
}
|
|
1182
|
-
this._validateOutboundCall(method, p);
|
|
1183
|
-
this._injectMessageCursorContext(method, p);
|
|
1184
|
-
if (method.startsWith('group.')
|
|
1185
|
-
&& !('_group_cursor_params' in p)
|
|
1186
|
-
&& !Boolean(p._pull_gate_locked)) {
|
|
1187
|
-
const explicitCursorParams = this._groupCursorParams(p);
|
|
1188
|
-
if (Object.keys(explicitCursorParams).length > 0) {
|
|
1189
|
-
p._group_cursor_params = explicitCursorParams;
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
// group.* 方法的 group_id 归一化为 canonical 格式(兼容老/污染数据)
|
|
1193
|
-
if (method.startsWith('group.') && p.group_id !== undefined && p.group_id !== null) {
|
|
1194
|
-
const rawGroupId = String(p.group_id);
|
|
1195
|
-
const normalizedGroupId = normalizeGroupId(rawGroupId);
|
|
1196
|
-
if (normalizedGroupId && normalizedGroupId !== rawGroupId) {
|
|
1197
|
-
this._clientLog.debug(`call group_id normalized: ${rawGroupId} -> ${normalizedGroupId} method=${method}`);
|
|
1198
|
-
}
|
|
1199
|
-
p.group_id = normalizedGroupId;
|
|
1200
|
-
}
|
|
1201
|
-
// group.* 方法注入 device_id(服务端用于多设备消息路由)
|
|
1202
|
-
if (method.startsWith('group.') && p.device_id === undefined) {
|
|
1203
|
-
p.device_id = this._deviceId;
|
|
1204
|
-
}
|
|
1205
|
-
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
1206
|
-
p.slot_id = this._slotId;
|
|
1207
|
-
}
|
|
1208
923
|
const pullGateLocked = Boolean(p._pull_gate_locked);
|
|
1209
924
|
if ('_pull_gate_locked' in p) {
|
|
1210
925
|
delete p._pull_gate_locked;
|
|
@@ -1321,14 +1036,7 @@ export class AUNClient {
|
|
|
1321
1036
|
}
|
|
1322
1037
|
delete p._group_cursor_params;
|
|
1323
1038
|
// 关键操作自动附加客户端签名
|
|
1324
|
-
|
|
1325
|
-
if (this._shouldSkipClientSignature(method, p)) {
|
|
1326
|
-
delete p.client_signature;
|
|
1327
|
-
}
|
|
1328
|
-
else {
|
|
1329
|
-
this._signClientOperation(method, p);
|
|
1330
|
-
}
|
|
1331
|
-
}
|
|
1039
|
+
this._rpcPipeline.applyClientSignature(method, p);
|
|
1332
1040
|
// P1-23: 非幂等方法使用更长超时
|
|
1333
1041
|
const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT_MS : undefined;
|
|
1334
1042
|
if (method === 'group.thought.get' || method === 'message.thought.get') {
|
|
@@ -1341,31 +1049,7 @@ export class AUNClient {
|
|
|
1341
1049
|
: (rpcBackground
|
|
1342
1050
|
? await this._transport.call(method, p, undefined, undefined, true)
|
|
1343
1051
|
: await this._transport.call(method, p));
|
|
1344
|
-
|
|
1345
|
-
this._clientLog.debug(`group.thought.get transport result: found=${String(result.found ?? '')}, raw_count=${Array.isArray(result.thoughts) ? result.thoughts.length : 0}`);
|
|
1346
|
-
result = await this._decryptGroupThoughts(result);
|
|
1347
|
-
}
|
|
1348
|
-
if (method === 'message.thought.get' && isJsonObject(result)) {
|
|
1349
|
-
this._clientLog.debug(`message.thought.get transport result: found=${String(result.found ?? '')}, raw_count=${Array.isArray(result.thoughts) ? result.thoughts.length : 0}`);
|
|
1350
|
-
result = await this._decryptMessageThoughts(result);
|
|
1351
|
-
}
|
|
1352
|
-
// ── V2-only 群状态编排:成员变更后 propose+confirm state。
|
|
1353
|
-
const membershipMethods = new Set([
|
|
1354
|
-
'group.create', 'group.add_member', 'group.kick', 'group.remove_member', 'group.leave',
|
|
1355
|
-
'group.review_join_request', 'group.batch_review_join_request',
|
|
1356
|
-
'group.use_invite_code', 'group.request_join',
|
|
1357
|
-
]);
|
|
1358
|
-
if (membershipMethods.has(method) && isJsonObject(result) && !('error' in result) && this._v2Session) {
|
|
1359
|
-
const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
|
|
1360
|
-
if (groupId) {
|
|
1361
|
-
try {
|
|
1362
|
-
await this._v2AutoProposeState(groupId);
|
|
1363
|
-
}
|
|
1364
|
-
catch (exc) {
|
|
1365
|
-
this._clientLog.debug(`V2 post-membership propose failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
|
|
1366
|
-
}
|
|
1367
|
-
}
|
|
1368
|
-
}
|
|
1052
|
+
result = await this._rpcPipeline.postprocessResult(method, p, result);
|
|
1369
1053
|
this._clientLog.debug(`call exit: method=${method} elapsed=${Date.now() - tStart}ms`);
|
|
1370
1054
|
return result;
|
|
1371
1055
|
}
|
|
@@ -1400,17 +1084,7 @@ export class AUNClient {
|
|
|
1400
1084
|
if (method.startsWith('group.') && p.slot_id === undefined) {
|
|
1401
1085
|
p.slot_id = this._slotId;
|
|
1402
1086
|
}
|
|
1403
|
-
|
|
1404
|
-
if (this._shouldSkipClientSignature(method, p)) {
|
|
1405
|
-
delete p.client_signature;
|
|
1406
|
-
}
|
|
1407
|
-
else {
|
|
1408
|
-
this._signClientOperation(method, p);
|
|
1409
|
-
}
|
|
1410
|
-
}
|
|
1411
|
-
return rpcBackground
|
|
1412
|
-
? await this._transport.call(method, p, undefined, undefined, true)
|
|
1413
|
-
: await this._transport.call(method, p);
|
|
1087
|
+
return await this._rpcPipeline.rawCall(method, p, { background: rpcBackground });
|
|
1414
1088
|
}
|
|
1415
1089
|
/** P2-13: 取消订阅事件(对齐 Python/JS off 方法) */
|
|
1416
1090
|
off(event, handler) {
|
|
@@ -1439,153 +1113,20 @@ export class AUNClient {
|
|
|
1439
1113
|
* 签名覆盖所有非 _ 前缀且非 client_signature 的业务字段。
|
|
1440
1114
|
*/
|
|
1441
1115
|
_signClientOperation(method, params) {
|
|
1442
|
-
|
|
1443
|
-
if (!currentAid?.privateKeyPem)
|
|
1444
|
-
return;
|
|
1445
|
-
try {
|
|
1446
|
-
const aid = currentAid.aid;
|
|
1447
|
-
const ts = String(Math.floor(Date.now() / 1000));
|
|
1448
|
-
const paramsForHash = {};
|
|
1449
|
-
for (const [k, v] of Object.entries(params)) {
|
|
1450
|
-
if (k !== 'client_signature' && !k.startsWith('_')) {
|
|
1451
|
-
paramsForHash[k] = v;
|
|
1452
|
-
}
|
|
1453
|
-
}
|
|
1454
|
-
const paramsJson = stableStringify(paramsForHash);
|
|
1455
|
-
const paramsHash = crypto.createHash('sha256').update(paramsJson, 'utf-8').digest('hex');
|
|
1456
|
-
const signData = Buffer.from(`${method}|${aid}|${ts}|${paramsHash}`, 'utf-8');
|
|
1457
|
-
const privateKey = crypto.createPrivateKey(currentAid.privateKeyPem);
|
|
1458
|
-
const signature = crypto.sign('SHA256', signData, privateKey);
|
|
1459
|
-
// 证书指纹
|
|
1460
|
-
let certFingerprint = '';
|
|
1461
|
-
const certPem = currentAid.certPem;
|
|
1462
|
-
if (certPem) {
|
|
1463
|
-
const certObj = new crypto.X509Certificate(certPem);
|
|
1464
|
-
certFingerprint = 'sha256:' + certObj.fingerprint256.replace(/:/g, '').toLowerCase();
|
|
1465
|
-
}
|
|
1466
|
-
params.client_signature = {
|
|
1467
|
-
aid,
|
|
1468
|
-
cert_fingerprint: certFingerprint,
|
|
1469
|
-
timestamp: ts,
|
|
1470
|
-
params_hash: paramsHash,
|
|
1471
|
-
signature: signature.toString('base64'),
|
|
1472
|
-
};
|
|
1473
|
-
}
|
|
1474
|
-
catch (exc) {
|
|
1475
|
-
throw new Error(`客户端签名失败,拒绝发送无签名请求: ${formatCaughtError(exc)}`);
|
|
1476
|
-
}
|
|
1116
|
+
this._rpcPipeline.signClientOperation(method, params);
|
|
1477
1117
|
}
|
|
1478
1118
|
// ── 事件自动解密管线 ──────────────────────────────────────
|
|
1479
1119
|
/** 处理 transport 层推送的原始 P2P 消息 */
|
|
1480
1120
|
async _onRawMessageReceived(data) {
|
|
1481
|
-
|
|
1482
|
-
if (isJsonObject(data)) {
|
|
1483
|
-
this._logMessageDebug('server-push', '_raw.message.received', 'message.received', data);
|
|
1484
|
-
this._clientLog.debug(`_onRawMessageReceived enter: from=${String(data.from ?? '')}, message_id=${String(data.message_id ?? '')}, seq=${String(data.seq ?? '')}`);
|
|
1485
|
-
}
|
|
1486
|
-
else {
|
|
1487
|
-
this._clientLog.debug(`_onRawMessageReceived enter: non-object payload`);
|
|
1488
|
-
}
|
|
1489
|
-
// 异步处理,不阻塞事件调度
|
|
1490
|
-
this._processAndPublishMessage(data).catch((exc) => {
|
|
1491
|
-
this._clientLog.warn(`P2P message decrypt failed: ${formatCaughtError(exc)}`);
|
|
1492
|
-
// H26: 不再投递原始密文 payload;改发 message.undecryptable 事件,仅携带安全 header
|
|
1493
|
-
if (isJsonObject(data)) {
|
|
1494
|
-
const safeEvent = {
|
|
1495
|
-
message_id: data.message_id,
|
|
1496
|
-
from: data.from,
|
|
1497
|
-
to: data.to,
|
|
1498
|
-
seq: data.seq,
|
|
1499
|
-
timestamp: data.timestamp,
|
|
1500
|
-
_decrypt_error: String(exc),
|
|
1501
|
-
};
|
|
1502
|
-
this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1503
|
-
Promise.resolve(this._publishAppEvent('message.undecryptable', safeEvent)).catch(() => { });
|
|
1504
|
-
}
|
|
1505
|
-
});
|
|
1506
|
-
this._clientLog.debug(`_onRawMessageReceived exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
|
|
1121
|
+
return this._delivery.onRawMessageReceived(data);
|
|
1507
1122
|
}
|
|
1508
1123
|
/** 实际处理推送消息的异步任务 */
|
|
1509
1124
|
async _processAndPublishMessage(data) {
|
|
1510
|
-
|
|
1511
|
-
await this._publishAppEvent('message.received', data, 'push');
|
|
1512
|
-
return;
|
|
1513
|
-
}
|
|
1514
|
-
const msg = { ...data };
|
|
1515
|
-
if (!this._messageTargetsCurrentInstance(msg)) {
|
|
1516
|
-
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}`);
|
|
1517
|
-
return;
|
|
1518
|
-
}
|
|
1519
|
-
const encryptedPush = this._isEncryptedPushMessage(msg);
|
|
1520
|
-
// P2P 空洞检测
|
|
1521
|
-
const seq = msg.seq;
|
|
1522
|
-
if (seq !== undefined && seq !== null && this._aid) {
|
|
1523
|
-
const ns = `p2p:${this._aid}`;
|
|
1524
|
-
// Push 只先更新 maxSeenSeq;contiguous_seq 是已交付游标,必须等应用层发布返回后再推进。
|
|
1525
|
-
if (seq > 0)
|
|
1526
|
-
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1527
|
-
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1528
|
-
const published = encryptedPush
|
|
1529
|
-
? await this._publishEncryptedPushMessage('message.received', 'message.undecryptable', ns, seq, msg, false)
|
|
1530
|
-
: await this._publishOrderedMessage('message.received', ns, seq, msg);
|
|
1531
|
-
const contigAfter = this._seqTracker.getContiguousSeq(ns);
|
|
1532
|
-
const needPull = Number(seq) > contigAfter && !published;
|
|
1533
|
-
if (needPull) {
|
|
1534
|
-
this._clientLog.debug(`P2P seq gap detected: ns=${ns}, seq=${seq}, contiguous=${contigAfter}`);
|
|
1535
|
-
this._fillP2pGap().catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
1536
|
-
}
|
|
1537
|
-
// auto-ack contiguous_seq
|
|
1538
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1539
|
-
if (contig > 0) {
|
|
1540
|
-
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1541
|
-
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1542
|
-
this._clientLog.debug(`P2P push auto-ack send: ns=${ns}, seq=${ackSeq}, contiguous=${contig}, max_seen=${maxSeen}`);
|
|
1543
|
-
this._withBackgroundRpc(() => this._ackV2(ackSeq))
|
|
1544
|
-
.then(() => { this._clientLog.debug(`P2P push auto-ack ok: ns=${ns}, seq=${ackSeq}`); })
|
|
1545
|
-
.catch((e) => { this._clientLog.debug(`P2P auto-ack failed: ${formatCaughtError(e)}`); });
|
|
1546
|
-
}
|
|
1547
|
-
// 即时持久化 cursor,异常断连后不回退
|
|
1548
|
-
if (contigAfter !== contigBefore)
|
|
1549
|
-
this._saveSeqTrackerState();
|
|
1550
|
-
if (encryptedPush)
|
|
1551
|
-
return;
|
|
1552
|
-
}
|
|
1553
|
-
else {
|
|
1554
|
-
if (encryptedPush) {
|
|
1555
|
-
await this._publishEncryptedPushMessage('message.received', 'message.undecryptable', '', seq ?? 0, msg, false);
|
|
1556
|
-
return;
|
|
1557
|
-
}
|
|
1558
|
-
// V2-only:普通 _raw.message.received 只承载明文;V2 密文由 peer.v2.message_received 通知触发 pull。
|
|
1559
|
-
await this._publishAppEvent('message.received', msg, 'push');
|
|
1560
|
-
}
|
|
1125
|
+
return this._delivery.processAndPublishMessage(data);
|
|
1561
1126
|
}
|
|
1562
1127
|
/** 处理群组消息推送:自动解密后 re-publish */
|
|
1563
1128
|
async _onRawGroupMessageCreated(data) {
|
|
1564
|
-
|
|
1565
|
-
if (isJsonObject(data)) {
|
|
1566
|
-
this._logMessageDebug('server-push', '_raw.group.message_created', 'group.message_created', data);
|
|
1567
|
-
this._clientLog.debug(`_onRawGroupMessageCreated enter: group_id=${String(data.group_id ?? '')}, message_id=${String(data.message_id ?? '')}, seq=${String(data.seq ?? '')}`);
|
|
1568
|
-
}
|
|
1569
|
-
else {
|
|
1570
|
-
this._clientLog.debug(`_onRawGroupMessageCreated enter: non-object payload`);
|
|
1571
|
-
}
|
|
1572
|
-
this._processAndPublishGroupMessage(data).catch((exc) => {
|
|
1573
|
-
this._clientLog.warn(`group message decrypt failed: ${formatCaughtError(exc)}`);
|
|
1574
|
-
// H26: 不再投递原始密文 payload;改发 group.message_undecryptable 事件
|
|
1575
|
-
if (isJsonObject(data)) {
|
|
1576
|
-
const safeEvent = {
|
|
1577
|
-
message_id: data.message_id,
|
|
1578
|
-
group_id: data.group_id,
|
|
1579
|
-
from: data.from,
|
|
1580
|
-
seq: data.seq,
|
|
1581
|
-
timestamp: data.timestamp,
|
|
1582
|
-
_decrypt_error: String(exc),
|
|
1583
|
-
};
|
|
1584
|
-
this._attachV2EnvelopeMetadataFromSource(safeEvent, data);
|
|
1585
|
-
Promise.resolve(this._publishAppEvent('group.message_undecryptable', safeEvent)).catch(() => { });
|
|
1586
|
-
}
|
|
1587
|
-
});
|
|
1588
|
-
this._clientLog.debug(`_onRawGroupMessageCreated exit: elapsed=${Date.now() - tStart}ms (handler dispatched)`);
|
|
1129
|
+
return this._delivery.onRawGroupMessageCreated(data);
|
|
1589
1130
|
}
|
|
1590
1131
|
/**
|
|
1591
1132
|
* 处理群组推送消息的异步任务。
|
|
@@ -1594,177 +1135,19 @@ export class AUNClient {
|
|
|
1594
1135
|
* 不带 payload 的事件(通知):自动 pull 最新消息,逐条解密后 re-publish。
|
|
1595
1136
|
*/
|
|
1596
1137
|
async _processAndPublishGroupMessage(data) {
|
|
1597
|
-
|
|
1598
|
-
await this._publishAppEvent('group.message_created', data, 'group-push');
|
|
1599
|
-
return;
|
|
1600
|
-
}
|
|
1601
|
-
const msg = { ...data };
|
|
1602
|
-
const groupId = (msg.group_id ?? '');
|
|
1603
|
-
const seq = msg.seq;
|
|
1604
|
-
const payload = msg.payload;
|
|
1605
|
-
if (groupId) {
|
|
1606
|
-
this._groupSynced.add(groupId); // 收到推送即视为已激活
|
|
1607
|
-
}
|
|
1608
|
-
if (payload === undefined || payload === null
|
|
1609
|
-
|| (typeof payload === 'object' && Object.keys(payload).length === 0)) {
|
|
1610
|
-
// 不带 payload 的通知不能先推进 seq,否则 auto-pull 会用推进后的 cursor 跳过该消息。
|
|
1611
|
-
void this._autoPullGroupMessages(msg).catch((exc) => {
|
|
1612
|
-
this._clientLog.warn(`auto pull group message task failed: ${formatCaughtError(exc)}`);
|
|
1613
|
-
});
|
|
1614
|
-
return;
|
|
1615
|
-
}
|
|
1616
|
-
const encryptedPush = this._isEncryptedPushMessage(msg);
|
|
1617
|
-
if (groupId && seq !== undefined && seq !== null) {
|
|
1618
|
-
const ns = `group:${groupId}`;
|
|
1619
|
-
// Push 只先更新 maxSeenSeq;contiguous_seq 是已交付游标,必须等应用层发布返回后再推进。
|
|
1620
|
-
if (seq > 0)
|
|
1621
|
-
this._seqTracker.updateMaxSeen(ns, seq);
|
|
1622
|
-
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1623
|
-
const published = encryptedPush
|
|
1624
|
-
? await this._publishEncryptedPushMessage('group.message_created', 'group.message_undecryptable', ns, seq, msg, true)
|
|
1625
|
-
: await this._publishOrderedMessage('group.message_created', ns, seq, msg);
|
|
1626
|
-
const contigAfter = this._seqTracker.getContiguousSeq(ns);
|
|
1627
|
-
const needPull = Number(seq) > contigAfter && !published;
|
|
1628
|
-
if (needPull) {
|
|
1629
|
-
this._clientLog.debug(`group message seq gap detected: group=${groupId}, seq=${seq}, contiguous=${contigAfter}`);
|
|
1630
|
-
this._fillGroupGap(groupId).catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
1631
|
-
}
|
|
1632
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1633
|
-
if (contig > 0) {
|
|
1634
|
-
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
1635
|
-
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
1636
|
-
this._clientLog.debug(`group push auto-ack send: group=${groupId}, ns=${ns}, seq=${ackSeq}, contiguous=${contig}, max_seen=${maxSeen}`);
|
|
1637
|
-
this._withBackgroundRpc(() => this._ackGroupV2(groupId, ackSeq))
|
|
1638
|
-
.then(() => { this._clientLog.debug(`group push auto-ack ok: group=${groupId}, seq=${ackSeq}`); })
|
|
1639
|
-
.catch((e) => { this._clientLog.debug(`group message auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
1640
|
-
}
|
|
1641
|
-
if (contigAfter !== contigBefore)
|
|
1642
|
-
this._saveSeqTrackerState();
|
|
1643
|
-
if (encryptedPush)
|
|
1644
|
-
return;
|
|
1645
|
-
}
|
|
1646
|
-
else {
|
|
1647
|
-
if (encryptedPush) {
|
|
1648
|
-
await this._publishEncryptedPushMessage('group.message_created', 'group.message_undecryptable', '', seq ?? 0, msg, true);
|
|
1649
|
-
return;
|
|
1650
|
-
}
|
|
1651
|
-
// V2-only:普通 group.message_created 只承载明文;V2 密文由 group.v2.message_created 通知触发 pull。
|
|
1652
|
-
await this._publishAppEvent('group.message_created', msg, 'group-push');
|
|
1653
|
-
}
|
|
1654
|
-
}
|
|
1655
|
-
/** 收到不带 payload 的 group.message_created 通知后,自动 pull 最新消息 */
|
|
1656
|
-
async _autoPullGroupMessages(notification) {
|
|
1657
|
-
let groupId = String(notification.group_id ?? '').trim();
|
|
1658
|
-
if (!groupId) {
|
|
1659
|
-
await this._publishAppEvent('group.message_created', notification);
|
|
1660
|
-
return;
|
|
1661
|
-
}
|
|
1662
|
-
groupId = normalizeGroupId(groupId) || groupId;
|
|
1663
|
-
const ns = `group:${groupId}`;
|
|
1664
|
-
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1665
|
-
this._clientLog.debug(`auto pull group messages start: group=${groupId}, after_seq=${afterSeq}, seq=${String(notification.seq ?? '')}`);
|
|
1666
|
-
const started = await this._tryRunBackgroundPull(ns, async () => {
|
|
1667
|
-
const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1668
|
-
const messages = await this._pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
|
|
1669
|
-
this._prunePushedSeqs(ns);
|
|
1670
|
-
return messages.length;
|
|
1671
|
-
}, true);
|
|
1672
|
-
if (!started) {
|
|
1673
|
-
this._clientLog.debug(`auto pull group messages skipped: pull in-flight group=${groupId}`);
|
|
1674
|
-
}
|
|
1138
|
+
return this._delivery.processAndPublishGroupMessage(data);
|
|
1675
1139
|
}
|
|
1676
1140
|
/** 后台补齐群消息空洞 */
|
|
1677
1141
|
async _fillGroupGap(groupId) {
|
|
1678
|
-
|
|
1679
|
-
if (!groupId)
|
|
1680
|
-
return;
|
|
1681
|
-
const ns = `group:${groupId}`;
|
|
1682
|
-
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1683
|
-
// 去重:同一 (group:id:after_seq) 只补一次
|
|
1684
|
-
const dedupKey = `group_msg:${groupId}:${afterSeq}`;
|
|
1685
|
-
if (this._gapFillDone.has(dedupKey))
|
|
1686
|
-
return;
|
|
1687
|
-
const token = this._tryAcquirePullGate(ns);
|
|
1688
|
-
if (token === null) {
|
|
1689
|
-
this._clientLog.debug(`group message gap fill skipped: pull in-flight group=${groupId}`);
|
|
1690
|
-
return;
|
|
1691
|
-
}
|
|
1692
|
-
this._gapFillDone.set(dedupKey, Date.now());
|
|
1693
|
-
this._clientLog.debug(`group message gap fill start: group=${groupId}, after_seq=${afterSeq}`);
|
|
1694
|
-
let filled = 0;
|
|
1695
|
-
try {
|
|
1696
|
-
const messages = await this._withBackgroundRpc(() => this._pullGroupV2(groupId, afterSeq, 50, { gateLocked: true }));
|
|
1697
|
-
filled = messages.length;
|
|
1698
|
-
this._prunePushedSeqs(ns);
|
|
1699
|
-
if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
|
|
1700
|
-
await this._drainOrderedMessages(ns, undefined, true);
|
|
1701
|
-
this._saveSeqTrackerState();
|
|
1702
|
-
}
|
|
1703
|
-
this._clientLog.debug(`group message gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
|
|
1704
|
-
}
|
|
1705
|
-
catch (exc) {
|
|
1706
|
-
this._clientLog.warn(`group message gap fill failed: ${formatCaughtError(exc)}`);
|
|
1707
|
-
}
|
|
1708
|
-
finally {
|
|
1709
|
-
this._gapFillDone.delete(dedupKey);
|
|
1710
|
-
this._releasePullGate(ns, token);
|
|
1711
|
-
if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
|
|
1712
|
-
void this._fillGroupGap(groupId);
|
|
1713
|
-
}
|
|
1714
|
-
}
|
|
1142
|
+
return this._delivery.fillGroupGap(groupId);
|
|
1715
1143
|
}
|
|
1716
1144
|
/** 后台补齐 P2P 消息空洞 */
|
|
1717
1145
|
async _fillP2pGap() {
|
|
1718
|
-
|
|
1719
|
-
return;
|
|
1720
|
-
const ns = `p2p:${this._aid}`;
|
|
1721
|
-
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1722
|
-
// 去重:同一 (type:after_seq) 只补一次
|
|
1723
|
-
const dedupKey = `p2p:${afterSeq}`;
|
|
1724
|
-
if (this._gapFillDone.has(dedupKey))
|
|
1725
|
-
return;
|
|
1726
|
-
const token = this._tryAcquirePullGate(ns);
|
|
1727
|
-
if (token === null) {
|
|
1728
|
-
this._clientLog.debug(`P2P message gap fill skipped: pull in-flight ns=${ns}`);
|
|
1729
|
-
return;
|
|
1730
|
-
}
|
|
1731
|
-
this._gapFillDone.set(dedupKey, Date.now());
|
|
1732
|
-
this._clientLog.debug(`P2P message gap fill start: after_seq=${afterSeq}`);
|
|
1733
|
-
let filled = 0;
|
|
1734
|
-
try {
|
|
1735
|
-
const messages = await this._withBackgroundRpc(() => this._pullV2(afterSeq, 50, { skipAutoAck: true, gateLocked: true }));
|
|
1736
|
-
filled = messages.length;
|
|
1737
|
-
this._prunePushedSeqs(ns);
|
|
1738
|
-
if (this._seqTracker.getContiguousSeq(ns) !== afterSeq) {
|
|
1739
|
-
await this._drainOrderedMessages(ns, undefined, true);
|
|
1740
|
-
this._saveSeqTrackerState();
|
|
1741
|
-
}
|
|
1742
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1743
|
-
if (contig > 0 && contig !== afterSeq) {
|
|
1744
|
-
await this._withBackgroundRpc(() => this._ackV2(contig));
|
|
1745
|
-
}
|
|
1746
|
-
this._clientLog.debug(`P2P message gap fill done: after_seq=${afterSeq}, filled=${filled}`);
|
|
1747
|
-
}
|
|
1748
|
-
catch (exc) {
|
|
1749
|
-
this._clientLog.warn(`P2P message gap fill failed: ${formatCaughtError(exc)}`);
|
|
1750
|
-
}
|
|
1751
|
-
finally {
|
|
1752
|
-
this._gapFillDone.delete(dedupKey);
|
|
1753
|
-
this._releasePullGate(ns, token);
|
|
1754
|
-
if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
|
|
1755
|
-
void this._fillP2pGap();
|
|
1756
|
-
}
|
|
1757
|
-
}
|
|
1146
|
+
return this._delivery.fillP2pGap();
|
|
1758
1147
|
}
|
|
1759
1148
|
/** 只按硬上限裁剪 published guard,不能按 contiguousSeq 清理。 */
|
|
1760
1149
|
_prunePushedSeqs(ns) {
|
|
1761
|
-
|
|
1762
|
-
if (!pushed)
|
|
1763
|
-
return;
|
|
1764
|
-
if (pushed.size > PUSHED_SEQS_LIMIT) {
|
|
1765
|
-
const keep = [...pushed].sort((a, b) => a - b).slice(-PUSHED_SEQS_LIMIT);
|
|
1766
|
-
this._pushedSeqs.set(ns, new Set(keep));
|
|
1767
|
-
}
|
|
1150
|
+
this._delivery.prunePushedSeqs(ns);
|
|
1768
1151
|
}
|
|
1769
1152
|
_recordPendingP2pPull(ns, seq) {
|
|
1770
1153
|
if (!ns || seq <= 0)
|
|
@@ -1799,74 +1182,13 @@ export class AUNClient {
|
|
|
1799
1182
|
return true;
|
|
1800
1183
|
}
|
|
1801
1184
|
_markPublishedSeq(ns, seq) {
|
|
1802
|
-
|
|
1803
|
-
if (!pushed) {
|
|
1804
|
-
pushed = new Set();
|
|
1805
|
-
this._pushedSeqs.set(ns, pushed);
|
|
1806
|
-
}
|
|
1807
|
-
pushed.add(seq);
|
|
1808
|
-
if (pushed.size > PUSHED_SEQS_LIMIT) {
|
|
1809
|
-
const keep = [...pushed].sort((a, b) => a - b).slice(-PUSHED_SEQS_LIMIT);
|
|
1810
|
-
this._pushedSeqs.set(ns, new Set(keep));
|
|
1811
|
-
}
|
|
1812
|
-
}
|
|
1813
|
-
_enqueueOrderedMessage(ns, event, seq, payload) {
|
|
1814
|
-
let queue = this._pendingOrderedMsgs.get(ns);
|
|
1815
|
-
if (!queue) {
|
|
1816
|
-
queue = new Map();
|
|
1817
|
-
this._pendingOrderedMsgs.set(ns, queue);
|
|
1818
|
-
}
|
|
1819
|
-
queue.set(seq, { event, payload });
|
|
1820
|
-
if (queue.size > PENDING_ORDERED_LIMIT) {
|
|
1821
|
-
const drop = [...queue.keys()].sort((a, b) => a - b).slice(0, queue.size - PENDING_ORDERED_LIMIT);
|
|
1822
|
-
for (const oldSeq of drop)
|
|
1823
|
-
queue.delete(oldSeq);
|
|
1824
|
-
}
|
|
1825
|
-
}
|
|
1826
|
-
_isInstanceScopedMessageEvent(event) {
|
|
1827
|
-
return event === 'message.received'
|
|
1828
|
-
|| event === 'message.undecryptable'
|
|
1829
|
-
|| event === 'group.message_created'
|
|
1830
|
-
|| event === 'group.message_undecryptable';
|
|
1185
|
+
this._delivery.markPublishedSeq(ns, seq);
|
|
1831
1186
|
}
|
|
1832
1187
|
_attachCurrentInstanceContext(payload) {
|
|
1833
|
-
|
|
1834
|
-
return payload;
|
|
1835
|
-
const result = { ...payload };
|
|
1836
|
-
if (!('device_id' in result)) {
|
|
1837
|
-
result.device_id = this._deviceId;
|
|
1838
|
-
}
|
|
1839
|
-
if (!('slot_id' in result)) {
|
|
1840
|
-
result.slot_id = this._slotId;
|
|
1841
|
-
}
|
|
1842
|
-
return result;
|
|
1843
|
-
}
|
|
1844
|
-
_normalizePublishedMessagePayload(event, payload) {
|
|
1845
|
-
if (!this._isInstanceScopedMessageEvent(event))
|
|
1846
|
-
return payload;
|
|
1847
|
-
return this._attachCurrentInstanceContext(payload);
|
|
1188
|
+
return this._delivery.attachCurrentInstanceContext(payload);
|
|
1848
1189
|
}
|
|
1849
1190
|
_publishAppEvent(event, payload, source = 'direct') {
|
|
1850
|
-
|
|
1851
|
-
this._maybeAppendEchoTraceReceive(payload);
|
|
1852
|
-
}
|
|
1853
|
-
this._logAppMessagePublish(event, payload, source);
|
|
1854
|
-
// 注入本地/远端 agent.md etag,让应用层判断版本一致性;失败不影响业务。
|
|
1855
|
-
if (isJsonObject(payload)) {
|
|
1856
|
-
try {
|
|
1857
|
-
const snapshot = this._agentMdManager.eventSnapshot();
|
|
1858
|
-
if (snapshot) {
|
|
1859
|
-
const obj = payload;
|
|
1860
|
-
if (!('_agent_md' in obj)) {
|
|
1861
|
-
obj._agent_md = snapshot;
|
|
1862
|
-
}
|
|
1863
|
-
}
|
|
1864
|
-
}
|
|
1865
|
-
catch (err) {
|
|
1866
|
-
this._clientLog.debug(`agent_md etag inject skipped: ${err instanceof Error ? err.message : String(err)}`);
|
|
1867
|
-
}
|
|
1868
|
-
}
|
|
1869
|
-
return this._dispatcher.publishSyncAware(event, this._normalizePublishedMessagePayload(event, payload));
|
|
1191
|
+
return this._delivery.publishAppEvent(event, payload, source);
|
|
1870
1192
|
}
|
|
1871
1193
|
_echoTimestamp() {
|
|
1872
1194
|
const now = new Date();
|
|
@@ -1892,13 +1214,6 @@ export class AUNClient {
|
|
|
1892
1214
|
const trace = `${this._echoTimestamp()} [AUN-SDK.send] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
|
|
1893
1215
|
params.payload = { ...payload, text: payload.text + '\n' + trace };
|
|
1894
1216
|
}
|
|
1895
|
-
_shouldSkipClientSignature(method, params) {
|
|
1896
|
-
if (method !== 'message.send' && method !== 'group.send')
|
|
1897
|
-
return false;
|
|
1898
|
-
if (params.encrypted || params.encrypt)
|
|
1899
|
-
return false;
|
|
1900
|
-
return this._isEchoPayload(params.payload);
|
|
1901
|
-
}
|
|
1902
1217
|
_shouldSkipEventSignature(event) {
|
|
1903
1218
|
if (event.encrypted || event.encrypt)
|
|
1904
1219
|
return false;
|
|
@@ -1939,126 +1254,23 @@ export class AUNClient {
|
|
|
1939
1254
|
return String(value);
|
|
1940
1255
|
}
|
|
1941
1256
|
}
|
|
1942
|
-
_messagePayloadForDebug(message) {
|
|
1943
|
-
if (!isJsonObject(message))
|
|
1944
|
-
return message;
|
|
1945
|
-
const msg = message;
|
|
1946
|
-
if ('payload' in msg)
|
|
1947
|
-
return msg.payload;
|
|
1948
|
-
if ('content' in msg)
|
|
1949
|
-
return msg.content;
|
|
1950
|
-
if (typeof msg.envelope_json === 'string' && msg.envelope_json) {
|
|
1951
|
-
try {
|
|
1952
|
-
return JSON.parse(msg.envelope_json);
|
|
1953
|
-
}
|
|
1954
|
-
catch {
|
|
1955
|
-
return msg.envelope_json;
|
|
1956
|
-
}
|
|
1957
|
-
}
|
|
1958
|
-
if (isJsonObject(msg.legacy_v1)) {
|
|
1959
|
-
const legacy = msg.legacy_v1;
|
|
1960
|
-
if ('payload' in legacy)
|
|
1961
|
-
return legacy.payload;
|
|
1962
|
-
if ('content' in legacy)
|
|
1963
|
-
return legacy.content;
|
|
1964
|
-
}
|
|
1965
|
-
return null;
|
|
1966
|
-
}
|
|
1967
1257
|
_messageEnvelopeFieldsForDebug(message) {
|
|
1968
|
-
|
|
1969
|
-
return { value_type: typeof message };
|
|
1970
|
-
}
|
|
1971
|
-
const msg = message;
|
|
1972
|
-
const keys = [
|
|
1973
|
-
'message_id', 'id', 'from', 'from_aid', 'sender_aid', 'to', 'to_aid',
|
|
1974
|
-
'group_id', 'seq', 'msg_seq', 'type', 'version', 'timestamp', 't_server',
|
|
1975
|
-
'device_id', 'slot_id', 'encrypted', 'dispatch_mode', 'dispatch',
|
|
1976
|
-
'e2ee', 'headers', 'protected_headers', 'context', 'status',
|
|
1977
|
-
'_decrypt_error', '_decrypt_stage',
|
|
1978
|
-
];
|
|
1979
|
-
const out = {};
|
|
1980
|
-
for (const key of keys) {
|
|
1981
|
-
if (Object.prototype.hasOwnProperty.call(msg, key))
|
|
1982
|
-
out[key] = msg[key];
|
|
1983
|
-
}
|
|
1984
|
-
return out;
|
|
1258
|
+
return this._delivery.messageEnvelopeFieldsForDebug(message);
|
|
1985
1259
|
}
|
|
1986
1260
|
_logMessageDebug(stage, source, event, message, opts = {}) {
|
|
1987
|
-
|
|
1988
|
-
const record = {
|
|
1989
|
-
stage,
|
|
1990
|
-
source,
|
|
1991
|
-
event,
|
|
1992
|
-
envelope: this._messageEnvelopeFieldsForDebug(message),
|
|
1993
|
-
payload: opts.payloadOverride !== undefined ? opts.payloadOverride : this._messagePayloadForDebug(message),
|
|
1994
|
-
};
|
|
1995
|
-
if (opts.extra)
|
|
1996
|
-
record.extra = opts.extra;
|
|
1997
|
-
this._clientLog.debug(`message.debug ${this._debugJson(record)}`);
|
|
1998
|
-
}
|
|
1999
|
-
_logAppMessagePublish(event, payload, source) {
|
|
2000
|
-
if (!['message.received', 'message.undecryptable', 'group.message_created', 'group.message_undecryptable'].includes(event)) {
|
|
2001
|
-
return;
|
|
2002
|
-
}
|
|
2003
|
-
this._logMessageDebug('publish', source, event, payload);
|
|
1261
|
+
this._delivery.logMessageDebug(stage, source, event, message, opts);
|
|
2004
1262
|
}
|
|
2005
1263
|
_messageTargetsCurrentInstance(message) {
|
|
2006
|
-
|
|
2007
|
-
return true;
|
|
2008
|
-
if ('device_id' in message) {
|
|
2009
|
-
const targetDeviceId = String(message.device_id ?? '').trim();
|
|
2010
|
-
if (targetDeviceId !== this._deviceId) {
|
|
2011
|
-
return false;
|
|
2012
|
-
}
|
|
2013
|
-
}
|
|
2014
|
-
if ('slot_id' in message) {
|
|
2015
|
-
const targetSlotId = String(message.slot_id ?? '').trim();
|
|
2016
|
-
if (slotIsolationKey(targetSlotId) !== slotIsolationKey(this._slotId)) {
|
|
2017
|
-
return false;
|
|
2018
|
-
}
|
|
2019
|
-
}
|
|
2020
|
-
return true;
|
|
1264
|
+
return this._delivery.messageTargetsCurrentInstance(message);
|
|
2021
1265
|
}
|
|
2022
1266
|
_tryAcquirePullGate(key) {
|
|
2023
|
-
|
|
2024
|
-
return 0;
|
|
2025
|
-
const now = Date.now();
|
|
2026
|
-
const gate = this._pullGates.get(key) ?? { inflight: false, startedAt: 0, token: 0 };
|
|
2027
|
-
if (gate.inflight && now - gate.startedAt <= AUNClient.PULL_GATE_STALE_MS) {
|
|
2028
|
-
return null;
|
|
2029
|
-
}
|
|
2030
|
-
if (gate.inflight) {
|
|
2031
|
-
this._clientLog.warn(`pull in-flight stale reset: key=${key} age=${now - gate.startedAt}ms`);
|
|
2032
|
-
}
|
|
2033
|
-
gate.token += 1;
|
|
2034
|
-
gate.inflight = true;
|
|
2035
|
-
gate.startedAt = now;
|
|
2036
|
-
this._pullGates.set(key, gate);
|
|
2037
|
-
return gate.token;
|
|
1267
|
+
return this._rpcPipeline.tryAcquirePullGate(key);
|
|
2038
1268
|
}
|
|
2039
1269
|
_releasePullGate(key, token) {
|
|
2040
|
-
|
|
2041
|
-
return;
|
|
2042
|
-
const gate = this._pullGates.get(key);
|
|
2043
|
-
if (!gate || gate.token !== token)
|
|
2044
|
-
return;
|
|
2045
|
-
gate.inflight = false;
|
|
2046
|
-
gate.startedAt = 0;
|
|
2047
|
-
if (key.startsWith('p2p:')) {
|
|
2048
|
-
this._schedulePendingP2pPullIfNeeded(key, 'pull-gate-release');
|
|
2049
|
-
}
|
|
1270
|
+
this._rpcPipeline.releasePullGate(key, token);
|
|
2050
1271
|
}
|
|
2051
1272
|
_pullGateKeyForCall(method, params) {
|
|
2052
|
-
|
|
2053
|
-
return this._aid ? `p2p:${this._aid}` : '';
|
|
2054
|
-
}
|
|
2055
|
-
if ((method === 'group.pull' || method === 'group.v2.pull') && String(params.group_id ?? '').trim()) {
|
|
2056
|
-
return `group:${String(params.group_id ?? '').trim()}`;
|
|
2057
|
-
}
|
|
2058
|
-
if (method === 'group.pull_events' && String(params.group_id ?? '').trim()) {
|
|
2059
|
-
return `group_event:${String(params.group_id ?? '').trim()}`;
|
|
2060
|
-
}
|
|
2061
|
-
return '';
|
|
1273
|
+
return this._rpcPipeline.pullGateKeyForCall(method, params);
|
|
2062
1274
|
}
|
|
2063
1275
|
_isPullResponseProcessing(key) {
|
|
2064
1276
|
if (!key)
|
|
@@ -2099,58 +1311,6 @@ export class AUNClient {
|
|
|
2099
1311
|
throw exc;
|
|
2100
1312
|
}
|
|
2101
1313
|
}
|
|
2102
|
-
_pullResultCount(result) {
|
|
2103
|
-
if (Array.isArray(result))
|
|
2104
|
-
return result.length;
|
|
2105
|
-
if (!isJsonObject(result))
|
|
2106
|
-
return 0;
|
|
2107
|
-
const obj = result;
|
|
2108
|
-
const rawCount = Number(obj.raw_count ?? 0);
|
|
2109
|
-
if (Number.isFinite(rawCount) && rawCount > 0)
|
|
2110
|
-
return rawCount;
|
|
2111
|
-
if (Array.isArray(obj.messages))
|
|
2112
|
-
return obj.messages.length;
|
|
2113
|
-
if (Array.isArray(obj.events))
|
|
2114
|
-
return obj.events.length;
|
|
2115
|
-
return 0;
|
|
2116
|
-
}
|
|
2117
|
-
_nextPullParams(method, params) {
|
|
2118
|
-
const next = { ...params };
|
|
2119
|
-
delete next._pull_gate_locked;
|
|
2120
|
-
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
2121
|
-
if (!this._aid)
|
|
2122
|
-
return null;
|
|
2123
|
-
next.after_seq = this._seqTracker.getContiguousSeq(`p2p:${this._aid}`);
|
|
2124
|
-
return next;
|
|
2125
|
-
}
|
|
2126
|
-
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
2127
|
-
const groupId = normalizeGroupId(String(next.group_id ?? '').trim()) || String(next.group_id ?? '').trim();
|
|
2128
|
-
if (!groupId)
|
|
2129
|
-
return null;
|
|
2130
|
-
next.group_id = groupId;
|
|
2131
|
-
next.after_seq = this._seqTracker.getContiguousSeq(`group:${groupId}`);
|
|
2132
|
-
delete next.after_message_seq;
|
|
2133
|
-
return next;
|
|
2134
|
-
}
|
|
2135
|
-
if (method === 'group.pull_events') {
|
|
2136
|
-
const groupId = normalizeGroupId(String(next.group_id ?? '').trim()) || String(next.group_id ?? '').trim();
|
|
2137
|
-
if (!groupId)
|
|
2138
|
-
return null;
|
|
2139
|
-
next.group_id = groupId;
|
|
2140
|
-
next.after_event_seq = this._seqTracker.getContiguousSeq(`group_event:${groupId}`);
|
|
2141
|
-
return next;
|
|
2142
|
-
}
|
|
2143
|
-
return null;
|
|
2144
|
-
}
|
|
2145
|
-
_pullRequestAfter(method, params) {
|
|
2146
|
-
if (method === 'message.pull' || method === 'message.v2.pull')
|
|
2147
|
-
return Number(params.after_seq ?? 0) || 0;
|
|
2148
|
-
if (method === 'group.pull' || method === 'group.v2.pull')
|
|
2149
|
-
return Number(params.after_seq ?? params.after_message_seq ?? 0) || 0;
|
|
2150
|
-
if (method === 'group.pull_events')
|
|
2151
|
-
return Number(params.after_event_seq ?? 0) || 0;
|
|
2152
|
-
return 0;
|
|
2153
|
-
}
|
|
2154
1314
|
_pullRetentionFloor(result, topLevelKey, cursorKey) {
|
|
2155
1315
|
const values = [Number(result[topLevelKey] ?? 0)];
|
|
2156
1316
|
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
@@ -2160,15 +1320,6 @@ export class AUNClient {
|
|
|
2160
1320
|
}
|
|
2161
1321
|
return Math.max(0, ...values.filter((value) => Number.isFinite(value)));
|
|
2162
1322
|
}
|
|
2163
|
-
_groupCursorParams(params) {
|
|
2164
|
-
const cursorParams = {};
|
|
2165
|
-
for (const key of ['device_id', 'slot_id', 'device_name', 'device_type']) {
|
|
2166
|
-
const value = params[key];
|
|
2167
|
-
if (value !== undefined && value !== null)
|
|
2168
|
-
cursorParams[key] = value;
|
|
2169
|
-
}
|
|
2170
|
-
return cursorParams;
|
|
2171
|
-
}
|
|
2172
1323
|
_explicitGroupCursorParams(params) {
|
|
2173
1324
|
const value = params._group_cursor_params;
|
|
2174
1325
|
if (!isJsonObject(value))
|
|
@@ -2181,40 +1332,6 @@ export class AUNClient {
|
|
|
2181
1332
|
return (!deviceId || deviceId === (this._deviceId ?? ''))
|
|
2182
1333
|
&& (!slotId || slotId === (this._slotId ?? ''));
|
|
2183
1334
|
}
|
|
2184
|
-
_schedulePullFollowup(method, params, result) {
|
|
2185
|
-
if (method === 'message.pull')
|
|
2186
|
-
method = 'message.v2.pull';
|
|
2187
|
-
else if (method === 'group.pull')
|
|
2188
|
-
method = 'group.v2.pull';
|
|
2189
|
-
if (this._pullResultCount(result) <= 0)
|
|
2190
|
-
return;
|
|
2191
|
-
const next = this._nextPullParams(method, params);
|
|
2192
|
-
if (!next)
|
|
2193
|
-
return;
|
|
2194
|
-
if (this._pullRequestAfter(method, next) <= this._pullRequestAfter(method, params))
|
|
2195
|
-
return;
|
|
2196
|
-
void (async () => {
|
|
2197
|
-
try {
|
|
2198
|
-
await this._withBackgroundRpc(async () => {
|
|
2199
|
-
if (method === 'message.pull' || method === 'message.v2.pull') {
|
|
2200
|
-
await this._pullV2(Number(next.after_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
|
|
2201
|
-
return;
|
|
2202
|
-
}
|
|
2203
|
-
if (method === 'group.pull' || method === 'group.v2.pull') {
|
|
2204
|
-
const groupId = String(next.group_id ?? '').trim();
|
|
2205
|
-
if (!groupId)
|
|
2206
|
-
return;
|
|
2207
|
-
await this._pullGroupV2(groupId, Number(next.after_seq ?? next.after_message_seq ?? 0) || 0, Number(next.limit ?? 50) || 50);
|
|
2208
|
-
return;
|
|
2209
|
-
}
|
|
2210
|
-
await this.call(method, next);
|
|
2211
|
-
});
|
|
2212
|
-
}
|
|
2213
|
-
catch (exc) {
|
|
2214
|
-
this._clientLog.debug(`pull follow-up skipped/failed: method=${method} err=${formatCaughtError(exc)}`);
|
|
2215
|
-
}
|
|
2216
|
-
})();
|
|
2217
|
-
}
|
|
2218
1335
|
async _withBackgroundRpc(operation) {
|
|
2219
1336
|
this._backgroundRpcDepth += 1;
|
|
2220
1337
|
try {
|
|
@@ -2225,29 +1342,7 @@ export class AUNClient {
|
|
|
2225
1342
|
}
|
|
2226
1343
|
}
|
|
2227
1344
|
async _runPullSerialized(key, operation) {
|
|
2228
|
-
|
|
2229
|
-
this._clientLog.debug(`pull skipped while processing pull response: key=${key}`);
|
|
2230
|
-
return [];
|
|
2231
|
-
}
|
|
2232
|
-
let token = this._tryAcquirePullGate(key);
|
|
2233
|
-
if (token === null) {
|
|
2234
|
-
// 显式 pull 可能撞上 push/gap-fill 的后台 pull。这里不并行发第二个 pull,
|
|
2235
|
-
// 也不把后台 in-flight 暴露成业务错误;短等待 gate 释放后再进入连接级 RPC queue。
|
|
2236
|
-
const deadline = Date.now() + AUNClient.PULL_GATE_STALE_MS + 100;
|
|
2237
|
-
while (token === null && Date.now() <= deadline) {
|
|
2238
|
-
await this._sleep(25);
|
|
2239
|
-
token = this._tryAcquirePullGate(key);
|
|
2240
|
-
}
|
|
2241
|
-
if (token === null) {
|
|
2242
|
-
throw new StateError(`pull already in-flight for ${key}`);
|
|
2243
|
-
}
|
|
2244
|
-
}
|
|
2245
|
-
try {
|
|
2246
|
-
return await this._withBackgroundRpc(operation);
|
|
2247
|
-
}
|
|
2248
|
-
finally {
|
|
2249
|
-
this._releasePullGate(key, token);
|
|
2250
|
-
}
|
|
1345
|
+
return await this._rpcPipeline.runPullSerialized(key, operation);
|
|
2251
1346
|
}
|
|
2252
1347
|
async _tryRunBackgroundPull(key, operation, followupOnMessages = false, onBusy) {
|
|
2253
1348
|
if (key && this._isPullResponseProcessing(key)) {
|
|
@@ -2276,129 +1371,13 @@ export class AUNClient {
|
|
|
2276
1371
|
return true;
|
|
2277
1372
|
}
|
|
2278
1373
|
async _drainOrderedMessages(ns, beforeSeq, pullResponse = false) {
|
|
2279
|
-
|
|
2280
|
-
if (!queue || queue.size === 0)
|
|
2281
|
-
return;
|
|
2282
|
-
while (true) {
|
|
2283
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2284
|
-
const ready = [...queue.keys()]
|
|
2285
|
-
.filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
|
|
2286
|
-
.sort((a, b) => a - b);
|
|
2287
|
-
let seq = ready[0];
|
|
2288
|
-
if (seq === undefined) {
|
|
2289
|
-
const nextSeq = contig + 1;
|
|
2290
|
-
if (beforeSeq !== undefined && nextSeq >= beforeSeq)
|
|
2291
|
-
break;
|
|
2292
|
-
if (!queue.has(nextSeq))
|
|
2293
|
-
break;
|
|
2294
|
-
seq = nextSeq;
|
|
2295
|
-
}
|
|
2296
|
-
const item = queue.get(seq);
|
|
2297
|
-
queue.delete(seq);
|
|
2298
|
-
if (!item)
|
|
2299
|
-
continue;
|
|
2300
|
-
if (this._pushedSeqs.get(ns)?.has(seq)) {
|
|
2301
|
-
this._clientLog.debug(`publish ordered drain skipped duplicate: ns=${ns}, seq=${seq}, event=${item.event}`);
|
|
2302
|
-
this._markOrderedSeqDelivered(ns, seq);
|
|
2303
|
-
continue;
|
|
2304
|
-
}
|
|
2305
|
-
if (pullResponse) {
|
|
2306
|
-
const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(item.event, item.payload, 'ordered-drain'));
|
|
2307
|
-
if (isPromiseLike(published))
|
|
2308
|
-
await published;
|
|
2309
|
-
}
|
|
2310
|
-
else {
|
|
2311
|
-
const published = this._publishAppEvent(item.event, item.payload, 'ordered-drain');
|
|
2312
|
-
if (isPromiseLike(published))
|
|
2313
|
-
await published;
|
|
2314
|
-
}
|
|
2315
|
-
this._markPublishedSeq(ns, seq);
|
|
2316
|
-
this._markOrderedSeqDelivered(ns, seq);
|
|
2317
|
-
this._clientLog.debug(`publish ordered drain delivered: ns=${ns}, seq=${seq}, event=${item.event}`);
|
|
2318
|
-
}
|
|
2319
|
-
if (queue.size === 0)
|
|
2320
|
-
this._pendingOrderedMsgs.delete(ns);
|
|
1374
|
+
return this._delivery.drainOrderedMessages(ns, beforeSeq, pullResponse);
|
|
2321
1375
|
}
|
|
2322
1376
|
async _publishOrderedMessage(event, ns, seq, payload) {
|
|
2323
|
-
|
|
2324
|
-
if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0) {
|
|
2325
|
-
this._clientLog.debug(`publish ordered direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
|
|
2326
|
-
const published = this._publishAppEvent(event, payload, 'ordered');
|
|
2327
|
-
if (isPromiseLike(published))
|
|
2328
|
-
await published;
|
|
2329
|
-
return true;
|
|
2330
|
-
}
|
|
2331
|
-
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
2332
|
-
this._clientLog.debug(`publish ordered skipped duplicate: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
2333
|
-
const queue = this._pendingOrderedMsgs.get(ns);
|
|
2334
|
-
queue?.delete(seqNum);
|
|
2335
|
-
if (queue && queue.size === 0)
|
|
2336
|
-
this._pendingOrderedMsgs.delete(ns);
|
|
2337
|
-
return false;
|
|
2338
|
-
}
|
|
2339
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2340
|
-
if (seqNum <= contig) {
|
|
2341
|
-
this._clientLog.debug(`publish ordered stale covered: event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
|
|
2342
|
-
const queue = this._pendingOrderedMsgs.get(ns);
|
|
2343
|
-
queue?.delete(seqNum);
|
|
2344
|
-
if (queue && queue.size === 0)
|
|
2345
|
-
this._pendingOrderedMsgs.delete(ns);
|
|
2346
|
-
return false;
|
|
2347
|
-
}
|
|
2348
|
-
if (seqNum !== contig + 1) {
|
|
2349
|
-
this._clientLog.debug(`publish ordered enqueue(gap): event=${event}, ns=${ns}, seq=${seqNum}, contiguous=${contig}`);
|
|
2350
|
-
this._enqueueOrderedMessage(ns, event, seqNum, payload);
|
|
2351
|
-
return false;
|
|
2352
|
-
}
|
|
2353
|
-
await this._drainOrderedMessages(ns, seqNum);
|
|
2354
|
-
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
2355
|
-
this._clientLog.debug(`publish ordered skipped after-drain duplicate: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
2356
|
-
return false;
|
|
2357
|
-
}
|
|
2358
|
-
const queue = this._pendingOrderedMsgs.get(ns);
|
|
2359
|
-
queue?.delete(seqNum);
|
|
2360
|
-
if (queue && queue.size === 0)
|
|
2361
|
-
this._pendingOrderedMsgs.delete(ns);
|
|
2362
|
-
const published = this._publishAppEvent(event, payload, 'ordered');
|
|
2363
|
-
if (isPromiseLike(published))
|
|
2364
|
-
await published;
|
|
2365
|
-
this._markPublishedSeq(ns, seqNum);
|
|
2366
|
-
this._markOrderedSeqDelivered(ns, seqNum);
|
|
2367
|
-
this._clientLog.debug(`publish ordered delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
2368
|
-
await this._drainOrderedMessages(ns);
|
|
2369
|
-
return true;
|
|
1377
|
+
return this._delivery.publishOrderedMessage(event, ns, seq, payload);
|
|
2370
1378
|
}
|
|
2371
1379
|
async _publishPulledMessage(event, ns, seq, payload) {
|
|
2372
|
-
|
|
2373
|
-
// 这里只能做 namespace+seq 去重并按返回顺序发布,不能套用 push 路径的
|
|
2374
|
-
// seq == contiguous_seq + 1 门控,否则会把空洞后的可用消息错误卡住。
|
|
2375
|
-
const seqNum = Number(seq);
|
|
2376
|
-
if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0 || !ns) {
|
|
2377
|
-
this._clientLog.debug(`publish pulled direct(no-seq): event=${event}, ns=${ns || '<none>'}, seq=${String(seq)}`);
|
|
2378
|
-
const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(event, payload, 'pull'));
|
|
2379
|
-
if (isPromiseLike(published))
|
|
2380
|
-
await published;
|
|
2381
|
-
return true;
|
|
2382
|
-
}
|
|
2383
|
-
const queue = this._pendingOrderedMsgs.get(ns);
|
|
2384
|
-
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
2385
|
-
this._clientLog.debug(`publish pulled skipped duplicate: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
2386
|
-
queue?.delete(seqNum);
|
|
2387
|
-
if (queue && queue.size === 0)
|
|
2388
|
-
this._pendingOrderedMsgs.delete(ns);
|
|
2389
|
-
return false;
|
|
2390
|
-
}
|
|
2391
|
-
queue?.delete(seqNum);
|
|
2392
|
-
if (queue && queue.size === 0)
|
|
2393
|
-
this._pendingOrderedMsgs.delete(ns);
|
|
2394
|
-
const published = this._withPullResponseProcessing(ns, () => this._publishAppEvent(event, payload, 'pull'));
|
|
2395
|
-
if (isPromiseLike(published))
|
|
2396
|
-
await published;
|
|
2397
|
-
this._markPublishedSeq(ns, seqNum);
|
|
2398
|
-
this._markPulledSeqDelivered(ns, seqNum);
|
|
2399
|
-
await this._drainOrderedMessages(ns, undefined, true);
|
|
2400
|
-
this._clientLog.debug(`publish pulled delivered: event=${event}, ns=${ns}, seq=${seqNum}`);
|
|
2401
|
-
return true;
|
|
1380
|
+
return this._delivery.publishPulledMessage(event, ns, seq, payload);
|
|
2402
1381
|
}
|
|
2403
1382
|
_markPulledSeqDelivered(ns, seq) {
|
|
2404
1383
|
// Pull 批次是 after_seq 之后服务端当前可用的结果集,可能跨过永久空洞。
|
|
@@ -2419,111 +1398,7 @@ export class AUNClient {
|
|
|
2419
1398
|
}
|
|
2420
1399
|
/** 后台补齐群事件空洞 */
|
|
2421
1400
|
async _fillGroupEventGap(groupId) {
|
|
2422
|
-
|
|
2423
|
-
if (!groupId)
|
|
2424
|
-
return;
|
|
2425
|
-
const ns = `group_event:${groupId}`;
|
|
2426
|
-
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
2427
|
-
// 去重:同一 (group_evt:id:after_seq) 只补一次
|
|
2428
|
-
const dedupKey = `group_evt:${groupId}:${afterSeq}`;
|
|
2429
|
-
if (this._gapFillDone.has(dedupKey))
|
|
2430
|
-
return;
|
|
2431
|
-
const token = this._tryAcquirePullGate(ns);
|
|
2432
|
-
if (token === null) {
|
|
2433
|
-
this._clientLog.debug(`group event gap fill skipped: pull in-flight group=${groupId}`);
|
|
2434
|
-
return;
|
|
2435
|
-
}
|
|
2436
|
-
this._gapFillDone.set(dedupKey, Date.now());
|
|
2437
|
-
let filled = 0;
|
|
2438
|
-
try {
|
|
2439
|
-
let nextAfterSeq = afterSeq;
|
|
2440
|
-
const maxPages = 100;
|
|
2441
|
-
let pageCount = 0;
|
|
2442
|
-
while (pageCount < maxPages) {
|
|
2443
|
-
pageCount += 1;
|
|
2444
|
-
this._clientLog.debug(`group event gap fill start: group=${groupId}, after_seq=${nextAfterSeq}`);
|
|
2445
|
-
const result = await this.call('group.pull_events', {
|
|
2446
|
-
group_id: groupId,
|
|
2447
|
-
after_event_seq: nextAfterSeq,
|
|
2448
|
-
device_id: this._deviceId,
|
|
2449
|
-
limit: 50,
|
|
2450
|
-
_pull_gate_locked: true,
|
|
2451
|
-
});
|
|
2452
|
-
if (!isJsonObject(result))
|
|
2453
|
-
return;
|
|
2454
|
-
const events = result.events;
|
|
2455
|
-
if (!Array.isArray(events))
|
|
2456
|
-
return;
|
|
2457
|
-
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
2458
|
-
const eventObjects = events.filter(isJsonObject);
|
|
2459
|
-
const retentionFloor = this._pullRetentionFloor(result, 'retention_floor_event_seq', 'retention_floor_event_seq');
|
|
2460
|
-
if (retentionFloor > 0) {
|
|
2461
|
-
const contigBeforeFloor = this._seqTracker.getContiguousSeq(ns);
|
|
2462
|
-
if (contigBeforeFloor < retentionFloor) {
|
|
2463
|
-
this._clientLog.info(`group.pull_events retention-floor advance: ns=${ns} contiguous=${contigBeforeFloor} -> retention_floor=${retentionFloor}`);
|
|
2464
|
-
this._seqTracker.forceContiguousSeq(ns, retentionFloor);
|
|
2465
|
-
}
|
|
2466
|
-
}
|
|
2467
|
-
const eventSeqs = [];
|
|
2468
|
-
for (const evt of eventObjects) {
|
|
2469
|
-
const eventSeq = Number(evt.event_seq ?? 0);
|
|
2470
|
-
if (Number.isFinite(eventSeq) && eventSeq > 0)
|
|
2471
|
-
eventSeqs.push(eventSeq);
|
|
2472
|
-
evt._from_gap_fill = true;
|
|
2473
|
-
const et = String(evt.event_type ?? '');
|
|
2474
|
-
// 消息事件由 _fillGroupGap 负责,事件补洞不重复投递
|
|
2475
|
-
if (et !== 'group.message_created') {
|
|
2476
|
-
// 验签:有 client_signature 就验(与实时事件路径对齐)
|
|
2477
|
-
const cs = evt.client_signature;
|
|
2478
|
-
if (cs && typeof cs === 'object') {
|
|
2479
|
-
if (this._shouldSkipEventSignature(evt)) {
|
|
2480
|
-
delete evt.client_signature;
|
|
2481
|
-
}
|
|
2482
|
-
else {
|
|
2483
|
-
evt._verified = await this._verifyEventSignatureAsync(evt, cs);
|
|
2484
|
-
}
|
|
2485
|
-
}
|
|
2486
|
-
// group.changed 或缺失/其他 → 发布到 group.changed(向后兼容)
|
|
2487
|
-
await this._dispatcher.publish('group.changed', evt);
|
|
2488
|
-
}
|
|
2489
|
-
if (Number.isFinite(eventSeq) && eventSeq > 0) {
|
|
2490
|
-
this._markPulledSeqDelivered(ns, eventSeq);
|
|
2491
|
-
}
|
|
2492
|
-
filled += 1;
|
|
2493
|
-
}
|
|
2494
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
2495
|
-
if (contig !== pageContigBefore) {
|
|
2496
|
-
this._saveSeqTrackerState();
|
|
2497
|
-
}
|
|
2498
|
-
if (eventObjects.length > 0 && contig > 0 && contig !== pageContigBefore) {
|
|
2499
|
-
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
2500
|
-
const ackSeq = maxSeen > 0 ? Math.min(contig, maxSeen) : contig;
|
|
2501
|
-
this._transport.call('group.ack_events', {
|
|
2502
|
-
group_id: groupId,
|
|
2503
|
-
event_seq: ackSeq,
|
|
2504
|
-
device_id: this._deviceId,
|
|
2505
|
-
slot_id: this._slotId,
|
|
2506
|
-
}, undefined, undefined, true).catch((e) => { this._clientLog.debug(`group event auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2507
|
-
}
|
|
2508
|
-
// pull_events 与其它 pull 一样:一次后台任务只消费一个批次。
|
|
2509
|
-
// 非空批次返回后由 pull gate 的 fire-and-forget follow-up 重新排队,直到空批停止。
|
|
2510
|
-
break;
|
|
2511
|
-
}
|
|
2512
|
-
if (pageCount >= maxPages) {
|
|
2513
|
-
this._clientLog.warn(`group event gap fill reached max_pages=${maxPages} group=${groupId} after_seq=${nextAfterSeq}`);
|
|
2514
|
-
}
|
|
2515
|
-
this._clientLog.debug(`group event gap fill done: group=${groupId}, after_seq=${afterSeq}, filled=${filled}`);
|
|
2516
|
-
}
|
|
2517
|
-
catch (exc) {
|
|
2518
|
-
this._clientLog.warn(`group event gap fill failed: ${formatCaughtError(exc)}`);
|
|
2519
|
-
}
|
|
2520
|
-
finally {
|
|
2521
|
-
this._gapFillDone.delete(dedupKey);
|
|
2522
|
-
this._releasePullGate(ns, token);
|
|
2523
|
-
if (filled > 0 && this._seqTracker.getContiguousSeq(ns) > afterSeq) {
|
|
2524
|
-
void this._fillGroupEventGap(groupId);
|
|
2525
|
-
}
|
|
2526
|
-
}
|
|
1401
|
+
return this._delivery.fillGroupEventGap(groupId);
|
|
2527
1402
|
}
|
|
2528
1403
|
_extractGroupIdFromResult(result) {
|
|
2529
1404
|
const group = isJsonObject(result.group) ? result.group : null;
|
|
@@ -2555,64 +1430,8 @@ export class AUNClient {
|
|
|
2555
1430
|
}
|
|
2556
1431
|
}
|
|
2557
1432
|
await this._dispatcher.publish('group.changed', d);
|
|
2558
|
-
|
|
2559
|
-
|
|
2560
|
-
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
2561
|
-
}
|
|
2562
|
-
const membershipActions = new Set([
|
|
2563
|
-
'member_added', 'member_left', 'member_removed', 'role_changed',
|
|
2564
|
-
'owner_transferred', 'joined', 'join_approved', 'invite_code_used',
|
|
2565
|
-
]);
|
|
2566
|
-
if (groupId && this._v2Session && (action === 'upsert' || membershipActions.has(action))) {
|
|
2567
|
-
this._safeAsync(this._v2AutoProposeState(groupId, { leaderDelay: true }));
|
|
2568
|
-
}
|
|
2569
|
-
// Group SPK 编排:成员变更触发注册/轮换
|
|
2570
|
-
if (this._v2Session && groupId) {
|
|
2571
|
-
if (membershipActions.has(action)) {
|
|
2572
|
-
const callFn = async (method, params) => this.call(method, params);
|
|
2573
|
-
const joinedAid = String(d.joined_aid ?? d.member_aid ?? d.aid ?? '').trim();
|
|
2574
|
-
const actorAid = String(d.actor_aid ?? '').trim();
|
|
2575
|
-
const selfAid = String(this._aid ?? '').trim();
|
|
2576
|
-
const joinActions = new Set(['member_added', 'joined', 'join_approved', 'invite_code_used']);
|
|
2577
|
-
const isSelfJoin = joinActions.has(action) && !!selfAid && (joinedAid === selfAid ||
|
|
2578
|
-
(!joinedAid && (action === 'joined' || action === 'invite_code_used') && actorAid === selfAid));
|
|
2579
|
-
if (isSelfJoin) {
|
|
2580
|
-
this._v2Session.ensureGroupRegistered?.(groupId, callFn)?.catch(exc => {
|
|
2581
|
-
this._clientLog.debug(`group SPK registration failed (non-fatal): group=${groupId} action=${action} err=${formatCaughtError(exc)}`);
|
|
2582
|
-
});
|
|
2583
|
-
}
|
|
2584
|
-
else {
|
|
2585
|
-
this._v2Session.rotateGroupSPK?.(groupId, callFn)?.catch(exc => {
|
|
2586
|
-
this._clientLog.debug(`group SPK rotation failed (non-fatal): group=${groupId} action=${action} err=${formatCaughtError(exc)}`);
|
|
2587
|
-
});
|
|
2588
|
-
}
|
|
2589
|
-
}
|
|
2590
|
-
}
|
|
2591
|
-
// event_seq 空洞检测:持久化后的 group.changed 会携带 event_seq。
|
|
2592
|
-
// 用 onMessageSeq 返回值决定是否补拉,与 P2P / group.message 路径对齐。
|
|
2593
|
-
let needPull = false;
|
|
2594
|
-
const rawEventSeq = d.event_seq;
|
|
2595
|
-
if (rawEventSeq != null && groupId) {
|
|
2596
|
-
const es = Number(rawEventSeq);
|
|
2597
|
-
if (Number.isFinite(es) && es > 0) {
|
|
2598
|
-
needPull = this._seqTracker.onMessageSeq(`group_event:${groupId}`, es);
|
|
2599
|
-
}
|
|
2600
|
-
// ISSUE-TS-002: 群事件推送路径 ack + 持久化,与 P2P/群消息路径对齐
|
|
2601
|
-
this._saveSeqTrackerState();
|
|
2602
|
-
const contig = this._seqTracker.getContiguousSeq(`group_event:${groupId}`);
|
|
2603
|
-
if (contig > 0) {
|
|
2604
|
-
this._transport.call('group.ack_events', {
|
|
2605
|
-
group_id: groupId,
|
|
2606
|
-
event_seq: contig,
|
|
2607
|
-
device_id: this._deviceId,
|
|
2608
|
-
slot_id: this._slotId,
|
|
2609
|
-
}, undefined, undefined, true).catch((e) => { this._clientLog.debug(`group event push auto-ack failed: group=${groupId} ${formatCaughtError(e)}`); });
|
|
2610
|
-
}
|
|
2611
|
-
}
|
|
2612
|
-
// 仅在真实 event gap 时才触发补拉(补洞回来的事件不再触发新补洞)
|
|
2613
|
-
if (needPull && groupId && !d._from_gap_fill) {
|
|
2614
|
-
this._fillGroupEventGap(groupId).catch(exc => this._clientLog.warn(`background gap fill trigger failed: ${formatCaughtError(exc)}`));
|
|
2615
|
-
}
|
|
1433
|
+
this._groupState.handleGroupChangedV2Membership(d);
|
|
1434
|
+
this._delivery.handleGroupChangedEventSeq(d, groupId);
|
|
2616
1435
|
// 群组解散 → 清理本地 epoch key、seq_tracker、补洞去重缓存
|
|
2617
1436
|
if (d.action === 'dissolved') {
|
|
2618
1437
|
if (groupId) {
|
|
@@ -2636,105 +1455,11 @@ export class AUNClient {
|
|
|
2636
1455
|
* 当链断裂时回源 group.get_state,并对回源结果做本地 hash 重算验证。
|
|
2637
1456
|
*/
|
|
2638
1457
|
async _onGroupStateCommitted(data) {
|
|
2639
|
-
|
|
2640
|
-
if (!isJsonObject(data)) {
|
|
2641
|
-
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms (non-object payload)`);
|
|
2642
|
-
return;
|
|
2643
|
-
}
|
|
2644
|
-
const d = data;
|
|
2645
|
-
const groupId = String(d.group_id ?? '').trim();
|
|
2646
|
-
if (!groupId) {
|
|
2647
|
-
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms (no group_id)`);
|
|
2648
|
-
return;
|
|
2649
|
-
}
|
|
2650
|
-
this._clientLog.debug(`_onGroupStateCommitted enter: group_id=${groupId}, state_version=${String(d.state_version ?? '')}`);
|
|
2651
|
-
try {
|
|
2652
|
-
// 提交者签名验证(兼容旧版:无签名时继续)
|
|
2653
|
-
const cs = d.client_signature;
|
|
2654
|
-
if (cs && isJsonObject(cs)) {
|
|
2655
|
-
if (this._shouldSkipEventSignature(d)) {
|
|
2656
|
-
delete d.client_signature;
|
|
2657
|
-
}
|
|
2658
|
-
else {
|
|
2659
|
-
const verified = await this._verifyEventSignatureAsync(d, cs);
|
|
2660
|
-
if (verified === false) {
|
|
2661
|
-
this._clientLog.warn(`state_committed committer signature verification failed group=${groupId}`);
|
|
2662
|
-
return;
|
|
2663
|
-
}
|
|
2664
|
-
d._verified = verified;
|
|
2665
|
-
}
|
|
2666
|
-
}
|
|
2667
|
-
const stateVersion = Number(d.state_version ?? 0);
|
|
2668
|
-
const stateHash = String(d.state_hash ?? '').trim();
|
|
2669
|
-
const prevStateHash = String(d.prev_state_hash ?? '').trim();
|
|
2670
|
-
const keyEpoch = Number(d.key_epoch ?? 0);
|
|
2671
|
-
const membershipSnapshot = String(d.membership_snapshot ?? '').trim();
|
|
2672
|
-
const policySnapshot = String(d.policy_snapshot ?? '').trim();
|
|
2673
|
-
// 1. 验证 prev_state_hash 连续性
|
|
2674
|
-
const loadFn = this._tokenStore.loadGroupState;
|
|
2675
|
-
const localState = loadFn ? loadFn.call(this._tokenStore, groupId) : null;
|
|
2676
|
-
if (localState && localState.state_hash && localState.state_hash !== prevStateHash) {
|
|
2677
|
-
this._clientLog.warn(`state_hash chain discontinuous group=${groupId} local_sv=${localState.state_version} event_sv=${stateVersion}`);
|
|
2678
|
-
// 回源同步
|
|
2679
|
-
try {
|
|
2680
|
-
const serverState = await this._transport.call('group.get_state', { group_id: groupId });
|
|
2681
|
-
if (serverState && isJsonObject(serverState) && 'state_version' in serverState) {
|
|
2682
|
-
const sv = Number(serverState.state_version ?? 0);
|
|
2683
|
-
const sHash = String(serverState.state_hash ?? '');
|
|
2684
|
-
const sEpoch = Number(serverState.key_epoch ?? 0);
|
|
2685
|
-
const sMembersJson = String(serverState.membership_snapshot ?? '');
|
|
2686
|
-
const sPolicyJson = String(serverState.policy_snapshot ?? '');
|
|
2687
|
-
const sPrev = String(serverState.prev_state_hash ?? '');
|
|
2688
|
-
// 回源也做 hash 验证
|
|
2689
|
-
if (sMembersJson && sHash) {
|
|
2690
|
-
const sMembers = sMembersJson ? JSON.parse(sMembersJson) : [];
|
|
2691
|
-
const sPolicy = sPolicyJson ? JSON.parse(sPolicyJson) : {};
|
|
2692
|
-
const computed = computeStateHash({
|
|
2693
|
-
groupId, stateVersion: sv, keyEpoch: sEpoch,
|
|
2694
|
-
members: sMembers, policy: sPolicy, prevStateHash: sPrev,
|
|
2695
|
-
});
|
|
2696
|
-
if (computed !== sHash) {
|
|
2697
|
-
this._clientLog.warn(`backfill state_hash verification failed group=${groupId} sv=${sv} expected=${sHash} got=${computed}`);
|
|
2698
|
-
return;
|
|
2699
|
-
}
|
|
2700
|
-
}
|
|
2701
|
-
const saveFn = this._tokenStore.saveGroupState;
|
|
2702
|
-
if (saveFn) {
|
|
2703
|
-
saveFn.call(this._tokenStore, groupId, sv, sHash, sEpoch, sMembersJson || membershipSnapshot, sPolicyJson || policySnapshot);
|
|
2704
|
-
}
|
|
2705
|
-
}
|
|
2706
|
-
}
|
|
2707
|
-
catch (exc) {
|
|
2708
|
-
this._clientLog.warn(`state backfill failed group=${groupId}: ${formatCaughtError(exc)}`);
|
|
2709
|
-
}
|
|
2710
|
-
return;
|
|
2711
|
-
}
|
|
2712
|
-
// 2. 本地重算验证
|
|
2713
|
-
const members = membershipSnapshot ? JSON.parse(membershipSnapshot) : [];
|
|
2714
|
-
const policy = policySnapshot ? JSON.parse(policySnapshot) : {};
|
|
2715
|
-
const computed = computeStateHash({
|
|
2716
|
-
groupId, stateVersion, keyEpoch,
|
|
2717
|
-
members, policy, prevStateHash,
|
|
2718
|
-
});
|
|
2719
|
-
if (computed !== stateHash) {
|
|
2720
|
-
this._clientLog.warn(`state_hash recompute mismatch group=${groupId} sv=${stateVersion} expected=${stateHash} got=${computed}`);
|
|
2721
|
-
return;
|
|
2722
|
-
}
|
|
2723
|
-
// 3. 更新本地存储
|
|
2724
|
-
const saveFn = this._tokenStore.saveGroupState;
|
|
2725
|
-
if (saveFn) {
|
|
2726
|
-
saveFn.call(this._tokenStore, groupId, stateVersion, stateHash, keyEpoch, membershipSnapshot, policySnapshot);
|
|
2727
|
-
}
|
|
2728
|
-
this._clientLog.debug(`_onGroupStateCommitted exit: elapsed=${Date.now() - tStart}ms group=${groupId}`);
|
|
2729
|
-
}
|
|
2730
|
-
catch (err) {
|
|
2731
|
-
this._clientLog.debug(`_onGroupStateCommitted exit (error): elapsed=${Date.now() - tStart}ms group=${groupId} err=${err instanceof Error ? err.message : String(err)}`);
|
|
2732
|
-
throw err;
|
|
2733
|
-
}
|
|
1458
|
+
return this._groupState.onGroupStateCommitted(data);
|
|
2734
1459
|
}
|
|
2735
1460
|
/** 群组解散后清理本地 V2 缓存、seq_tracker 和补洞去重缓存。 */
|
|
2736
1461
|
_cleanupDissolvedGroup(groupId) {
|
|
2737
|
-
this.
|
|
1462
|
+
this._v2E2EE.deleteBootstrapCacheEntry(`group:${groupId}`);
|
|
2738
1463
|
this._v2GroupSecurityLevels.delete(groupId);
|
|
2739
1464
|
this._v2StateChains.delete(groupId);
|
|
2740
1465
|
this._seqTracker.removeNamespace(`group:${groupId}`);
|
|
@@ -2781,8 +1506,7 @@ export class AUNClient {
|
|
|
2781
1506
|
}
|
|
2782
1507
|
const certObj = new crypto.X509Certificate(certPem);
|
|
2783
1508
|
if (expectedFP) {
|
|
2784
|
-
|
|
2785
|
-
if (actualFP !== expectedFP) {
|
|
1509
|
+
if (!certMatchesFingerprint(certPem, expectedFP)) {
|
|
2786
1510
|
this._clientLog.warn(`signature verification failed: cert fingerprint mismatch aid=${sigAid}`);
|
|
2787
1511
|
return false;
|
|
2788
1512
|
}
|
|
@@ -2826,23 +1550,13 @@ export class AUNClient {
|
|
|
2826
1550
|
}
|
|
2827
1551
|
const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
2828
1552
|
const x509Cert = new crypto.X509Certificate(certPem);
|
|
2829
|
-
// H7: 严格校验指纹(DER
|
|
1553
|
+
// H7: 严格校验指纹(DER/SPKI,64 位或 16 位短格式任一匹配即可)
|
|
2830
1554
|
if (certFingerprint) {
|
|
2831
1555
|
const expectedFP = certFingerprint.toLowerCase();
|
|
2832
|
-
if (!expectedFP
|
|
1556
|
+
if (!normalizeFingerprintHex(expectedFP)) {
|
|
2833
1557
|
throw new ValidationError(`unsupported cert_fingerprint format for ${aid}: ${expectedFP.slice(0, 24)}`);
|
|
2834
1558
|
}
|
|
2835
|
-
|
|
2836
|
-
const derHex = x509Cert.fingerprint256.replace(/:/g, '').toLowerCase();
|
|
2837
|
-
let spkiHex = '';
|
|
2838
|
-
try {
|
|
2839
|
-
const spkiDer = x509Cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
2840
|
-
spkiHex = crypto.createHash('sha256').update(spkiDer).digest('hex');
|
|
2841
|
-
}
|
|
2842
|
-
catch {
|
|
2843
|
-
spkiHex = '';
|
|
2844
|
-
}
|
|
2845
|
-
if (expectedHex !== derHex && (!spkiHex || expectedHex !== spkiHex)) {
|
|
1559
|
+
if (!certMatchesFingerprint(certPem, expectedFP)) {
|
|
2846
1560
|
throw new ValidationError(`peer cert fingerprint mismatch for ${aid}: expected=${expectedFP.slice(0, 24)}...`);
|
|
2847
1561
|
}
|
|
2848
1562
|
}
|
|
@@ -2874,10 +1588,9 @@ export class AUNClient {
|
|
|
2874
1588
|
};
|
|
2875
1589
|
const cacheKey = AUNClient._certCacheKey(aid, certFingerprint);
|
|
2876
1590
|
this._certCache.set(cacheKey, entry);
|
|
2877
|
-
const bareKey = AUNClient._certCacheKey(aid);
|
|
2878
|
-
if (bareKey !== cacheKey)
|
|
2879
|
-
this._certCache.set(bareKey, entry);
|
|
2880
1591
|
if (!certFingerprint) {
|
|
1592
|
+
const bareKey = AUNClient._certCacheKey(aid);
|
|
1593
|
+
this._certCache.set(bareKey, entry);
|
|
2881
1594
|
const actualFp = `sha256:${x509Cert.fingerprint256.replace(/:/g, '').toLowerCase()}`;
|
|
2882
1595
|
this._certCache.set(AUNClient._certCacheKey(aid, actualFp), entry);
|
|
2883
1596
|
}
|
|
@@ -2907,15 +1620,7 @@ export class AUNClient {
|
|
|
2907
1620
|
throw new ValidationError('gateway url unavailable for e2ee cert fetch');
|
|
2908
1621
|
}
|
|
2909
1622
|
const peerGatewayUrl = AUNClient._resolvePeerGatewayUrl(gatewayUrl, aid);
|
|
2910
|
-
|
|
2911
|
-
try {
|
|
2912
|
-
certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid, certFingerprint), this._configModel.verifySsl, timeoutMs);
|
|
2913
|
-
}
|
|
2914
|
-
catch (exc) {
|
|
2915
|
-
if (!certFingerprint)
|
|
2916
|
-
throw exc;
|
|
2917
|
-
certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid), this._configModel.verifySsl, timeoutMs);
|
|
2918
|
-
}
|
|
1623
|
+
const certPem = await _httpGetText(AUNClient._buildCertUrl(peerGatewayUrl, aid, certFingerprint), this._configModel.verifySsl, timeoutMs);
|
|
2919
1624
|
const validated = await this._validateAndCachePeerCert({
|
|
2920
1625
|
aid,
|
|
2921
1626
|
certPem,
|
|
@@ -3010,234 +1715,14 @@ export class AUNClient {
|
|
|
3010
1715
|
}
|
|
3011
1716
|
}
|
|
3012
1717
|
async _decryptGroupThoughts(result) {
|
|
3013
|
-
|
|
3014
|
-
if (!result.found) {
|
|
3015
|
-
this._clientLog.debug('group.thought.get decrypt exit: not found');
|
|
3016
|
-
return { ...result, thoughts: [] };
|
|
3017
|
-
}
|
|
3018
|
-
const items = Array.isArray(result.thoughts) ? result.thoughts.filter(isJsonObject) : [];
|
|
3019
|
-
if (items.length === 0) {
|
|
3020
|
-
this._clientLog.debug('group.thought.get decrypt exit: empty thoughts');
|
|
3021
|
-
return { ...result, thoughts: [] };
|
|
3022
|
-
}
|
|
3023
|
-
const groupId = String(result.group_id ?? '');
|
|
3024
|
-
const senderAid = String(result.sender_aid ?? '');
|
|
3025
|
-
const thoughts = [];
|
|
3026
|
-
for (const item of items) {
|
|
3027
|
-
const payload = isJsonObject(item.payload) ? item.payload : null;
|
|
3028
|
-
const thoughtId = String(item.thought_id ?? item.message_id ?? '');
|
|
3029
|
-
const fromAid = String(item.from ?? item.sender_aid ?? senderAid);
|
|
3030
|
-
this._logMessageDebug('thought-get-raw', 'group.thought.get', 'group.thought.get', item, {
|
|
3031
|
-
extra: { group_id: groupId, thought_id: thoughtId, from: fromAid },
|
|
3032
|
-
});
|
|
3033
|
-
let decryptFailed = false;
|
|
3034
|
-
let decryptedPayload = payload ?? {};
|
|
3035
|
-
let e2ee;
|
|
3036
|
-
if (payload?.type === 'e2ee.group_encrypted' && String(payload.version ?? '') === 'v2') {
|
|
3037
|
-
e2ee = this._v2E2eeMeta(payload);
|
|
3038
|
-
const plain = await this._decryptV2EnvelopeForThought({ envelope: payload, fromAid });
|
|
3039
|
-
if (plain === null) {
|
|
3040
|
-
decryptFailed = true;
|
|
3041
|
-
this._clientLog.debug(`group.thought.get decrypt returned null: group=${groupId}, thought_id=${thoughtId}, from=${fromAid}`);
|
|
3042
|
-
}
|
|
3043
|
-
else {
|
|
3044
|
-
decryptedPayload = plain;
|
|
3045
|
-
this._logMessageDebug('thought-decrypt-ok', 'group.thought.get', 'group.thought.get', {
|
|
3046
|
-
group_id: groupId,
|
|
3047
|
-
thought_id: thoughtId,
|
|
3048
|
-
from: fromAid,
|
|
3049
|
-
payload: plain,
|
|
3050
|
-
});
|
|
3051
|
-
}
|
|
3052
|
-
}
|
|
3053
|
-
else if (payload?.type === 'e2ee.group_encrypted') {
|
|
3054
|
-
decryptFailed = true;
|
|
3055
|
-
this._clientLog.debug(`group.thought.get unsupported encrypted payload: group=${groupId}, thought_id=${thoughtId}, type=${String(payload.type ?? '')}, version=${String(payload.version ?? '')}`);
|
|
3056
|
-
}
|
|
3057
|
-
const thought = {
|
|
3058
|
-
thought_id: thoughtId,
|
|
3059
|
-
message_id: thoughtId,
|
|
3060
|
-
payload: decryptFailed ? (payload ?? {}) : decryptedPayload,
|
|
3061
|
-
created_at: item.created_at,
|
|
3062
|
-
};
|
|
3063
|
-
if (e2ee !== undefined) {
|
|
3064
|
-
thought.e2ee = e2ee;
|
|
3065
|
-
if (isJsonObject(e2ee))
|
|
3066
|
-
this._attachV2EnvelopeMetadata(thought, e2ee);
|
|
3067
|
-
}
|
|
3068
|
-
if (decryptFailed)
|
|
3069
|
-
thought.decrypt_failed = true;
|
|
3070
|
-
if ('context' in item)
|
|
3071
|
-
thought.context = item.context;
|
|
3072
|
-
this._logMessageDebug(decryptFailed ? 'thought-decrypt-fail' : 'thought-result', 'group.thought.get', 'group.thought.get', thought, {
|
|
3073
|
-
extra: { group_id: groupId, thought_id: thoughtId },
|
|
3074
|
-
});
|
|
3075
|
-
thoughts.push(thought);
|
|
3076
|
-
}
|
|
3077
|
-
this._clientLog.debug(`group.thought.get decrypt exit: group=${groupId}, total=${items.length}, returned=${thoughts.length}`);
|
|
3078
|
-
return { ...result, thoughts };
|
|
1718
|
+
return await this._v2E2EE.decryptGroupThoughts(result);
|
|
3079
1719
|
}
|
|
3080
1720
|
async _decryptMessageThoughts(result) {
|
|
3081
|
-
|
|
3082
|
-
if (!result.found) {
|
|
3083
|
-
this._clientLog.debug('message.thought.get decrypt exit: not found');
|
|
3084
|
-
return { ...result, thoughts: [] };
|
|
3085
|
-
}
|
|
3086
|
-
const items = Array.isArray(result.thoughts) ? result.thoughts.filter(isJsonObject) : [];
|
|
3087
|
-
if (items.length === 0) {
|
|
3088
|
-
this._clientLog.debug('message.thought.get decrypt exit: empty thoughts');
|
|
3089
|
-
return { ...result, thoughts: [] };
|
|
3090
|
-
}
|
|
3091
|
-
const senderAid = String(result.sender_aid ?? '');
|
|
3092
|
-
const peerAid = String(result.peer_aid ?? '');
|
|
3093
|
-
const thoughts = [];
|
|
3094
|
-
for (const item of items) {
|
|
3095
|
-
const payload = isJsonObject(item.payload) ? item.payload : null;
|
|
3096
|
-
const thoughtId = String(item.thought_id ?? item.message_id ?? '');
|
|
3097
|
-
const fromAid = String(item.from ?? senderAid);
|
|
3098
|
-
const toAid = String(item.to ?? peerAid);
|
|
3099
|
-
this._logMessageDebug('thought-get-raw', 'message.thought.get', 'message.thought.get', item, {
|
|
3100
|
-
extra: { thought_id: thoughtId, from: fromAid, to: toAid },
|
|
3101
|
-
});
|
|
3102
|
-
let decryptFailed = false;
|
|
3103
|
-
let decryptedPayload = payload ?? {};
|
|
3104
|
-
let e2ee;
|
|
3105
|
-
if (payload?.type === 'e2ee.p2p_encrypted' && String(payload.version ?? '') === 'v2') {
|
|
3106
|
-
e2ee = this._v2E2eeMeta(payload);
|
|
3107
|
-
const plain = await this._decryptV2EnvelopeForThought({ envelope: payload, fromAid });
|
|
3108
|
-
if (plain === null) {
|
|
3109
|
-
decryptFailed = true;
|
|
3110
|
-
this._clientLog.debug(`message.thought.get decrypt returned null: thought_id=${thoughtId}, from=${fromAid}, to=${toAid}`);
|
|
3111
|
-
}
|
|
3112
|
-
else {
|
|
3113
|
-
decryptedPayload = plain;
|
|
3114
|
-
this._logMessageDebug('thought-decrypt-ok', 'message.thought.get', 'message.thought.get', {
|
|
3115
|
-
thought_id: thoughtId,
|
|
3116
|
-
from: fromAid,
|
|
3117
|
-
to: toAid,
|
|
3118
|
-
payload: plain,
|
|
3119
|
-
});
|
|
3120
|
-
}
|
|
3121
|
-
}
|
|
3122
|
-
else if (payload?.type === 'e2ee.encrypted' || payload?.type === 'e2ee.p2p_encrypted') {
|
|
3123
|
-
decryptFailed = true;
|
|
3124
|
-
this._clientLog.debug(`message.thought.get unsupported encrypted payload: thought_id=${thoughtId}, type=${String(payload.type ?? '')}, version=${String(payload.version ?? '')}`);
|
|
3125
|
-
}
|
|
3126
|
-
const thought = {
|
|
3127
|
-
thought_id: thoughtId,
|
|
3128
|
-
message_id: thoughtId,
|
|
3129
|
-
from: fromAid,
|
|
3130
|
-
to: toAid,
|
|
3131
|
-
payload: decryptFailed ? (payload ?? {}) : decryptedPayload,
|
|
3132
|
-
created_at: item.created_at,
|
|
3133
|
-
};
|
|
3134
|
-
if (e2ee !== undefined) {
|
|
3135
|
-
thought.e2ee = e2ee;
|
|
3136
|
-
if (isJsonObject(e2ee))
|
|
3137
|
-
this._attachV2EnvelopeMetadata(thought, e2ee);
|
|
3138
|
-
}
|
|
3139
|
-
if (decryptFailed)
|
|
3140
|
-
thought.decrypt_failed = true;
|
|
3141
|
-
if ('context' in item)
|
|
3142
|
-
thought.context = item.context;
|
|
3143
|
-
this._logMessageDebug(decryptFailed ? 'thought-decrypt-fail' : 'thought-result', 'message.thought.get', 'message.thought.get', thought, {
|
|
3144
|
-
extra: { thought_id: thoughtId },
|
|
3145
|
-
});
|
|
3146
|
-
thoughts.push(thought);
|
|
3147
|
-
}
|
|
3148
|
-
this._clientLog.debug(`message.thought.get decrypt exit: total=${items.length}, returned=${thoughts.length}`);
|
|
3149
|
-
return { ...result, thoughts };
|
|
1721
|
+
return await this._v2E2EE.decryptMessageThoughts(result);
|
|
3150
1722
|
}
|
|
3151
|
-
/** 从 keystore 恢复 SeqTracker 状态 */
|
|
1723
|
+
/** 从 keystore 恢复 SeqTracker 状态 */
|
|
3152
1724
|
_restoreSeqTrackerState() {
|
|
3153
|
-
|
|
3154
|
-
return;
|
|
3155
|
-
try {
|
|
3156
|
-
// 优先从 seq_tracker 表按行读取
|
|
3157
|
-
const loadAll = this._tokenStore.loadAllSeqs;
|
|
3158
|
-
if (typeof loadAll === 'function') {
|
|
3159
|
-
let state = loadAll.call(this._tokenStore, this._aid, this._deviceId, this._slotId);
|
|
3160
|
-
if (state && Object.keys(state).length > 0) {
|
|
3161
|
-
state = this._migrateSeqStateGroupIds(state);
|
|
3162
|
-
this._seqTracker.restoreState(state);
|
|
3163
|
-
return;
|
|
3164
|
-
}
|
|
3165
|
-
}
|
|
3166
|
-
// fallback: 从旧 instance_state JSON blob 恢复
|
|
3167
|
-
const loader = this._tokenStore.loadInstanceState;
|
|
3168
|
-
if (typeof loader === 'function') {
|
|
3169
|
-
const instanceState = loader.call(this._tokenStore, this._aid, this._deviceId, this._slotId);
|
|
3170
|
-
if (instanceState && typeof instanceState.seq_tracker_state === 'object') {
|
|
3171
|
-
let state = instanceState.seq_tracker_state;
|
|
3172
|
-
state = this._migrateSeqStateGroupIds(state);
|
|
3173
|
-
this._seqTracker.restoreState(state);
|
|
3174
|
-
}
|
|
3175
|
-
}
|
|
3176
|
-
}
|
|
3177
|
-
catch (exc) {
|
|
3178
|
-
this._clientLog.warn(`restore SeqTracker state failed: ${formatCaughtError(exc)}`);
|
|
3179
|
-
// 通过内部 dispatcher 发布可观测事件,便于上层监控
|
|
3180
|
-
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
3181
|
-
phase: 'restore',
|
|
3182
|
-
aid: this._aid,
|
|
3183
|
-
device_id: this._deviceId,
|
|
3184
|
-
slot_id: this._slotId,
|
|
3185
|
-
error: String(formatCaughtError(exc)),
|
|
3186
|
-
}).catch(() => { });
|
|
3187
|
-
}
|
|
3188
|
-
}
|
|
3189
|
-
/**
|
|
3190
|
-
* 把 seq_tracker state 里 group_event:/group_msg: 前缀的老/污染 group_id 归一化为 canonical。
|
|
3191
|
-
* 冲突取 max。同时落盘删除老 ns、写入新 ns,避免下次启动重复迁移。
|
|
3192
|
-
*/
|
|
3193
|
-
_migrateSeqStateGroupIds(state) {
|
|
3194
|
-
if (!state || Object.keys(state).length === 0)
|
|
3195
|
-
return state;
|
|
3196
|
-
const renameMap = {};
|
|
3197
|
-
for (const ns of Object.keys(state)) {
|
|
3198
|
-
for (const prefix of ['group_event:', 'group_msg:']) {
|
|
3199
|
-
if (ns.startsWith(prefix)) {
|
|
3200
|
-
const oldGid = ns.slice(prefix.length);
|
|
3201
|
-
const newGid = normalizeGroupId(oldGid);
|
|
3202
|
-
if (newGid && newGid !== oldGid) {
|
|
3203
|
-
renameMap[ns] = `${prefix}${newGid}`;
|
|
3204
|
-
}
|
|
3205
|
-
break;
|
|
3206
|
-
}
|
|
3207
|
-
}
|
|
3208
|
-
}
|
|
3209
|
-
if (Object.keys(renameMap).length === 0)
|
|
3210
|
-
return state;
|
|
3211
|
-
const newState = { ...state };
|
|
3212
|
-
for (const [oldNs, newNs] of Object.entries(renameMap)) {
|
|
3213
|
-
const oldVal = Number(newState[oldNs] ?? 0);
|
|
3214
|
-
const curVal = Number(newState[newNs] ?? 0);
|
|
3215
|
-
delete newState[oldNs];
|
|
3216
|
-
newState[newNs] = Math.max(oldVal, curVal);
|
|
3217
|
-
}
|
|
3218
|
-
this._clientLog.info(`SeqTracker group_id migration: ${Object.keys(renameMap).length} namespaces rewritten`);
|
|
3219
|
-
// 落盘
|
|
3220
|
-
const saver = this._tokenStore.saveSeq;
|
|
3221
|
-
const deleter = this._tokenStore.deleteSeq;
|
|
3222
|
-
if (typeof saver === 'function' && this._aid) {
|
|
3223
|
-
for (const [oldNs, newNs] of Object.entries(renameMap)) {
|
|
3224
|
-
if (typeof deleter === 'function') {
|
|
3225
|
-
try {
|
|
3226
|
-
deleter.call(this._tokenStore, this._aid, this._deviceId, this._slotId, oldNs);
|
|
3227
|
-
}
|
|
3228
|
-
catch (e) {
|
|
3229
|
-
this._clientLog.debug(`delete old seq ns failed: ns=${oldNs} err=${formatCaughtError(e)}`);
|
|
3230
|
-
}
|
|
3231
|
-
}
|
|
3232
|
-
try {
|
|
3233
|
-
saver.call(this._tokenStore, this._aid, this._deviceId, this._slotId, newNs, newState[newNs]);
|
|
3234
|
-
}
|
|
3235
|
-
catch (e) {
|
|
3236
|
-
this._clientLog.debug(`write new seq ns failed: ns=${newNs} err=${formatCaughtError(e)}`);
|
|
3237
|
-
}
|
|
3238
|
-
}
|
|
3239
|
-
}
|
|
3240
|
-
return newState;
|
|
1725
|
+
return this._delivery.restoreSeqTrackerState();
|
|
3241
1726
|
}
|
|
3242
1727
|
_currentSeqTrackerContext() {
|
|
3243
1728
|
if (!this._aid)
|
|
@@ -3245,6 +1730,7 @@ export class AUNClient {
|
|
|
3245
1730
|
return JSON.stringify([this._aid, this._deviceId, this._slotId]);
|
|
3246
1731
|
}
|
|
3247
1732
|
_resetSeqTrackingState() {
|
|
1733
|
+
this._resetV2IdentityRuntime();
|
|
3248
1734
|
this._seqTracker = new SeqTracker();
|
|
3249
1735
|
this._seqTrackerContext = null;
|
|
3250
1736
|
this._gapFillDone.clear();
|
|
@@ -3254,6 +1740,12 @@ export class AUNClient {
|
|
|
3254
1740
|
this._v2SenderIKPending.clear();
|
|
3255
1741
|
this._v2SenderIKFetching.clear();
|
|
3256
1742
|
this._groupSynced.clear();
|
|
1743
|
+
this._onlineUnreadHintQueue.clear();
|
|
1744
|
+
if (this._onlineUnreadHintTimer) {
|
|
1745
|
+
clearTimeout(this._onlineUnreadHintTimer);
|
|
1746
|
+
this._onlineUnreadHintTimer = null;
|
|
1747
|
+
}
|
|
1748
|
+
this._onlineUnreadHintDrainActive = false;
|
|
3257
1749
|
}
|
|
3258
1750
|
_refreshSeqTrackerContext() {
|
|
3259
1751
|
const nextContext = this._currentSeqTrackerContext();
|
|
@@ -3267,66 +1759,26 @@ export class AUNClient {
|
|
|
3267
1759
|
this._v2SenderIKPending.clear();
|
|
3268
1760
|
this._v2SenderIKFetching.clear();
|
|
3269
1761
|
this._groupSynced.clear();
|
|
1762
|
+
this._onlineUnreadHintQueue.clear();
|
|
1763
|
+
if (this._onlineUnreadHintTimer) {
|
|
1764
|
+
clearTimeout(this._onlineUnreadHintTimer);
|
|
1765
|
+
this._onlineUnreadHintTimer = null;
|
|
1766
|
+
}
|
|
1767
|
+
this._onlineUnreadHintDrainActive = false;
|
|
3270
1768
|
this._seqTrackerContext = nextContext;
|
|
3271
1769
|
}
|
|
3272
1770
|
/** 将 SeqTracker 状态保存到 keystore */
|
|
3273
1771
|
_saveSeqTrackerState() {
|
|
3274
|
-
|
|
3275
|
-
return;
|
|
3276
|
-
const state = this._seqTracker.exportState();
|
|
3277
|
-
if (Object.keys(state).length === 0)
|
|
3278
|
-
return;
|
|
3279
|
-
try {
|
|
3280
|
-
// 优先按行写入 seq_tracker 表
|
|
3281
|
-
const saveFn = this._tokenStore.saveSeq;
|
|
3282
|
-
if (typeof saveFn === 'function') {
|
|
3283
|
-
for (const [ns, seq] of Object.entries(state)) {
|
|
3284
|
-
saveFn.call(this._tokenStore, this._aid, this._deviceId, this._slotId, ns, seq);
|
|
3285
|
-
}
|
|
3286
|
-
return;
|
|
3287
|
-
}
|
|
3288
|
-
// fallback: 旧版 updateInstanceState JSON blob
|
|
3289
|
-
const updater = this._tokenStore.updateInstanceState;
|
|
3290
|
-
if (typeof updater === 'function') {
|
|
3291
|
-
updater.call(this._tokenStore, this._aid, this._deviceId, this._slotId, (metadata) => {
|
|
3292
|
-
metadata.seq_tracker_state = state;
|
|
3293
|
-
return metadata;
|
|
3294
|
-
});
|
|
3295
|
-
}
|
|
3296
|
-
}
|
|
3297
|
-
catch (exc) {
|
|
3298
|
-
this._clientLog.warn(`save SeqTracker state failed: ${formatCaughtError(exc)}`);
|
|
3299
|
-
// 通过内部 dispatcher 发布可观测事件,便于上层监控
|
|
3300
|
-
this._dispatcher.publish('seq_tracker.persist_error', {
|
|
3301
|
-
phase: 'save',
|
|
3302
|
-
aid: this._aid,
|
|
3303
|
-
device_id: this._deviceId,
|
|
3304
|
-
slot_id: this._slotId,
|
|
3305
|
-
error: String(formatCaughtError(exc)),
|
|
3306
|
-
}).catch(() => { });
|
|
3307
|
-
}
|
|
1772
|
+
return this._delivery.saveSeqTrackerState();
|
|
3308
1773
|
}
|
|
3309
1774
|
_persistRepairedSeq(ns) {
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
3317
|
-
}
|
|
3318
|
-
const deleteSeq = this._tokenStore.deleteSeq;
|
|
3319
|
-
if (seq <= 0 && typeof deleteSeq === 'function') {
|
|
3320
|
-
deleteSeq.call(this._tokenStore, this._aid, this._deviceId, this._slotId, ns);
|
|
3321
|
-
return;
|
|
3322
|
-
}
|
|
3323
|
-
if (seq > 0) {
|
|
3324
|
-
this._saveSeqTrackerState();
|
|
3325
|
-
}
|
|
3326
|
-
}
|
|
3327
|
-
catch (exc) {
|
|
3328
|
-
this._clientLog.debug(`persist repaired seq failed: ns=${ns} err=${formatCaughtError(exc)}`);
|
|
3329
|
-
}
|
|
1775
|
+
return this._delivery.persistRepairedSeq(ns);
|
|
1776
|
+
}
|
|
1777
|
+
_clampAckSeq(method, field, ns, seq) {
|
|
1778
|
+
return this._delivery.clampAckSeq(method, field, ns, seq);
|
|
1779
|
+
}
|
|
1780
|
+
_clampAckParams(method, params) {
|
|
1781
|
+
return this._delivery.clampAckParams(method, params);
|
|
3330
1782
|
}
|
|
3331
1783
|
_repairPushContiguousBound(ns, pushSeq, hasPayload, label) {
|
|
3332
1784
|
if (!ns || !Number.isFinite(pushSeq) || pushSeq <= 0) {
|
|
@@ -3343,17 +1795,6 @@ export class AUNClient {
|
|
|
3343
1795
|
this._clientLog.warn(`${label} push repaired contiguous_seq: ns=${ns} payload=${hasPayload} push_seq=${pushSeq} contiguous=${contig}->${repaired}`);
|
|
3344
1796
|
return repaired;
|
|
3345
1797
|
}
|
|
3346
|
-
/** 记录 E2EE 自动编排错误 */
|
|
3347
|
-
_logE2eeError(stage, groupId, aid, exc) {
|
|
3348
|
-
try {
|
|
3349
|
-
this._dispatcher.publish('e2ee.orchestration_error', {
|
|
3350
|
-
stage, group_id: groupId, aid, error: String(exc),
|
|
3351
|
-
}).catch(() => { });
|
|
3352
|
-
}
|
|
3353
|
-
catch {
|
|
3354
|
-
// 日志本身不应阻断主流程
|
|
3355
|
-
}
|
|
3356
|
-
}
|
|
3357
1798
|
// ── URL 辅助 ──────────────────────────────────────────────
|
|
3358
1799
|
/** 跨域时将 Gateway URL 替换为 peer 所在域的 Gateway URL */
|
|
3359
1800
|
static _resolvePeerGatewayUrl(localGatewayUrl, peerAid) {
|
|
@@ -3462,23 +1903,17 @@ export class AUNClient {
|
|
|
3462
1903
|
this._startBackgroundTasks();
|
|
3463
1904
|
const connectionKind = String(params.connection_kind ?? 'long');
|
|
3464
1905
|
const isShortConnection = connectionKind === 'short';
|
|
1906
|
+
const hasExplicitBackgroundSync = Object.prototype.hasOwnProperty.call(params, 'background_sync');
|
|
1907
|
+
const backgroundSyncEnabled = this._sessionOptions.background_sync !== false
|
|
1908
|
+
&& (!isShortConnection || hasExplicitBackgroundSync);
|
|
3465
1909
|
if (!isShortConnection) {
|
|
3466
|
-
|
|
3467
|
-
try {
|
|
3468
|
-
await this._initV2Session();
|
|
3469
|
-
}
|
|
3470
|
-
catch (exc) {
|
|
3471
|
-
this._clientLog.warn(`V2 session init failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
3472
|
-
}
|
|
1910
|
+
await this._v2E2EE.onConnected({ backgroundSync: backgroundSyncEnabled });
|
|
3473
1911
|
}
|
|
3474
1912
|
else {
|
|
3475
1913
|
this._clientLog.debug('V2 session init deferred for short connection');
|
|
3476
1914
|
}
|
|
3477
1915
|
// connect/reconnect 成功后自动触发一次 P2P message.v2.pull,补齐离线期间积压
|
|
3478
1916
|
// 群消息按惰性触发,不在此处主动 pull
|
|
3479
|
-
const hasExplicitBackgroundSync = Object.prototype.hasOwnProperty.call(params, 'background_sync');
|
|
3480
|
-
const backgroundSyncEnabled = this._sessionOptions.background_sync !== false
|
|
3481
|
-
&& (!isShortConnection || hasExplicitBackgroundSync);
|
|
3482
1917
|
if (backgroundSyncEnabled) {
|
|
3483
1918
|
void this._fillP2pGap().catch((exc) => {
|
|
3484
1919
|
this._clientLog.warn(`schedule post-connect P2P gap fill failed: ${formatCaughtError(exc)}`);
|
|
@@ -3492,23 +1927,9 @@ export class AUNClient {
|
|
|
3492
1927
|
throw err;
|
|
3493
1928
|
}
|
|
3494
1929
|
}
|
|
3495
|
-
/**
|
|
1930
|
+
/** 兼容旧调用点;缺失时按 SDK 默认能力(V2)处理。 */
|
|
3496
1931
|
_captureCapabilitiesFromConnect(params) {
|
|
3497
1932
|
void params;
|
|
3498
|
-
this._connectCapabilities = {
|
|
3499
|
-
e2ee: true,
|
|
3500
|
-
group_e2ee: true,
|
|
3501
|
-
supported_p2p_e2ee: ['e2ee_v2'],
|
|
3502
|
-
supported_group_e2ee: ['group_e2ee_v2'],
|
|
3503
|
-
};
|
|
3504
|
-
}
|
|
3505
|
-
/** 当前连接是否按 V2 P2P E2EE 处理;未声明 capabilities 时视同支持 V2。 */
|
|
3506
|
-
_clientUsesV2P2P() {
|
|
3507
|
-
return true;
|
|
3508
|
-
}
|
|
3509
|
-
/** 当前连接是否按 V2 Group E2EE 处理;未声明 capabilities 时视同支持 V2。 */
|
|
3510
|
-
_clientUsesV2Group() {
|
|
3511
|
-
return true;
|
|
3512
1933
|
}
|
|
3513
1934
|
/** 后台 Promise 统一兜底,避免事件回调里的异步异常变成未处理拒绝。 */
|
|
3514
1935
|
_safeAsync(promise) {
|
|
@@ -3518,7 +1939,7 @@ export class AUNClient {
|
|
|
3518
1939
|
}
|
|
3519
1940
|
/** V2-only:所有加密入口都必须有 V2 session。 */
|
|
3520
1941
|
async _ensureV2SessionReady(method, errorMessage) {
|
|
3521
|
-
if (!this.
|
|
1942
|
+
if (!this._v2SessionMatchesIdentity()) {
|
|
3522
1943
|
if (!this._v2SessionInitInFlight) {
|
|
3523
1944
|
this._v2SessionInitInFlight = this._initV2Session()
|
|
3524
1945
|
.finally(() => {
|
|
@@ -3527,7 +1948,7 @@ export class AUNClient {
|
|
|
3527
1948
|
}
|
|
3528
1949
|
await this._v2SessionInitInFlight;
|
|
3529
1950
|
}
|
|
3530
|
-
if (!this.
|
|
1951
|
+
if (!this._v2SessionMatchesIdentity()) {
|
|
3531
1952
|
throw new StateError(errorMessage ?? `V2 session not initialized; encrypted ${method} requires E2EE V2`);
|
|
3532
1953
|
}
|
|
3533
1954
|
}
|
|
@@ -3539,62 +1960,7 @@ export class AUNClient {
|
|
|
3539
1960
|
* connect 成功后会自动调用;重复调用幂等。
|
|
3540
1961
|
*/
|
|
3541
1962
|
async _initV2Session() {
|
|
3542
|
-
|
|
3543
|
-
return;
|
|
3544
|
-
const existing = this._v2Session;
|
|
3545
|
-
if (existing && existing.aid === this._aid && existing.deviceId === this._deviceId) {
|
|
3546
|
-
return;
|
|
3547
|
-
}
|
|
3548
|
-
if (existing) {
|
|
3549
|
-
this._v2BootstrapCache.clear();
|
|
3550
|
-
}
|
|
3551
|
-
let identity = this._identity;
|
|
3552
|
-
// 私钥来自当前 AID 值对象,AUNClient 不从持久化存储读取私钥。
|
|
3553
|
-
const currentAid = this._currentAid;
|
|
3554
|
-
if (!currentAid?.privateKeyPem) {
|
|
3555
|
-
this._clientLog.warn('V2 session init skipped: no AID private key');
|
|
3556
|
-
return;
|
|
3557
|
-
}
|
|
3558
|
-
const privateKey = crypto.createPrivateKey(currentAid.privateKeyPem);
|
|
3559
|
-
const jwk = privateKey.export({ format: 'jwk' });
|
|
3560
|
-
if (jwk.kty !== 'EC' || jwk.crv !== 'P-256' || !jwk.d) {
|
|
3561
|
-
throw new StateError('AID private key must be EC P-256');
|
|
3562
|
-
}
|
|
3563
|
-
const aidPriv = _v2LeftPad32(_v2B64uToBytes(jwk.d));
|
|
3564
|
-
const pubDer = crypto.createPublicKey(privateKey).export({ format: 'der', type: 'spki' });
|
|
3565
|
-
const aidPubDer = new Uint8Array(pubDer);
|
|
3566
|
-
const storeProvider = this._tokenStore;
|
|
3567
|
-
const v2Store = storeProvider.getV2KeyStore?.call(this._tokenStore, this._aid);
|
|
3568
|
-
if (!v2Store) {
|
|
3569
|
-
throw new StateError('V2 key store is unavailable for current keystore');
|
|
3570
|
-
}
|
|
3571
|
-
this._v2KeyStore = v2Store;
|
|
3572
|
-
this._v2Session = new V2Session(v2Store, this._deviceId, this._aid, aidPriv, aidPubDer);
|
|
3573
|
-
await this._v2Session.ensureRegistered(this._v2CallFn());
|
|
3574
|
-
this._clientLog.debug(`V2 session initialized aid=${this._aid} device=${this._deviceId}`);
|
|
3575
|
-
// 群 state proposal 由服务端在 client.online 时定向通知。
|
|
3576
|
-
}
|
|
3577
|
-
_currentV2KeyStore() {
|
|
3578
|
-
if (this._v2KeyStore)
|
|
3579
|
-
return this._v2KeyStore;
|
|
3580
|
-
if (!this._aid)
|
|
3581
|
-
throw new StateError('V2 key store requires a loaded AID');
|
|
3582
|
-
const storeProvider = this._tokenStore;
|
|
3583
|
-
const v2Store = storeProvider.getV2KeyStore?.call(this._tokenStore, this._aid);
|
|
3584
|
-
if (!v2Store) {
|
|
3585
|
-
throw new StateError('V2 key store is unavailable for current identity');
|
|
3586
|
-
}
|
|
3587
|
-
this._v2KeyStore = v2Store;
|
|
3588
|
-
return v2Store;
|
|
3589
|
-
}
|
|
3590
|
-
_saveGroupIdentityToV2(groupAid, identity) {
|
|
3591
|
-
const privateKeyPem = String(identity.private_key_pem ?? '').trim();
|
|
3592
|
-
const publicKeyDerB64 = String(identity.public_key_der_b64 ?? '').trim();
|
|
3593
|
-
if (!groupAid || !privateKeyPem || !publicKeyDerB64) {
|
|
3594
|
-
throw new StateError('group identity is incomplete');
|
|
3595
|
-
}
|
|
3596
|
-
const pubDer = new Uint8Array(Buffer.from(publicKeyDerB64, 'base64'));
|
|
3597
|
-
this._currentV2KeyStore().saveGroupIdentity(this._deviceId, groupAid, privateKeyPem, pubDer);
|
|
1963
|
+
return this._v2E2EE.initV2Session();
|
|
3598
1964
|
}
|
|
3599
1965
|
async _v2TrustedIKPubDer(aid) {
|
|
3600
1966
|
const normalizedAid = String(aid ?? '').trim();
|
|
@@ -3697,35 +2063,8 @@ export class AUNClient {
|
|
|
3697
2063
|
spkId: String(args.dev.spk_id ?? '').trim(),
|
|
3698
2064
|
};
|
|
3699
2065
|
}
|
|
3700
|
-
async _getV2SenderPubDer(fromAid, senderDeviceId) {
|
|
3701
|
-
|
|
3702
|
-
if (!session || !fromAid)
|
|
3703
|
-
return null;
|
|
3704
|
-
const senderPubDer = session.getPeerIK(fromAid, senderDeviceId);
|
|
3705
|
-
if (senderPubDer)
|
|
3706
|
-
return senderPubDer;
|
|
3707
|
-
try {
|
|
3708
|
-
const certPem = await this._fetchPeerCert(fromAid, undefined, 3000);
|
|
3709
|
-
const cert = new crypto.X509Certificate(certPem);
|
|
3710
|
-
const certPubDer = cert.publicKey.export({ type: 'spki', format: 'der' });
|
|
3711
|
-
const certPub = new Uint8Array(certPubDer);
|
|
3712
|
-
session.cachePeerIK(fromAid, senderDeviceId, certPub);
|
|
3713
|
-
this._clientLog.debug(`V2 decrypt: sender IK fallback from PKI cert for ${fromAid}`);
|
|
3714
|
-
return certPub;
|
|
3715
|
-
}
|
|
3716
|
-
catch (exc) {
|
|
3717
|
-
this._clientLog.warn(`V2 decrypt: PKI cert sender IK fallback failed for ${fromAid}: ${formatCaughtError(exc)}`);
|
|
3718
|
-
return null;
|
|
3719
|
-
}
|
|
3720
|
-
}
|
|
3721
|
-
_v2PendingSenderIKMessageKey(msg, groupId) {
|
|
3722
|
-
const messageId = String(msg.message_id ?? '').trim();
|
|
3723
|
-
const seq = String(msg.seq ?? '').trim();
|
|
3724
|
-
const prefix = groupId ? `group:${groupId}` : `p2p:${this._aid ?? ''}`;
|
|
3725
|
-
return `${prefix}:${messageId || seq || Math.random().toString(36).slice(2)}`;
|
|
3726
|
-
}
|
|
3727
|
-
_v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId) {
|
|
3728
|
-
return `${fromAid}#${senderDeviceId}#${groupId || ''}`;
|
|
2066
|
+
async _getV2SenderPubDer(fromAid, senderDeviceId, certFingerprint) {
|
|
2067
|
+
return await this._v2E2EE.getV2SenderPubDer(fromAid, senderDeviceId, certFingerprint);
|
|
3729
2068
|
}
|
|
3730
2069
|
_cacheV2PeerIKFromDevice(dev, fallbackAid = '') {
|
|
3731
2070
|
const session = this._v2Session;
|
|
@@ -3745,794 +2084,40 @@ export class AUNClient {
|
|
|
3745
2084
|
}
|
|
3746
2085
|
}
|
|
3747
2086
|
_scheduleV2SenderIKPending(args) {
|
|
3748
|
-
|
|
3749
|
-
if (!fromAid)
|
|
3750
|
-
return;
|
|
3751
|
-
const senderDeviceId = String(args.senderDeviceId ?? '');
|
|
3752
|
-
const groupId = String(args.groupId ?? '').trim();
|
|
3753
|
-
const messageKey = this._v2PendingSenderIKMessageKey(args.msg, groupId);
|
|
3754
|
-
this._v2SenderIKPending.set(messageKey, {
|
|
3755
|
-
msg: { ...args.msg },
|
|
3756
|
-
fromAid,
|
|
3757
|
-
senderDeviceId,
|
|
3758
|
-
groupId,
|
|
3759
|
-
createdAt: Date.now(),
|
|
3760
|
-
});
|
|
3761
|
-
this._clientLog.debug(`V2 decrypt pending sender IK: key=${messageKey} from=${fromAid} device=${senderDeviceId || '-'} group=${groupId || '<p2p>'} pending=${this._v2SenderIKPending.size}`);
|
|
3762
|
-
this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId);
|
|
3763
|
-
}
|
|
3764
|
-
_scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupId) {
|
|
3765
|
-
const fetchKey = this._v2PendingSenderIKFetchKey(fromAid, senderDeviceId, groupId);
|
|
3766
|
-
if (!fromAid || this._v2SenderIKFetching.has(fetchKey))
|
|
3767
|
-
return;
|
|
3768
|
-
this._v2SenderIKFetching.add(fetchKey);
|
|
3769
|
-
this._safeAsync(this._resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey));
|
|
2087
|
+
return this._v2E2EE.scheduleSenderIKPending(args);
|
|
3770
2088
|
}
|
|
3771
2089
|
async _resolveV2SenderIKPending(fromAid, senderDeviceId, groupId, fetchKey) {
|
|
3772
|
-
|
|
3773
|
-
const session = this._v2Session;
|
|
3774
|
-
if (session && fromAid) {
|
|
3775
|
-
try {
|
|
3776
|
-
const bs = await this.call('message.v2.bootstrap', {
|
|
3777
|
-
peer_aid: fromAid,
|
|
3778
|
-
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
3779
|
-
});
|
|
3780
|
-
await this._primeBootstrapPeerCerts(bs, fromAid);
|
|
3781
|
-
const peers = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
3782
|
-
for (const dev of peers)
|
|
3783
|
-
this._cacheV2PeerIKFromDevice(dev, fromAid);
|
|
3784
|
-
}
|
|
3785
|
-
catch (exc) {
|
|
3786
|
-
this._clientLog.warn(`V2 sender IK pending bootstrap failed peer=${fromAid}: ${formatCaughtError(exc)}`);
|
|
3787
|
-
}
|
|
3788
|
-
if (groupId) {
|
|
3789
|
-
try {
|
|
3790
|
-
const gbs = await this.call('group.v2.bootstrap', {
|
|
3791
|
-
group_id: groupId,
|
|
3792
|
-
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
3793
|
-
});
|
|
3794
|
-
const devices = (Array.isArray(gbs?.devices) ? gbs.devices : []);
|
|
3795
|
-
const audit = (Array.isArray(gbs?.audit_recipients) ? gbs.audit_recipients : []);
|
|
3796
|
-
for (const dev of devices)
|
|
3797
|
-
this._cacheV2PeerIKFromDevice(dev);
|
|
3798
|
-
for (const dev of audit)
|
|
3799
|
-
this._cacheV2PeerIKFromDevice(dev);
|
|
3800
|
-
}
|
|
3801
|
-
catch (exc) {
|
|
3802
|
-
this._clientLog.warn(`V2 sender IK pending group bootstrap failed group=${groupId}: ${formatCaughtError(exc)}`);
|
|
3803
|
-
}
|
|
3804
|
-
}
|
|
3805
|
-
if (!session.getPeerIK(fromAid, senderDeviceId)) {
|
|
3806
|
-
await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
3807
|
-
}
|
|
3808
|
-
}
|
|
3809
|
-
const pendingItems = [...this._v2SenderIKPending.entries()].filter(([, entry]) => entry.fromAid === fromAid && entry.senderDeviceId === senderDeviceId && entry.groupId === groupId);
|
|
3810
|
-
for (const [key, entry] of pendingItems) {
|
|
3811
|
-
let plaintext = null;
|
|
3812
|
-
try {
|
|
3813
|
-
plaintext = await this._decryptV2Message(entry.msg, false);
|
|
3814
|
-
}
|
|
3815
|
-
catch (exc) {
|
|
3816
|
-
this._clientLog.warn(`V2 sender IK pending retry raised: key=${key} err=${formatCaughtError(exc)}`);
|
|
3817
|
-
}
|
|
3818
|
-
this._v2SenderIKPending.delete(key);
|
|
3819
|
-
if (plaintext === null) {
|
|
3820
|
-
this._clientLog.debug(`V2 sender IK pending retry failed: key=${key}`);
|
|
3821
|
-
continue;
|
|
3822
|
-
}
|
|
3823
|
-
const seq = Number(entry.msg.seq ?? 0);
|
|
3824
|
-
if (entry.groupId) {
|
|
3825
|
-
plaintext.group_id = entry.groupId;
|
|
3826
|
-
await this._publishPulledMessage('group.message_created', `group:${entry.groupId}`, seq, plaintext);
|
|
3827
|
-
}
|
|
3828
|
-
else {
|
|
3829
|
-
await this._publishPulledMessage('message.received', `p2p:${this._aid ?? ''}`, seq, plaintext);
|
|
3830
|
-
}
|
|
3831
|
-
this._clientLog.debug(`V2 sender IK pending retry delivered: key=${key}`);
|
|
3832
|
-
}
|
|
3833
|
-
}
|
|
3834
|
-
finally {
|
|
3835
|
-
this._v2SenderIKFetching.delete(fetchKey);
|
|
3836
|
-
}
|
|
2090
|
+
return await this._v2E2EE.resolveSenderIKPending(fromAid, senderDeviceId, groupId, fetchKey);
|
|
3837
2091
|
}
|
|
3838
2092
|
/**
|
|
3839
2093
|
* 构造 V2 P2P envelope;message.send 与 message.thought.put 共用。
|
|
3840
2094
|
*/
|
|
3841
2095
|
async _buildV2P2PEnvelope(opts) {
|
|
3842
|
-
|
|
3843
|
-
throw new StateError('V2 session not initialized');
|
|
3844
|
-
}
|
|
3845
|
-
const session = this._v2Session;
|
|
3846
|
-
const to = String(opts.to ?? '').trim();
|
|
3847
|
-
if (!to)
|
|
3848
|
-
throw new ValidationError("message.send requires 'to'");
|
|
3849
|
-
const useCache = opts.useCache !== false;
|
|
3850
|
-
let peerDevices = [];
|
|
3851
|
-
let auditRaw = [];
|
|
3852
|
-
let wrapPolicy = normalizeV2WrapPolicy(undefined);
|
|
3853
|
-
const cached = useCache ? this._v2BootstrapCache.get(to) : undefined;
|
|
3854
|
-
if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
3855
|
-
peerDevices = cached.devices;
|
|
3856
|
-
auditRaw = cached.auditRecipients;
|
|
3857
|
-
wrapPolicy = cached.wrapPolicy ?? wrapPolicy;
|
|
3858
|
-
this._clientLog.debug(`message.v2.bootstrap cache hit: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
|
|
3859
|
-
}
|
|
3860
|
-
else {
|
|
3861
|
-
const bs = await this.call('message.v2.bootstrap', {
|
|
3862
|
-
peer_aid: to,
|
|
3863
|
-
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
3864
|
-
});
|
|
3865
|
-
await this._primeBootstrapPeerCerts(bs, to);
|
|
3866
|
-
wrapPolicy = normalizeV2WrapPolicy(bs.e2ee_wrap_policy);
|
|
3867
|
-
peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
|
|
3868
|
-
auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
|
|
3869
|
-
this._clientLog.debug(`message.v2.bootstrap fetched: to=${to}, devices=${peerDevices.length}, audit=${auditRaw.length}`);
|
|
3870
|
-
if (peerDevices.length > 0) {
|
|
3871
|
-
this._v2BootstrapCache.set(to, {
|
|
3872
|
-
devices: peerDevices,
|
|
3873
|
-
auditRecipients: auditRaw,
|
|
3874
|
-
cachedAt: Date.now(),
|
|
3875
|
-
wrapPolicy,
|
|
3876
|
-
});
|
|
3877
|
-
}
|
|
3878
|
-
}
|
|
3879
|
-
if (peerDevices.length === 0) {
|
|
3880
|
-
throw new E2EEError(`V2 bootstrap: no devices found for ${to}`);
|
|
3881
|
-
}
|
|
3882
|
-
const targets = [];
|
|
3883
|
-
for (const dev of peerDevices) {
|
|
3884
|
-
const devId = getV2DeviceId(dev);
|
|
3885
|
-
const target = await this._v2BuildTargetFromDevice({
|
|
3886
|
-
dev,
|
|
3887
|
-
aid: to,
|
|
3888
|
-
deviceId: devId.value,
|
|
3889
|
-
role: 'peer',
|
|
3890
|
-
defaultKeySource: 'peer_device_prekey',
|
|
3891
|
-
});
|
|
3892
|
-
if (target)
|
|
3893
|
-
targets.push(target);
|
|
3894
|
-
}
|
|
3895
|
-
const auditTargets = [];
|
|
3896
|
-
for (const dev of auditRaw) {
|
|
3897
|
-
const target = await this._v2BuildTargetFromDevice({
|
|
3898
|
-
dev,
|
|
3899
|
-
aid: String(dev.aid ?? ''),
|
|
3900
|
-
deviceId: String(dev.device_id ?? ''),
|
|
3901
|
-
role: 'audit',
|
|
3902
|
-
defaultKeySource: 'peer_device_prekey',
|
|
3903
|
-
});
|
|
3904
|
-
if (target)
|
|
3905
|
-
auditTargets.push(target);
|
|
3906
|
-
}
|
|
3907
|
-
// self-sync:给同 AID 其它在线/注册设备也 wrap 一份。
|
|
3908
|
-
if (this._aid && this._aid !== to) {
|
|
3909
|
-
try {
|
|
3910
|
-
const selfCached = this._v2BootstrapCache.get(this._aid);
|
|
3911
|
-
let selfDevices = [];
|
|
3912
|
-
if (selfCached && Date.now() - selfCached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
3913
|
-
selfDevices = selfCached.devices;
|
|
3914
|
-
}
|
|
3915
|
-
else {
|
|
3916
|
-
const selfBs = await this.call('message.v2.bootstrap', {
|
|
3917
|
-
peer_aid: this._aid,
|
|
3918
|
-
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
3919
|
-
});
|
|
3920
|
-
await this._primeBootstrapPeerCerts(selfBs, this._aid);
|
|
3921
|
-
selfDevices = (Array.isArray(selfBs?.peer_devices) ? selfBs.peer_devices : []);
|
|
3922
|
-
const selfWrapPolicy = normalizeV2WrapPolicy(selfBs.e2ee_wrap_policy);
|
|
3923
|
-
if (selfDevices.length > 0) {
|
|
3924
|
-
this._v2BootstrapCache.set(this._aid, {
|
|
3925
|
-
devices: selfDevices,
|
|
3926
|
-
auditRecipients: [],
|
|
3927
|
-
cachedAt: Date.now(),
|
|
3928
|
-
wrapPolicy: selfWrapPolicy,
|
|
3929
|
-
});
|
|
3930
|
-
}
|
|
3931
|
-
}
|
|
3932
|
-
for (const dev of selfDevices) {
|
|
3933
|
-
const devId = getV2DeviceId(dev);
|
|
3934
|
-
if (!devId.present || devId.value === this._deviceId)
|
|
3935
|
-
continue;
|
|
3936
|
-
const target = await this._v2BuildTargetFromDevice({
|
|
3937
|
-
dev,
|
|
3938
|
-
aid: this._aid,
|
|
3939
|
-
deviceId: devId.value,
|
|
3940
|
-
role: 'self_sync',
|
|
3941
|
-
defaultKeySource: 'peer_device_prekey',
|
|
3942
|
-
});
|
|
3943
|
-
if (target)
|
|
3944
|
-
targets.push(target);
|
|
3945
|
-
}
|
|
3946
|
-
}
|
|
3947
|
-
catch (exc) {
|
|
3948
|
-
this._clientLog.debug(`V2 self-sync bootstrap failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
3949
|
-
}
|
|
3950
|
-
}
|
|
3951
|
-
if (targets.length === 0) {
|
|
3952
|
-
throw new E2EEError(`V2 bootstrap: no usable devices found for ${to}`);
|
|
3953
|
-
}
|
|
3954
|
-
const envelope = encryptP2PMessage(session.getSenderIdentity(), {
|
|
3955
|
-
targets: applyV2WrapPolicyToTargets(targets, wrapPolicy),
|
|
3956
|
-
auditRecipients: applyV2WrapPolicyToTargets(auditTargets, wrapPolicy),
|
|
3957
|
-
}, opts.payload, {
|
|
3958
|
-
messageId: opts.messageId,
|
|
3959
|
-
timestamp: opts.timestamp,
|
|
3960
|
-
protectedHeaders: opts.protectedHeaders,
|
|
3961
|
-
context: opts.context,
|
|
3962
|
-
});
|
|
3963
|
-
this._logMessageDebug('send-envelope', 'message.send.v2', 'message.send', {
|
|
3964
|
-
message_id: envelope.message_id,
|
|
3965
|
-
to,
|
|
3966
|
-
type: envelope.type,
|
|
3967
|
-
version: envelope.version,
|
|
3968
|
-
protected_headers: envelope.protected_headers,
|
|
3969
|
-
context: envelope.context,
|
|
3970
|
-
}, {
|
|
3971
|
-
payloadOverride: envelope,
|
|
3972
|
-
extra: {
|
|
3973
|
-
plaintext_payload: opts.payload,
|
|
3974
|
-
target_count: targets.length,
|
|
3975
|
-
audit_count: auditTargets.length,
|
|
3976
|
-
use_cache: useCache,
|
|
3977
|
-
},
|
|
3978
|
-
});
|
|
3979
|
-
return envelope;
|
|
2096
|
+
return await this._v2E2EE.buildV2P2PEnvelope(opts);
|
|
3980
2097
|
}
|
|
3981
2098
|
/** V2 P2P 加密发送,推测性缓存失败后刷新 bootstrap 重试一次。 */
|
|
3982
2099
|
async _sendV2(to, payload, opts) {
|
|
3983
|
-
await this.
|
|
3984
|
-
const toAid = String(to ?? '').trim();
|
|
3985
|
-
if (!toAid)
|
|
3986
|
-
throw new ValidationError("message.send requires 'to'");
|
|
3987
|
-
if (!isJsonObject(payload))
|
|
3988
|
-
throw new ValidationError('message.send payload must be a dict for V2 encryption');
|
|
3989
|
-
this._logMessageDebug('send-plaintext', 'message.send.v2', 'message.send', {
|
|
3990
|
-
to: toAid,
|
|
3991
|
-
message_id: opts?.messageId ?? '',
|
|
3992
|
-
payload,
|
|
3993
|
-
}, { payloadOverride: payload });
|
|
3994
|
-
const attempt = async (useCache) => {
|
|
3995
|
-
this._clientLog.debug(`message.v2.send attempt: to=${toAid}, use_cache=${useCache}`);
|
|
3996
|
-
const envelope = await this._buildV2P2PEnvelope({
|
|
3997
|
-
to: toAid,
|
|
3998
|
-
payload,
|
|
3999
|
-
messageId: opts?.messageId,
|
|
4000
|
-
timestamp: opts?.timestamp,
|
|
4001
|
-
protectedHeaders: opts?.protectedHeaders,
|
|
4002
|
-
context: opts?.context,
|
|
4003
|
-
useCache,
|
|
4004
|
-
});
|
|
4005
|
-
const result = await this.call('message.send', {
|
|
4006
|
-
to: toAid,
|
|
4007
|
-
payload: envelope,
|
|
4008
|
-
encrypt: false,
|
|
4009
|
-
});
|
|
4010
|
-
this._clientLog.debug(`message.v2.send ok: to=${toAid}, use_cache=${useCache}, seq=${String((isJsonObject(result) ? result.seq : '') ?? '')}`);
|
|
4011
|
-
return result;
|
|
4012
|
-
};
|
|
4013
|
-
try {
|
|
4014
|
-
return await attempt(true);
|
|
4015
|
-
}
|
|
4016
|
-
catch (exc) {
|
|
4017
|
-
const excCode = exc?.code;
|
|
4018
|
-
if (AUNClient.V2_RETRYABLE_CODES.has(Number(excCode))) {
|
|
4019
|
-
this._clientLog.debug(`V2 P2P speculative send rejected (code=${String(excCode)}), refreshing bootstrap`);
|
|
4020
|
-
this._v2BootstrapCache.delete(toAid);
|
|
4021
|
-
return await attempt(false);
|
|
4022
|
-
}
|
|
4023
|
-
throw exc;
|
|
4024
|
-
}
|
|
2100
|
+
return await this._v2E2EE.sendV2(to, payload, opts);
|
|
4025
2101
|
}
|
|
4026
2102
|
/** V2 P2P 拉取并解密;直接方法返回消息数组,call("message.pull") 会包装为 {messages}. */
|
|
4027
2103
|
async _pullV2(afterSeq = 0, limit = 50, opts) {
|
|
4028
|
-
await this.
|
|
4029
|
-
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
4030
|
-
if (ns && !opts?.gateLocked) {
|
|
4031
|
-
return await this._runPullSerialized(ns, async () => this._pullV2(afterSeq, limit, {
|
|
4032
|
-
...(opts ?? {}),
|
|
4033
|
-
gateLocked: true,
|
|
4034
|
-
scheduleFollowup: true,
|
|
4035
|
-
}));
|
|
4036
|
-
}
|
|
4037
|
-
const decrypted = [];
|
|
4038
|
-
let totalRawCount = 0;
|
|
4039
|
-
let nextAfterSeq = opts?.force ? afterSeq : (afterSeq || (ns ? this._seqTracker.getContiguousSeq(ns) : 0));
|
|
4040
|
-
let pageCount = 0;
|
|
4041
|
-
const maxPages = 100;
|
|
4042
|
-
while (pageCount < maxPages) {
|
|
4043
|
-
pageCount += 1;
|
|
4044
|
-
this._clientLog.debug(`message.v2.pull page request: page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ns=${ns || '<none>'}`);
|
|
4045
|
-
const result = await this._callRawV2Rpc('message.v2.pull', {
|
|
4046
|
-
after_seq: nextAfterSeq,
|
|
4047
|
-
limit,
|
|
4048
|
-
...(opts?.force ? { force: true } : {}),
|
|
4049
|
-
});
|
|
4050
|
-
const messages = (Array.isArray(result?.messages) ? result.messages : []);
|
|
4051
|
-
totalRawCount += messages.length;
|
|
4052
|
-
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 ?? '')}`);
|
|
4053
|
-
for (const msg of messages) {
|
|
4054
|
-
this._logMessageDebug('pull-raw', 'message.v2.pull', 'message.received', msg);
|
|
4055
|
-
}
|
|
4056
|
-
const seqs = messages
|
|
4057
|
-
.map((msg) => Number(msg.seq ?? 0))
|
|
4058
|
-
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
4059
|
-
const pageContigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
4060
|
-
let pageMaxSeq = nextAfterSeq;
|
|
4061
|
-
if (seqs.length > 0) {
|
|
4062
|
-
pageMaxSeq = Math.max(...seqs);
|
|
4063
|
-
if (ns) {
|
|
4064
|
-
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
4065
|
-
this._clientLog.debug(`message.v2.pull force contiguous: ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
|
|
4066
|
-
}
|
|
4067
|
-
}
|
|
4068
|
-
for (const msg of messages) {
|
|
4069
|
-
const seq = Number(msg.seq ?? 0);
|
|
4070
|
-
if (!Number.isFinite(seq) || seq <= 0)
|
|
4071
|
-
continue;
|
|
4072
|
-
const version = String(msg.version ?? 'v2');
|
|
4073
|
-
if (version === 'v1') {
|
|
4074
|
-
const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
|
|
4075
|
-
const legacyPayload = legacy.payload;
|
|
4076
|
-
const payloadType = isJsonObject(legacyPayload)
|
|
4077
|
-
? String(legacyPayload.type ?? '').trim()
|
|
4078
|
-
: '';
|
|
4079
|
-
if (legacyPayload !== undefined && legacyPayload !== null && payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
|
|
4080
|
-
const v1Msg = {
|
|
4081
|
-
message_id: String(msg.message_id ?? ''),
|
|
4082
|
-
from: String(msg.from_aid ?? ''),
|
|
4083
|
-
to: String(legacy.to ?? this._aid ?? ''),
|
|
4084
|
-
seq: msg.seq,
|
|
4085
|
-
type: String(msg.type ?? ''),
|
|
4086
|
-
timestamp: msg.t_server,
|
|
4087
|
-
payload: legacyPayload,
|
|
4088
|
-
encrypted: false,
|
|
4089
|
-
};
|
|
4090
|
-
if (ns) {
|
|
4091
|
-
await this._publishPulledMessage('message.received', ns, seq, v1Msg);
|
|
4092
|
-
}
|
|
4093
|
-
else {
|
|
4094
|
-
await this._publishAppEvent('message.received', v1Msg, 'pull');
|
|
4095
|
-
}
|
|
4096
|
-
decrypted.push(v1Msg);
|
|
4097
|
-
this._clientLog.debug(`message.v2.pull plaintext V1 delivered: seq=${seq}, ns=${ns || '<none>'}`);
|
|
4098
|
-
}
|
|
4099
|
-
else {
|
|
4100
|
-
this._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
|
|
4101
|
-
}
|
|
4102
|
-
continue;
|
|
4103
|
-
}
|
|
4104
|
-
if (version !== 'v2') {
|
|
4105
|
-
this._clientLog.debug(`message.v2.pull skipping non-V2 row seq=${seq} version=${String(msg.version ?? '')}`);
|
|
4106
|
-
continue;
|
|
4107
|
-
}
|
|
4108
|
-
const spkId = String(msg.spk_id ?? '');
|
|
4109
|
-
if (spkId && this._v2Session && !this._v2Session.isCurrentSPK(spkId)) {
|
|
4110
|
-
this._v2Session.trackOldSPKMaxSeq(spkId, seq);
|
|
4111
|
-
}
|
|
4112
|
-
const plaintext = await this._decryptV2Message(msg);
|
|
4113
|
-
if (plaintext === null) {
|
|
4114
|
-
this._clientLog.debug(`message.v2.pull decrypt returned null: seq=${seq}, ns=${ns || '<none>'}`);
|
|
4115
|
-
continue;
|
|
4116
|
-
}
|
|
4117
|
-
if (ns) {
|
|
4118
|
-
await this._publishPulledMessage('message.received', ns, seq, plaintext);
|
|
4119
|
-
}
|
|
4120
|
-
else {
|
|
4121
|
-
await this._publishAppEvent('message.received', plaintext, 'pull');
|
|
4122
|
-
}
|
|
4123
|
-
decrypted.push(plaintext);
|
|
4124
|
-
this._logMessageDebug('decrypt-ok', 'message.v2.pull', 'message.received', plaintext);
|
|
4125
|
-
}
|
|
4126
|
-
const hasServerAckSeq = Object.prototype.hasOwnProperty.call(result, 'server_ack_seq');
|
|
4127
|
-
const serverAckSeq = Number(result.server_ack_seq ?? 0);
|
|
4128
|
-
if (ns && Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
|
|
4129
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
4130
|
-
if (contig < serverAckSeq) {
|
|
4131
|
-
this._clientLog.info(`message.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAckSeq}`);
|
|
4132
|
-
this._seqTracker.forceContiguousSeq(ns, serverAckSeq);
|
|
4133
|
-
}
|
|
4134
|
-
}
|
|
4135
|
-
if (ns) {
|
|
4136
|
-
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
4137
|
-
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
4138
|
-
if (contigAdvanced) {
|
|
4139
|
-
await this._drainOrderedMessages(ns, undefined, true);
|
|
4140
|
-
this._saveSeqTrackerState();
|
|
4141
|
-
}
|
|
4142
|
-
const ackNeeded = messages.length > 0
|
|
4143
|
-
&& ackSeq > 0
|
|
4144
|
-
&& !opts?.skipAutoAck
|
|
4145
|
-
&& (contigAdvanced || (hasServerAckSeq && ackSeq > serverAckSeq));
|
|
4146
|
-
if (ackNeeded) {
|
|
4147
|
-
this._clientLog.debug(`message.v2.pull scheduling auto-ack: ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
|
|
4148
|
-
this._safeAsync(this._ackV2(ackSeq).then(() => undefined));
|
|
4149
|
-
}
|
|
4150
|
-
}
|
|
4151
|
-
const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
|
|
4152
|
-
if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
4153
|
-
break;
|
|
4154
|
-
nextAfterSeq = nextAfter;
|
|
4155
|
-
}
|
|
4156
|
-
if (pageCount >= maxPages) {
|
|
4157
|
-
this._clientLog.warn(`message.v2.pull reached max_pages=${maxPages} after_seq=${nextAfterSeq}`);
|
|
4158
|
-
}
|
|
4159
|
-
this._clientLog.debug(`message.v2.pull done: requested_after_seq=${afterSeq}, pages=${pageCount}, decrypted=${decrypted.length}, ns=${ns || '<none>'}`);
|
|
4160
|
-
return decrypted;
|
|
2104
|
+
return await this._v2E2EE.pullV2(afterSeq, limit, opts);
|
|
4161
2105
|
}
|
|
4162
2106
|
/** V2 P2P ack,并触发旧 SPK 销毁自检。 */
|
|
4163
2107
|
async _ackV2(upToSeq) {
|
|
4164
|
-
|
|
4165
|
-
let seq = Number(upToSeq ?? (ns ? this._seqTracker.getContiguousSeq(ns) : 0));
|
|
4166
|
-
if (!Number.isFinite(seq) || seq <= 0) {
|
|
4167
|
-
this._clientLog.debug(`message.v2.ack skipped: ns=${ns || '<none>'}, up_to_seq=${String(upToSeq ?? '')}`);
|
|
4168
|
-
return { acked: 0 };
|
|
4169
|
-
}
|
|
4170
|
-
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
4171
|
-
if (ns) {
|
|
4172
|
-
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
4173
|
-
if (maxSeen > 0 && seq > maxSeen) {
|
|
4174
|
-
this._clientLog.warn(`ackV2 clamp: up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
|
|
4175
|
-
seq = maxSeen;
|
|
4176
|
-
}
|
|
4177
|
-
}
|
|
4178
|
-
this._clientLog.debug(`message.v2.ack send: ns=${ns || '<none>'}, up_to_seq=${seq}`);
|
|
4179
|
-
const raw = await this._callRawV2Rpc('message.v2.ack', { up_to_seq: seq });
|
|
4180
|
-
const result = isJsonObject(raw)
|
|
4181
|
-
? { ...raw }
|
|
4182
|
-
: { result: raw };
|
|
4183
|
-
let actualAckSeq = seq;
|
|
4184
|
-
if ('effective_ack_seq' in result)
|
|
4185
|
-
actualAckSeq = Number(result.effective_ack_seq ?? 0);
|
|
4186
|
-
else if ('ack_seq' in result)
|
|
4187
|
-
actualAckSeq = Number(result.ack_seq ?? 0);
|
|
4188
|
-
else if ('cursor' in result)
|
|
4189
|
-
actualAckSeq = Number(result.cursor ?? 0);
|
|
4190
|
-
if (!Number.isFinite(actualAckSeq))
|
|
4191
|
-
actualAckSeq = seq;
|
|
4192
|
-
result.ack_seq = actualAckSeq;
|
|
4193
|
-
result.success = true;
|
|
4194
|
-
if (Number(result.acked ?? 0) === 0)
|
|
4195
|
-
result.acked = actualAckSeq;
|
|
4196
|
-
if (this._v2Session) {
|
|
4197
|
-
try {
|
|
4198
|
-
const destroyed = this._v2Session.maybeDestroyOldSPKs(actualAckSeq);
|
|
4199
|
-
if (destroyed.length > 0) {
|
|
4200
|
-
this._clientLog.info(`V2 destroyed old SPKs after ack: ${destroyed.slice(0, 3).join(',')} (PFS)`);
|
|
4201
|
-
}
|
|
4202
|
-
}
|
|
4203
|
-
catch (exc) {
|
|
4204
|
-
this._clientLog.debug(`V2 SPK destroy failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
4205
|
-
}
|
|
4206
|
-
}
|
|
4207
|
-
this._clientLog.debug(`message.v2.ack ok: ns=${ns || '<none>'}, requested=${seq}, effective=${actualAckSeq}, acked=${String(result.acked ?? '')}`);
|
|
4208
|
-
return result;
|
|
2108
|
+
return await this._v2E2EE.ackV2(upToSeq);
|
|
4209
2109
|
}
|
|
4210
2110
|
/** V2 Group 加密发送,推测性缓存失败后刷新 bootstrap 重试一次。 */
|
|
4211
2111
|
async _sendGroupV2(groupId, payload, opts) {
|
|
4212
|
-
await this.
|
|
4213
|
-
const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
4214
|
-
if (!gid)
|
|
4215
|
-
throw new ValidationError("group.send requires 'group_id'");
|
|
4216
|
-
if (!isJsonObject(payload))
|
|
4217
|
-
throw new ValidationError('group.send payload must be a dict for V2 encryption');
|
|
4218
|
-
this._logMessageDebug('send-plaintext', 'group.send.v2', 'group.send', {
|
|
4219
|
-
group_id: gid,
|
|
4220
|
-
message_id: opts?.messageId ?? '',
|
|
4221
|
-
payload,
|
|
4222
|
-
}, { payloadOverride: payload });
|
|
4223
|
-
const attempt = async (useCache) => {
|
|
4224
|
-
this._clientLog.debug(`group.v2.send attempt: group=${gid}, use_cache=${useCache}`);
|
|
4225
|
-
const envelope = await this._buildV2GroupEnvelope({
|
|
4226
|
-
groupId: gid,
|
|
4227
|
-
payload,
|
|
4228
|
-
messageId: opts?.messageId,
|
|
4229
|
-
timestamp: opts?.timestamp,
|
|
4230
|
-
protectedHeaders: opts?.protectedHeaders,
|
|
4231
|
-
context: opts?.context,
|
|
4232
|
-
useCache,
|
|
4233
|
-
});
|
|
4234
|
-
const result = await this.call('group.v2.send', {
|
|
4235
|
-
group_id: gid,
|
|
4236
|
-
envelope: envelope,
|
|
4237
|
-
});
|
|
4238
|
-
this._clientLog.debug(`group.v2.send ok: group=${gid}, use_cache=${useCache}, seq=${String((isJsonObject(result) ? result.seq : '') ?? '')}`);
|
|
4239
|
-
return result;
|
|
4240
|
-
};
|
|
4241
|
-
const markSentSeq = (result) => {
|
|
4242
|
-
if (!isJsonObject(result))
|
|
4243
|
-
return;
|
|
4244
|
-
const obj = result;
|
|
4245
|
-
const seq = Number(obj.seq ?? 0);
|
|
4246
|
-
if (!Number.isFinite(seq) || seq <= 0)
|
|
4247
|
-
return;
|
|
4248
|
-
const ns = `group:${gid}`;
|
|
4249
|
-
this._seqTracker.onMessageSeq(ns, seq);
|
|
4250
|
-
this._markPublishedSeq(ns, seq);
|
|
4251
|
-
this._saveSeqTrackerState();
|
|
4252
|
-
this._clientLog.debug(`group.v2.send marked own seq: group=${gid}, ns=${ns}, seq=${seq}`);
|
|
4253
|
-
};
|
|
4254
|
-
try {
|
|
4255
|
-
const result = await attempt(true);
|
|
4256
|
-
markSentSeq(result);
|
|
4257
|
-
return result;
|
|
4258
|
-
}
|
|
4259
|
-
catch (exc) {
|
|
4260
|
-
const excCode = Number(exc?.code);
|
|
4261
|
-
if (AUNClient.V2_RETRYABLE_CODES.has(excCode)) {
|
|
4262
|
-
this._clientLog.debug(`V2 group speculative send rejected (code=${String(excCode)}), refreshing bootstrap`);
|
|
4263
|
-
this._v2BootstrapCache.delete(`group:${gid}`);
|
|
4264
|
-
const result = await attempt(false);
|
|
4265
|
-
markSentSeq(result);
|
|
4266
|
-
return result;
|
|
4267
|
-
}
|
|
4268
|
-
throw exc;
|
|
4269
|
-
}
|
|
2112
|
+
return await this._v2E2EE.sendGroupV2(groupId, payload, opts);
|
|
4270
2113
|
}
|
|
4271
2114
|
/** 构造 V2 Group envelope;group.send 与 group.thought.put 共用。 */
|
|
4272
2115
|
async _buildV2GroupEnvelope(opts) {
|
|
4273
|
-
|
|
4274
|
-
throw new StateError('V2 session not initialized');
|
|
4275
|
-
const session = this._v2Session;
|
|
4276
|
-
const groupId = normalizeGroupId(opts.groupId) || String(opts.groupId ?? '').trim();
|
|
4277
|
-
if (!groupId)
|
|
4278
|
-
throw new ValidationError("group.send requires 'group_id'");
|
|
4279
|
-
const cacheKey = `group:${groupId}`;
|
|
4280
|
-
const useCache = opts.useCache !== false;
|
|
4281
|
-
let allDevices = [];
|
|
4282
|
-
let auditRecipientsRaw = [];
|
|
4283
|
-
let epoch = 0;
|
|
4284
|
-
let stateCommitment = { state_version: 0, state_hash: '', state_chain: '' };
|
|
4285
|
-
let wrapPolicy = normalizeV2WrapPolicy(undefined);
|
|
4286
|
-
const cached = useCache ? this._v2BootstrapCache.get(cacheKey) : undefined;
|
|
4287
|
-
if (cached && Date.now() - cached.cachedAt < AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
4288
|
-
allDevices = cached.devices;
|
|
4289
|
-
auditRecipientsRaw = cached.auditRecipients;
|
|
4290
|
-
epoch = cached.epoch ?? 0;
|
|
4291
|
-
stateCommitment = cached.stateCommitment ?? stateCommitment;
|
|
4292
|
-
wrapPolicy = cached.wrapPolicy ?? wrapPolicy;
|
|
4293
|
-
this._clientLog.debug(`group.v2.bootstrap cache hit: group=${groupId}, devices=${allDevices.length}, audit=${auditRecipientsRaw.length}, epoch=${epoch}, state_version=${stateCommitment.state_version}`);
|
|
4294
|
-
}
|
|
4295
|
-
else {
|
|
4296
|
-
const bs = await this.call('group.v2.bootstrap', {
|
|
4297
|
-
group_id: groupId,
|
|
4298
|
-
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
4299
|
-
});
|
|
4300
|
-
allDevices = (Array.isArray(bs.devices) ? bs.devices : []);
|
|
4301
|
-
auditRecipientsRaw = (Array.isArray(bs.audit_recipients) ? bs.audit_recipients : []);
|
|
4302
|
-
epoch = Number(bs.epoch ?? 0) || 0;
|
|
4303
|
-
wrapPolicy = normalizeV2WrapPolicy(bs.e2ee_wrap_policy);
|
|
4304
|
-
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}`);
|
|
4305
|
-
const stateChain = String(bs.state_chain ?? '');
|
|
4306
|
-
await this._v2CheckFork(groupId, stateChain);
|
|
4307
|
-
await this._v2VerifyStateSignature(groupId, bs);
|
|
4308
|
-
await this._publishV2GroupSecurityLevel(groupId, bs);
|
|
4309
|
-
stateCommitment = {
|
|
4310
|
-
state_version: Number(bs.state_version ?? 0) || 0,
|
|
4311
|
-
state_hash: String(bs.state_hash_signed ?? bs.state_hash ?? ''),
|
|
4312
|
-
state_chain: stateChain,
|
|
4313
|
-
};
|
|
4314
|
-
if (allDevices.length > 0) {
|
|
4315
|
-
this._v2BootstrapCache.set(cacheKey, {
|
|
4316
|
-
devices: allDevices,
|
|
4317
|
-
auditRecipients: auditRecipientsRaw,
|
|
4318
|
-
cachedAt: Date.now(),
|
|
4319
|
-
epoch,
|
|
4320
|
-
stateCommitment,
|
|
4321
|
-
wrapPolicy,
|
|
4322
|
-
});
|
|
4323
|
-
}
|
|
4324
|
-
// lazy sync 触发:发现 pending members 时异步发起提案
|
|
4325
|
-
const pendingAdds = Array.isArray(bs.pending_adds) ? bs.pending_adds : [];
|
|
4326
|
-
if (pendingAdds.length > 0 && this._v2Session) {
|
|
4327
|
-
this._v2MaybeTriggerAutoPropose(groupId);
|
|
4328
|
-
}
|
|
4329
|
-
}
|
|
4330
|
-
if (allDevices.length === 0) {
|
|
4331
|
-
throw new E2EEError(`V2 group bootstrap: no devices found for group ${groupId}`);
|
|
4332
|
-
}
|
|
4333
|
-
const targets = [];
|
|
4334
|
-
for (const dev of allDevices) {
|
|
4335
|
-
const devAid = String(dev.aid ?? '').trim();
|
|
4336
|
-
const devId = getV2DeviceId(dev);
|
|
4337
|
-
if (devAid === this._aid && devId.present && devId.value === this._deviceId)
|
|
4338
|
-
continue;
|
|
4339
|
-
const role = devAid === this._aid ? 'self_sync' : 'member';
|
|
4340
|
-
const target = await this._v2BuildTargetFromDevice({
|
|
4341
|
-
dev,
|
|
4342
|
-
aid: devAid,
|
|
4343
|
-
deviceId: devId.value,
|
|
4344
|
-
role,
|
|
4345
|
-
defaultKeySource: 'peer_device_prekey',
|
|
4346
|
-
});
|
|
4347
|
-
if (target)
|
|
4348
|
-
targets.push(target);
|
|
4349
|
-
}
|
|
4350
|
-
if (targets.length === 0) {
|
|
4351
|
-
throw new E2EEError(`V2 group: no target devices for group ${groupId}`);
|
|
4352
|
-
}
|
|
4353
|
-
for (const dev of auditRecipientsRaw) {
|
|
4354
|
-
const target = await this._v2BuildTargetFromDevice({
|
|
4355
|
-
dev,
|
|
4356
|
-
aid: String(dev.aid ?? ''),
|
|
4357
|
-
deviceId: String(dev.device_id ?? ''),
|
|
4358
|
-
role: 'audit',
|
|
4359
|
-
defaultKeySource: 'peer_device_prekey',
|
|
4360
|
-
});
|
|
4361
|
-
if (target)
|
|
4362
|
-
targets.push(target);
|
|
4363
|
-
}
|
|
4364
|
-
const envelope = encryptGroupMessage(session.getSenderIdentity(), groupId, epoch, applyV2WrapPolicyToTargets(targets, wrapPolicy), opts.payload, {
|
|
4365
|
-
messageId: opts.messageId,
|
|
4366
|
-
timestamp: opts.timestamp,
|
|
4367
|
-
protectedHeaders: opts.protectedHeaders,
|
|
4368
|
-
context: opts.context,
|
|
4369
|
-
}, stateCommitment);
|
|
4370
|
-
this._logMessageDebug('send-envelope', 'group.send.v2', 'group.send', {
|
|
4371
|
-
group_id: groupId,
|
|
4372
|
-
message_id: envelope.message_id,
|
|
4373
|
-
type: envelope.type,
|
|
4374
|
-
version: envelope.version,
|
|
4375
|
-
protected_headers: envelope.protected_headers,
|
|
4376
|
-
context: envelope.context,
|
|
4377
|
-
}, {
|
|
4378
|
-
payloadOverride: envelope,
|
|
4379
|
-
extra: {
|
|
4380
|
-
plaintext_payload: opts.payload,
|
|
4381
|
-
epoch,
|
|
4382
|
-
target_count: targets.length,
|
|
4383
|
-
audit_count: auditRecipientsRaw.length,
|
|
4384
|
-
state_version: stateCommitment.state_version,
|
|
4385
|
-
use_cache: useCache,
|
|
4386
|
-
},
|
|
4387
|
-
});
|
|
4388
|
-
return envelope;
|
|
4389
|
-
}
|
|
4390
|
-
async _pullGroupV2Internal(params) {
|
|
4391
|
-
await this._pullGroupV2(params.group_id, params.after_seq, params.limit, { gateLocked: true });
|
|
2116
|
+
return await this._v2E2EE.buildV2GroupEnvelope(opts);
|
|
4392
2117
|
}
|
|
4393
2118
|
/** V2 Group 拉取并解密;直接方法返回消息数组,call("group.pull") 会包装为 {messages}. */
|
|
4394
2119
|
async _pullGroupV2(groupId, afterSeq = 0, limit = 50, opts) {
|
|
4395
|
-
await this.
|
|
4396
|
-
const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
4397
|
-
if (!gid)
|
|
4398
|
-
throw new ValidationError('group.pull requires group_id');
|
|
4399
|
-
const ns = `group:${gid}`;
|
|
4400
|
-
if (!opts?.gateLocked) {
|
|
4401
|
-
return await this._runPullSerialized(ns, async () => this._pullGroupV2(gid, afterSeq, limit, {
|
|
4402
|
-
...(opts ?? {}),
|
|
4403
|
-
gateLocked: true,
|
|
4404
|
-
scheduleFollowup: true,
|
|
4405
|
-
}));
|
|
4406
|
-
}
|
|
4407
|
-
const decrypted = [];
|
|
4408
|
-
let totalRawCount = 0;
|
|
4409
|
-
const cursorParams = opts?.cursorParams ?? {};
|
|
4410
|
-
const ownsCursor = opts?.ownsCursor !== false;
|
|
4411
|
-
let nextAfterSeq = opts?.explicitAfterSeq ? afterSeq : (afterSeq || this._seqTracker.getContiguousSeq(ns));
|
|
4412
|
-
let pageCount = 0;
|
|
4413
|
-
const maxPages = 100;
|
|
4414
|
-
while (pageCount < maxPages) {
|
|
4415
|
-
pageCount += 1;
|
|
4416
|
-
this._clientLog.debug(`group.v2.pull page request: group=${gid}, page=${pageCount}, after_seq=${nextAfterSeq}, limit=${limit}, ns=${ns}`);
|
|
4417
|
-
const result = await this._callRawV2Rpc('group.v2.pull', {
|
|
4418
|
-
group_id: gid,
|
|
4419
|
-
after_seq: nextAfterSeq,
|
|
4420
|
-
limit,
|
|
4421
|
-
...cursorParams,
|
|
4422
|
-
});
|
|
4423
|
-
const messages = (Array.isArray(result.messages) ? result.messages : []);
|
|
4424
|
-
totalRawCount += messages.length;
|
|
4425
|
-
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
4426
|
-
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 ?? '')}`);
|
|
4427
|
-
for (const msg of messages) {
|
|
4428
|
-
this._logMessageDebug('pull-raw', 'group.v2.pull', 'group.message_created', msg);
|
|
4429
|
-
}
|
|
4430
|
-
const seqs = messages
|
|
4431
|
-
.map((msg) => Number(msg.seq ?? 0))
|
|
4432
|
-
.filter((seq) => Number.isFinite(seq) && seq > 0);
|
|
4433
|
-
const pageContigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
4434
|
-
let pageMaxSeq = nextAfterSeq;
|
|
4435
|
-
if (seqs.length > 0) {
|
|
4436
|
-
pageMaxSeq = Math.max(...seqs);
|
|
4437
|
-
this._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
|
|
4438
|
-
this._clientLog.debug(`group.v2.pull force contiguous: group=${gid}, ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
|
|
4439
|
-
}
|
|
4440
|
-
for (const msg of messages) {
|
|
4441
|
-
const seq = Number(msg.seq ?? 0);
|
|
4442
|
-
if (!Number.isFinite(seq) || seq <= 0)
|
|
4443
|
-
continue;
|
|
4444
|
-
const version = String(msg.version ?? 'v2');
|
|
4445
|
-
if (version === 'v1') {
|
|
4446
|
-
const payload = msg.payload;
|
|
4447
|
-
const payloadObj = isJsonObject(payload) ? payload : null;
|
|
4448
|
-
if (payloadObj) {
|
|
4449
|
-
const payloadType = String(payloadObj.type ?? '').trim();
|
|
4450
|
-
if (payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
|
|
4451
|
-
const v1Msg = {
|
|
4452
|
-
message_id: String(msg.message_id ?? ''),
|
|
4453
|
-
from: String(msg.from_aid ?? ''),
|
|
4454
|
-
group_id: gid,
|
|
4455
|
-
seq: msg.seq,
|
|
4456
|
-
type: String(msg.type ?? ''),
|
|
4457
|
-
timestamp: msg.t_server,
|
|
4458
|
-
payload,
|
|
4459
|
-
encrypted: false,
|
|
4460
|
-
};
|
|
4461
|
-
await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
|
|
4462
|
-
decrypted.push(v1Msg);
|
|
4463
|
-
this._clientLog.debug(`group.v2.pull plaintext V1 delivered: group=${gid}, seq=${seq}`);
|
|
4464
|
-
continue;
|
|
4465
|
-
}
|
|
4466
|
-
}
|
|
4467
|
-
else if (payload !== undefined && payload !== null) {
|
|
4468
|
-
const v1Msg = {
|
|
4469
|
-
message_id: String(msg.message_id ?? ''),
|
|
4470
|
-
from: String(msg.from_aid ?? ''),
|
|
4471
|
-
group_id: gid,
|
|
4472
|
-
seq: msg.seq,
|
|
4473
|
-
type: String(msg.type ?? ''),
|
|
4474
|
-
timestamp: msg.t_server,
|
|
4475
|
-
payload,
|
|
4476
|
-
encrypted: false,
|
|
4477
|
-
};
|
|
4478
|
-
await this._publishPulledMessage('group.message_created', ns, seq, v1Msg);
|
|
4479
|
-
decrypted.push(v1Msg);
|
|
4480
|
-
this._clientLog.debug(`group.v2.pull plaintext V1 delivered: group=${gid}, seq=${seq}`);
|
|
4481
|
-
continue;
|
|
4482
|
-
}
|
|
4483
|
-
this._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
|
|
4484
|
-
continue;
|
|
4485
|
-
}
|
|
4486
|
-
if (version !== 'v2') {
|
|
4487
|
-
this._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
|
|
4488
|
-
continue;
|
|
4489
|
-
}
|
|
4490
|
-
const plaintext = await this._decryptV2Message(msg);
|
|
4491
|
-
if (plaintext === null) {
|
|
4492
|
-
this._clientLog.debug(`group.v2.pull decrypt returned null: group=${gid}, seq=${seq}`);
|
|
4493
|
-
continue;
|
|
4494
|
-
}
|
|
4495
|
-
plaintext.group_id = gid;
|
|
4496
|
-
await this._publishPulledMessage('group.message_created', ns, seq, plaintext);
|
|
4497
|
-
decrypted.push(plaintext);
|
|
4498
|
-
this._logMessageDebug('decrypt-ok', 'group.v2.pull', 'group.message_created', plaintext);
|
|
4499
|
-
}
|
|
4500
|
-
const cursorCurrentSeq = Number(cursor?.current_seq ?? 0);
|
|
4501
|
-
const hasServerCursor = cursor !== null && Object.prototype.hasOwnProperty.call(cursor, 'current_seq');
|
|
4502
|
-
const retentionFloor = Math.max(this._pullRetentionFloor(result, 'retention_floor_message_seq', 'retention_floor_message_seq'), Number.isFinite(cursorCurrentSeq) ? cursorCurrentSeq : 0);
|
|
4503
|
-
if (retentionFloor > 0) {
|
|
4504
|
-
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
4505
|
-
if (contig < retentionFloor) {
|
|
4506
|
-
this._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> retention_floor=${retentionFloor}`);
|
|
4507
|
-
this._seqTracker.forceContiguousSeq(ns, retentionFloor);
|
|
4508
|
-
}
|
|
4509
|
-
}
|
|
4510
|
-
const ackSeq = this._seqTracker.getContiguousSeq(ns);
|
|
4511
|
-
const contigAdvanced = ackSeq !== pageContigBefore;
|
|
4512
|
-
if (contigAdvanced) {
|
|
4513
|
-
await this._drainOrderedMessages(ns, undefined, true);
|
|
4514
|
-
this._saveSeqTrackerState();
|
|
4515
|
-
}
|
|
4516
|
-
const ackNeeded = messages.length > 0
|
|
4517
|
-
&& ackSeq > 0
|
|
4518
|
-
&& ownsCursor
|
|
4519
|
-
&& (contigAdvanced || (hasServerCursor && ackSeq > cursorCurrentSeq));
|
|
4520
|
-
if (ackNeeded) {
|
|
4521
|
-
this._clientLog.debug(`group.v2.pull scheduling auto-ack: group=${gid}, ns=${ns}, ack_seq=${ackSeq}, raw_count=${messages.length}`);
|
|
4522
|
-
this._safeAsync(this._ackGroupV2(gid, ackSeq).then(() => undefined));
|
|
4523
|
-
}
|
|
4524
|
-
const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
|
|
4525
|
-
if (!ownsCursor)
|
|
4526
|
-
break;
|
|
4527
|
-
if (messages.length === 0 || nextAfter <= nextAfterSeq || result.has_more === false)
|
|
4528
|
-
break;
|
|
4529
|
-
nextAfterSeq = nextAfter;
|
|
4530
|
-
}
|
|
4531
|
-
if (pageCount >= maxPages) {
|
|
4532
|
-
this._clientLog.warn(`group.v2.pull reached max_pages=${maxPages} group=${gid} after_seq=${nextAfterSeq}`);
|
|
4533
|
-
}
|
|
4534
|
-
this._clientLog.debug(`group.v2.pull done: group=${gid}, requested_after_seq=${afterSeq}, pages=${pageCount}, decrypted=${decrypted.length}, ns=${ns}`);
|
|
4535
|
-
return decrypted;
|
|
2120
|
+
return await this._v2E2EE.pullGroupV2(groupId, afterSeq, limit, opts);
|
|
4536
2121
|
}
|
|
4537
2122
|
async _rawGroupAckMessages(params) {
|
|
4538
2123
|
const p = { ...params };
|
|
@@ -4540,25 +2125,7 @@ export class AUNClient {
|
|
|
4540
2125
|
}
|
|
4541
2126
|
/** V2 Group ack。 */
|
|
4542
2127
|
async _ackGroupV2(groupId, upToSeq) {
|
|
4543
|
-
|
|
4544
|
-
if (!gid)
|
|
4545
|
-
throw new ValidationError('group.ack_messages requires group_id');
|
|
4546
|
-
const ns = `group:${gid}`;
|
|
4547
|
-
let seq = Number(upToSeq ?? this._seqTracker.getContiguousSeq(ns));
|
|
4548
|
-
if (!Number.isFinite(seq) || seq <= 0) {
|
|
4549
|
-
this._clientLog.debug(`group.v2.ack skipped: group=${gid}, ns=${ns}, up_to_seq=${String(upToSeq ?? '')}`);
|
|
4550
|
-
return { acked: 0 };
|
|
4551
|
-
}
|
|
4552
|
-
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
4553
|
-
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
4554
|
-
if (maxSeen > 0 && seq > maxSeen) {
|
|
4555
|
-
this._clientLog.warn(`ackGroupV2 clamp: group=${gid} up_to_seq=${seq} > max_seen=${maxSeen}, clamp`);
|
|
4556
|
-
seq = maxSeen;
|
|
4557
|
-
}
|
|
4558
|
-
this._clientLog.debug(`group.v2.ack send: group=${gid}, ns=${ns}, up_to_seq=${seq}`);
|
|
4559
|
-
const result = await this._callRawV2Rpc('group.v2.ack', { group_id: gid, up_to_seq: seq });
|
|
4560
|
-
this._clientLog.debug(`group.v2.ack ok: group=${gid}, ns=${ns}, requested=${seq}, result=${this._debugJson(result)}`);
|
|
4561
|
-
return result;
|
|
2128
|
+
return await this._v2E2EE.ackGroupV2(groupId, upToSeq);
|
|
4562
2129
|
}
|
|
4563
2130
|
/** 解密单条 V2 pull 消息。缺 sender IK 时先入 pending,后台补齐后重试。 */
|
|
4564
2131
|
async _decryptV2Message(msg, allowPending = true) {
|
|
@@ -4644,7 +2211,8 @@ export class AUNClient {
|
|
|
4644
2211
|
this._clientLog.debug(`V2 decrypt key lookup ok: seq=${String(msg.seq ?? '')}, group=${groupIdForKeys || '<p2p>'}, ik_len=${ikPriv.byteLength}, spk_len=${spkPriv?.byteLength ?? 0}`);
|
|
4645
2212
|
const fromAid = String(msg.from_aid ?? '');
|
|
4646
2213
|
const senderDeviceId = String(aad.from_device ?? '');
|
|
4647
|
-
const
|
|
2214
|
+
const senderCertFingerprint = String(envelope.sender_cert_fingerprint ?? '').trim().toLowerCase();
|
|
2215
|
+
const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId, senderCertFingerprint);
|
|
4648
2216
|
if (!senderPubDer) {
|
|
4649
2217
|
this._clientLog.warn(`V2 decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
|
|
4650
2218
|
if (allowPending) {
|
|
@@ -4663,7 +2231,6 @@ export class AUNClient {
|
|
|
4663
2231
|
_decrypt_stage: 'sender_ik',
|
|
4664
2232
|
_envelope_type: String(envelope.type ?? ''),
|
|
4665
2233
|
_suite: String(envelope.suite ?? ''),
|
|
4666
|
-
_sender_device_id: String(aad.from_device ?? ''),
|
|
4667
2234
|
};
|
|
4668
2235
|
this._attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4669
2236
|
this._logMessageDebug('decrypt-fail', 'v2.decrypt', undecryptableEvent, event);
|
|
@@ -4688,7 +2255,6 @@ export class AUNClient {
|
|
|
4688
2255
|
_decrypt_stage: 'decrypt',
|
|
4689
2256
|
_envelope_type: String(envelope.type ?? ''),
|
|
4690
2257
|
_suite: String(envelope.suite ?? ''),
|
|
4691
|
-
_sender_device_id: String(aad.from_device ?? ''),
|
|
4692
2258
|
};
|
|
4693
2259
|
this._attachV2EnvelopeMetadata(event, e2eeMeta);
|
|
4694
2260
|
this._logMessageDebug('decrypt-fail', 'v2.decrypt', undecryptableEvent, event);
|
|
@@ -4701,18 +2267,10 @@ export class AUNClient {
|
|
|
4701
2267
|
}
|
|
4702
2268
|
// 消费触发 SPK 轮换
|
|
4703
2269
|
if (groupIdForKeys && recipientKeySource === 'group_device_prekey' && session.isLastUploadedGroupSPK(groupIdForKeys, spkId)) {
|
|
4704
|
-
|
|
4705
|
-
const callFn = async (method, params) => this.call(method, params);
|
|
4706
|
-
session.rotateGroupSPK(groupIdForKeys, callFn).catch(exc => {
|
|
4707
|
-
this._clientLog.debug(`V2 group SPK rotation failed (non-fatal): group=${groupIdForKeys} err=${formatCaughtError(exc)}`);
|
|
4708
|
-
});
|
|
2270
|
+
this._v2E2EE.scheduleGroupSpkRotation(groupIdForKeys, { reason: 'group_spk_consumed' });
|
|
4709
2271
|
}
|
|
4710
2272
|
else if (groupIdForKeys && recipientKeySource === 'peer_device_prekey') {
|
|
4711
|
-
|
|
4712
|
-
const callFn = async (method, params) => this.call(method, params);
|
|
4713
|
-
session.ensureGroupRegistered(groupIdForKeys, callFn).catch(exc => {
|
|
4714
|
-
this._clientLog.debug(`V2 group SPK registration after peer fallback failed (non-fatal): group=${groupIdForKeys} err=${formatCaughtError(exc)}`);
|
|
4715
|
-
});
|
|
2273
|
+
this._v2E2EE.scheduleGroupSpkRegistrationAfterPeerFallback(groupIdForKeys);
|
|
4716
2274
|
}
|
|
4717
2275
|
else if (!groupIdForKeys && session.isLastUploadedSPK(spkId)) {
|
|
4718
2276
|
// P2P SPK 消费触发轮换
|
|
@@ -4738,6 +2296,7 @@ export class AUNClient {
|
|
|
4738
2296
|
result.device_id = msg.device_id;
|
|
4739
2297
|
if (msg.slot_id !== undefined)
|
|
4740
2298
|
result.slot_id = msg.slot_id;
|
|
2299
|
+
attachGatewayProximity(result, msg);
|
|
4741
2300
|
this._attachV2EnvelopeMetadata(result, e2ee);
|
|
4742
2301
|
this._logMessageDebug('decrypt-ok', 'v2.decrypt', groupIdForKeys ? 'group.message_created' : 'message.received', result);
|
|
4743
2302
|
return result;
|
|
@@ -4758,1107 +2317,130 @@ export class AUNClient {
|
|
|
4758
2317
|
if (payloadType) {
|
|
4759
2318
|
meta.payload_type = payloadType;
|
|
4760
2319
|
}
|
|
4761
|
-
const context = this._metadataWithoutAuth(envelope.context);
|
|
4762
|
-
if (context && Object.keys(context).length > 0) {
|
|
4763
|
-
meta.context = context;
|
|
4764
|
-
}
|
|
4765
|
-
const agentMd = this._metadataWithoutAuth(envelope.agent_md);
|
|
4766
|
-
if (agentMd && Object.keys(agentMd).length > 0) {
|
|
4767
|
-
meta.agent_md = agentMd;
|
|
4768
|
-
}
|
|
4769
|
-
return meta;
|
|
4770
|
-
}
|
|
4771
|
-
_attachV2EnvelopeMetadata(message, meta) {
|
|
4772
|
-
const payloadType = typeof meta.payload_type === 'string' ? meta.payload_type.trim() : '';
|
|
4773
|
-
if (payloadType)
|
|
4774
|
-
message.payload_type = payloadType;
|
|
4775
|
-
if (isJsonObject(meta.protected_headers)) {
|
|
4776
|
-
message.protected_headers = { ...meta.protected_headers };
|
|
4777
|
-
}
|
|
4778
|
-
if (isJsonObject(meta.agent_md)) {
|
|
4779
|
-
message.agent_md = { ...meta.agent_md };
|
|
4780
|
-
}
|
|
4781
|
-
}
|
|
4782
|
-
_attachV2EnvelopeMetadataFromSource(message, source) {
|
|
4783
|
-
const envelope = this._extractV2EnvelopeFromSource(source);
|
|
4784
|
-
if (envelope) {
|
|
4785
|
-
this._agentMdManager.observeEnvelope(envelope);
|
|
4786
|
-
this._attachV2EnvelopeMetadata(message, this._v2E2eeMeta(envelope));
|
|
4787
|
-
}
|
|
4788
|
-
}
|
|
4789
|
-
_extractV2EnvelopeFromSource(source) {
|
|
4790
|
-
const candidate = source;
|
|
4791
|
-
if (!isJsonObject(candidate))
|
|
4792
|
-
return null;
|
|
4793
|
-
if (isJsonObject(candidate.payload))
|
|
4794
|
-
return candidate.payload;
|
|
4795
|
-
if (typeof candidate.envelope_json === 'string' && candidate.envelope_json) {
|
|
4796
|
-
try {
|
|
4797
|
-
const parsed = JSON.parse(candidate.envelope_json);
|
|
4798
|
-
if (isJsonObject(parsed))
|
|
4799
|
-
return parsed;
|
|
4800
|
-
}
|
|
4801
|
-
catch {
|
|
4802
|
-
return null;
|
|
4803
|
-
}
|
|
4804
|
-
}
|
|
4805
|
-
return null;
|
|
4806
|
-
}
|
|
4807
|
-
_truthyBool(value) {
|
|
4808
|
-
if (value === true || value === 1)
|
|
4809
|
-
return true;
|
|
4810
|
-
if (typeof value === 'string') {
|
|
4811
|
-
const normalized = value.trim().toLowerCase();
|
|
4812
|
-
return normalized === 'true' || normalized === '1' || normalized === 'yes' || normalized === 'on';
|
|
4813
|
-
}
|
|
4814
|
-
return false;
|
|
4815
|
-
}
|
|
4816
|
-
_encryptedPushEnvelope(msg) {
|
|
4817
|
-
const payload = msg.payload;
|
|
4818
|
-
if (this._isEncryptedEnvelopePayload(payload))
|
|
4819
|
-
return payload;
|
|
4820
|
-
if (typeof msg.envelope_json === 'string' && msg.envelope_json.trim()) {
|
|
4821
|
-
try {
|
|
4822
|
-
const parsed = JSON.parse(msg.envelope_json);
|
|
4823
|
-
if (this._isEncryptedEnvelopePayload(parsed))
|
|
4824
|
-
return parsed;
|
|
4825
|
-
}
|
|
4826
|
-
catch {
|
|
4827
|
-
return null;
|
|
4828
|
-
}
|
|
4829
|
-
}
|
|
4830
|
-
return null;
|
|
4831
|
-
}
|
|
4832
|
-
_isEncryptedPushMessage(msg) {
|
|
4833
|
-
if (this._truthyBool(msg.encrypted))
|
|
4834
|
-
return true;
|
|
4835
|
-
return this._encryptedPushEnvelope(msg) !== null;
|
|
4836
|
-
}
|
|
4837
|
-
_isEncryptedEnvelopePayload(payload) {
|
|
4838
|
-
if (!isJsonObject(payload))
|
|
4839
|
-
return false;
|
|
4840
|
-
const envelope = payload;
|
|
4841
|
-
const payloadType = String(envelope.type ?? '').trim();
|
|
4842
|
-
if (payloadType.startsWith('e2ee.'))
|
|
4843
|
-
return true;
|
|
4844
|
-
if (!String(envelope.ciphertext ?? '').trim())
|
|
4845
|
-
return false;
|
|
4846
|
-
return envelope.nonce !== undefined
|
|
4847
|
-
|| envelope.tag !== undefined
|
|
4848
|
-
|| envelope.recipient !== undefined
|
|
4849
|
-
|| envelope.recipients !== undefined
|
|
4850
|
-
|| envelope.wrapped_key !== undefined
|
|
4851
|
-
|| envelope.recipients_digest !== undefined;
|
|
4852
|
-
}
|
|
4853
|
-
_isV2EncryptedEnvelopePayload(envelope) {
|
|
4854
|
-
if (!envelope)
|
|
4855
|
-
return false;
|
|
4856
|
-
const payloadType = String(envelope.type ?? '').trim();
|
|
4857
|
-
if (payloadType === 'e2ee.p2p_encrypted' || payloadType === 'e2ee.group_encrypted')
|
|
4858
|
-
return true;
|
|
4859
|
-
return String(envelope.version ?? '').trim().toLowerCase() === 'v2' && payloadType.startsWith('e2ee.');
|
|
4860
|
-
}
|
|
4861
|
-
_safeUndecryptablePushEvent(msg, group) {
|
|
4862
|
-
const event = {
|
|
4863
|
-
message_id: msg.message_id,
|
|
4864
|
-
from: msg.from,
|
|
4865
|
-
seq: msg.seq,
|
|
4866
|
-
timestamp: (msg.timestamp ?? msg.t_server),
|
|
4867
|
-
device_id: msg.device_id,
|
|
4868
|
-
slot_id: msg.slot_id,
|
|
4869
|
-
_decrypt_error: 'encrypted push payload is not decryptable on raw push path',
|
|
4870
|
-
_decrypt_stage: 'push_envelope',
|
|
4871
|
-
};
|
|
4872
|
-
if (group) {
|
|
4873
|
-
event.group_id = msg.group_id;
|
|
4874
|
-
}
|
|
4875
|
-
else {
|
|
4876
|
-
event.to = msg.to;
|
|
4877
|
-
}
|
|
4878
|
-
const envelope = this._encryptedPushEnvelope(msg);
|
|
4879
|
-
if (envelope) {
|
|
4880
|
-
event._envelope_type = String(envelope.type ?? '');
|
|
4881
|
-
event._suite = String(envelope.suite ?? '');
|
|
4882
|
-
if (this._isV2EncryptedEnvelopePayload(envelope)) {
|
|
4883
|
-
this._attachV2EnvelopeMetadata(event, this._v2E2eeMeta(envelope));
|
|
4884
|
-
}
|
|
4885
|
-
}
|
|
4886
|
-
return event;
|
|
4887
|
-
}
|
|
4888
|
-
async _decryptEncryptedPushPayload(msg, group) {
|
|
4889
|
-
const envelope = this._encryptedPushEnvelope(msg);
|
|
4890
|
-
if (!this._isV2EncryptedEnvelopePayload(envelope))
|
|
4891
|
-
return null;
|
|
4892
|
-
const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
|
|
4893
|
-
const fromAid = String(msg.from_aid ?? msg.from ?? msg.sender_aid ?? aad.from ?? '').trim();
|
|
4894
|
-
const plaintext = await this._decryptV2EnvelopeForThought({ envelope, fromAid });
|
|
4895
|
-
if (!plaintext)
|
|
4896
|
-
return null;
|
|
4897
|
-
const e2ee = this._v2E2eeMeta(envelope);
|
|
4898
|
-
const result = {
|
|
4899
|
-
message_id: String(msg.message_id ?? ''),
|
|
4900
|
-
from: fromAid,
|
|
4901
|
-
seq: msg.seq,
|
|
4902
|
-
timestamp: (msg.t_server ?? msg.timestamp),
|
|
4903
|
-
payload: plaintext,
|
|
4904
|
-
encrypted: true,
|
|
4905
|
-
e2ee,
|
|
4906
|
-
};
|
|
4907
|
-
result.direction = fromAid && fromAid === this._aid ? 'outbound_sync' : 'inbound';
|
|
4908
|
-
if (msg.t_server !== undefined)
|
|
4909
|
-
result.t_server = msg.t_server;
|
|
4910
|
-
if (msg.device_id !== undefined)
|
|
4911
|
-
result.device_id = msg.device_id;
|
|
4912
|
-
if (msg.slot_id !== undefined)
|
|
4913
|
-
result.slot_id = msg.slot_id;
|
|
4914
|
-
if (group) {
|
|
4915
|
-
result.group_id = (msg.group_id ?? aad.group_id ?? envelope.group_id);
|
|
4916
|
-
}
|
|
4917
|
-
else {
|
|
4918
|
-
result.to = (msg.to ?? this._aid ?? '');
|
|
4919
|
-
}
|
|
4920
|
-
this._attachV2EnvelopeMetadata(result, e2ee);
|
|
4921
|
-
this._logMessageDebug('decrypt-ok', 'push.encrypted', group ? 'group.message_created' : 'message.received', result);
|
|
4922
|
-
return result;
|
|
4923
|
-
}
|
|
4924
|
-
async _publishEncryptedPushAsUndecryptable(event, ns, seq, msg, group) {
|
|
4925
|
-
const safeEvent = this._safeUndecryptablePushEvent(msg, group);
|
|
4926
|
-
this._logMessageDebug('decrypt-fail', 'push.encrypted', event, safeEvent);
|
|
4927
|
-
if (ns) {
|
|
4928
|
-
return await this._publishOrderedMessage(event, ns, seq, safeEvent);
|
|
4929
|
-
}
|
|
4930
|
-
const published = this._publishAppEvent(event, safeEvent, 'push');
|
|
4931
|
-
if (isPromiseLike(published))
|
|
4932
|
-
await published;
|
|
4933
|
-
return true;
|
|
4934
|
-
}
|
|
4935
|
-
async _publishEncryptedPushMessage(normalEvent, undecryptableEvent, ns, seq, msg, group) {
|
|
4936
|
-
const decrypted = await this._decryptEncryptedPushPayload(msg, group);
|
|
4937
|
-
if (decrypted) {
|
|
4938
|
-
if (ns)
|
|
4939
|
-
return await this._publishOrderedMessage(normalEvent, ns, seq, decrypted);
|
|
4940
|
-
const published = this._publishAppEvent(normalEvent, decrypted, 'push');
|
|
4941
|
-
if (isPromiseLike(published))
|
|
4942
|
-
await published;
|
|
4943
|
-
return true;
|
|
4944
|
-
}
|
|
4945
|
-
return await this._publishEncryptedPushAsUndecryptable(undecryptableEvent, ns, seq, msg, group);
|
|
4946
|
-
}
|
|
4947
|
-
_metadataWithoutAuth(value) {
|
|
4948
|
-
const candidate = value;
|
|
4949
|
-
if (!isJsonObject(candidate))
|
|
4950
|
-
return null;
|
|
4951
|
-
const body = {};
|
|
4952
|
-
for (const [key, item] of Object.entries(candidate)) {
|
|
4953
|
-
if (key !== '_auth')
|
|
4954
|
-
body[key] = item;
|
|
4955
|
-
}
|
|
4956
|
-
return body;
|
|
4957
|
-
}
|
|
4958
|
-
async _putMessageThoughtEncryptedV2(params) {
|
|
4959
|
-
const toAid = String(params.to ?? '').trim();
|
|
4960
|
-
this._validateMessageRecipient(toAid);
|
|
4961
|
-
const payload = isJsonObject(params.payload) ? params.payload : null;
|
|
4962
|
-
if (!toAid)
|
|
4963
|
-
throw new ValidationError('message.thought.put requires to');
|
|
4964
|
-
if (payload === null)
|
|
4965
|
-
throw new ValidationError('message.thought.put payload must be an object when encrypt=true');
|
|
4966
|
-
const thoughtId = String(params.thought_id ?? '').trim() || `mt-${crypto.randomUUID()}`;
|
|
4967
|
-
const timestamp = Number(params.timestamp ?? Date.now());
|
|
4968
|
-
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
4969
|
-
this._logMessageDebug('thought-send-plaintext', 'message.thought.put.v2', 'message.thought.put', {
|
|
4970
|
-
to: toAid,
|
|
4971
|
-
thought_id: thoughtId,
|
|
4972
|
-
timestamp,
|
|
4973
|
-
payload,
|
|
4974
|
-
}, { payloadOverride: payload });
|
|
4975
|
-
const attempt = async (useCache) => {
|
|
4976
|
-
this._clientLog.debug(`message.thought.put attempt: to=${toAid}, thought_id=${thoughtId}, use_cache=${useCache}`);
|
|
4977
|
-
const context = isJsonObject(params.context) ? params.context : undefined;
|
|
4978
|
-
const envelope = await this._buildV2P2PEnvelope({
|
|
4979
|
-
to: toAid,
|
|
4980
|
-
payload,
|
|
4981
|
-
messageId: thoughtId,
|
|
4982
|
-
timestamp,
|
|
4983
|
-
useCache,
|
|
4984
|
-
protectedHeaders,
|
|
4985
|
-
context,
|
|
4986
|
-
});
|
|
4987
|
-
const sendParams = {
|
|
4988
|
-
to: toAid,
|
|
4989
|
-
payload: envelope,
|
|
4990
|
-
encrypted: true,
|
|
4991
|
-
thought_id: thoughtId,
|
|
4992
|
-
timestamp,
|
|
4993
|
-
};
|
|
4994
|
-
if ('context' in params)
|
|
4995
|
-
sendParams.context = params.context;
|
|
4996
|
-
this._signClientOperation('message.thought.put', sendParams);
|
|
4997
|
-
this._logMessageDebug('thought-send-envelope', 'message.thought.put.v2', 'message.thought.put', sendParams, {
|
|
4998
|
-
payloadOverride: envelope,
|
|
4999
|
-
extra: { to: toAid, thought_id: thoughtId, use_cache: useCache },
|
|
5000
|
-
});
|
|
5001
|
-
const result = await this._transport.call('message.thought.put', sendParams);
|
|
5002
|
-
this._clientLog.debug(`message.thought.put ok: to=${toAid}, thought_id=${thoughtId}, use_cache=${useCache}`);
|
|
5003
|
-
return result;
|
|
5004
|
-
};
|
|
5005
|
-
try {
|
|
5006
|
-
return await attempt(true);
|
|
5007
|
-
}
|
|
5008
|
-
catch (exc) {
|
|
5009
|
-
const excCode = Number(exc?.code);
|
|
5010
|
-
if (AUNClient.V2_RETRYABLE_CODES.has(excCode)) {
|
|
5011
|
-
this._clientLog.debug(`V2 P2P thought put speculative rejected (code=${String(excCode)}), refreshing bootstrap`);
|
|
5012
|
-
this._v2BootstrapCache.delete(toAid);
|
|
5013
|
-
return await attempt(false);
|
|
5014
|
-
}
|
|
5015
|
-
throw exc;
|
|
5016
|
-
}
|
|
5017
|
-
}
|
|
5018
|
-
async _putGroupThoughtEncryptedV2(params) {
|
|
5019
|
-
const groupId = String(params.group_id ?? '').trim();
|
|
5020
|
-
const payload = isJsonObject(params.payload) ? params.payload : null;
|
|
5021
|
-
if (!groupId)
|
|
5022
|
-
throw new ValidationError("group.thought.put requires 'group_id'");
|
|
5023
|
-
if (payload === null)
|
|
5024
|
-
throw new ValidationError('group.thought.put payload must be an object when encrypt=true');
|
|
5025
|
-
const thoughtId = String(params.thought_id ?? '').trim() || `gt-${crypto.randomUUID()}`;
|
|
5026
|
-
const timestamp = Number(params.timestamp ?? Date.now());
|
|
5027
|
-
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
5028
|
-
this._logMessageDebug('thought-send-plaintext', 'group.thought.put.v2', 'group.thought.put', {
|
|
5029
|
-
group_id: groupId,
|
|
5030
|
-
thought_id: thoughtId,
|
|
5031
|
-
timestamp,
|
|
5032
|
-
payload,
|
|
5033
|
-
}, { payloadOverride: payload });
|
|
5034
|
-
const attempt = async (useCache) => {
|
|
5035
|
-
this._clientLog.debug(`group.thought.put attempt: group=${groupId}, thought_id=${thoughtId}, use_cache=${useCache}`);
|
|
5036
|
-
const context = isJsonObject(params.context) ? params.context : undefined;
|
|
5037
|
-
const envelope = await this._buildV2GroupEnvelope({
|
|
5038
|
-
groupId,
|
|
5039
|
-
payload,
|
|
5040
|
-
messageId: thoughtId,
|
|
5041
|
-
timestamp,
|
|
5042
|
-
useCache,
|
|
5043
|
-
protectedHeaders,
|
|
5044
|
-
context,
|
|
5045
|
-
});
|
|
5046
|
-
const sendParams = {
|
|
5047
|
-
group_id: groupId,
|
|
5048
|
-
payload: envelope,
|
|
5049
|
-
encrypted: true,
|
|
5050
|
-
thought_id: thoughtId,
|
|
5051
|
-
timestamp,
|
|
5052
|
-
};
|
|
5053
|
-
if ('context' in params)
|
|
5054
|
-
sendParams.context = params.context;
|
|
5055
|
-
this._signClientOperation('group.thought.put', sendParams);
|
|
5056
|
-
this._logMessageDebug('thought-send-envelope', 'group.thought.put.v2', 'group.thought.put', sendParams, {
|
|
5057
|
-
payloadOverride: envelope,
|
|
5058
|
-
extra: { group_id: groupId, thought_id: thoughtId, use_cache: useCache },
|
|
5059
|
-
});
|
|
5060
|
-
const result = await this._transport.call('group.thought.put', sendParams);
|
|
5061
|
-
this._clientLog.debug(`group.thought.put ok: group=${groupId}, thought_id=${thoughtId}, use_cache=${useCache}`);
|
|
5062
|
-
return result;
|
|
5063
|
-
};
|
|
5064
|
-
try {
|
|
5065
|
-
return await attempt(true);
|
|
5066
|
-
}
|
|
5067
|
-
catch (exc) {
|
|
5068
|
-
const excCode = Number(exc?.code);
|
|
5069
|
-
if (AUNClient.V2_RETRYABLE_CODES.has(excCode)) {
|
|
5070
|
-
this._clientLog.debug(`V2 group thought put speculative rejected (code=${String(excCode)}), refreshing bootstrap`);
|
|
5071
|
-
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
5072
|
-
return await attempt(false);
|
|
5073
|
-
}
|
|
5074
|
-
throw exc;
|
|
5075
|
-
}
|
|
5076
|
-
}
|
|
5077
|
-
/** 解密 thought 中直接透传的 V2 envelope。 */
|
|
5078
|
-
async _decryptV2EnvelopeForThought(opts) {
|
|
5079
|
-
const session = this._v2Session;
|
|
5080
|
-
if (!session)
|
|
5081
|
-
return null;
|
|
5082
|
-
const envelope = opts.envelope;
|
|
5083
|
-
let spkId = '';
|
|
5084
|
-
let recipientKeySource = '';
|
|
5085
|
-
if (Array.isArray(envelope.recipients)) {
|
|
5086
|
-
for (const row of envelope.recipients) {
|
|
5087
|
-
if (!Array.isArray(row) || row.length < 6)
|
|
5088
|
-
continue;
|
|
5089
|
-
if (String(row[0] ?? '') === this._aid
|
|
5090
|
-
&& (String(row[1] ?? '') === this._deviceId || String(row[1] ?? '') === '')) {
|
|
5091
|
-
spkId = String(row[5] ?? '');
|
|
5092
|
-
recipientKeySource = String(row[3] ?? '');
|
|
5093
|
-
break;
|
|
5094
|
-
}
|
|
5095
|
-
}
|
|
5096
|
-
}
|
|
5097
|
-
else if (isJsonObject(envelope.recipient)) {
|
|
5098
|
-
const recipient = envelope.recipient;
|
|
5099
|
-
spkId = String(recipient.spk_id ?? '');
|
|
5100
|
-
recipientKeySource = String(recipient.key_source ?? '');
|
|
5101
|
-
}
|
|
5102
|
-
const aad = isJsonObject(envelope.aad) ? envelope.aad : {};
|
|
5103
|
-
const groupIdForKeys = String(aad.group_id ?? envelope.group_id ?? '').trim();
|
|
5104
|
-
const fromAid = String(opts.fromAid || aad.from || '').trim();
|
|
5105
|
-
const senderDeviceId = String(aad.from_device ?? '');
|
|
5106
|
-
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 ?? '')}`);
|
|
5107
|
-
// group_id 只表示群上下文;group lookup 内部按 group SPK -> P2P device SPK -> IK fallback。
|
|
5108
|
-
let ikPriv;
|
|
5109
|
-
let spkPriv;
|
|
5110
|
-
try {
|
|
5111
|
-
if (groupIdForKeys) {
|
|
5112
|
-
const keys = session.getGroupDecryptKeys(groupIdForKeys, spkId);
|
|
5113
|
-
ikPriv = keys.ikPriv;
|
|
5114
|
-
spkPriv = keys.spkPriv ?? undefined;
|
|
5115
|
-
}
|
|
5116
|
-
else {
|
|
5117
|
-
const keys = session.getDecryptKeys(spkId);
|
|
5118
|
-
ikPriv = keys.ikPriv;
|
|
5119
|
-
spkPriv = keys.spkPriv;
|
|
5120
|
-
}
|
|
5121
|
-
}
|
|
5122
|
-
catch (exc) {
|
|
5123
|
-
this._clientLog.warn(`V2 thought decrypt: SPK lookup failed from=${fromAid}, group=${groupIdForKeys || '<p2p>'}, spk_id=${spkId || '<empty>'}: ${formatCaughtError(exc)}`);
|
|
5124
|
-
return null;
|
|
5125
|
-
}
|
|
5126
|
-
const senderPubDer = await this._getV2SenderPubDer(fromAid, senderDeviceId);
|
|
5127
|
-
if (!senderPubDer) {
|
|
5128
|
-
this._clientLog.warn(`V2 thought decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
|
|
5129
|
-
this._scheduleV2SenderIKFetch(fromAid, senderDeviceId, groupIdForKeys);
|
|
5130
|
-
return null;
|
|
5131
|
-
}
|
|
5132
|
-
try {
|
|
5133
|
-
const plain = decryptMessage(envelope, this._aid ?? '', this._deviceId, ikPriv, spkPriv, senderPubDer);
|
|
5134
|
-
this._clientLog.debug(`V2 thought decrypt ok: from=${fromAid}, sender_device=${senderDeviceId}, group=${groupIdForKeys || '<p2p>'}`);
|
|
5135
|
-
return plain;
|
|
5136
|
-
}
|
|
5137
|
-
catch (exc) {
|
|
5138
|
-
this._clientLog.warn(`V2 thought decrypt failed from=${fromAid}: ${formatCaughtError(exc)}`);
|
|
5139
|
-
return null;
|
|
5140
|
-
}
|
|
5141
|
-
}
|
|
5142
|
-
async _publishV2GroupSecurityLevel(groupId, bootstrap) {
|
|
5143
|
-
const level = String(bootstrap.e2ee_security_level ?? '').trim() || 'end_to_end';
|
|
5144
|
-
const previous = this._v2GroupSecurityLevels.get(groupId);
|
|
5145
|
-
if (previous === level)
|
|
5146
|
-
return;
|
|
5147
|
-
this._v2GroupSecurityLevels.set(groupId, level);
|
|
5148
|
-
await this._dispatcher.publish('group.v2.security_level', {
|
|
5149
|
-
group_id: groupId,
|
|
5150
|
-
level,
|
|
5151
|
-
warning: String(bootstrap.e2ee_security_warning ?? ''),
|
|
5152
|
-
previous_level: previous ?? null,
|
|
5153
|
-
});
|
|
5154
|
-
}
|
|
5155
|
-
async _v2VerifyStateSignature(groupId, bootstrap) {
|
|
5156
|
-
const stateSignature = String(bootstrap.state_signature ?? '');
|
|
5157
|
-
const actorAid = String(bootstrap.state_actor_aid ?? '');
|
|
5158
|
-
const stateHashSigned = String(bootstrap.state_hash_signed ?? '');
|
|
5159
|
-
const membershipSnapshot = String(bootstrap.state_membership_snapshot ?? '');
|
|
5160
|
-
const stateVersion = Number(bootstrap.state_version ?? 0) || 0;
|
|
5161
|
-
if (stateVersion === 0 || !stateSignature || !actorAid)
|
|
5162
|
-
return;
|
|
5163
|
-
try {
|
|
5164
|
-
const signPayload = stableStringify({
|
|
5165
|
-
group_id: groupId,
|
|
5166
|
-
membership_snapshot: membershipSnapshot,
|
|
5167
|
-
state_hash: stateHashSigned,
|
|
5168
|
-
state_version: stateVersion,
|
|
5169
|
-
});
|
|
5170
|
-
const sigBytes = Buffer.from(stateSignature, 'base64');
|
|
5171
|
-
const cacheKey = crypto.createHash('sha256')
|
|
5172
|
-
.update(lengthPrefixedBytesKey(Buffer.from(actorAid, 'utf-8'), Buffer.from(signPayload, 'utf-8'), sigBytes))
|
|
5173
|
-
.digest('hex');
|
|
5174
|
-
const now = Date.now();
|
|
5175
|
-
const cachedExp = this._v2SigCache.get(cacheKey);
|
|
5176
|
-
if (cachedExp === undefined || cachedExp <= now) {
|
|
5177
|
-
const certPem = await this._fetchPeerCert(actorAid);
|
|
5178
|
-
const cert = new crypto.X509Certificate(certPem);
|
|
5179
|
-
const ok = crypto.verify('SHA256', Buffer.from(signPayload, 'utf-8'), cert.publicKey, sigBytes);
|
|
5180
|
-
if (!ok) {
|
|
5181
|
-
throw new E2EEError(`V2 state signature verification failed: group=${groupId} actor=${actorAid}`);
|
|
5182
|
-
}
|
|
5183
|
-
this._v2SigCache.set(cacheKey, now + AUNClient.V2_SIG_CACHE_TTL_MS);
|
|
5184
|
-
if (this._v2SigCache.size > AUNClient.V2_SIG_CACHE_MAX) {
|
|
5185
|
-
const stale = [];
|
|
5186
|
-
for (const [key, exp] of this._v2SigCache) {
|
|
5187
|
-
if (exp <= now)
|
|
5188
|
-
stale.push(key);
|
|
5189
|
-
}
|
|
5190
|
-
for (const key of stale)
|
|
5191
|
-
this._v2SigCache.delete(key);
|
|
5192
|
-
if (this._v2SigCache.size > AUNClient.V2_SIG_CACHE_MAX) {
|
|
5193
|
-
const entries = [...this._v2SigCache.entries()].sort((a, b) => a[1] - b[1]);
|
|
5194
|
-
const evictCount = Math.floor(AUNClient.V2_SIG_CACHE_MAX / 4);
|
|
5195
|
-
for (let i = 0; i < evictCount && i < entries.length; i++) {
|
|
5196
|
-
this._v2SigCache.delete(entries[i][0]);
|
|
5197
|
-
}
|
|
5198
|
-
}
|
|
5199
|
-
}
|
|
5200
|
-
}
|
|
5201
|
-
try {
|
|
5202
|
-
if (membershipSnapshot.startsWith('[')) {
|
|
5203
|
-
const signedSnapshot = JSON.parse(membershipSnapshot);
|
|
5204
|
-
if (Array.isArray(signedSnapshot)) {
|
|
5205
|
-
const signedMembers = new Set(signedSnapshot.map((item) => String(item)));
|
|
5206
|
-
const serverMembers = Array.isArray(bootstrap.member_aids)
|
|
5207
|
-
? bootstrap.member_aids.map((item) => String(item))
|
|
5208
|
-
: [];
|
|
5209
|
-
const extra = serverMembers.filter((aid) => !signedMembers.has(aid));
|
|
5210
|
-
if (extra.length > 0) {
|
|
5211
|
-
let mode = '';
|
|
5212
|
-
try {
|
|
5213
|
-
const req = await this.call('group.get_join_requirements', { group_id: groupId });
|
|
5214
|
-
mode = isJsonObject(req) ? String(req.mode ?? '') : '';
|
|
5215
|
-
}
|
|
5216
|
-
catch {
|
|
5217
|
-
mode = '';
|
|
5218
|
-
}
|
|
5219
|
-
if (!['open', 'invite_code', 'invite_only'].includes(mode)) {
|
|
5220
|
-
await this._dispatcher.publish('group.v2.state_tampered', {
|
|
5221
|
-
group_id: groupId,
|
|
5222
|
-
pending_extra: extra.sort(),
|
|
5223
|
-
mode,
|
|
5224
|
-
});
|
|
5225
|
-
}
|
|
5226
|
-
}
|
|
5227
|
-
}
|
|
5228
|
-
}
|
|
5229
|
-
}
|
|
5230
|
-
catch {
|
|
5231
|
-
// snapshot 解析失败不阻断已完成的签名验证。
|
|
5232
|
-
}
|
|
5233
|
-
}
|
|
5234
|
-
catch (exc) {
|
|
5235
|
-
if (exc instanceof E2EEError)
|
|
5236
|
-
throw exc;
|
|
5237
|
-
throw new E2EEError(`V2 state signature verification failed: ${formatCaughtError(exc)}`);
|
|
5238
|
-
}
|
|
5239
|
-
}
|
|
5240
|
-
async _v2CheckFork(groupId, serverChain) {
|
|
5241
|
-
if (!serverChain)
|
|
5242
|
-
return;
|
|
5243
|
-
try {
|
|
5244
|
-
const local = this._v2StateChains.get(groupId);
|
|
5245
|
-
if (!local) {
|
|
5246
|
-
this._v2StateChains.set(groupId, [0, serverChain]);
|
|
5247
|
-
return;
|
|
5248
|
-
}
|
|
5249
|
-
const [localSv, localChain] = local;
|
|
5250
|
-
if (localChain === serverChain)
|
|
5251
|
-
return;
|
|
5252
|
-
try {
|
|
5253
|
-
const stateResp = await this.call('group.get_state', { group_id: groupId });
|
|
5254
|
-
if (isJsonObject(stateResp)) {
|
|
5255
|
-
const serverSv = Number(stateResp.state_version ?? 0);
|
|
5256
|
-
if (serverSv > localSv) {
|
|
5257
|
-
this._v2StateChains.set(groupId, [serverSv, serverChain]);
|
|
5258
|
-
return;
|
|
5259
|
-
}
|
|
5260
|
-
if (serverSv < localSv) {
|
|
5261
|
-
this._clientLog.warn(`V2 state chain rollback detected: group=${groupId} server_sv=${serverSv} local_sv=${localSv}`);
|
|
5262
|
-
}
|
|
5263
|
-
}
|
|
5264
|
-
}
|
|
5265
|
-
catch {
|
|
5266
|
-
// get_state 失败时继续发布 fork 告警。
|
|
5267
|
-
}
|
|
5268
|
-
this._clientLog.warn(`V2 state chain fork detected: group=${groupId} local_chain=${localChain.slice(0, 16)}... server_chain=${serverChain.slice(0, 16)}...`);
|
|
5269
|
-
await this._dispatcher.publish('group.v2.fork_detected', {
|
|
5270
|
-
group_id: groupId,
|
|
5271
|
-
local_chain: localChain,
|
|
5272
|
-
server_chain: serverChain,
|
|
5273
|
-
});
|
|
5274
|
-
}
|
|
5275
|
-
catch (exc) {
|
|
5276
|
-
this._clientLog.debug(`V2 fork check failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
5277
|
-
}
|
|
5278
|
-
}
|
|
5279
|
-
_v2MaybeTriggerAutoPropose(groupId) {
|
|
5280
|
-
const now = Date.now();
|
|
5281
|
-
const last = this._v2LazyProposeTriggered.get(groupId) ?? 0;
|
|
5282
|
-
if (now - last < 10000)
|
|
5283
|
-
return;
|
|
5284
|
-
this._v2LazyProposeTriggered.set(groupId, now);
|
|
5285
|
-
this._safeAsync(this._v2AutoProposeState(groupId, { leaderDelay: true }));
|
|
5286
|
-
}
|
|
5287
|
-
async _v2AutoProposeState(groupId, options) {
|
|
5288
|
-
const normalizedGroupId = normalizeGroupId(groupId) || String(groupId ?? '').trim();
|
|
5289
|
-
if (!normalizedGroupId)
|
|
5290
|
-
return;
|
|
5291
|
-
if (options?.leaderDelay) {
|
|
5292
|
-
const shouldContinue = await this._v2AutoProposeLeaderDelay(normalizedGroupId);
|
|
5293
|
-
if (!shouldContinue)
|
|
5294
|
-
return;
|
|
5295
|
-
}
|
|
5296
|
-
const inflight = this._v2AutoProposeInflight.get(normalizedGroupId);
|
|
5297
|
-
if (inflight) {
|
|
5298
|
-
this._v2AutoProposePending.add(normalizedGroupId);
|
|
5299
|
-
await inflight;
|
|
5300
|
-
return;
|
|
5301
|
-
}
|
|
5302
|
-
let resolveTask;
|
|
5303
|
-
let rejectTask;
|
|
5304
|
-
const task = new Promise((resolve, reject) => {
|
|
5305
|
-
resolveTask = resolve;
|
|
5306
|
-
rejectTask = reject;
|
|
5307
|
-
});
|
|
5308
|
-
this._v2AutoProposeInflight.set(normalizedGroupId, task);
|
|
5309
|
-
void (async () => {
|
|
5310
|
-
try {
|
|
5311
|
-
do {
|
|
5312
|
-
this._v2AutoProposePending.delete(normalizedGroupId);
|
|
5313
|
-
await this._doV2AutoProposeState(normalizedGroupId);
|
|
5314
|
-
} while (this._v2AutoProposePending.delete(normalizedGroupId));
|
|
5315
|
-
resolveTask();
|
|
5316
|
-
}
|
|
5317
|
-
catch (exc) {
|
|
5318
|
-
rejectTask(exc);
|
|
5319
|
-
}
|
|
5320
|
-
finally {
|
|
5321
|
-
if (this._v2AutoProposeInflight.get(normalizedGroupId) === task) {
|
|
5322
|
-
this._v2AutoProposeInflight.delete(normalizedGroupId);
|
|
5323
|
-
}
|
|
5324
|
-
this._v2AutoProposePending.delete(normalizedGroupId);
|
|
5325
|
-
}
|
|
5326
|
-
})();
|
|
5327
|
-
try {
|
|
5328
|
-
await task;
|
|
5329
|
-
}
|
|
5330
|
-
finally {
|
|
5331
|
-
if (this._v2AutoProposeInflight.get(normalizedGroupId) === task) {
|
|
5332
|
-
this._v2AutoProposeInflight.delete(normalizedGroupId);
|
|
5333
|
-
}
|
|
5334
|
-
this._v2AutoProposePending.delete(normalizedGroupId);
|
|
5335
|
-
}
|
|
5336
|
-
}
|
|
5337
|
-
_v2LeaderDelayMs(input) {
|
|
5338
|
-
let h = 2166136261;
|
|
5339
|
-
for (let i = 0; i < input.length; i++) {
|
|
5340
|
-
h ^= input.charCodeAt(i);
|
|
5341
|
-
h = Math.imul(h, 16777619);
|
|
5342
|
-
}
|
|
5343
|
-
return 2000 + ((h >>> 0) % 4000);
|
|
5344
|
-
}
|
|
5345
|
-
async _v2AutoProposeLeaderDelay(groupId) {
|
|
5346
|
-
try {
|
|
5347
|
-
const membersResp = await this.call('group.get_online_members', { group_id: groupId });
|
|
5348
|
-
const members = isJsonObject(membersResp)
|
|
5349
|
-
? (Array.isArray(membersResp.members) ? membersResp.members
|
|
5350
|
-
: Array.isArray(membersResp.items) ? membersResp.items
|
|
5351
|
-
: Array.isArray(membersResp.online_members) ? membersResp.online_members : [])
|
|
5352
|
-
: [];
|
|
5353
|
-
if (!Array.isArray(members))
|
|
5354
|
-
return true;
|
|
5355
|
-
const myAid = this._aid ?? '';
|
|
5356
|
-
let myRole = '';
|
|
5357
|
-
const onlineAdminAids = new Set();
|
|
5358
|
-
for (const item of members) {
|
|
5359
|
-
if (!isJsonObject(item))
|
|
5360
|
-
continue;
|
|
5361
|
-
const aid = String(item.aid ?? '').trim();
|
|
5362
|
-
const role = String(item.role ?? '').trim();
|
|
5363
|
-
if (!aid)
|
|
5364
|
-
continue;
|
|
5365
|
-
if ('online' in item && !Boolean(item.online))
|
|
5366
|
-
continue;
|
|
5367
|
-
if (role === 'owner' || role === 'admin')
|
|
5368
|
-
onlineAdminAids.add(aid);
|
|
5369
|
-
if (aid === myAid)
|
|
5370
|
-
myRole = role;
|
|
5371
|
-
}
|
|
5372
|
-
if (myRole !== 'owner' && myRole !== 'admin')
|
|
5373
|
-
return false;
|
|
5374
|
-
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5375
|
-
group_id: groupId,
|
|
5376
|
-
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5377
|
-
});
|
|
5378
|
-
const devices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
|
|
5379
|
-
? bootstrapResp.devices.filter(isJsonObject)
|
|
5380
|
-
: [];
|
|
5381
|
-
const candidates = [];
|
|
5382
|
-
for (const dev of devices) {
|
|
5383
|
-
const aid = String(dev.aid ?? '').trim();
|
|
5384
|
-
const hasDeviceId = 'device_id' in dev;
|
|
5385
|
-
const deviceId = String(dev.device_id ?? '').trim();
|
|
5386
|
-
if (aid && hasDeviceId && onlineAdminAids.has(aid)) {
|
|
5387
|
-
candidates.push(`${aid}\x1f${deviceId}`);
|
|
5388
|
-
}
|
|
5389
|
-
}
|
|
5390
|
-
if (candidates.length === 0) {
|
|
5391
|
-
for (const aid of [...onlineAdminAids].sort())
|
|
5392
|
-
candidates.push(`${aid}\x1f`);
|
|
5393
|
-
}
|
|
5394
|
-
const myKey = `${myAid}\x1f${this._deviceId ?? ''}`;
|
|
5395
|
-
if (!candidates.includes(myKey))
|
|
5396
|
-
candidates.push(myKey);
|
|
5397
|
-
const leader = [...new Set(candidates)].sort()[0];
|
|
5398
|
-
if (leader === myKey) {
|
|
5399
|
-
this._clientLog.debug(`V2 auto propose leader elected: group=${groupId} leader=${leader}`);
|
|
5400
|
-
return true;
|
|
5401
|
-
}
|
|
5402
|
-
const delayMs = this._v2LeaderDelayMs(lengthPrefixedTextKey(groupId, myKey));
|
|
5403
|
-
this._clientLog.debug(`V2 auto propose non-leader delay: group=${groupId} leader=${leader} self=${myKey} delay_ms=${delayMs}`);
|
|
5404
|
-
await this._sleep(delayMs);
|
|
5405
|
-
return true;
|
|
2320
|
+
const context = this._metadataWithoutAuth(envelope.context);
|
|
2321
|
+
if (context && Object.keys(context).length > 0) {
|
|
2322
|
+
meta.context = context;
|
|
5406
2323
|
}
|
|
5407
|
-
|
|
5408
|
-
|
|
5409
|
-
|
|
2324
|
+
const agentMd = this._metadataWithoutAuth(envelope.agent_md);
|
|
2325
|
+
if (agentMd && Object.keys(agentMd).length > 0) {
|
|
2326
|
+
meta.agent_md = agentMd;
|
|
5410
2327
|
}
|
|
2328
|
+
return meta;
|
|
5411
2329
|
}
|
|
5412
|
-
|
|
5413
|
-
const
|
|
5414
|
-
if (
|
|
5415
|
-
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
if (!currentSh || !membershipSnapshot) {
|
|
5419
|
-
this._clientLog.warn(`V2 committed state base incomplete: group=${groupId} sv=${currentSv}`);
|
|
5420
|
-
return false;
|
|
2330
|
+
_attachV2EnvelopeMetadata(message, meta) {
|
|
2331
|
+
const payloadType = typeof meta.payload_type === 'string' ? meta.payload_type.trim() : '';
|
|
2332
|
+
if (payloadType)
|
|
2333
|
+
message.payload_type = payloadType;
|
|
2334
|
+
if (isJsonObject(meta.protected_headers)) {
|
|
2335
|
+
message.protected_headers = { ...meta.protected_headers };
|
|
5421
2336
|
}
|
|
5422
|
-
|
|
5423
|
-
|
|
5424
|
-
if (!isJsonObject(parsed)) {
|
|
5425
|
-
this._clientLog.warn(`V2 committed state base snapshot is not object: group=${groupId} sv=${currentSv}`);
|
|
5426
|
-
return false;
|
|
5427
|
-
}
|
|
5428
|
-
const computed = computeStateCommitment(groupId, currentSv, parsed);
|
|
5429
|
-
if (computed !== currentSh) {
|
|
5430
|
-
this._clientLog.warn(`V2 committed state base hash mismatch: group=${groupId} sv=${currentSv}`);
|
|
5431
|
-
return false;
|
|
5432
|
-
}
|
|
5433
|
-
return true;
|
|
2337
|
+
if (isJsonObject(meta.agent_md)) {
|
|
2338
|
+
message.agent_md = { ...meta.agent_md };
|
|
5434
2339
|
}
|
|
5435
|
-
|
|
5436
|
-
|
|
5437
|
-
|
|
2340
|
+
}
|
|
2341
|
+
_attachV2EnvelopeMetadataFromSource(message, source) {
|
|
2342
|
+
const envelope = this._extractV2EnvelopeFromSource(source);
|
|
2343
|
+
if (envelope) {
|
|
2344
|
+
this._agentMdManager.observeEnvelope(envelope);
|
|
2345
|
+
this._attachV2EnvelopeMetadata(message, this._v2E2eeMeta(envelope));
|
|
5438
2346
|
}
|
|
5439
2347
|
}
|
|
5440
|
-
|
|
5441
|
-
|
|
5442
|
-
|
|
5443
|
-
|
|
5444
|
-
|
|
5445
|
-
|
|
5446
|
-
|
|
5447
|
-
|
|
5448
|
-
|
|
5449
|
-
|
|
5450
|
-
|
|
5451
|
-
let myRole = '';
|
|
5452
|
-
const memberAids = [];
|
|
5453
|
-
const adminAids = [];
|
|
5454
|
-
for (const item of members) {
|
|
5455
|
-
if (!isJsonObject(item))
|
|
5456
|
-
continue;
|
|
5457
|
-
const aid = String(item.aid ?? '').trim();
|
|
5458
|
-
const role = String(item.role ?? '').trim();
|
|
5459
|
-
if (!aid)
|
|
5460
|
-
continue;
|
|
5461
|
-
memberAids.push(aid);
|
|
5462
|
-
if (role === 'owner' || role === 'admin')
|
|
5463
|
-
adminAids.push(aid);
|
|
5464
|
-
if (aid === myAid)
|
|
5465
|
-
myRole = role;
|
|
5466
|
-
}
|
|
5467
|
-
if (myRole !== 'owner' && myRole !== 'admin')
|
|
5468
|
-
return;
|
|
5469
|
-
// 前置检查:如果已有 pending proposal,先尝试 confirm 而非重复 propose
|
|
5470
|
-
const proposalResp = await this.call('group.v2.get_proposal', { group_id: groupId });
|
|
5471
|
-
if (isJsonObject(proposalResp)) {
|
|
5472
|
-
const pendingProposal = proposalResp.proposal;
|
|
5473
|
-
if (isJsonObject(pendingProposal) && String(pendingProposal.proposal_id ?? '').trim()) {
|
|
5474
|
-
const confirmed = await this._v2ConfirmPendingProposal(groupId);
|
|
5475
|
-
if (confirmed)
|
|
5476
|
-
return;
|
|
5477
|
-
const autoConfirmAt = Number(pendingProposal.auto_confirm_at ?? 0) || 0;
|
|
5478
|
-
const nowMs = Date.now();
|
|
5479
|
-
if (autoConfirmAt > nowMs) {
|
|
5480
|
-
const waitMs = Math.min(autoConfirmAt - nowMs + 500, 35000);
|
|
5481
|
-
this._clientLog.debug(`V2 auto propose: pending proposal exists, waiting ${waitMs}ms group=${groupId}`);
|
|
5482
|
-
await new Promise((r) => setTimeout(r, waitMs));
|
|
5483
|
-
}
|
|
5484
|
-
}
|
|
5485
|
-
}
|
|
5486
|
-
const bootstrapResp = await this.call('group.v2.bootstrap', {
|
|
5487
|
-
group_id: groupId,
|
|
5488
|
-
e2ee_wrap_capabilities: v2WrapCapabilities(),
|
|
5489
|
-
});
|
|
5490
|
-
const allDevices = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.devices)
|
|
5491
|
-
? bootstrapResp.devices.filter(isJsonObject)
|
|
5492
|
-
: [];
|
|
5493
|
-
const auditRecipients = isJsonObject(bootstrapResp) && Array.isArray(bootstrapResp.audit_recipients)
|
|
5494
|
-
? bootstrapResp.audit_recipients.filter(isJsonObject)
|
|
5495
|
-
: [];
|
|
5496
|
-
const auditAids = [...new Set(auditRecipients.map((item) => String(item.aid ?? '').trim()).filter(Boolean))].sort();
|
|
5497
|
-
const membersWithDevices = {};
|
|
5498
|
-
for (const aid of memberAids)
|
|
5499
|
-
membersWithDevices[aid] = [];
|
|
5500
|
-
for (const dev of allDevices) {
|
|
5501
|
-
const aid = String(dev.aid ?? '').trim();
|
|
5502
|
-
if (aid in membersWithDevices) {
|
|
5503
|
-
membersWithDevices[aid].push({
|
|
5504
|
-
device_id: String(dev.device_id ?? ''),
|
|
5505
|
-
ik_fp: String(dev.ik_fp ?? ''),
|
|
5506
|
-
});
|
|
5507
|
-
}
|
|
5508
|
-
}
|
|
5509
|
-
const statePayload = {
|
|
5510
|
-
members: Object.entries(membersWithDevices).map(([aid, devices]) => ({ aid, devices })),
|
|
5511
|
-
audit_aids: auditAids,
|
|
5512
|
-
admin_set: { admin_aids: adminAids.sort(), threshold: 1 },
|
|
5513
|
-
join_policy_hash: null,
|
|
5514
|
-
recovery_quorum: null,
|
|
5515
|
-
history_policy: 'recent_7_days',
|
|
5516
|
-
wrap_protocol: '3DH',
|
|
5517
|
-
};
|
|
5518
|
-
const stateResp = await this.call('group.get_state', { group_id: groupId });
|
|
5519
|
-
if (!isJsonObject(stateResp))
|
|
5520
|
-
return;
|
|
5521
|
-
if (!this._v2VerifyCommittedStateBase(groupId, stateResp))
|
|
5522
|
-
return;
|
|
5523
|
-
const currentSv = Number(stateResp.state_version ?? 0) || 0;
|
|
5524
|
-
const currentSh = String(stateResp.state_hash ?? '');
|
|
5525
|
-
const keyEpoch = Number(stateResp.key_epoch ?? 0) || 0;
|
|
5526
|
-
const stateHash = computeStateCommitment(groupId, currentSv + 1, statePayload);
|
|
5527
|
-
const membershipSnapshot = stableStringify(statePayload);
|
|
5528
|
-
const lastMembershipSnapshot = this._v2AutoProposeLastSnapshot.get(groupId);
|
|
5529
|
-
if (lastMembershipSnapshot === membershipSnapshot) {
|
|
5530
|
-
return;
|
|
5531
|
-
}
|
|
5532
|
-
// 如果前 state 已经包含同样的 membership_snapshot,说明前一个自动提案已生效,
|
|
5533
|
-
// 直接跳过,避免并发触发时重复推进 state_version。
|
|
5534
|
-
const currentMembershipSnapshot = String(stateResp.membership_snapshot ?? '');
|
|
5535
|
-
if (currentMembershipSnapshot && currentMembershipSnapshot === membershipSnapshot) {
|
|
5536
|
-
this._v2AutoProposeLastSnapshot.set(groupId, membershipSnapshot);
|
|
5537
|
-
return;
|
|
5538
|
-
}
|
|
5539
|
-
let signature = '';
|
|
5540
|
-
const privateKeyPem = this._currentAid?.privateKeyPem ?? '';
|
|
5541
|
-
if (privateKeyPem) {
|
|
5542
|
-
try {
|
|
5543
|
-
const signPayload = stableStringify({
|
|
5544
|
-
group_id: groupId,
|
|
5545
|
-
membership_snapshot: membershipSnapshot,
|
|
5546
|
-
state_hash: stateHash,
|
|
5547
|
-
state_version: currentSv + 1,
|
|
5548
|
-
});
|
|
5549
|
-
const key = crypto.createPrivateKey(privateKeyPem);
|
|
5550
|
-
signature = crypto.sign('SHA256', Buffer.from(signPayload, 'utf-8'), key).toString('base64');
|
|
5551
|
-
}
|
|
5552
|
-
catch (exc) {
|
|
5553
|
-
this._clientLog.debug(`V2 propose_state signature failed: ${formatCaughtError(exc)}`);
|
|
5554
|
-
}
|
|
2348
|
+
_extractV2EnvelopeFromSource(source) {
|
|
2349
|
+
const candidate = source;
|
|
2350
|
+
if (!isJsonObject(candidate))
|
|
2351
|
+
return null;
|
|
2352
|
+
if (isJsonObject(candidate.payload))
|
|
2353
|
+
return candidate.payload;
|
|
2354
|
+
if (typeof candidate.envelope_json === 'string' && candidate.envelope_json) {
|
|
2355
|
+
try {
|
|
2356
|
+
const parsed = JSON.parse(candidate.envelope_json);
|
|
2357
|
+
if (isJsonObject(parsed))
|
|
2358
|
+
return parsed;
|
|
5555
2359
|
}
|
|
5556
|
-
|
|
5557
|
-
|
|
5558
|
-
state_version: currentSv + 1,
|
|
5559
|
-
key_epoch: keyEpoch,
|
|
5560
|
-
state_hash: stateHash,
|
|
5561
|
-
prev_state_hash: currentSh,
|
|
5562
|
-
membership_snapshot: membershipSnapshot,
|
|
5563
|
-
signature,
|
|
5564
|
-
reason: 'membership_changed',
|
|
5565
|
-
auto_confirm_seconds: 30,
|
|
5566
|
-
});
|
|
5567
|
-
const proposalId = isJsonObject(propose) ? String(propose.proposal_id ?? '').trim() : '';
|
|
5568
|
-
if (proposalId) {
|
|
5569
|
-
try {
|
|
5570
|
-
await this.call('group.v2.confirm_state', { proposal_id: proposalId });
|
|
5571
|
-
this._v2AutoProposeLastSnapshot.set(groupId, membershipSnapshot);
|
|
5572
|
-
}
|
|
5573
|
-
catch (exc) {
|
|
5574
|
-
this._clientLog.debug(`V2 auto confirm_state failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
|
|
5575
|
-
}
|
|
2360
|
+
catch {
|
|
2361
|
+
return null;
|
|
5576
2362
|
}
|
|
5577
2363
|
}
|
|
5578
|
-
|
|
5579
|
-
this._clientLog.debug(`V2 auto propose_state failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
|
|
5580
|
-
}
|
|
2364
|
+
return null;
|
|
5581
2365
|
}
|
|
5582
|
-
|
|
5583
|
-
|
|
5584
|
-
|
|
5585
|
-
|
|
5586
|
-
|
|
5587
|
-
|
|
5588
|
-
|
|
5589
|
-
const
|
|
5590
|
-
|
|
5591
|
-
|
|
5592
|
-
|
|
5593
|
-
|
|
5594
|
-
|
|
5595
|
-
|
|
5596
|
-
const parsed = JSON.parse(membershipSnapshot);
|
|
5597
|
-
if (!isJsonObject(parsed))
|
|
5598
|
-
return false;
|
|
5599
|
-
const computed = computeStateCommitment(groupId, proposalSv, parsed);
|
|
5600
|
-
if (computed !== proposalHash) {
|
|
5601
|
-
this._clientLog.warn(`V2 pending proposal hash mismatch: group=${groupId} proposal_sv=${proposalSv}`);
|
|
5602
|
-
return false;
|
|
5603
|
-
}
|
|
5604
|
-
return true;
|
|
5605
|
-
}
|
|
5606
|
-
catch (exc) {
|
|
5607
|
-
this._clientLog.warn(`V2 pending proposal verification failed: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
5608
|
-
return false;
|
|
2366
|
+
_isEncryptedPushMessage(msg) {
|
|
2367
|
+
return this._v2E2EE.isEncryptedPushMessage(msg);
|
|
2368
|
+
}
|
|
2369
|
+
async _publishEncryptedPushMessage(normalEvent, undecryptableEvent, ns, seq, msg, group) {
|
|
2370
|
+
return await this._v2E2EE.publishEncryptedPushMessage(normalEvent, undecryptableEvent, ns, seq, msg, group);
|
|
2371
|
+
}
|
|
2372
|
+
_metadataWithoutAuth(value) {
|
|
2373
|
+
const candidate = value;
|
|
2374
|
+
if (!isJsonObject(candidate))
|
|
2375
|
+
return null;
|
|
2376
|
+
const body = {};
|
|
2377
|
+
for (const [key, item] of Object.entries(candidate)) {
|
|
2378
|
+
if (key !== '_auth')
|
|
2379
|
+
body[key] = item;
|
|
5609
2380
|
}
|
|
2381
|
+
return body;
|
|
2382
|
+
}
|
|
2383
|
+
async _putMessageThoughtEncryptedV2(params) {
|
|
2384
|
+
return await this._v2E2EE.putMessageThoughtEncryptedV2(params);
|
|
2385
|
+
}
|
|
2386
|
+
async _putGroupThoughtEncryptedV2(params) {
|
|
2387
|
+
return await this._v2E2EE.putGroupThoughtEncryptedV2(params);
|
|
2388
|
+
}
|
|
2389
|
+
/** 解密 thought 中直接透传的 V2 envelope。 */
|
|
2390
|
+
async _decryptV2EnvelopeForThought(opts) {
|
|
2391
|
+
return await this._v2E2EE.decryptV2EnvelopeForThought(opts);
|
|
2392
|
+
}
|
|
2393
|
+
async _publishV2GroupSecurityLevel(groupId, bootstrap) {
|
|
2394
|
+
return this._groupState.publishV2GroupSecurityLevel(groupId, bootstrap);
|
|
2395
|
+
}
|
|
2396
|
+
async _v2VerifyStateSignature(groupId, bootstrap) {
|
|
2397
|
+
return this._groupState.verifyStateSignature(groupId, bootstrap);
|
|
2398
|
+
}
|
|
2399
|
+
async _v2CheckFork(groupId, serverChain) {
|
|
2400
|
+
return this._groupState.checkFork(groupId, serverChain);
|
|
2401
|
+
}
|
|
2402
|
+
_v2MaybeTriggerAutoPropose(groupId) {
|
|
2403
|
+
this._groupState.maybeTriggerAutoPropose(groupId);
|
|
2404
|
+
}
|
|
2405
|
+
async _v2AutoProposeState(groupId, options) {
|
|
2406
|
+
return this._groupState.autoProposeState(groupId, options);
|
|
2407
|
+
}
|
|
2408
|
+
_v2LeaderDelayMs(input) {
|
|
2409
|
+
return this._groupState.leaderDelayMs(input);
|
|
2410
|
+
}
|
|
2411
|
+
async _v2AutoProposeLeaderDelay(groupId) {
|
|
2412
|
+
return this._groupState.autoProposeLeaderDelay(groupId);
|
|
2413
|
+
}
|
|
2414
|
+
async _doV2AutoProposeState(groupId) {
|
|
2415
|
+
return this._groupState.doAutoProposeState(groupId);
|
|
5610
2416
|
}
|
|
5611
2417
|
async _v2ConfirmPendingProposal(groupId) {
|
|
5612
|
-
|
|
5613
|
-
const proposal = isJsonObject(proposalResp) && isJsonObject(proposalResp.proposal)
|
|
5614
|
-
? proposalResp.proposal
|
|
5615
|
-
: null;
|
|
5616
|
-
const proposalId = proposal ? String(proposal.proposal_id ?? '').trim() : '';
|
|
5617
|
-
if (!proposal || !proposalId)
|
|
5618
|
-
return false;
|
|
5619
|
-
const stateResp = await this.call('group.get_state', { group_id: groupId });
|
|
5620
|
-
if (!isJsonObject(stateResp))
|
|
5621
|
-
return false;
|
|
5622
|
-
const currentSv = Number(stateResp.state_version ?? 0) || 0;
|
|
5623
|
-
const proposalSv = Number(proposal.state_version ?? 0) || 0;
|
|
5624
|
-
if (proposalSv <= currentSv) {
|
|
5625
|
-
this._clientLog.debug(`V2 pending proposal already settled: group=${groupId} current_sv=${currentSv} proposal_sv=${proposalSv}`);
|
|
5626
|
-
return false;
|
|
5627
|
-
}
|
|
5628
|
-
if (!this._v2VerifyPendingProposalAgainstBase(groupId, proposal, stateResp))
|
|
5629
|
-
return false;
|
|
5630
|
-
await this.call('group.v2.confirm_state', { proposal_id: proposalId });
|
|
5631
|
-
this._clientLog.info(`V2 confirmed pending proposal: group=${groupId} proposal=${proposalId}`);
|
|
5632
|
-
return true;
|
|
2418
|
+
return this._groupState.confirmPendingProposal(groupId);
|
|
5633
2419
|
}
|
|
5634
2420
|
async _v2AutoConfirmPendingProposals() {
|
|
5635
|
-
|
|
5636
|
-
const myAid = this._aid ?? '';
|
|
5637
|
-
if (!myAid)
|
|
5638
|
-
return;
|
|
5639
|
-
const groupsResp = await this.call('group.list_my', {});
|
|
5640
|
-
const groups = isJsonObject(groupsResp)
|
|
5641
|
-
? (Array.isArray(groupsResp.groups) ? groupsResp.groups : groupsResp.items)
|
|
5642
|
-
: [];
|
|
5643
|
-
if (!Array.isArray(groups))
|
|
5644
|
-
return;
|
|
5645
|
-
for (const group of groups) {
|
|
5646
|
-
if (!isJsonObject(group))
|
|
5647
|
-
continue;
|
|
5648
|
-
const groupId = String(group.group_id ?? '').trim();
|
|
5649
|
-
const myRole = String(group.role ?? group.my_role ?? '').trim();
|
|
5650
|
-
if (!groupId || (myRole !== 'owner' && myRole !== 'admin'))
|
|
5651
|
-
continue;
|
|
5652
|
-
try {
|
|
5653
|
-
const confirmed = await this._v2ConfirmPendingProposal(groupId);
|
|
5654
|
-
if (!confirmed) {
|
|
5655
|
-
await this._v2AutoProposeState(groupId);
|
|
5656
|
-
}
|
|
5657
|
-
}
|
|
5658
|
-
catch (exc) {
|
|
5659
|
-
this._clientLog.debug(`V2 auto confirm/propose failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
|
|
5660
|
-
}
|
|
5661
|
-
}
|
|
5662
|
-
}
|
|
5663
|
-
catch (exc) {
|
|
5664
|
-
this._clientLog.debug(`V2 auto confirm pending proposals failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
5665
|
-
}
|
|
2421
|
+
return this._groupState.autoConfirmPendingProposals();
|
|
5666
2422
|
}
|
|
5667
2423
|
async _onV2PushNotification(data) {
|
|
5668
|
-
|
|
5669
|
-
return;
|
|
5670
|
-
// 提取 push 通知中的元数据
|
|
5671
|
-
const pushSeq = isJsonObject(data) ? Number(data.seq ?? 0) || 0 : 0;
|
|
5672
|
-
const pushFrom = isJsonObject(data) ? String(data.from_aid ?? '') : '';
|
|
5673
|
-
const pushMsgId = isJsonObject(data) ? String(data.message_id ?? '') : '';
|
|
5674
|
-
const envelopeJson = isJsonObject(data) ? data.envelope_json : undefined;
|
|
5675
|
-
const hasPayload = !!envelopeJson;
|
|
5676
|
-
const ns = this._aid ? `p2p:${this._aid}` : '';
|
|
5677
|
-
let contigBefore = ns ? this._seqTracker.getContiguousSeq(ns) : 0;
|
|
5678
|
-
this._clientLog.debug(`_onV2PushNotification: push_seq=${pushSeq || 'null'} push_from=${pushFrom} push_msg_id=${pushMsgId} has_payload=${hasPayload} contiguous_seq=${contigBefore}`);
|
|
5679
|
-
// ── Push 修上界:只更新 maxSeenSeq,不动 contiguousSeq ──
|
|
5680
|
-
// 即使 pushSeq 是脏数据(如服务端 bug 导致的 99999),也只影响"已知上界",
|
|
5681
|
-
// 不会污染下界 contiguousSeq,更不会导致 SDK 把脏数据 ack 回服务端。
|
|
5682
|
-
if (pushSeq > 0 && ns) {
|
|
5683
|
-
this._seqTracker.updateMaxSeen(ns, pushSeq);
|
|
5684
|
-
if (contigBefore === pushSeq) {
|
|
5685
|
-
this._clientLog.debug(`_onV2PushNotification: push seq=${pushSeq} already covered by contiguous_seq=${contigBefore}, ignore duplicate push`);
|
|
5686
|
-
return;
|
|
5687
|
-
}
|
|
5688
|
-
contigBefore = this._repairPushContiguousBound(ns, pushSeq, hasPayload, '_raw.peer.v2.message_received');
|
|
5689
|
-
}
|
|
5690
|
-
// ── 带 payload 的 push:尝试就地解密 ──
|
|
5691
|
-
if (hasPayload && pushSeq > 0 && ns) {
|
|
5692
|
-
try {
|
|
5693
|
-
const decrypted = await this._decryptV2PushMessage(data);
|
|
5694
|
-
if (decrypted) {
|
|
5695
|
-
// 解密成功也不能先推进 contiguousSeq;必须等应用层发布返回后再推进和 ACK。
|
|
5696
|
-
const published = await this._publishOrderedMessage('message.received', ns, pushSeq, decrypted);
|
|
5697
|
-
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5698
|
-
const needPull = pushSeq > newContig && !published;
|
|
5699
|
-
if (newContig !== contigBefore) {
|
|
5700
|
-
this._saveSeqTrackerState();
|
|
5701
|
-
}
|
|
5702
|
-
if (newContig > 0 && newContig !== contigBefore) {
|
|
5703
|
-
// ack clamp:永远不发送超过 maxSeenSeq 的 up_to_seq
|
|
5704
|
-
const maxSeen = this._seqTracker.getMaxSeenSeq(ns);
|
|
5705
|
-
const ackSeq = maxSeen > 0 ? Math.min(newContig, maxSeen) : newContig;
|
|
5706
|
-
this.call('message.v2.ack', { up_to_seq: ackSeq, _rpc_background: true })
|
|
5707
|
-
.catch(e => this._clientLog.debug(`V2 P2P push-ack failed: ${formatCaughtError(e)}`));
|
|
5708
|
-
}
|
|
5709
|
-
this._clientLog.debug(`_onV2PushNotification: push 带 payload 解密成功, contiguous_seq=${contigBefore}->${newContig} push_seq=${pushSeq}`);
|
|
5710
|
-
if (!needPull && (published || newContig >= pushSeq || pushSeq <= contigBefore)) {
|
|
5711
|
-
return;
|
|
5712
|
-
}
|
|
5713
|
-
this._clientLog.debug(`_onV2PushNotification: payload push seq=${pushSeq} 因空洞挂起,继续 pull 补齐 after_seq=${newContig}`);
|
|
5714
|
-
}
|
|
5715
|
-
}
|
|
5716
|
-
catch (exc) {
|
|
5717
|
-
this._clientLog.debug(`_onV2PushNotification: push payload 解密失败, fallback to pull: ${formatCaughtError(exc)}`);
|
|
5718
|
-
}
|
|
5719
|
-
}
|
|
5720
|
-
// ── 不带 payload 或解密失败:触发 pull ──
|
|
5721
|
-
// 纯通知只表示服务端已有 pushSeq 这条消息,内容还没有进入本地,不能先推进 contiguousSeq。
|
|
5722
|
-
// 后续 pull 必须从当前 contiguousSeq 开始,否则会跳过 pushSeq 本身。
|
|
5723
|
-
if (pushSeq > 0 && ns) {
|
|
5724
|
-
this._clientLog.debug(`_onV2PushNotification: 纯通知 push_seq=${pushSeq} > contiguous_seq=${contigBefore}, 触发 pull(after_seq=${contigBefore})`);
|
|
5725
|
-
}
|
|
5726
|
-
if (!ns)
|
|
5727
|
-
return;
|
|
5728
|
-
void this._tryRunBackgroundPull(ns, async () => {
|
|
5729
|
-
const operationBefore = this._seqTracker.getContiguousSeq(ns);
|
|
5730
|
-
const dedupKey = `p2p_pull:${ns}`;
|
|
5731
|
-
if (this._gapFillDone.has(dedupKey)) {
|
|
5732
|
-
this._recordPendingP2pPull(ns, pushSeq);
|
|
5733
|
-
return 0;
|
|
5734
|
-
}
|
|
5735
|
-
this._gapFillDone.set(dedupKey, Date.now());
|
|
5736
|
-
try {
|
|
5737
|
-
const pulled = await this._pullV2(0, 50, { gateLocked: true });
|
|
5738
|
-
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5739
|
-
this._clientLog.debug(`_onV2PushNotification pull done: contiguous_seq=${contigBefore}->${newContig} (push_seq=${pushSeq || 'null'})`);
|
|
5740
|
-
if (newContig <= operationBefore)
|
|
5741
|
-
return 0;
|
|
5742
|
-
return pulled.length;
|
|
5743
|
-
}
|
|
5744
|
-
finally {
|
|
5745
|
-
this._gapFillDone.delete(dedupKey);
|
|
5746
|
-
}
|
|
5747
|
-
}, true, () => this._recordPendingP2pPull(ns, pushSeq)).catch((exc) => {
|
|
5748
|
-
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5749
|
-
this._clientLog.warn(`V2 push auto-pull failed: contiguous_seq=${contigBefore}->${newContig} err=${formatCaughtError(exc)}`);
|
|
5750
|
-
});
|
|
2424
|
+
return this._delivery.onV2PushNotification(data);
|
|
5751
2425
|
}
|
|
5752
2426
|
async _onV2StateProposed(data) {
|
|
5753
|
-
|
|
5754
|
-
return;
|
|
5755
|
-
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5756
|
-
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
5757
|
-
if (!groupId)
|
|
5758
|
-
return;
|
|
5759
|
-
await this._dispatcher.publish('group.v2.state_proposed', data);
|
|
5760
|
-
try {
|
|
5761
|
-
await this._v2ConfirmPendingProposal(groupId);
|
|
5762
|
-
}
|
|
5763
|
-
catch (exc) {
|
|
5764
|
-
this._clientLog.debug(`V2 state_proposed handling failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
|
|
5765
|
-
}
|
|
2427
|
+
return this._groupState.onV2StateProposed(data);
|
|
5766
2428
|
}
|
|
5767
2429
|
async _onV2StateRetryNeeded(data) {
|
|
5768
|
-
|
|
5769
|
-
return;
|
|
5770
|
-
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5771
|
-
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
5772
|
-
if (!groupId)
|
|
5773
|
-
return;
|
|
5774
|
-
await this._dispatcher.publish('group.v2.state_retry_needed', data);
|
|
5775
|
-
try {
|
|
5776
|
-
await this._v2AutoProposeState(groupId, { leaderDelay: true });
|
|
5777
|
-
}
|
|
5778
|
-
catch (exc) {
|
|
5779
|
-
this._clientLog.debug(`V2 state_retry_needed handling failed (non-fatal): group=${groupId} err=${formatCaughtError(exc)}`);
|
|
5780
|
-
}
|
|
2430
|
+
return this._groupState.onV2StateRetryNeeded(data);
|
|
5781
2431
|
}
|
|
5782
2432
|
async _onV2StateConfirmed(data) {
|
|
5783
|
-
|
|
5784
|
-
return;
|
|
5785
|
-
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5786
|
-
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
5787
|
-
if (groupId) {
|
|
5788
|
-
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
5789
|
-
this._v2AutoProposeLastSnapshot.delete(groupId);
|
|
5790
|
-
}
|
|
5791
|
-
await this._dispatcher.publish('group.v2.state_confirmed', data);
|
|
2433
|
+
return this._groupState.onV2StateConfirmed(data);
|
|
5792
2434
|
}
|
|
5793
2435
|
async _onRawGroupV2MessageCreated(data) {
|
|
5794
|
-
|
|
5795
|
-
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: is_object=${String(isJsonObject(data))}, has_v2_session=${String(!!this._v2Session)}`);
|
|
5796
|
-
return;
|
|
5797
|
-
}
|
|
5798
|
-
this._logMessageDebug('server-push', '_raw.group.v2.message_created', 'group.message_created', data);
|
|
5799
|
-
const rawGroupId = String(data.group_id ?? '').trim();
|
|
5800
|
-
const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
|
|
5801
|
-
const seq = Number(data.seq ?? 0);
|
|
5802
|
-
if (!groupId || !Number.isFinite(seq) || seq <= 0) {
|
|
5803
|
-
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped: group=${groupId || '<empty>'}, seq=${String(data.seq ?? '')}`);
|
|
5804
|
-
return;
|
|
5805
|
-
}
|
|
5806
|
-
const ns = `group:${groupId}`;
|
|
5807
|
-
// Push 修上界:先更新 maxSeenSeq
|
|
5808
|
-
this._seqTracker.updateMaxSeen(ns, seq);
|
|
5809
|
-
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
5810
|
-
this._clientLog.debug(`_onRawGroupV2MessageCreated enter: group=${groupId}, seq=${seq}, contiguous=${contigBefore}, max_seen=${this._seqTracker.getMaxSeenSeq(ns)}`);
|
|
5811
|
-
if (contigBefore === seq) {
|
|
5812
|
-
this._clientLog.debug(`_onRawGroupV2MessageCreated duplicate push already covered: group=${groupId} seq=${seq}`);
|
|
5813
|
-
return;
|
|
5814
|
-
}
|
|
5815
|
-
const afterSeq = this._repairPushContiguousBound(ns, seq, false, '_raw.group.v2.message_created');
|
|
5816
|
-
const dedupKey = `v2_group_push:${groupId}:${afterSeq}`;
|
|
5817
|
-
void this._tryRunBackgroundPull(ns, async () => {
|
|
5818
|
-
const pullAfterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
5819
|
-
if (this._gapFillDone.has(dedupKey)) {
|
|
5820
|
-
this._clientLog.debug(`_onRawGroupV2MessageCreated skipped duplicate in-flight pull: group=${groupId}, dedup=${dedupKey}`);
|
|
5821
|
-
return 0;
|
|
5822
|
-
}
|
|
5823
|
-
this._gapFillDone.set(dedupKey, Date.now());
|
|
5824
|
-
try {
|
|
5825
|
-
this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull start: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}`);
|
|
5826
|
-
const pulled = await this._pullGroupV2(groupId, pullAfterSeq, 50, { gateLocked: true });
|
|
5827
|
-
const newContig = this._seqTracker.getContiguousSeq(ns);
|
|
5828
|
-
this._clientLog.debug(`_onRawGroupV2MessageCreated auto-pull done: group=${groupId}, after_seq=${pullAfterSeq}, push_seq=${seq}, contiguous=${newContig}`);
|
|
5829
|
-
if (newContig <= pullAfterSeq)
|
|
5830
|
-
return 0;
|
|
5831
|
-
return pulled.length;
|
|
5832
|
-
}
|
|
5833
|
-
finally {
|
|
5834
|
-
this._gapFillDone.delete(dedupKey);
|
|
5835
|
-
}
|
|
5836
|
-
}, true).catch((exc) => {
|
|
5837
|
-
this._clientLog.warn(`V2 group push auto-pull failed: group=${groupId} err=${formatCaughtError(exc)}`);
|
|
5838
|
-
});
|
|
2436
|
+
return this._delivery.onRawGroupV2MessageCreated(data);
|
|
5839
2437
|
}
|
|
5840
2438
|
/** Push 通知带 payload 时的就地解密(复用 _decryptV2Message) */
|
|
5841
2439
|
async _decryptV2PushMessage(data) {
|
|
5842
|
-
|
|
5843
|
-
return null;
|
|
5844
|
-
return await this._decryptV2Message(data);
|
|
2440
|
+
return await this._v2E2EE.decryptV2PushMessage(data);
|
|
5845
2441
|
}
|
|
5846
2442
|
async _onV2EpochRotated(data) {
|
|
5847
|
-
|
|
5848
|
-
return;
|
|
5849
|
-
const groupId = String(data.group_id ?? '').trim();
|
|
5850
|
-
if (!groupId)
|
|
5851
|
-
return;
|
|
5852
|
-
this._v2BootstrapCache.delete(`group:${groupId}`);
|
|
5853
|
-
if (!this._v2Session)
|
|
5854
|
-
return;
|
|
5855
|
-
try {
|
|
5856
|
-
await this._v2Session.rotateSPK(this._v2CallFn());
|
|
5857
|
-
this._clientLog.info(`SPK rotated after V2 epoch change: group=${groupId} epoch=${String(data.epoch ?? '')}`);
|
|
5858
|
-
}
|
|
5859
|
-
catch (exc) {
|
|
5860
|
-
this._clientLog.debug(`SPK rotation after V2 epoch change failed (non-fatal): ${formatCaughtError(exc)}`);
|
|
5861
|
-
}
|
|
2443
|
+
return this._v2E2EE.handleV2EpochRotated(data);
|
|
5862
2444
|
}
|
|
5863
2445
|
/** 按当前 AID 发现 Gateway;用于 authenticate()/connect() 的新入口。 */
|
|
5864
2446
|
async _resolveGatewayForAid(aid) {
|
|
@@ -6199,78 +2781,14 @@ export class AUNClient {
|
|
|
6199
2781
|
};
|
|
6200
2782
|
scheduleNext(0);
|
|
6201
2783
|
}
|
|
6202
|
-
_normalizeOutboundMessagePayload(params, method = '') {
|
|
6203
|
-
if (!Object.prototype.hasOwnProperty.call(params, 'payload') && Object.prototype.hasOwnProperty.call(params, 'content')) {
|
|
6204
|
-
params.payload = params.content;
|
|
6205
|
-
delete params.content;
|
|
6206
|
-
}
|
|
6207
|
-
const payload = params.payload;
|
|
6208
|
-
if (isJsonObject(payload) && !Object.prototype.hasOwnProperty.call(payload, 'type') && typeof payload.text === 'string') {
|
|
6209
|
-
params.payload = { type: 'text', ...payload };
|
|
6210
|
-
}
|
|
6211
|
-
}
|
|
6212
2784
|
_validateMessageRecipient(toAid) {
|
|
6213
|
-
|
|
6214
|
-
throw new ValidationError('message.send receiver cannot be group.{issuer}; use group.send instead');
|
|
6215
|
-
}
|
|
2785
|
+
this._rpcPipeline.validateMessageRecipient(toAid);
|
|
6216
2786
|
}
|
|
6217
2787
|
_validateOutboundCall(method, params) {
|
|
6218
|
-
|
|
6219
|
-
this._validateMessageRecipient(params.to);
|
|
6220
|
-
if ('persist' in params) {
|
|
6221
|
-
throw new ValidationError("message.send no longer accepts 'persist'; configure delivery_mode during connect");
|
|
6222
|
-
}
|
|
6223
|
-
if ('delivery_mode' in params || 'queue_routing' in params || 'affinity_ttl_ms' in params) {
|
|
6224
|
-
throw new ValidationError('message.send does not accept delivery_mode; configure delivery_mode during connect');
|
|
6225
|
-
}
|
|
6226
|
-
}
|
|
6227
|
-
if (method === 'group.send') {
|
|
6228
|
-
if ('persist' in params) {
|
|
6229
|
-
throw new ValidationError("group.send does not accept 'persist'; group messages are always fanout");
|
|
6230
|
-
}
|
|
6231
|
-
if ('delivery_mode' in params || 'queue_routing' in params || 'affinity_ttl_ms' in params) {
|
|
6232
|
-
throw new ValidationError('group.send does not accept delivery_mode; group messages are always fanout');
|
|
6233
|
-
}
|
|
6234
|
-
}
|
|
6235
|
-
if (method === 'group.thought.put' || method === 'group.thought.get'
|
|
6236
|
-
|| method === 'message.thought.put' || method === 'message.thought.get') {
|
|
6237
|
-
const context = isJsonObject(params.context) ? params.context : null;
|
|
6238
|
-
const contextType = String(context?.type ?? '').trim();
|
|
6239
|
-
const contextId = String(context?.id ?? '').trim();
|
|
6240
|
-
const hasContext = contextType.length > 0 && contextId.length > 0;
|
|
6241
|
-
if (!hasContext) {
|
|
6242
|
-
throw new ValidationError(`${method} requires context.type + context.id`);
|
|
6243
|
-
}
|
|
6244
|
-
}
|
|
6245
|
-
if (method === 'group.thought.get' && !String(params.sender_aid ?? '').trim()) {
|
|
6246
|
-
throw new ValidationError('group.thought.get requires sender_aid');
|
|
6247
|
-
}
|
|
6248
|
-
if (method === 'message.thought.put') {
|
|
6249
|
-
this._validateMessageRecipient(params.to);
|
|
6250
|
-
if (!String(params.to ?? '').trim()) {
|
|
6251
|
-
throw new ValidationError('message.thought.put requires to');
|
|
6252
|
-
}
|
|
6253
|
-
}
|
|
6254
|
-
if (method === 'message.thought.get' && !String(params.sender_aid ?? '').trim()) {
|
|
6255
|
-
throw new ValidationError('message.thought.get requires sender_aid');
|
|
6256
|
-
}
|
|
6257
|
-
}
|
|
6258
|
-
_currentMessageDeliveryMode() {
|
|
6259
|
-
return { ...this._connectDeliveryMode };
|
|
2788
|
+
this._rpcPipeline.validateOutboundCall(method, params);
|
|
6260
2789
|
}
|
|
6261
2790
|
_injectMessageCursorContext(method, params) {
|
|
6262
|
-
|
|
6263
|
-
return;
|
|
6264
|
-
}
|
|
6265
|
-
if ('device_id' in params && String(params.device_id ?? '').trim() !== this._deviceId) {
|
|
6266
|
-
throw new ValidationError('message.pull/message.ack device_id must match the current client instance');
|
|
6267
|
-
}
|
|
6268
|
-
const slotId = normalizeSlotId(params.slot_id ?? this._slotId, this._slotId);
|
|
6269
|
-
if (slotIsolationKey(slotId) !== slotIsolationKey(this._slotId)) {
|
|
6270
|
-
throw new ValidationError('message.pull/message.ack slot_id must match the current client instance');
|
|
6271
|
-
}
|
|
6272
|
-
params.device_id = this._deviceId;
|
|
6273
|
-
params.slot_id = this._slotId;
|
|
2791
|
+
this._rpcPipeline.injectMessageCursorContext(method, params);
|
|
6274
2792
|
}
|
|
6275
2793
|
/** 启动 V2 缓存清理后台任务 */
|
|
6276
2794
|
_startV2MaintenanceTasks() {
|
|
@@ -6289,11 +2807,7 @@ export class AUNClient {
|
|
|
6289
2807
|
this._gapFillDone.delete(k);
|
|
6290
2808
|
}
|
|
6291
2809
|
const now = Date.now();
|
|
6292
|
-
|
|
6293
|
-
if (now - entry.cachedAt >= AUNClient.V2_BOOTSTRAP_TTL_MS) {
|
|
6294
|
-
this._v2BootstrapCache.delete(key);
|
|
6295
|
-
}
|
|
6296
|
-
}
|
|
2810
|
+
this._v2E2EE.pruneExpiredBootstrapCache(AUNClient.V2_BOOTSTRAP_TTL_MS, now);
|
|
6297
2811
|
for (const [key, exp] of this._v2SigCache) {
|
|
6298
2812
|
if (exp <= now)
|
|
6299
2813
|
this._v2SigCache.delete(key);
|
|
@@ -6484,76 +2998,6 @@ export class AUNClient {
|
|
|
6484
2998
|
}
|
|
6485
2999
|
this._reconnectActive = false;
|
|
6486
3000
|
}
|
|
6487
|
-
// ── Named Group(命名群)高层 API ────────────────────────────
|
|
6488
|
-
/**
|
|
6489
|
-
* 创建命名群:群/P2P 私钥由 V2 数据库存储,不写入 AID 身份私钥存储。
|
|
6490
|
-
*/
|
|
6491
|
-
async createNamedGroup(groupName, opts = {}) {
|
|
6492
|
-
const tStart = Date.now();
|
|
6493
|
-
this._clientLog.debug(`createNamedGroup enter: groupName=${groupName}`);
|
|
6494
|
-
try {
|
|
6495
|
-
const cp = new CryptoProvider();
|
|
6496
|
-
const identity = cp.generateIdentity();
|
|
6497
|
-
const params = {};
|
|
6498
|
-
for (const [k, v] of Object.entries(opts)) {
|
|
6499
|
-
params[k] = v;
|
|
6500
|
-
}
|
|
6501
|
-
params.group_name = groupName;
|
|
6502
|
-
params.public_key = identity.public_key_der_b64;
|
|
6503
|
-
params.curve = 'P-256';
|
|
6504
|
-
const result = await this.call('group.create', params);
|
|
6505
|
-
const groupInfo = result?.group;
|
|
6506
|
-
const aidCert = result?.aid_cert;
|
|
6507
|
-
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
6508
|
-
if (groupAid && aidCert) {
|
|
6509
|
-
this._saveGroupIdentityToV2(groupAid, identity);
|
|
6510
|
-
const certPem = String(aidCert.cert ?? '');
|
|
6511
|
-
if (certPem) {
|
|
6512
|
-
this._tokenStore.saveCert(groupAid, certPem);
|
|
6513
|
-
}
|
|
6514
|
-
}
|
|
6515
|
-
this._clientLog.debug(`createNamedGroup exit: elapsed=${Date.now() - tStart}ms groupAid=${groupAid}`);
|
|
6516
|
-
return result;
|
|
6517
|
-
}
|
|
6518
|
-
catch (err) {
|
|
6519
|
-
this._clientLog.debug(`createNamedGroup exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
6520
|
-
throw err;
|
|
6521
|
-
}
|
|
6522
|
-
}
|
|
6523
|
-
/**
|
|
6524
|
-
* 为已有普通群绑定命名 AID(升级为命名群)。
|
|
6525
|
-
*/
|
|
6526
|
-
async bindGroupAid(groupId, groupName) {
|
|
6527
|
-
const tStart = Date.now();
|
|
6528
|
-
this._clientLog.debug(`bindGroupAid enter: groupId=${groupId}, groupName=${groupName}`);
|
|
6529
|
-
try {
|
|
6530
|
-
const cp = new CryptoProvider();
|
|
6531
|
-
const identity = cp.generateIdentity();
|
|
6532
|
-
const params = {
|
|
6533
|
-
group_id: groupId,
|
|
6534
|
-
group_name: groupName,
|
|
6535
|
-
public_key: identity.public_key_der_b64,
|
|
6536
|
-
curve: 'P-256',
|
|
6537
|
-
};
|
|
6538
|
-
const result = await this.call('group.bind_aid', params);
|
|
6539
|
-
const groupInfo = result?.group;
|
|
6540
|
-
const aidCert = result?.aid_cert;
|
|
6541
|
-
const groupAid = String(groupInfo?.group_aid ?? '');
|
|
6542
|
-
if (groupAid && aidCert) {
|
|
6543
|
-
this._saveGroupIdentityToV2(groupAid, identity);
|
|
6544
|
-
const certPem = String(aidCert.cert ?? '');
|
|
6545
|
-
if (certPem) {
|
|
6546
|
-
this._tokenStore.saveCert(groupAid, certPem);
|
|
6547
|
-
}
|
|
6548
|
-
}
|
|
6549
|
-
this._clientLog.debug(`bindGroupAid exit: elapsed=${Date.now() - tStart}ms groupAid=${groupAid}`);
|
|
6550
|
-
return result;
|
|
6551
|
-
}
|
|
6552
|
-
catch (err) {
|
|
6553
|
-
this._clientLog.debug(`bindGroupAid exit (error): elapsed=${Date.now() - tStart}ms err=${err instanceof Error ? err.message : String(err)}`);
|
|
6554
|
-
throw err;
|
|
6555
|
-
}
|
|
6556
|
-
}
|
|
6557
3001
|
/** 判断是否应重试重连 */
|
|
6558
3002
|
static _shouldRetryReconnect(error) {
|
|
6559
3003
|
if (error instanceof AuthError) {
|