@agentunion/fastaun 0.3.2 → 0.3.3

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