@agentunion/fastaun-browser 0.2.13 → 0.2.15

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/client.js CHANGED
@@ -12,6 +12,7 @@ import { AuthFlow } from './auth.js';
12
12
  import { SeqTracker } from './seq-tracker.js';
13
13
  import { AuthNamespace } from './namespaces/auth.js';
14
14
  import { CustodyNamespace } from './namespaces/custody.js';
15
+ import { MetaNamespace } from './namespaces/meta.js';
15
16
  import { CryptoProvider, uint8ToBase64, base64ToUint8, pemToArrayBuffer, p1363ToDer, toBufferSource } from './crypto.js';
16
17
  import { E2EEManager, _certificateSha256Fingerprint as certificateSha256Fingerprint, _ecdsaVerifyDer as ecdsaVerifyDer, _importCertPublicKeyEcdsa as importCertPublicKeyEcdsa, } from './e2ee.js';
17
18
  import { GroupE2EEManager, computeMembershipCommitment, storeGroupSecret, buildKeyDistribution, buildKeyRequest, buildMembershipManifest, signMembershipManifest, } from './e2ee-group.js';
@@ -59,6 +60,9 @@ const SIGNED_METHODS = new Set([
59
60
  'group.transfer_owner', 'group.review_join_request',
60
61
  'group.batch_review_join_request',
61
62
  'group.request_join', 'group.use_invite_code',
63
+ 'group.thought.put',
64
+ 'message.thought.put',
65
+ 'group.set_settings',
62
66
  'group.resources.put', 'group.resources.update',
63
67
  'group.resources.delete', 'group.resources.request_add',
64
68
  'group.resources.direct_add', 'group.resources.approve_request',
@@ -84,6 +88,21 @@ const RECONNECT_MIN_BASE_DELAY_SECONDS = 1.0;
84
88
  const RECONNECT_MAX_BASE_DELAY_SECONDS = 64.0;
85
89
  const GROUP_ROTATION_LEASE_MS = 120_000;
86
90
  const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
91
+ const PENDING_DECRYPT_LIMIT = 100;
92
+ const PUSHED_SEQS_LIMIT = 50_000;
93
+ const PENDING_ORDERED_LIMIT = 50_000;
94
+ // P1-23: 非幂等方法使用更长超时(35s),避免 SDK 10s 超时 < gateway 30s 处理时间
95
+ const NON_IDEMPOTENT_TIMEOUT = 35;
96
+ const NON_IDEMPOTENT_METHODS = new Set([
97
+ 'message.send', 'group.send', 'group.create', 'group.invite',
98
+ 'group.kick', 'group.remove_member', 'group.leave', 'group.dissolve',
99
+ 'group.update_name', 'group.update_avatar', 'group.update_announcement',
100
+ 'group.update_settings', 'group.rotate_epoch',
101
+ 'storage.upload', 'storage.complete_upload', 'storage.delete',
102
+ 'auth.create_aid', 'auth.renew_cert', 'auth.rekey',
103
+ 'message.thought.put', 'group.thought.put',
104
+ 'group.add_member',
105
+ ]);
87
106
  function clampReconnectDelaySeconds(value, fallback, upper = RECONNECT_MAX_BASE_DELAY_SECONDS) {
88
107
  const parsed = Number(value);
89
108
  const seconds = Number.isFinite(parsed) ? parsed : fallback;
@@ -151,6 +170,7 @@ function isGroupServiceAid(value) {
151
170
  const [name, ...issuerParts] = text.split('.');
152
171
  return name === 'group' && issuerParts.join('.').length > 0;
153
172
  }
173
+ const PREKEY_FALLBACK_DEVICE_ID = 'aun_device_id';
154
174
  function isPeerPrekeyMaterial(value) {
155
175
  if (!isJsonObject(value))
156
176
  return false;
@@ -168,6 +188,52 @@ function isPeerPrekeyResponse(value) {
168
188
  return false;
169
189
  return candidate.prekey === undefined || isPeerPrekeyMaterial(candidate.prekey);
170
190
  }
191
+ function normalizePeerPrekeys(prekeys) {
192
+ const normalized = [];
193
+ for (const item of prekeys) {
194
+ if (!isPeerPrekeyMaterial(item))
195
+ continue;
196
+ const prekeyId = item.prekey_id.trim();
197
+ const publicKey = item.public_key.trim();
198
+ const signature = item.signature.trim();
199
+ if (!prekeyId || !publicKey || !signature)
200
+ continue;
201
+ const deviceId = String(item.device_id ?? '').trim();
202
+ const certFingerprint = String(item.cert_fingerprint ?? '').trim().toLowerCase();
203
+ const candidate = {
204
+ ...item,
205
+ prekey_id: prekeyId,
206
+ public_key: publicKey,
207
+ signature,
208
+ device_id: deviceId,
209
+ };
210
+ if (certFingerprint) {
211
+ candidate.cert_fingerprint = certFingerprint;
212
+ }
213
+ else {
214
+ delete candidate.cert_fingerprint;
215
+ }
216
+ normalized.push(candidate);
217
+ }
218
+ if (normalized.length === 0)
219
+ return [];
220
+ if (normalized.length === 1) {
221
+ if (!String(normalized[0].device_id ?? '').trim()) {
222
+ normalized[0].device_id = PREKEY_FALLBACK_DEVICE_ID;
223
+ }
224
+ return normalized;
225
+ }
226
+ const seen = new Set();
227
+ const filtered = [];
228
+ for (const item of normalized) {
229
+ const deviceId = String(item.device_id ?? '').trim();
230
+ if (!deviceId || deviceId === PREKEY_FALLBACK_DEVICE_ID || seen.has(deviceId))
231
+ continue;
232
+ seen.add(deviceId);
233
+ filtered.push(item);
234
+ }
235
+ return filtered;
236
+ }
171
237
  function formatCaughtError(error) {
172
238
  return error instanceof Error ? error : String(error);
173
239
  }
@@ -249,6 +315,8 @@ export class AUNClient {
249
315
  auth;
250
316
  /** AID 托管命名空间 */
251
317
  custody;
318
+ /** 元数据命名空间(心跳、状态、信任根管理) */
319
+ meta;
252
320
  // E2EE 编排状态(内存缓存)
253
321
  _certCache = new Map();
254
322
  _prekeyReplenishInflight = new Set();
@@ -269,8 +337,10 @@ export class AUNClient {
269
337
  _seqTrackerContext = null;
270
338
  /** 补洞去重:已完成/进行中的 key 集合,防止重复 pull 同一区间 */
271
339
  _gapFillDone = new Set();
272
- /** 推送路径已分发的 seq 集合(按命名空间),补洞路径 publish 前检查以避免重复分发 */
340
+ /** 已发布到应用层的 seq 集合(按命名空间),补洞路径 publish 前检查以避免重复分发 */
273
341
  _pushedSeqs = new Map();
342
+ /** 已解密但因 seq 空洞暂缓发布的应用层消息(按 namespace -> seq) */
343
+ _pendingOrderedMsgs = new Map();
274
344
  _pendingDecryptMsgs = new Map();
275
345
  _groupEpochRotationInflight = new Set();
276
346
  _groupEpochRecoveryInflight = new Map();
@@ -330,6 +400,7 @@ export class AUNClient {
330
400
  });
331
401
  this.auth = new AuthNamespace(this);
332
402
  this.custody = new CustodyNamespace(this);
403
+ this.meta = new MetaNamespace(this);
333
404
  // 内部订阅:推送消息自动解密后 re-publish 给用户
334
405
  this._dispatcher.subscribe('_raw.message.received', (data) => {
335
406
  this._onRawMessageReceived(data);
@@ -343,7 +414,7 @@ export class AUNClient {
343
414
  this._onRawGroupChanged(data);
344
415
  });
345
416
  // 其他事件直接透传
346
- for (const evt of ['message.recalled', 'message.ack']) {
417
+ for (const evt of ['message.recalled', 'message.ack', 'storage.object_changed']) {
347
418
  this._dispatcher.subscribe(`_raw.${evt}`, (data) => {
348
419
  this._dispatcher.publish(evt, data);
349
420
  });
@@ -513,6 +584,8 @@ export class AUNClient {
513
584
  if (encrypt) {
514
585
  return this._sendEncrypted(p);
515
586
  }
587
+ delete p.protected_headers;
588
+ delete p.headers;
516
589
  }
517
590
  // 自动加密:group.send 默认加密(encrypt 默认 true)
518
591
  if (method === 'group.send') {
@@ -521,12 +594,34 @@ export class AUNClient {
521
594
  if (encrypt) {
522
595
  return this._sendGroupEncrypted(p);
523
596
  }
597
+ delete p.protected_headers;
598
+ delete p.headers;
599
+ }
600
+ if (method === 'group.thought.put') {
601
+ const encrypt = p.encrypt !== undefined ? p.encrypt : true;
602
+ delete p.encrypt;
603
+ if (!encrypt) {
604
+ throw new ValidationError('group.thought.put requires encrypt=true');
605
+ }
606
+ return this._putGroupThoughtEncrypted(p);
607
+ }
608
+ if (method === 'message.thought.put') {
609
+ const encrypt = p.encrypt !== undefined ? p.encrypt : true;
610
+ delete p.encrypt;
611
+ if (!encrypt) {
612
+ throw new ValidationError('message.thought.put requires encrypt=true');
613
+ }
614
+ return this._putMessageThoughtEncrypted(p);
524
615
  }
525
616
  // 关键操作自动附加客户端签名
526
617
  if (SIGNED_METHODS.has(method)) {
527
618
  await this._signClientOperation(method, p);
528
619
  }
529
- const result = await this._transport.call(method, p);
620
+ // P1-23: 非幂等方法使用更长超时
621
+ const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT : undefined;
622
+ const result = callTimeout
623
+ ? await this._transport.call(method, p, callTimeout)
624
+ : await this._transport.call(method, p);
530
625
  // 自动解密:message.pull 返回的消息
531
626
  if (method === 'message.pull' && isJsonObject(result)) {
532
627
  const r = result;
@@ -560,6 +655,7 @@ export class AUNClient {
560
655
  this._transport.call('message.ack', {
561
656
  seq: contig,
562
657
  device_id: this._deviceId,
658
+ slot_id: this._slotId,
563
659
  }).catch((e) => { console.warn('message.pull auto-ack 失败:', e); });
564
660
  }
565
661
  }
@@ -608,6 +704,12 @@ export class AUNClient {
608
704
  }
609
705
  }
610
706
  }
707
+ if (method === 'group.thought.get' && isJsonObject(result)) {
708
+ return this._decryptGroupThoughts(result);
709
+ }
710
+ if (method === 'message.thought.get' && isJsonObject(result)) {
711
+ return this._decryptMessageThoughts(result);
712
+ }
611
713
  // ── Group E2EE 自动编排 ────────────────────────────
612
714
  // ── Group E2EE 自动编排(必备能力,始终启用)────────
613
715
  {
@@ -639,20 +741,23 @@ export class AUNClient {
639
741
  const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
640
742
  if (groupId && this._membershipRotationChanged(method, result)) {
641
743
  const expectedEpoch = this._membershipRotationExpectedEpoch(result);
642
- this._safeAsync(this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch));
744
+ // P0-12: await rotation 完成(带超时兜底),确保后续 group.send 使用新 epoch
745
+ const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch);
746
+ const timeoutPromise = new Promise((resolve) => globalThis.setTimeout(resolve, 5000));
747
+ await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => console.warn('membership RPC epoch rotation fallback failed:', exc));
643
748
  }
644
749
  }
645
750
  return result;
646
751
  }
647
752
  // ── 便利方法 ──────────────────────────────────────
648
753
  async ping(params) {
649
- return this.call('meta.ping', params ?? {});
754
+ return this.meta.ping(params);
650
755
  }
651
756
  async status(params) {
652
- return this.call('meta.status', params ?? {});
757
+ return this.meta.status(params);
653
758
  }
654
759
  async trustRoots(params) {
655
- return this.call('meta.trust_roots', params ?? {});
760
+ return this.meta.trustRoots(params);
656
761
  }
657
762
  // ── 事件 ──────────────────────────────────────────
658
763
  /**
@@ -677,10 +782,13 @@ export class AUNClient {
677
782
  async _processAndPublishMessage(data) {
678
783
  try {
679
784
  if (!isJsonObject(data)) {
680
- await this._dispatcher.publish('message.received', data);
785
+ await this._publishAppEvent('message.received', data);
681
786
  return;
682
787
  }
683
788
  const msg = { ...data };
789
+ if (!this._messageTargetsCurrentInstance(msg)) {
790
+ return;
791
+ }
684
792
  // 拦截 P2P 传输的群组密钥分发/请求/响应消息
685
793
  if (await this._tryHandleGroupKeyMessage(msg)) {
686
794
  return;
@@ -701,20 +809,20 @@ export class AUNClient {
701
809
  this._transport.call('message.ack', {
702
810
  seq: contig,
703
811
  device_id: this._deviceId,
812
+ slot_id: this._slotId,
704
813
  }).catch((e) => { console.warn('P2P auto-ack 失败:', e); });
705
814
  }
706
815
  // 即时持久化 cursor,异常断连后不回退
707
816
  this._saveSeqTrackerState();
708
817
  }
709
818
  const decrypted = await this._decryptSingleMessage(msg);
710
- // 记录已推送的 seq,补洞路径据此去重
711
819
  if (seq !== undefined && seq !== null && this._aid) {
712
820
  const ns = `p2p:${this._aid}`;
713
- if (!this._pushedSeqs.has(ns))
714
- this._pushedSeqs.set(ns, new Set());
715
- this._pushedSeqs.get(ns).add(seq);
821
+ await this._publishOrderedMessage('message.received', ns, seq, decrypted);
822
+ }
823
+ else {
824
+ await this._publishAppEvent('message.received', decrypted);
716
825
  }
717
- await this._dispatcher.publish('message.received', decrypted);
718
826
  }
719
827
  catch (exc) {
720
828
  console.warn('消息解密失败:', exc);
@@ -730,7 +838,7 @@ export class AUNClient {
730
838
  timestamp: (src.timestamp ?? null),
731
839
  _decrypt_error: String(exc),
732
840
  };
733
- await this._dispatcher.publish('message.undecryptable', safeEvent);
841
+ await this._publishAppEvent('message.undecryptable', safeEvent);
734
842
  }
735
843
  }
736
844
  }
@@ -747,7 +855,7 @@ export class AUNClient {
747
855
  async _processAndPublishGroupMessage(data) {
748
856
  try {
749
857
  if (!isJsonObject(data)) {
750
- await this._dispatcher.publish('group.message_created', data);
858
+ await this._publishAppEvent('group.message_created', data);
751
859
  return;
752
860
  }
753
861
  const msg = { ...data };
@@ -783,19 +891,12 @@ export class AUNClient {
783
891
  }
784
892
  this._saveSeqTrackerState();
785
893
  }
786
- // 记录已推送的 seq,补洞路径据此去重
787
- if (groupId && seq !== undefined && seq !== null) {
788
- const nsKey = `group:${groupId}`;
789
- if (!this._pushedSeqs.has(nsKey))
790
- this._pushedSeqs.set(nsKey, new Set());
791
- this._pushedSeqs.get(nsKey).add(seq);
792
- }
793
894
  // R3: 解密失败 → 不 publish 密文给应用层,入 pending 队列 + 发 undecryptable 事件
794
895
  const payloadForCheck = isJsonObject(msg.payload) ? msg.payload : null;
795
896
  if (payloadForCheck?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
796
897
  if (groupId)
797
898
  this._enqueuePendingDecrypt(groupId, msg);
798
- await this._dispatcher.publish('group.message_undecryptable', {
899
+ await this._publishAppEvent('group.message_undecryptable', {
799
900
  message_id: msg.message_id ?? null,
800
901
  group_id: groupId,
801
902
  from: msg.from ?? null,
@@ -805,7 +906,13 @@ export class AUNClient {
805
906
  });
806
907
  return;
807
908
  }
808
- await this._dispatcher.publish('group.message_created', decrypted);
909
+ if (groupId && seq !== undefined && seq !== null) {
910
+ const nsKey = `group:${groupId}`;
911
+ await this._publishOrderedMessage('group.message_created', nsKey, seq, decrypted);
912
+ }
913
+ else {
914
+ await this._publishAppEvent('group.message_created', decrypted);
915
+ }
809
916
  }
810
917
  catch (exc) {
811
918
  console.warn('群消息解密失败:', exc);
@@ -820,7 +927,7 @@ export class AUNClient {
820
927
  timestamp: (src.timestamp ?? null),
821
928
  _decrypt_error: String(exc),
822
929
  };
823
- await this._dispatcher.publish('group.message_undecryptable', safeEvent);
930
+ await this._publishAppEvent('group.message_undecryptable', safeEvent);
824
931
  }
825
932
  }
826
933
  }
@@ -828,7 +935,7 @@ export class AUNClient {
828
935
  async _autoPullGroupMessages(notification) {
829
936
  const groupId = (notification.group_id ?? '');
830
937
  if (!groupId) {
831
- await this._dispatcher.publish('group.message_created', notification);
938
+ await this._publishAppEvent('group.message_created', notification);
832
939
  return;
833
940
  }
834
941
  const ns = `group:${groupId}`;
@@ -844,15 +951,19 @@ export class AUNClient {
844
951
  const messages = result.messages;
845
952
  if (Array.isArray(messages)) {
846
953
  // ⚠️ 不再重复调用 onPullResult:call('group.pull') 拦截器已在内部调用过一次
847
- // pushedSeqs 去重:跳过已通过推送路径分发的消息
848
954
  const pushed = this._pushedSeqs.get(ns);
849
955
  for (const msg of messages) {
850
956
  if (isJsonObject(msg)) {
851
957
  const s = msg.seq;
852
958
  if (pushed && s !== undefined && s !== null && pushed.has(s)) {
853
- continue; // 已通过推送路径分发,跳过
959
+ continue; // 已发布到应用层,跳过
960
+ }
961
+ if (s !== undefined && s !== null) {
962
+ await this._publishOrderedMessage('group.message_created', ns, s, msg);
963
+ }
964
+ else {
965
+ await this._publishAppEvent('group.message_created', msg);
854
966
  }
855
- await this._dispatcher.publish('group.message_created', msg);
856
967
  }
857
968
  }
858
969
  this._prunePushedSeqs(ns);
@@ -864,7 +975,7 @@ export class AUNClient {
864
975
  console.warn('自动 pull 群消息失败:', exc);
865
976
  }
866
977
  // pull 失败时仍透传原始通知
867
- await this._dispatcher.publish('group.message_created', notification);
978
+ await this._publishAppEvent('group.message_created', notification);
868
979
  }
869
980
  /** 后台补齐群消息空洞 */
870
981
  async _fillGroupGap(groupId) {
@@ -873,10 +984,6 @@ export class AUNClient {
873
984
  return;
874
985
  const ns = `group:${groupId}`;
875
986
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
876
- // 冷启动(seq=0):服务端推送会带全量消息,SDK 不主动补洞避免重复拉取
877
- if (afterSeq === 0) {
878
- return;
879
- }
880
987
  // 去重:同一 (group:id:after_seq) 在飞行中只补一次
881
988
  // S1: 使用 try/finally 保证无论成功失败都清理 dedupKey,避免旧实现只在异常路径清理
882
989
  // 导致成功后相同 afterSeq 再次出现空洞时被永久抑制。
@@ -902,7 +1009,12 @@ export class AUNClient {
902
1009
  const s = msg.seq;
903
1010
  if (pushed && s !== undefined && s !== null && pushed.has(s))
904
1011
  continue;
905
- await this._dispatcher.publish('group.message_created', msg);
1012
+ if (s !== undefined && s !== null) {
1013
+ await this._publishOrderedMessage('group.message_created', ns, s, msg);
1014
+ }
1015
+ else {
1016
+ await this._publishAppEvent('group.message_created', msg);
1017
+ }
906
1018
  }
907
1019
  }
908
1020
  this._prunePushedSeqs(ns);
@@ -925,10 +1037,6 @@ export class AUNClient {
925
1037
  return;
926
1038
  const ns = `group_event:${groupId}`;
927
1039
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
928
- // 冷启动(seq=0):服务端推送会带全量事件,SDK 不主动补洞避免重复拉取
929
- if (afterSeq === 0) {
930
- return;
931
- }
932
1040
  // 去重:同一 (group_evt:id:after_seq) 在飞行中只补一次
933
1041
  // S1: 使用 try/finally 保证无论成功失败都清理 dedupKey
934
1042
  const dedupKey = `group_evt:${groupId}:${afterSeq}`;
@@ -947,14 +1055,24 @@ export class AUNClient {
947
1055
  const events = result.events;
948
1056
  if (Array.isArray(events)) {
949
1057
  this._seqTracker.onPullResult(ns, events.filter(isJsonObject));
1058
+ const cursor = isJsonObject(result.cursor) ? result.cursor : null;
1059
+ const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
1060
+ if (serverAck > 0) {
1061
+ const contigBefore = this._seqTracker.getContiguousSeq(ns);
1062
+ if (contigBefore < serverAck) {
1063
+ console.info('[aun_core] group.pull_events retention-floor 推进: ns=' + ns + ' contiguous=' + contigBefore + ' -> cursor.current_seq=' + serverAck);
1064
+ this._seqTracker.forceContiguousSeq(ns, serverAck);
1065
+ }
1066
+ }
950
1067
  // 持久化 cursor + ack_events(与 Python 对齐)
951
1068
  this._saveSeqTrackerState();
952
1069
  const contig = this._seqTracker.getContiguousSeq(ns);
953
- if (contig > 0) {
1070
+ if (contig > 0 && (events.length > 0 || serverAck > 0)) {
954
1071
  this._transport.call('group.ack_events', {
955
1072
  group_id: groupId,
956
1073
  event_seq: contig,
957
1074
  device_id: this._deviceId,
1075
+ slot_id: this._slotId,
958
1076
  }).catch((e) => { console.warn('群事件 auto-ack 失败: group=' + groupId, e); });
959
1077
  }
960
1078
  for (const evt of events) {
@@ -989,10 +1107,6 @@ export class AUNClient {
989
1107
  return;
990
1108
  const ns = `p2p:${this._aid}`;
991
1109
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
992
- // 新设备(seq=0)没有历史 prekey,拉旧消息也解不了
993
- if (afterSeq === 0) {
994
- return;
995
- }
996
1110
  // 去重:同一 (type:after_seq) 在飞行中只补一次
997
1111
  // S1: 使用 try/finally 保证无论成功失败都清理 dedupKey
998
1112
  const dedupKey = `p2p:${afterSeq}`;
@@ -1016,7 +1130,12 @@ export class AUNClient {
1016
1130
  const s = msg.seq;
1017
1131
  if (pushed && s !== undefined && s !== null && pushed.has(s))
1018
1132
  continue;
1019
- await this._dispatcher.publish('message.received', msg);
1133
+ if (s !== undefined && s !== null) {
1134
+ await this._publishOrderedMessage('message.received', ns, s, msg);
1135
+ }
1136
+ else {
1137
+ await this._publishAppEvent('message.received', msg);
1138
+ }
1020
1139
  }
1021
1140
  }
1022
1141
  this._prunePushedSeqs(ns);
@@ -1045,6 +1164,119 @@ export class AUNClient {
1045
1164
  if (pushed.size === 0)
1046
1165
  this._pushedSeqs.delete(ns);
1047
1166
  }
1167
+ _markPublishedSeq(ns, seq) {
1168
+ let pushed = this._pushedSeqs.get(ns);
1169
+ if (!pushed) {
1170
+ pushed = new Set();
1171
+ this._pushedSeqs.set(ns, pushed);
1172
+ }
1173
+ pushed.add(seq);
1174
+ if (pushed.size > PUSHED_SEQS_LIMIT) {
1175
+ const keep = [...pushed].sort((a, b) => a - b).slice(-PUSHED_SEQS_LIMIT);
1176
+ this._pushedSeqs.set(ns, new Set(keep));
1177
+ }
1178
+ }
1179
+ _enqueueOrderedMessage(ns, event, seq, payload) {
1180
+ let queue = this._pendingOrderedMsgs.get(ns);
1181
+ if (!queue) {
1182
+ queue = new Map();
1183
+ this._pendingOrderedMsgs.set(ns, queue);
1184
+ }
1185
+ queue.set(seq, { event, payload });
1186
+ if (queue.size > PENDING_ORDERED_LIMIT) {
1187
+ const drop = [...queue.keys()].sort((a, b) => a - b).slice(0, queue.size - PENDING_ORDERED_LIMIT);
1188
+ for (const oldSeq of drop)
1189
+ queue.delete(oldSeq);
1190
+ }
1191
+ }
1192
+ _isInstanceScopedMessageEvent(event) {
1193
+ return event === 'message.received'
1194
+ || event === 'message.undecryptable'
1195
+ || event === 'group.message_created'
1196
+ || event === 'group.message_undecryptable';
1197
+ }
1198
+ _attachCurrentInstanceContext(payload) {
1199
+ if (!isJsonObject(payload))
1200
+ return payload;
1201
+ const result = { ...payload };
1202
+ if (this._deviceId && !String(result.device_id ?? '').trim()) {
1203
+ result.device_id = this._deviceId;
1204
+ }
1205
+ if (this._slotId && !String(result.slot_id ?? '').trim()) {
1206
+ result.slot_id = this._slotId;
1207
+ }
1208
+ return result;
1209
+ }
1210
+ _normalizePublishedMessagePayload(event, payload) {
1211
+ if (!this._isInstanceScopedMessageEvent(event))
1212
+ return payload;
1213
+ return this._attachCurrentInstanceContext(payload);
1214
+ }
1215
+ async _publishAppEvent(event, payload) {
1216
+ await this._dispatcher.publish(event, this._normalizePublishedMessagePayload(event, payload));
1217
+ }
1218
+ _messageTargetsCurrentInstance(message) {
1219
+ if (!isJsonObject(message))
1220
+ return true;
1221
+ const targetDeviceId = String(message.device_id ?? '').trim();
1222
+ if (targetDeviceId && this._deviceId && targetDeviceId !== this._deviceId) {
1223
+ return false;
1224
+ }
1225
+ const targetSlotId = String(message.slot_id ?? '').trim();
1226
+ if (targetSlotId && this._slotId && targetSlotId !== this._slotId) {
1227
+ return false;
1228
+ }
1229
+ return true;
1230
+ }
1231
+ async _drainOrderedMessages(ns, beforeSeq) {
1232
+ const queue = this._pendingOrderedMsgs.get(ns);
1233
+ if (!queue || queue.size === 0)
1234
+ return;
1235
+ const contig = this._seqTracker.getContiguousSeq(ns);
1236
+ const ready = [...queue.keys()]
1237
+ .filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
1238
+ .sort((a, b) => a - b);
1239
+ for (const seq of ready) {
1240
+ const item = queue.get(seq);
1241
+ queue.delete(seq);
1242
+ if (!item || this._pushedSeqs.get(ns)?.has(seq))
1243
+ continue;
1244
+ await this._publishAppEvent(item.event, item.payload);
1245
+ this._markPublishedSeq(ns, seq);
1246
+ }
1247
+ if (queue.size === 0)
1248
+ this._pendingOrderedMsgs.delete(ns);
1249
+ }
1250
+ async _publishOrderedMessage(event, ns, seq, payload) {
1251
+ const seqNum = Number(seq);
1252
+ if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0) {
1253
+ await this._publishAppEvent(event, payload);
1254
+ return true;
1255
+ }
1256
+ if (this._pushedSeqs.get(ns)?.has(seqNum)) {
1257
+ const queue = this._pendingOrderedMsgs.get(ns);
1258
+ queue?.delete(seqNum);
1259
+ if (queue && queue.size === 0)
1260
+ this._pendingOrderedMsgs.delete(ns);
1261
+ return false;
1262
+ }
1263
+ const contig = this._seqTracker.getContiguousSeq(ns);
1264
+ if (seqNum > contig) {
1265
+ this._enqueueOrderedMessage(ns, event, seqNum, payload);
1266
+ return false;
1267
+ }
1268
+ await this._drainOrderedMessages(ns, seqNum);
1269
+ if (this._pushedSeqs.get(ns)?.has(seqNum))
1270
+ return false;
1271
+ const queue = this._pendingOrderedMsgs.get(ns);
1272
+ queue?.delete(seqNum);
1273
+ if (queue && queue.size === 0)
1274
+ this._pendingOrderedMsgs.delete(ns);
1275
+ await this._publishAppEvent(event, payload);
1276
+ this._markPublishedSeq(ns, seqNum);
1277
+ await this._drainOrderedMessages(ns);
1278
+ return true;
1279
+ }
1048
1280
  /**
1049
1281
  * 上线/重连后一次性同步所有已加入群:
1050
1282
  * 1. 有 epoch key 的群 → 补消息 + 补事件
@@ -1313,6 +1545,8 @@ export class AUNClient {
1313
1545
  // 4. 清理推送 seq 去重缓存
1314
1546
  this._pushedSeqs.delete(`group:${groupId}`);
1315
1547
  this._pushedSeqs.delete(`group_event:${groupId}`);
1548
+ this._pendingOrderedMsgs.delete(`group:${groupId}`);
1549
+ this._pendingDecryptMsgs.delete(`group:${groupId}`);
1316
1550
  console.info(`[aun_core] 已清理解散群组 ${groupId} 的本地状态`);
1317
1551
  }
1318
1552
  async _verifyEventSignature(_event, cs) {
@@ -1346,15 +1580,34 @@ export class AUNClient {
1346
1580
  const ok = await ecdsaVerifyDer(pubKey, sigBytes, signData);
1347
1581
  if (!ok) {
1348
1582
  console.warn('[aun_core] 群事件验签失败 aid=%s method=%s', sigAid, method);
1583
+ // P1-16: 签名失败统一发布事件
1584
+ this._dispatcher.publish('signature.verification_failed', {
1585
+ aid: sigAid, method, error: 'ECDSA verification failed',
1586
+ });
1349
1587
  }
1350
1588
  return ok;
1351
1589
  }
1352
1590
  catch (exc) {
1353
1591
  console.warn('[aun_core] 群事件验签异常:', exc);
1592
+ // P1-16: 签名失败统一发布事件
1593
+ this._dispatcher.publish('signature.verification_failed', {
1594
+ aid: sigAid, method, error: String(exc),
1595
+ });
1354
1596
  return false;
1355
1597
  }
1356
1598
  }
1357
1599
  // ── E2EE 自动加密 ────────────────────────────────
1600
+ _protectedHeadersFromParams(params) {
1601
+ const value = params.protected_headers ?? params.headers;
1602
+ if (value == null)
1603
+ return null;
1604
+ if (isJsonObject(value))
1605
+ return value;
1606
+ if (typeof value === 'object' && typeof value.toObject === 'function') {
1607
+ return value;
1608
+ }
1609
+ return null;
1610
+ }
1358
1611
  /** 自动加密并发送 P2P 消息 */
1359
1612
  async _sendEncrypted(params) {
1360
1613
  const toAid = String(params.to ?? '');
@@ -1366,6 +1619,7 @@ export class AUNClient {
1366
1619
  throw new ValidationError('message.send payload must be an object when encrypt=true');
1367
1620
  }
1368
1621
  const persistRequired = Boolean(params.persist_required || params.durable);
1622
+ const protectedHeaders = this._protectedHeadersFromParams(params);
1369
1623
  // Lazy P2P sync:首次发送前自动拉取历史,避免重连后 seq 空洞
1370
1624
  if (!this._p2pSynced) {
1371
1625
  await this._lazySyncP2p();
@@ -1376,6 +1630,7 @@ export class AUNClient {
1376
1630
  payload,
1377
1631
  messageId,
1378
1632
  timestamp,
1633
+ protectedHeaders,
1379
1634
  });
1380
1635
  if (recipientPrekeys.length <= 1 && selfSyncCopies.length === 0) {
1381
1636
  return await this._sendEncryptedSingle({
@@ -1385,6 +1640,7 @@ export class AUNClient {
1385
1640
  timestamp,
1386
1641
  prekey: recipientPrekeys[0],
1387
1642
  persistRequired,
1643
+ protectedHeaders,
1388
1644
  });
1389
1645
  }
1390
1646
  const recipientCopies = await this._buildRecipientDeviceCopies({
@@ -1393,6 +1649,7 @@ export class AUNClient {
1393
1649
  messageId,
1394
1650
  timestamp,
1395
1651
  prekeys: recipientPrekeys,
1652
+ protectedHeaders,
1396
1653
  });
1397
1654
  const sendParams = {
1398
1655
  to: toAid,
@@ -1453,6 +1710,7 @@ export class AUNClient {
1453
1710
  prekey,
1454
1711
  messageId: opts.messageId,
1455
1712
  timestamp: opts.timestamp,
1713
+ protectedHeaders: opts.protectedHeaders,
1456
1714
  });
1457
1715
  await this._ensureEncryptResult(opts.toAid, encryptResult);
1458
1716
  const sendParams = {
@@ -1471,7 +1729,7 @@ export class AUNClient {
1471
1729
  async _buildRecipientDeviceCopies(opts) {
1472
1730
  const recipientCopies = [];
1473
1731
  const certCache = new Map();
1474
- for (const prekey of opts.prekeys) {
1732
+ for (const prekey of normalizePeerPrekeys(opts.prekeys)) {
1475
1733
  const deviceId = String(prekey.device_id ?? '').trim();
1476
1734
  const peerCertFingerprint = String(prekey.cert_fingerprint ?? '').trim().toLowerCase();
1477
1735
  const cacheKey = peerCertFingerprint || '__default__';
@@ -1487,6 +1745,7 @@ export class AUNClient {
1487
1745
  prekey,
1488
1746
  messageId: opts.messageId,
1489
1747
  timestamp: opts.timestamp,
1748
+ protectedHeaders: opts.protectedHeaders,
1490
1749
  });
1491
1750
  await this._ensureEncryptResult(opts.toAid, encryptResult);
1492
1751
  recipientCopies.push({
@@ -1528,7 +1787,7 @@ export class AUNClient {
1528
1787
  const myAid = this._aid;
1529
1788
  if (!myAid)
1530
1789
  return [];
1531
- const prekeys = await this._fetchPeerPrekeys(myAid);
1790
+ const prekeys = normalizePeerPrekeys(await this._fetchPeerPrekeys(myAid));
1532
1791
  if (prekeys.length === 0)
1533
1792
  return [];
1534
1793
  const copies = [];
@@ -1545,6 +1804,7 @@ export class AUNClient {
1545
1804
  prekey,
1546
1805
  messageId: opts.messageId,
1547
1806
  timestamp: opts.timestamp,
1807
+ protectedHeaders: opts.protectedHeaders,
1548
1808
  });
1549
1809
  await this._ensureEncryptResult(myAid, encryptResult);
1550
1810
  copies.push({
@@ -1560,6 +1820,8 @@ export class AUNClient {
1560
1820
  prekey: opts.prekey ?? null,
1561
1821
  messageId: opts.messageId,
1562
1822
  timestamp: opts.timestamp,
1823
+ protectedHeaders: opts.protectedHeaders,
1824
+ context: opts.context ?? null,
1563
1825
  });
1564
1826
  return [envelope, encryptResult];
1565
1827
  }
@@ -1666,49 +1928,131 @@ export class AUNClient {
1666
1928
  }
1667
1929
  /** 自动加密并发送群组消息 */
1668
1930
  async _sendGroupEncrypted(params) {
1669
- const groupId = String(params.group_id ?? '');
1931
+ return this._callGroupEncryptedRpc('group.send', params, {
1932
+ idField: 'message_id',
1933
+ idPrefix: 'gm',
1934
+ });
1935
+ }
1936
+ async _putGroupThoughtEncrypted(params) {
1937
+ return this._callGroupEncryptedRpc('group.thought.put', params, {
1938
+ idField: 'thought_id',
1939
+ idPrefix: 'gt',
1940
+ extraFields: ['context'],
1941
+ });
1942
+ }
1943
+ async _putMessageThoughtEncrypted(params) {
1944
+ const toAid = String(params.to ?? '').trim();
1945
+ this._validateMessageRecipient(toAid);
1670
1946
  const payload = isJsonObject(params.payload) ? params.payload : null;
1671
- if (!groupId) {
1672
- throw new ValidationError('group.send requires group_id');
1947
+ if (!toAid) {
1948
+ throw new ValidationError('message.thought.put requires to');
1673
1949
  }
1674
1950
  if (payload === null) {
1675
- throw new ValidationError('group.send payload must be an object when encrypt=true');
1676
- }
1677
- // Lazy group sync:首次发送群消息前自动拉取历史,避免重连后 seq 空洞
1678
- if (!this._groupSynced.has(groupId)) {
1679
- await this._lazySyncGroup(groupId);
1951
+ throw new ValidationError('message.thought.put payload must be an object when encrypt=true');
1680
1952
  }
1681
- await this._ensureGroupEpochReady(groupId, false);
1682
- await this._waitForGroupMembershipEpochFloor(groupId, 2000);
1953
+ const thoughtId = String(params.thought_id ?? '') || `mt-${_uuidV4()}`;
1954
+ const timestamp = Number(params.timestamp ?? Date.now());
1955
+ const prekey = await this._fetchPeerPrekey(toAid);
1956
+ const peerCertFingerprint = typeof prekey?.cert_fingerprint === 'string' ? prekey.cert_fingerprint : undefined;
1957
+ const peerCertPem = await this._fetchPeerCert(toAid, peerCertFingerprint);
1958
+ const [envelope, encryptResult] = await this._encryptCopyPayload({
1959
+ logicalToAid: toAid,
1960
+ payload,
1961
+ peerCertPem,
1962
+ prekey,
1963
+ messageId: thoughtId,
1964
+ timestamp,
1965
+ protectedHeaders: this._protectedHeadersFromParams(params),
1966
+ context: isJsonObject(params.context) ? params.context : null,
1967
+ });
1968
+ await this._ensureEncryptResult(toAid, encryptResult);
1969
+ const sendParams = {
1970
+ to: toAid,
1971
+ payload: envelope,
1972
+ type: 'e2ee.encrypted',
1973
+ encrypted: true,
1974
+ thought_id: thoughtId,
1975
+ timestamp,
1976
+ };
1977
+ if ('context' in params)
1978
+ sendParams.context = params.context;
1979
+ await this._signClientOperation('message.thought.put', sendParams);
1980
+ return this._transport.call('message.thought.put', sendParams);
1981
+ }
1982
+ async _callGroupEncryptedRpc(method, params, options) {
1983
+ let prepared = await this._prepareGroupEncryptedRpcParams(method, params, options);
1683
1984
  for (let attempt = 0; attempt < 2; attempt += 1) {
1684
- const epochResult = await this._committedGroupEpochState(groupId);
1685
- const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
1686
- const envelope = committedEpoch > 0
1687
- ? await this._groupE2ee.encryptWithEpoch(groupId, await this._ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult), payload)
1688
- : await this._groupE2ee.encrypt(groupId, payload);
1689
- const sendParams = {
1690
- group_id: groupId,
1691
- payload: envelope,
1692
- type: 'e2ee.group_encrypted',
1693
- encrypted: true,
1694
- };
1695
- if (this._deviceId && sendParams.device_id === undefined) {
1696
- sendParams.device_id = this._deviceId;
1697
- }
1698
- await this._signClientOperation('group.send', sendParams);
1699
1985
  try {
1700
- return await this._transport.call('group.send', sendParams);
1986
+ return await this._transport.call(method, prepared.sendParams);
1701
1987
  }
1702
1988
  catch (exc) {
1703
1989
  if (attempt === 0 && this._isRecoverableGroupEpochError(exc)) {
1704
- console.warn(`[aun_core] 群 ${groupId} 发送时 epoch 已过旧,恢复密钥后重加密重发一次: ${formatCaughtError(exc)}`);
1705
- await this._ensureGroupEpochReady(groupId, true);
1990
+ console.warn(`[aun_core] 群 ${prepared.groupId} 调用 ${method} 时 epoch 已过旧,恢复密钥后重加密重试一次: ${formatCaughtError(exc)}`);
1991
+ prepared = await this._prepareGroupEncryptedRpcParams(method, params, options, true);
1706
1992
  continue;
1707
1993
  }
1708
1994
  throw exc;
1709
1995
  }
1710
1996
  }
1711
- throw new StateError(`group ${groupId} send failed after epoch recovery retry`);
1997
+ throw new StateError(`${method} failed after epoch recovery retry: group=${prepared.groupId}`);
1998
+ }
1999
+ async _prepareGroupEncryptedRpcParams(method, params, options, strictEpochReady = false) {
2000
+ const groupId = String(params.group_id ?? '');
2001
+ const payload = isJsonObject(params.payload) ? params.payload : null;
2002
+ if (!groupId) {
2003
+ throw new ValidationError(`${method} requires group_id`);
2004
+ }
2005
+ if (payload === null) {
2006
+ throw new ValidationError(`${method} payload must be an object when encrypt=true`);
2007
+ }
2008
+ if (!this._groupSynced.has(groupId)) {
2009
+ await this._lazySyncGroup(groupId);
2010
+ }
2011
+ await this._ensureGroupEpochReady(groupId, strictEpochReady);
2012
+ await this._waitForGroupMembershipEpochFloor(groupId, 2000);
2013
+ const epochResult = await this._committedGroupEpochState(groupId);
2014
+ const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
2015
+ const operationId = String(params[options.idField] ?? '').trim()
2016
+ || `${options.idPrefix}-${crypto.randomUUID()}`;
2017
+ const timestamp = Number(params.timestamp ?? Date.now());
2018
+ const protectedHeaders = this._protectedHeadersFromParams(params);
2019
+ const context = method === 'group.thought.put' && isJsonObject(params.context)
2020
+ ? params.context
2021
+ : null;
2022
+ const envelope = committedEpoch > 0
2023
+ ? await this._groupE2ee.encryptWithEpoch(groupId, await this._ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult), payload, {
2024
+ messageId: operationId,
2025
+ timestamp,
2026
+ protectedHeaders,
2027
+ context,
2028
+ })
2029
+ : await this._groupE2ee.encrypt(groupId, payload, {
2030
+ messageId: operationId,
2031
+ timestamp,
2032
+ protectedHeaders,
2033
+ context,
2034
+ });
2035
+ const sendParams = {
2036
+ group_id: groupId,
2037
+ payload: envelope,
2038
+ type: 'e2ee.group_encrypted',
2039
+ encrypted: true,
2040
+ [options.idField]: operationId,
2041
+ timestamp,
2042
+ };
2043
+ if (this._deviceId && sendParams.device_id === undefined) {
2044
+ sendParams.device_id = this._deviceId;
2045
+ }
2046
+ if (sendParams.slot_id === undefined) {
2047
+ sendParams.slot_id = this._slotId;
2048
+ }
2049
+ for (const field of options.extraFields ?? []) {
2050
+ if (params[field] !== undefined) {
2051
+ sendParams[field] = params[field];
2052
+ }
2053
+ }
2054
+ await this._signClientOperation(method, sendParams);
2055
+ return { sendParams, groupId };
1712
2056
  }
1713
2057
  /**
1714
2058
  * 首次发送群消息前懒拉取历史消息,同步 seqTracker 避免空洞。
@@ -2030,7 +2374,7 @@ export class AUNClient {
2030
2374
  const ns = `group:${groupId}`;
2031
2375
  const queue = this._pendingDecryptMsgs.get(ns) ?? [];
2032
2376
  queue.push(msg);
2033
- this._pendingDecryptMsgs.set(ns, queue.slice(-200));
2377
+ this._pendingDecryptMsgs.set(ns, queue.slice(-PENDING_DECRYPT_LIMIT));
2034
2378
  }
2035
2379
  async _retryPendingDecryptMsgs(groupId) {
2036
2380
  const ns = `group:${groupId}`;
@@ -2047,14 +2391,20 @@ export class AUNClient {
2047
2391
  stillPending.push(msg);
2048
2392
  continue;
2049
2393
  }
2050
- await this._dispatcher.publish('group.message_created', decrypted);
2394
+ const seq = msg.seq;
2395
+ if (seq !== undefined && seq !== null) {
2396
+ await this._publishOrderedMessage('group.message_created', ns, seq, decrypted);
2397
+ }
2398
+ else {
2399
+ await this._publishAppEvent('group.message_created', decrypted);
2400
+ }
2051
2401
  }
2052
2402
  catch {
2053
2403
  stillPending.push(msg);
2054
2404
  }
2055
2405
  }
2056
2406
  const queuedDuringRetry = this._pendingDecryptMsgs.get(ns) ?? [];
2057
- const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-200);
2407
+ const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-PENDING_DECRYPT_LIMIT);
2058
2408
  if (mergedPending.length)
2059
2409
  this._pendingDecryptMsgs.set(ns, mergedPending);
2060
2410
  else
@@ -2144,7 +2494,7 @@ export class AUNClient {
2144
2494
  async _decryptGroupMessage(message, opts) {
2145
2495
  const payload = isJsonObject(message.payload) ? message.payload : null;
2146
2496
  if (payload === null || payload.type !== 'e2ee.group_encrypted') {
2147
- return message;
2497
+ return this._attachGroupDispatchModeToPayload(message);
2148
2498
  }
2149
2499
  // 确保发送方证书已缓存(签名验证需要)
2150
2500
  const senderAid = String(message.from ?? message.sender_aid ?? '');
@@ -2158,7 +2508,7 @@ export class AUNClient {
2158
2508
  // 先尝试直接解密
2159
2509
  const result = await this._groupE2ee.decrypt(message, opts);
2160
2510
  if (result !== null && isJsonObject(result.e2ee)) {
2161
- return result;
2511
+ return this._attachGroupDispatchModeToPayload(result);
2162
2512
  }
2163
2513
  // replay guard 命中:decrypt 返回了原消息(非 null)但无 e2ee 字段
2164
2514
  // 不是解密失败,不应触发 recover
@@ -2174,7 +2524,7 @@ export class AUNClient {
2174
2524
  if (await this._recoverGroupEpochKey(groupId, epoch, sender, 5000)) {
2175
2525
  const retry = await this._groupE2ee.decrypt(message, opts);
2176
2526
  if (retry !== null && retry.e2ee)
2177
- return retry;
2527
+ return this._attachGroupDispatchModeToPayload(retry);
2178
2528
  }
2179
2529
  }
2180
2530
  catch (exc) {
@@ -2183,6 +2533,21 @@ export class AUNClient {
2183
2533
  }
2184
2534
  return message;
2185
2535
  }
2536
+ _attachGroupDispatchModeToPayload(message) {
2537
+ const payload = message.payload;
2538
+ if (!isJsonObject(payload))
2539
+ return message;
2540
+ const rawMode = String(message.dispatch_mode ?? 'broadcast').trim().toLowerCase();
2541
+ const mode = rawMode === 'mention' || rawMode === 'broadcast' ? rawMode : 'broadcast';
2542
+ return {
2543
+ ...message,
2544
+ dispatch_mode: mode,
2545
+ payload: {
2546
+ ...payload,
2547
+ dispatch_mode: mode,
2548
+ },
2549
+ };
2550
+ }
2186
2551
  /** 批量解密群组消息(用于 group.pull)。跳过防重放检查。 */
2187
2552
  async _decryptGroupMessages(messages) {
2188
2553
  const result = [];
@@ -2194,10 +2559,108 @@ export class AUNClient {
2194
2559
  this._enqueuePendingDecrypt(groupId, msg);
2195
2560
  continue; // R3: 解密失败不入 result,不 publish 密文给应用层
2196
2561
  }
2562
+ if (payload?.type !== 'e2ee.group_encrypted') {
2563
+ result.push(this._attachGroupDispatchModeToPayload(decrypted));
2564
+ continue;
2565
+ }
2197
2566
  result.push(decrypted);
2198
2567
  }
2199
2568
  return result;
2200
2569
  }
2570
+ async _decryptGroupThoughts(result) {
2571
+ if (!result.found) {
2572
+ return { ...result, thoughts: [] };
2573
+ }
2574
+ const items = (Array.isArray(result.thoughts) ? result.thoughts : []).filter(isJsonObject);
2575
+ if (!items.length) {
2576
+ return { ...result, thoughts: [] };
2577
+ }
2578
+ const groupId = String(result.group_id ?? '');
2579
+ const senderAid = String(result.sender_aid ?? '');
2580
+ const thoughts = [];
2581
+ for (const item of items) {
2582
+ const payload = isJsonObject(item.payload) ? item.payload : null;
2583
+ const thoughtId = String(item.thought_id ?? item.message_id ?? '');
2584
+ const message = {
2585
+ group_id: groupId,
2586
+ sender_aid: senderAid,
2587
+ from: senderAid,
2588
+ message_id: thoughtId,
2589
+ payload: payload ?? {},
2590
+ created_at: Number(item.created_at ?? 0),
2591
+ };
2592
+ const decrypted = await this._decryptGroupMessage(message, { skipReplay: true });
2593
+ if (payload?.type === 'e2ee.group_encrypted' && groupId && !decrypted.e2ee) {
2594
+ this._enqueuePendingDecrypt(groupId, message);
2595
+ continue;
2596
+ }
2597
+ const thought = {
2598
+ thought_id: thoughtId,
2599
+ message_id: thoughtId,
2600
+ payload: decrypted.payload,
2601
+ created_at: item.created_at,
2602
+ e2ee: decrypted.e2ee,
2603
+ };
2604
+ if ('context' in item)
2605
+ thought.context = item.context;
2606
+ thoughts.push(thought);
2607
+ }
2608
+ return { ...result, thoughts };
2609
+ }
2610
+ async _decryptMessageThoughts(result) {
2611
+ if (!result.found) {
2612
+ return { ...result, thoughts: [] };
2613
+ }
2614
+ const items = (Array.isArray(result.thoughts) ? result.thoughts : []).filter(isJsonObject);
2615
+ if (!items.length) {
2616
+ return { ...result, thoughts: [] };
2617
+ }
2618
+ const senderAid = String(result.sender_aid ?? '');
2619
+ const peerAid = String(result.peer_aid ?? '');
2620
+ const thoughts = [];
2621
+ for (const item of items) {
2622
+ const payload = isJsonObject(item.payload) ? item.payload : null;
2623
+ const thoughtId = String(item.thought_id ?? item.message_id ?? '');
2624
+ const fromAid = String(item.from ?? senderAid);
2625
+ const toAid = String(item.to ?? peerAid);
2626
+ const message = {
2627
+ from: fromAid,
2628
+ to: toAid,
2629
+ message_id: thoughtId,
2630
+ payload: payload ?? {},
2631
+ encrypted: item.encrypted !== false,
2632
+ timestamp: Number(item.created_at ?? 0),
2633
+ };
2634
+ let decrypted = message;
2635
+ if (payload?.type === 'e2ee.encrypted') {
2636
+ const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
2637
+ if (fromAid) {
2638
+ const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
2639
+ if (!certReady) {
2640
+ console.warn('[aun_core] 无法获取发送方证书,跳过 message.thought.get 解密:', fromAid);
2641
+ continue;
2642
+ }
2643
+ }
2644
+ decrypted = await this._e2ee.decryptMessage(message, { skipReplay: true });
2645
+ if (decrypted === null || (isJsonObject(decrypted.payload) && decrypted.payload.type === 'e2ee.encrypted')) {
2646
+ continue;
2647
+ }
2648
+ }
2649
+ const thought = {
2650
+ thought_id: thoughtId,
2651
+ message_id: thoughtId,
2652
+ from: fromAid,
2653
+ to: toAid,
2654
+ payload: decrypted.payload,
2655
+ created_at: item.created_at,
2656
+ e2ee: decrypted.e2ee,
2657
+ };
2658
+ if ('context' in item)
2659
+ thought.context = item.context;
2660
+ thoughts.push(thought);
2661
+ }
2662
+ return { ...result, thoughts };
2663
+ }
2201
2664
  /**
2202
2665
  * 尝试处理 P2P 传输的群组密钥消息。
2203
2666
  *
@@ -2414,11 +2877,15 @@ export class AUNClient {
2414
2877
  async _fetchPeerPrekeys(peerAid) {
2415
2878
  const cachedList = this._peerPrekeysCache.get(peerAid);
2416
2879
  if (cachedList && Date.now() / 1000 < cachedList.expireAt) {
2417
- return cachedList.items.map((item) => ({ ...item }));
2880
+ const normalized = normalizePeerPrekeys(cachedList.items);
2881
+ if (normalized.length > 0)
2882
+ return normalized.map((item) => ({ ...item }));
2418
2883
  }
2419
2884
  const cached = this._e2ee.getCachedPrekey(peerAid);
2420
- if (cached !== null && isPeerPrekeyMaterial(cached)) {
2421
- return [{ ...cached }];
2885
+ if (cached !== null) {
2886
+ const normalized = normalizePeerPrekeys([cached]);
2887
+ if (normalized.length > 0)
2888
+ return normalized.map((item) => ({ ...item }));
2422
2889
  }
2423
2890
  let result;
2424
2891
  try {
@@ -2435,7 +2902,7 @@ export class AUNClient {
2435
2902
  }
2436
2903
  const devicePrekeys = Array.isArray(result.device_prekeys) ? result.device_prekeys : null;
2437
2904
  if (devicePrekeys) {
2438
- const normalized = devicePrekeys.filter(isPeerPrekeyMaterial).map((item) => ({ ...item }));
2905
+ const normalized = normalizePeerPrekeys(devicePrekeys);
2439
2906
  if (normalized.length > 0) {
2440
2907
  this._peerPrekeysCache.set(peerAid, {
2441
2908
  items: normalized.map((item) => ({ ...item })),
@@ -2449,12 +2916,15 @@ export class AUNClient {
2449
2916
  throw new ValidationError(`invalid prekey response for ${peerAid}`);
2450
2917
  }
2451
2918
  if (result.prekey) {
2452
- this._peerPrekeysCache.set(peerAid, {
2453
- items: [{ ...result.prekey }],
2454
- expireAt: Date.now() / 1000 + 300,
2455
- });
2456
- this._e2ee.cachePrekey(peerAid, result.prekey);
2457
- return [{ ...result.prekey }];
2919
+ const normalized = normalizePeerPrekeys([result.prekey]);
2920
+ if (normalized.length > 0) {
2921
+ this._peerPrekeysCache.set(peerAid, {
2922
+ items: normalized.map((item) => ({ ...item })),
2923
+ expireAt: Date.now() / 1000 + 300,
2924
+ });
2925
+ this._e2ee.cachePrekey(peerAid, normalized[0]);
2926
+ return normalized.map((item) => ({ ...item }));
2927
+ }
2458
2928
  }
2459
2929
  if (result.found) {
2460
2930
  throw new ValidationError(`invalid prekey response for ${peerAid}`);
@@ -2465,7 +2935,9 @@ export class AUNClient {
2465
2935
  async _fetchPeerPrekey(peerAid) {
2466
2936
  const cachedList = this._peerPrekeysCache.get(peerAid);
2467
2937
  if (cachedList && Date.now() / 1000 < cachedList.expireAt && cachedList.items.length > 0) {
2468
- return { ...cachedList.items[0] };
2938
+ const normalized = normalizePeerPrekeys(cachedList.items);
2939
+ if (normalized.length > 0)
2940
+ return { ...normalized[0] };
2469
2941
  }
2470
2942
  const prekeys = await this._fetchPeerPrekeys(peerAid);
2471
2943
  if (prekeys.length === 0) {
@@ -3292,34 +3764,45 @@ export class AUNClient {
3292
3764
  // 避免 reader 把积压 push 交给空 tracker 的 handler,触发 S2 历史 gap 误补拉。
3293
3765
  this._refreshSeqTrackerContext();
3294
3766
  await this._restoreSeqTrackerState();
3295
- const challenge = await this._transport.connect(gatewayUrl);
3296
- this._state = 'authenticating';
3297
- if (allowReauth) {
3298
- const authContext = await this._auth.connectSession(this._transport, challenge, gatewayUrl, {
3299
- accessToken: params.access_token,
3300
- deviceId: this._deviceId,
3301
- slotId: this._slotId,
3302
- deliveryMode: this._connectDeliveryMode,
3303
- });
3304
- if (isJsonObject(authContext)) {
3305
- const auth = authContext;
3306
- const identity = auth.identity;
3307
- if (identity && isJsonObject(identity)) {
3308
- this._identity = identity;
3309
- this._aid = String(identity.aid ?? this._aid ?? '');
3310
- if (this._sessionParams) {
3311
- this._sessionParams.access_token = String(auth.token ?? params.access_token ?? '');
3767
+ try {
3768
+ const challenge = await this._transport.connect(gatewayUrl);
3769
+ this._state = 'authenticating';
3770
+ if (allowReauth) {
3771
+ const authContext = await this._auth.connectSession(this._transport, challenge, gatewayUrl, {
3772
+ accessToken: params.access_token,
3773
+ deviceId: this._deviceId,
3774
+ slotId: this._slotId,
3775
+ deliveryMode: this._connectDeliveryMode,
3776
+ });
3777
+ if (isJsonObject(authContext)) {
3778
+ const auth = authContext;
3779
+ const identity = auth.identity;
3780
+ if (identity && isJsonObject(identity)) {
3781
+ this._identity = identity;
3782
+ this._aid = String(identity.aid ?? this._aid ?? '');
3783
+ if (this._sessionParams) {
3784
+ this._sessionParams.access_token = String(auth.token ?? params.access_token ?? '');
3785
+ }
3312
3786
  }
3313
3787
  }
3314
3788
  }
3789
+ else {
3790
+ await this._auth.initializeWithToken(this._transport, challenge, String(params.access_token), {
3791
+ deviceId: this._deviceId,
3792
+ slotId: this._slotId,
3793
+ deliveryMode: this._connectDeliveryMode,
3794
+ });
3795
+ await this._syncIdentityAfterConnect(String(params.access_token));
3796
+ }
3315
3797
  }
3316
- else {
3317
- await this._auth.initializeWithToken(this._transport, challenge, String(params.access_token), {
3318
- deviceId: this._deviceId,
3319
- slotId: this._slotId,
3320
- deliveryMode: this._connectDeliveryMode,
3321
- });
3322
- await this._syncIdentityAfterConnect(String(params.access_token));
3798
+ catch (err) {
3799
+ // P1-19: 首连失败时重置状态,避免半连接残留
3800
+ this._state = 'disconnected';
3801
+ try {
3802
+ await this._transport.close();
3803
+ }
3804
+ catch { /* 忽略关闭错误 */ }
3805
+ throw err;
3323
3806
  }
3324
3807
  this._state = 'connected';
3325
3808
  await this._dispatcher.publish('connection.state', {
@@ -3666,6 +4149,28 @@ export class AUNClient {
3666
4149
  throw new ValidationError('group.send does not accept delivery_mode; group messages are always fanout');
3667
4150
  }
3668
4151
  }
4152
+ if (method === 'group.thought.put' || method === 'group.thought.get'
4153
+ || method === 'message.thought.put' || method === 'message.thought.get') {
4154
+ const context = isJsonObject(params.context) ? params.context : null;
4155
+ const contextType = String(context?.type ?? '').trim();
4156
+ const contextId = String(context?.id ?? '').trim();
4157
+ const hasContext = contextType.length > 0 && contextId.length > 0;
4158
+ if (!hasContext) {
4159
+ throw new ValidationError(`${method} requires context.type + context.id`);
4160
+ }
4161
+ }
4162
+ if (method === 'group.thought.get' && !String(params.sender_aid ?? '').trim()) {
4163
+ throw new ValidationError('group.thought.get requires sender_aid');
4164
+ }
4165
+ if (method === 'message.thought.put') {
4166
+ this._validateMessageRecipient(params.to);
4167
+ if (!String(params.to ?? '').trim()) {
4168
+ throw new ValidationError('message.thought.put requires to');
4169
+ }
4170
+ }
4171
+ if (method === 'message.thought.get' && !String(params.sender_aid ?? '').trim()) {
4172
+ throw new ValidationError('message.thought.get requires sender_aid');
4173
+ }
3669
4174
  }
3670
4175
  _currentMessageDeliveryMode() {
3671
4176
  return { ...this._connectDeliveryMode };
@@ -3959,6 +4464,8 @@ export class AUNClient {
3959
4464
  this._seqTrackerContext = null;
3960
4465
  this._gapFillDone.clear();
3961
4466
  this._pushedSeqs.clear();
4467
+ this._pendingOrderedMsgs.clear();
4468
+ this._pendingDecryptMsgs.clear();
3962
4469
  this._groupSynced.clear();
3963
4470
  this._p2pSynced = false;
3964
4471
  }
@@ -3969,6 +4476,8 @@ export class AUNClient {
3969
4476
  this._seqTracker = new SeqTracker();
3970
4477
  this._gapFillDone.clear();
3971
4478
  this._pushedSeqs.clear();
4479
+ this._pendingOrderedMsgs.clear();
4480
+ this._pendingDecryptMsgs.clear();
3972
4481
  this._groupSynced.clear();
3973
4482
  this._p2pSynced = false;
3974
4483
  this._seqTrackerContext = nextContext;