@agentunion/fastaun-browser 0.2.13 → 0.2.14

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
@@ -59,6 +59,9 @@ const SIGNED_METHODS = new Set([
59
59
  'group.transfer_owner', 'group.review_join_request',
60
60
  'group.batch_review_join_request',
61
61
  'group.request_join', 'group.use_invite_code',
62
+ 'group.thought.put',
63
+ 'message.thought.put',
64
+ 'group.set_settings',
62
65
  'group.resources.put', 'group.resources.update',
63
66
  'group.resources.delete', 'group.resources.request_add',
64
67
  'group.resources.direct_add', 'group.resources.approve_request',
@@ -84,6 +87,21 @@ const RECONNECT_MIN_BASE_DELAY_SECONDS = 1.0;
84
87
  const RECONNECT_MAX_BASE_DELAY_SECONDS = 64.0;
85
88
  const GROUP_ROTATION_LEASE_MS = 120_000;
86
89
  const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
90
+ const PENDING_DECRYPT_LIMIT = 100;
91
+ const PUSHED_SEQS_LIMIT = 50_000;
92
+ const PENDING_ORDERED_LIMIT = 50_000;
93
+ // P1-23: 非幂等方法使用更长超时(35s),避免 SDK 10s 超时 < gateway 30s 处理时间
94
+ const NON_IDEMPOTENT_TIMEOUT = 35;
95
+ const NON_IDEMPOTENT_METHODS = new Set([
96
+ 'message.send', 'group.send', 'group.create', 'group.invite',
97
+ 'group.kick', 'group.remove_member', 'group.leave', 'group.dissolve',
98
+ 'group.update_name', 'group.update_avatar', 'group.update_announcement',
99
+ 'group.update_settings', 'group.rotate_epoch',
100
+ 'storage.upload', 'storage.complete_upload', 'storage.delete',
101
+ 'auth.create_aid', 'auth.renew_cert', 'auth.rekey',
102
+ 'message.thought.put', 'group.thought.put',
103
+ 'group.add_member',
104
+ ]);
87
105
  function clampReconnectDelaySeconds(value, fallback, upper = RECONNECT_MAX_BASE_DELAY_SECONDS) {
88
106
  const parsed = Number(value);
89
107
  const seconds = Number.isFinite(parsed) ? parsed : fallback;
@@ -269,8 +287,10 @@ export class AUNClient {
269
287
  _seqTrackerContext = null;
270
288
  /** 补洞去重:已完成/进行中的 key 集合,防止重复 pull 同一区间 */
271
289
  _gapFillDone = new Set();
272
- /** 推送路径已分发的 seq 集合(按命名空间),补洞路径 publish 前检查以避免重复分发 */
290
+ /** 已发布到应用层的 seq 集合(按命名空间),补洞路径 publish 前检查以避免重复分发 */
273
291
  _pushedSeqs = new Map();
292
+ /** 已解密但因 seq 空洞暂缓发布的应用层消息(按 namespace -> seq) */
293
+ _pendingOrderedMsgs = new Map();
274
294
  _pendingDecryptMsgs = new Map();
275
295
  _groupEpochRotationInflight = new Set();
276
296
  _groupEpochRecoveryInflight = new Map();
@@ -343,7 +363,7 @@ export class AUNClient {
343
363
  this._onRawGroupChanged(data);
344
364
  });
345
365
  // 其他事件直接透传
346
- for (const evt of ['message.recalled', 'message.ack']) {
366
+ for (const evt of ['message.recalled', 'message.ack', 'storage.object_changed']) {
347
367
  this._dispatcher.subscribe(`_raw.${evt}`, (data) => {
348
368
  this._dispatcher.publish(evt, data);
349
369
  });
@@ -522,11 +542,31 @@ export class AUNClient {
522
542
  return this._sendGroupEncrypted(p);
523
543
  }
524
544
  }
545
+ if (method === 'group.thought.put') {
546
+ const encrypt = p.encrypt !== undefined ? p.encrypt : true;
547
+ delete p.encrypt;
548
+ if (!encrypt) {
549
+ throw new ValidationError('group.thought.put requires encrypt=true');
550
+ }
551
+ return this._putGroupThoughtEncrypted(p);
552
+ }
553
+ if (method === 'message.thought.put') {
554
+ const encrypt = p.encrypt !== undefined ? p.encrypt : true;
555
+ delete p.encrypt;
556
+ if (!encrypt) {
557
+ throw new ValidationError('message.thought.put requires encrypt=true');
558
+ }
559
+ return this._putMessageThoughtEncrypted(p);
560
+ }
525
561
  // 关键操作自动附加客户端签名
526
562
  if (SIGNED_METHODS.has(method)) {
527
563
  await this._signClientOperation(method, p);
528
564
  }
529
- const result = await this._transport.call(method, p);
565
+ // P1-23: 非幂等方法使用更长超时
566
+ const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT : undefined;
567
+ const result = callTimeout
568
+ ? await this._transport.call(method, p, callTimeout)
569
+ : await this._transport.call(method, p);
530
570
  // 自动解密:message.pull 返回的消息
531
571
  if (method === 'message.pull' && isJsonObject(result)) {
532
572
  const r = result;
@@ -560,6 +600,7 @@ export class AUNClient {
560
600
  this._transport.call('message.ack', {
561
601
  seq: contig,
562
602
  device_id: this._deviceId,
603
+ slot_id: this._slotId,
563
604
  }).catch((e) => { console.warn('message.pull auto-ack 失败:', e); });
564
605
  }
565
606
  }
@@ -608,6 +649,12 @@ export class AUNClient {
608
649
  }
609
650
  }
610
651
  }
652
+ if (method === 'group.thought.get' && isJsonObject(result)) {
653
+ return this._decryptGroupThoughts(result);
654
+ }
655
+ if (method === 'message.thought.get' && isJsonObject(result)) {
656
+ return this._decryptMessageThoughts(result);
657
+ }
611
658
  // ── Group E2EE 自动编排 ────────────────────────────
612
659
  // ── Group E2EE 自动编排(必备能力,始终启用)────────
613
660
  {
@@ -639,7 +686,10 @@ export class AUNClient {
639
686
  const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
640
687
  if (groupId && this._membershipRotationChanged(method, result)) {
641
688
  const expectedEpoch = this._membershipRotationExpectedEpoch(result);
642
- this._safeAsync(this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch));
689
+ // P0-12: await rotation 完成(带超时兜底),确保后续 group.send 使用新 epoch
690
+ const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch);
691
+ const timeoutPromise = new Promise((resolve) => globalThis.setTimeout(resolve, 5000));
692
+ await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => console.warn('membership RPC epoch rotation fallback failed:', exc));
643
693
  }
644
694
  }
645
695
  return result;
@@ -677,10 +727,13 @@ export class AUNClient {
677
727
  async _processAndPublishMessage(data) {
678
728
  try {
679
729
  if (!isJsonObject(data)) {
680
- await this._dispatcher.publish('message.received', data);
730
+ await this._publishAppEvent('message.received', data);
681
731
  return;
682
732
  }
683
733
  const msg = { ...data };
734
+ if (!this._messageTargetsCurrentInstance(msg)) {
735
+ return;
736
+ }
684
737
  // 拦截 P2P 传输的群组密钥分发/请求/响应消息
685
738
  if (await this._tryHandleGroupKeyMessage(msg)) {
686
739
  return;
@@ -701,20 +754,20 @@ export class AUNClient {
701
754
  this._transport.call('message.ack', {
702
755
  seq: contig,
703
756
  device_id: this._deviceId,
757
+ slot_id: this._slotId,
704
758
  }).catch((e) => { console.warn('P2P auto-ack 失败:', e); });
705
759
  }
706
760
  // 即时持久化 cursor,异常断连后不回退
707
761
  this._saveSeqTrackerState();
708
762
  }
709
763
  const decrypted = await this._decryptSingleMessage(msg);
710
- // 记录已推送的 seq,补洞路径据此去重
711
764
  if (seq !== undefined && seq !== null && this._aid) {
712
765
  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);
766
+ await this._publishOrderedMessage('message.received', ns, seq, decrypted);
767
+ }
768
+ else {
769
+ await this._publishAppEvent('message.received', decrypted);
716
770
  }
717
- await this._dispatcher.publish('message.received', decrypted);
718
771
  }
719
772
  catch (exc) {
720
773
  console.warn('消息解密失败:', exc);
@@ -730,7 +783,7 @@ export class AUNClient {
730
783
  timestamp: (src.timestamp ?? null),
731
784
  _decrypt_error: String(exc),
732
785
  };
733
- await this._dispatcher.publish('message.undecryptable', safeEvent);
786
+ await this._publishAppEvent('message.undecryptable', safeEvent);
734
787
  }
735
788
  }
736
789
  }
@@ -747,7 +800,7 @@ export class AUNClient {
747
800
  async _processAndPublishGroupMessage(data) {
748
801
  try {
749
802
  if (!isJsonObject(data)) {
750
- await this._dispatcher.publish('group.message_created', data);
803
+ await this._publishAppEvent('group.message_created', data);
751
804
  return;
752
805
  }
753
806
  const msg = { ...data };
@@ -783,19 +836,12 @@ export class AUNClient {
783
836
  }
784
837
  this._saveSeqTrackerState();
785
838
  }
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
839
  // R3: 解密失败 → 不 publish 密文给应用层,入 pending 队列 + 发 undecryptable 事件
794
840
  const payloadForCheck = isJsonObject(msg.payload) ? msg.payload : null;
795
841
  if (payloadForCheck?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
796
842
  if (groupId)
797
843
  this._enqueuePendingDecrypt(groupId, msg);
798
- await this._dispatcher.publish('group.message_undecryptable', {
844
+ await this._publishAppEvent('group.message_undecryptable', {
799
845
  message_id: msg.message_id ?? null,
800
846
  group_id: groupId,
801
847
  from: msg.from ?? null,
@@ -805,7 +851,13 @@ export class AUNClient {
805
851
  });
806
852
  return;
807
853
  }
808
- await this._dispatcher.publish('group.message_created', decrypted);
854
+ if (groupId && seq !== undefined && seq !== null) {
855
+ const nsKey = `group:${groupId}`;
856
+ await this._publishOrderedMessage('group.message_created', nsKey, seq, decrypted);
857
+ }
858
+ else {
859
+ await this._publishAppEvent('group.message_created', decrypted);
860
+ }
809
861
  }
810
862
  catch (exc) {
811
863
  console.warn('群消息解密失败:', exc);
@@ -820,7 +872,7 @@ export class AUNClient {
820
872
  timestamp: (src.timestamp ?? null),
821
873
  _decrypt_error: String(exc),
822
874
  };
823
- await this._dispatcher.publish('group.message_undecryptable', safeEvent);
875
+ await this._publishAppEvent('group.message_undecryptable', safeEvent);
824
876
  }
825
877
  }
826
878
  }
@@ -828,7 +880,7 @@ export class AUNClient {
828
880
  async _autoPullGroupMessages(notification) {
829
881
  const groupId = (notification.group_id ?? '');
830
882
  if (!groupId) {
831
- await this._dispatcher.publish('group.message_created', notification);
883
+ await this._publishAppEvent('group.message_created', notification);
832
884
  return;
833
885
  }
834
886
  const ns = `group:${groupId}`;
@@ -844,15 +896,19 @@ export class AUNClient {
844
896
  const messages = result.messages;
845
897
  if (Array.isArray(messages)) {
846
898
  // ⚠️ 不再重复调用 onPullResult:call('group.pull') 拦截器已在内部调用过一次
847
- // pushedSeqs 去重:跳过已通过推送路径分发的消息
848
899
  const pushed = this._pushedSeqs.get(ns);
849
900
  for (const msg of messages) {
850
901
  if (isJsonObject(msg)) {
851
902
  const s = msg.seq;
852
903
  if (pushed && s !== undefined && s !== null && pushed.has(s)) {
853
- continue; // 已通过推送路径分发,跳过
904
+ continue; // 已发布到应用层,跳过
905
+ }
906
+ if (s !== undefined && s !== null) {
907
+ await this._publishOrderedMessage('group.message_created', ns, s, msg);
908
+ }
909
+ else {
910
+ await this._publishAppEvent('group.message_created', msg);
854
911
  }
855
- await this._dispatcher.publish('group.message_created', msg);
856
912
  }
857
913
  }
858
914
  this._prunePushedSeqs(ns);
@@ -864,7 +920,7 @@ export class AUNClient {
864
920
  console.warn('自动 pull 群消息失败:', exc);
865
921
  }
866
922
  // pull 失败时仍透传原始通知
867
- await this._dispatcher.publish('group.message_created', notification);
923
+ await this._publishAppEvent('group.message_created', notification);
868
924
  }
869
925
  /** 后台补齐群消息空洞 */
870
926
  async _fillGroupGap(groupId) {
@@ -873,10 +929,6 @@ export class AUNClient {
873
929
  return;
874
930
  const ns = `group:${groupId}`;
875
931
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
876
- // 冷启动(seq=0):服务端推送会带全量消息,SDK 不主动补洞避免重复拉取
877
- if (afterSeq === 0) {
878
- return;
879
- }
880
932
  // 去重:同一 (group:id:after_seq) 在飞行中只补一次
881
933
  // S1: 使用 try/finally 保证无论成功失败都清理 dedupKey,避免旧实现只在异常路径清理
882
934
  // 导致成功后相同 afterSeq 再次出现空洞时被永久抑制。
@@ -902,7 +954,12 @@ export class AUNClient {
902
954
  const s = msg.seq;
903
955
  if (pushed && s !== undefined && s !== null && pushed.has(s))
904
956
  continue;
905
- await this._dispatcher.publish('group.message_created', msg);
957
+ if (s !== undefined && s !== null) {
958
+ await this._publishOrderedMessage('group.message_created', ns, s, msg);
959
+ }
960
+ else {
961
+ await this._publishAppEvent('group.message_created', msg);
962
+ }
906
963
  }
907
964
  }
908
965
  this._prunePushedSeqs(ns);
@@ -925,10 +982,6 @@ export class AUNClient {
925
982
  return;
926
983
  const ns = `group_event:${groupId}`;
927
984
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
928
- // 冷启动(seq=0):服务端推送会带全量事件,SDK 不主动补洞避免重复拉取
929
- if (afterSeq === 0) {
930
- return;
931
- }
932
985
  // 去重:同一 (group_evt:id:after_seq) 在飞行中只补一次
933
986
  // S1: 使用 try/finally 保证无论成功失败都清理 dedupKey
934
987
  const dedupKey = `group_evt:${groupId}:${afterSeq}`;
@@ -947,14 +1000,24 @@ export class AUNClient {
947
1000
  const events = result.events;
948
1001
  if (Array.isArray(events)) {
949
1002
  this._seqTracker.onPullResult(ns, events.filter(isJsonObject));
1003
+ const cursor = isJsonObject(result.cursor) ? result.cursor : null;
1004
+ const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
1005
+ if (serverAck > 0) {
1006
+ const contigBefore = this._seqTracker.getContiguousSeq(ns);
1007
+ if (contigBefore < serverAck) {
1008
+ console.info('[aun_core] group.pull_events retention-floor 推进: ns=' + ns + ' contiguous=' + contigBefore + ' -> cursor.current_seq=' + serverAck);
1009
+ this._seqTracker.forceContiguousSeq(ns, serverAck);
1010
+ }
1011
+ }
950
1012
  // 持久化 cursor + ack_events(与 Python 对齐)
951
1013
  this._saveSeqTrackerState();
952
1014
  const contig = this._seqTracker.getContiguousSeq(ns);
953
- if (contig > 0) {
1015
+ if (contig > 0 && (events.length > 0 || serverAck > 0)) {
954
1016
  this._transport.call('group.ack_events', {
955
1017
  group_id: groupId,
956
1018
  event_seq: contig,
957
1019
  device_id: this._deviceId,
1020
+ slot_id: this._slotId,
958
1021
  }).catch((e) => { console.warn('群事件 auto-ack 失败: group=' + groupId, e); });
959
1022
  }
960
1023
  for (const evt of events) {
@@ -989,10 +1052,6 @@ export class AUNClient {
989
1052
  return;
990
1053
  const ns = `p2p:${this._aid}`;
991
1054
  const afterSeq = this._seqTracker.getContiguousSeq(ns);
992
- // 新设备(seq=0)没有历史 prekey,拉旧消息也解不了
993
- if (afterSeq === 0) {
994
- return;
995
- }
996
1055
  // 去重:同一 (type:after_seq) 在飞行中只补一次
997
1056
  // S1: 使用 try/finally 保证无论成功失败都清理 dedupKey
998
1057
  const dedupKey = `p2p:${afterSeq}`;
@@ -1016,7 +1075,12 @@ export class AUNClient {
1016
1075
  const s = msg.seq;
1017
1076
  if (pushed && s !== undefined && s !== null && pushed.has(s))
1018
1077
  continue;
1019
- await this._dispatcher.publish('message.received', msg);
1078
+ if (s !== undefined && s !== null) {
1079
+ await this._publishOrderedMessage('message.received', ns, s, msg);
1080
+ }
1081
+ else {
1082
+ await this._publishAppEvent('message.received', msg);
1083
+ }
1020
1084
  }
1021
1085
  }
1022
1086
  this._prunePushedSeqs(ns);
@@ -1045,6 +1109,119 @@ export class AUNClient {
1045
1109
  if (pushed.size === 0)
1046
1110
  this._pushedSeqs.delete(ns);
1047
1111
  }
1112
+ _markPublishedSeq(ns, seq) {
1113
+ let pushed = this._pushedSeqs.get(ns);
1114
+ if (!pushed) {
1115
+ pushed = new Set();
1116
+ this._pushedSeqs.set(ns, pushed);
1117
+ }
1118
+ pushed.add(seq);
1119
+ if (pushed.size > PUSHED_SEQS_LIMIT) {
1120
+ const keep = [...pushed].sort((a, b) => a - b).slice(-PUSHED_SEQS_LIMIT);
1121
+ this._pushedSeqs.set(ns, new Set(keep));
1122
+ }
1123
+ }
1124
+ _enqueueOrderedMessage(ns, event, seq, payload) {
1125
+ let queue = this._pendingOrderedMsgs.get(ns);
1126
+ if (!queue) {
1127
+ queue = new Map();
1128
+ this._pendingOrderedMsgs.set(ns, queue);
1129
+ }
1130
+ queue.set(seq, { event, payload });
1131
+ if (queue.size > PENDING_ORDERED_LIMIT) {
1132
+ const drop = [...queue.keys()].sort((a, b) => a - b).slice(0, queue.size - PENDING_ORDERED_LIMIT);
1133
+ for (const oldSeq of drop)
1134
+ queue.delete(oldSeq);
1135
+ }
1136
+ }
1137
+ _isInstanceScopedMessageEvent(event) {
1138
+ return event === 'message.received'
1139
+ || event === 'message.undecryptable'
1140
+ || event === 'group.message_created'
1141
+ || event === 'group.message_undecryptable';
1142
+ }
1143
+ _attachCurrentInstanceContext(payload) {
1144
+ if (!isJsonObject(payload))
1145
+ return payload;
1146
+ const result = { ...payload };
1147
+ if (this._deviceId && !String(result.device_id ?? '').trim()) {
1148
+ result.device_id = this._deviceId;
1149
+ }
1150
+ if (this._slotId && !String(result.slot_id ?? '').trim()) {
1151
+ result.slot_id = this._slotId;
1152
+ }
1153
+ return result;
1154
+ }
1155
+ _normalizePublishedMessagePayload(event, payload) {
1156
+ if (!this._isInstanceScopedMessageEvent(event))
1157
+ return payload;
1158
+ return this._attachCurrentInstanceContext(payload);
1159
+ }
1160
+ async _publishAppEvent(event, payload) {
1161
+ await this._dispatcher.publish(event, this._normalizePublishedMessagePayload(event, payload));
1162
+ }
1163
+ _messageTargetsCurrentInstance(message) {
1164
+ if (!isJsonObject(message))
1165
+ return true;
1166
+ const targetDeviceId = String(message.device_id ?? '').trim();
1167
+ if (targetDeviceId && this._deviceId && targetDeviceId !== this._deviceId) {
1168
+ return false;
1169
+ }
1170
+ const targetSlotId = String(message.slot_id ?? '').trim();
1171
+ if (targetSlotId && this._slotId && targetSlotId !== this._slotId) {
1172
+ return false;
1173
+ }
1174
+ return true;
1175
+ }
1176
+ async _drainOrderedMessages(ns, beforeSeq) {
1177
+ const queue = this._pendingOrderedMsgs.get(ns);
1178
+ if (!queue || queue.size === 0)
1179
+ return;
1180
+ const contig = this._seqTracker.getContiguousSeq(ns);
1181
+ const ready = [...queue.keys()]
1182
+ .filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
1183
+ .sort((a, b) => a - b);
1184
+ for (const seq of ready) {
1185
+ const item = queue.get(seq);
1186
+ queue.delete(seq);
1187
+ if (!item || this._pushedSeqs.get(ns)?.has(seq))
1188
+ continue;
1189
+ await this._publishAppEvent(item.event, item.payload);
1190
+ this._markPublishedSeq(ns, seq);
1191
+ }
1192
+ if (queue.size === 0)
1193
+ this._pendingOrderedMsgs.delete(ns);
1194
+ }
1195
+ async _publishOrderedMessage(event, ns, seq, payload) {
1196
+ const seqNum = Number(seq);
1197
+ if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0) {
1198
+ await this._publishAppEvent(event, payload);
1199
+ return true;
1200
+ }
1201
+ if (this._pushedSeqs.get(ns)?.has(seqNum)) {
1202
+ const queue = this._pendingOrderedMsgs.get(ns);
1203
+ queue?.delete(seqNum);
1204
+ if (queue && queue.size === 0)
1205
+ this._pendingOrderedMsgs.delete(ns);
1206
+ return false;
1207
+ }
1208
+ const contig = this._seqTracker.getContiguousSeq(ns);
1209
+ if (seqNum > contig) {
1210
+ this._enqueueOrderedMessage(ns, event, seqNum, payload);
1211
+ return false;
1212
+ }
1213
+ await this._drainOrderedMessages(ns, seqNum);
1214
+ if (this._pushedSeqs.get(ns)?.has(seqNum))
1215
+ return false;
1216
+ const queue = this._pendingOrderedMsgs.get(ns);
1217
+ queue?.delete(seqNum);
1218
+ if (queue && queue.size === 0)
1219
+ this._pendingOrderedMsgs.delete(ns);
1220
+ await this._publishAppEvent(event, payload);
1221
+ this._markPublishedSeq(ns, seqNum);
1222
+ await this._drainOrderedMessages(ns);
1223
+ return true;
1224
+ }
1048
1225
  /**
1049
1226
  * 上线/重连后一次性同步所有已加入群:
1050
1227
  * 1. 有 epoch key 的群 → 补消息 + 补事件
@@ -1313,6 +1490,8 @@ export class AUNClient {
1313
1490
  // 4. 清理推送 seq 去重缓存
1314
1491
  this._pushedSeqs.delete(`group:${groupId}`);
1315
1492
  this._pushedSeqs.delete(`group_event:${groupId}`);
1493
+ this._pendingOrderedMsgs.delete(`group:${groupId}`);
1494
+ this._pendingDecryptMsgs.delete(`group:${groupId}`);
1316
1495
  console.info(`[aun_core] 已清理解散群组 ${groupId} 的本地状态`);
1317
1496
  }
1318
1497
  async _verifyEventSignature(_event, cs) {
@@ -1346,11 +1525,19 @@ export class AUNClient {
1346
1525
  const ok = await ecdsaVerifyDer(pubKey, sigBytes, signData);
1347
1526
  if (!ok) {
1348
1527
  console.warn('[aun_core] 群事件验签失败 aid=%s method=%s', sigAid, method);
1528
+ // P1-16: 签名失败统一发布事件
1529
+ this._dispatcher.publish('signature.verification_failed', {
1530
+ aid: sigAid, method, error: 'ECDSA verification failed',
1531
+ });
1349
1532
  }
1350
1533
  return ok;
1351
1534
  }
1352
1535
  catch (exc) {
1353
1536
  console.warn('[aun_core] 群事件验签异常:', exc);
1537
+ // P1-16: 签名失败统一发布事件
1538
+ this._dispatcher.publish('signature.verification_failed', {
1539
+ aid: sigAid, method, error: String(exc),
1540
+ });
1354
1541
  return false;
1355
1542
  }
1356
1543
  }
@@ -1666,49 +1853,114 @@ export class AUNClient {
1666
1853
  }
1667
1854
  /** 自动加密并发送群组消息 */
1668
1855
  async _sendGroupEncrypted(params) {
1669
- const groupId = String(params.group_id ?? '');
1856
+ return this._callGroupEncryptedRpc('group.send', params, {
1857
+ idField: 'message_id',
1858
+ idPrefix: 'gm',
1859
+ });
1860
+ }
1861
+ async _putGroupThoughtEncrypted(params) {
1862
+ return this._callGroupEncryptedRpc('group.thought.put', params, {
1863
+ idField: 'thought_id',
1864
+ idPrefix: 'gt',
1865
+ extraFields: ['reply_to'],
1866
+ });
1867
+ }
1868
+ async _putMessageThoughtEncrypted(params) {
1869
+ const toAid = String(params.to ?? '').trim();
1870
+ this._validateMessageRecipient(toAid);
1670
1871
  const payload = isJsonObject(params.payload) ? params.payload : null;
1671
- if (!groupId) {
1672
- throw new ValidationError('group.send requires group_id');
1872
+ if (!toAid) {
1873
+ throw new ValidationError('message.thought.put requires to');
1673
1874
  }
1674
1875
  if (payload === null) {
1675
- throw new ValidationError('group.send payload must be an object when encrypt=true');
1876
+ throw new ValidationError('message.thought.put payload must be an object when encrypt=true');
1676
1877
  }
1677
- // Lazy group sync:首次发送群消息前自动拉取历史,避免重连后 seq 空洞
1678
- if (!this._groupSynced.has(groupId)) {
1679
- await this._lazySyncGroup(groupId);
1680
- }
1681
- await this._ensureGroupEpochReady(groupId, false);
1682
- await this._waitForGroupMembershipEpochFloor(groupId, 2000);
1878
+ const thoughtId = String(params.thought_id ?? '') || `mt-${_uuidV4()}`;
1879
+ const timestamp = Number(params.timestamp ?? Date.now());
1880
+ const prekey = await this._fetchPeerPrekey(toAid);
1881
+ const peerCertFingerprint = typeof prekey?.cert_fingerprint === 'string' ? prekey.cert_fingerprint : undefined;
1882
+ const peerCertPem = await this._fetchPeerCert(toAid, peerCertFingerprint);
1883
+ const [envelope, encryptResult] = await this._encryptCopyPayload({
1884
+ logicalToAid: toAid,
1885
+ payload,
1886
+ peerCertPem,
1887
+ prekey,
1888
+ messageId: thoughtId,
1889
+ timestamp,
1890
+ });
1891
+ await this._ensureEncryptResult(toAid, encryptResult);
1892
+ const sendParams = {
1893
+ to: toAid,
1894
+ payload: envelope,
1895
+ type: 'e2ee.encrypted',
1896
+ encrypted: true,
1897
+ thought_id: thoughtId,
1898
+ timestamp,
1899
+ reply_to: params.reply_to,
1900
+ };
1901
+ await this._signClientOperation('message.thought.put', sendParams);
1902
+ return this._transport.call('message.thought.put', sendParams);
1903
+ }
1904
+ async _callGroupEncryptedRpc(method, params, options) {
1905
+ let prepared = await this._prepareGroupEncryptedRpcParams(method, params, options);
1683
1906
  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
1907
  try {
1700
- return await this._transport.call('group.send', sendParams);
1908
+ return await this._transport.call(method, prepared.sendParams);
1701
1909
  }
1702
1910
  catch (exc) {
1703
1911
  if (attempt === 0 && this._isRecoverableGroupEpochError(exc)) {
1704
- console.warn(`[aun_core] 群 ${groupId} 发送时 epoch 已过旧,恢复密钥后重加密重发一次: ${formatCaughtError(exc)}`);
1705
- await this._ensureGroupEpochReady(groupId, true);
1912
+ console.warn(`[aun_core] 群 ${prepared.groupId} 调用 ${method} 时 epoch 已过旧,恢复密钥后重加密重试一次: ${formatCaughtError(exc)}`);
1913
+ prepared = await this._prepareGroupEncryptedRpcParams(method, params, options, true);
1706
1914
  continue;
1707
1915
  }
1708
1916
  throw exc;
1709
1917
  }
1710
1918
  }
1711
- throw new StateError(`group ${groupId} send failed after epoch recovery retry`);
1919
+ throw new StateError(`${method} failed after epoch recovery retry: group=${prepared.groupId}`);
1920
+ }
1921
+ async _prepareGroupEncryptedRpcParams(method, params, options, strictEpochReady = false) {
1922
+ const groupId = String(params.group_id ?? '');
1923
+ const payload = isJsonObject(params.payload) ? params.payload : null;
1924
+ if (!groupId) {
1925
+ throw new ValidationError(`${method} requires group_id`);
1926
+ }
1927
+ if (payload === null) {
1928
+ throw new ValidationError(`${method} payload must be an object when encrypt=true`);
1929
+ }
1930
+ if (!this._groupSynced.has(groupId)) {
1931
+ await this._lazySyncGroup(groupId);
1932
+ }
1933
+ await this._ensureGroupEpochReady(groupId, strictEpochReady);
1934
+ await this._waitForGroupMembershipEpochFloor(groupId, 2000);
1935
+ const epochResult = await this._committedGroupEpochState(groupId);
1936
+ const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
1937
+ const envelope = committedEpoch > 0
1938
+ ? await this._groupE2ee.encryptWithEpoch(groupId, await this._ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult), payload)
1939
+ : await this._groupE2ee.encrypt(groupId, payload);
1940
+ const operationId = String(params[options.idField] ?? '').trim()
1941
+ || `${options.idPrefix}-${crypto.randomUUID()}`;
1942
+ const timestamp = Number(params.timestamp ?? Date.now());
1943
+ const sendParams = {
1944
+ group_id: groupId,
1945
+ payload: envelope,
1946
+ type: 'e2ee.group_encrypted',
1947
+ encrypted: true,
1948
+ [options.idField]: operationId,
1949
+ timestamp,
1950
+ };
1951
+ if (this._deviceId && sendParams.device_id === undefined) {
1952
+ sendParams.device_id = this._deviceId;
1953
+ }
1954
+ if (sendParams.slot_id === undefined) {
1955
+ sendParams.slot_id = this._slotId;
1956
+ }
1957
+ for (const field of options.extraFields ?? []) {
1958
+ if (params[field] !== undefined) {
1959
+ sendParams[field] = params[field];
1960
+ }
1961
+ }
1962
+ await this._signClientOperation(method, sendParams);
1963
+ return { sendParams, groupId };
1712
1964
  }
1713
1965
  /**
1714
1966
  * 首次发送群消息前懒拉取历史消息,同步 seqTracker 避免空洞。
@@ -2030,7 +2282,7 @@ export class AUNClient {
2030
2282
  const ns = `group:${groupId}`;
2031
2283
  const queue = this._pendingDecryptMsgs.get(ns) ?? [];
2032
2284
  queue.push(msg);
2033
- this._pendingDecryptMsgs.set(ns, queue.slice(-200));
2285
+ this._pendingDecryptMsgs.set(ns, queue.slice(-PENDING_DECRYPT_LIMIT));
2034
2286
  }
2035
2287
  async _retryPendingDecryptMsgs(groupId) {
2036
2288
  const ns = `group:${groupId}`;
@@ -2047,14 +2299,20 @@ export class AUNClient {
2047
2299
  stillPending.push(msg);
2048
2300
  continue;
2049
2301
  }
2050
- await this._dispatcher.publish('group.message_created', decrypted);
2302
+ const seq = msg.seq;
2303
+ if (seq !== undefined && seq !== null) {
2304
+ await this._publishOrderedMessage('group.message_created', ns, seq, decrypted);
2305
+ }
2306
+ else {
2307
+ await this._publishAppEvent('group.message_created', decrypted);
2308
+ }
2051
2309
  }
2052
2310
  catch {
2053
2311
  stillPending.push(msg);
2054
2312
  }
2055
2313
  }
2056
2314
  const queuedDuringRetry = this._pendingDecryptMsgs.get(ns) ?? [];
2057
- const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-200);
2315
+ const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-PENDING_DECRYPT_LIMIT);
2058
2316
  if (mergedPending.length)
2059
2317
  this._pendingDecryptMsgs.set(ns, mergedPending);
2060
2318
  else
@@ -2144,7 +2402,7 @@ export class AUNClient {
2144
2402
  async _decryptGroupMessage(message, opts) {
2145
2403
  const payload = isJsonObject(message.payload) ? message.payload : null;
2146
2404
  if (payload === null || payload.type !== 'e2ee.group_encrypted') {
2147
- return message;
2405
+ return this._attachGroupDispatchModeToPayload(message);
2148
2406
  }
2149
2407
  // 确保发送方证书已缓存(签名验证需要)
2150
2408
  const senderAid = String(message.from ?? message.sender_aid ?? '');
@@ -2158,7 +2416,7 @@ export class AUNClient {
2158
2416
  // 先尝试直接解密
2159
2417
  const result = await this._groupE2ee.decrypt(message, opts);
2160
2418
  if (result !== null && isJsonObject(result.e2ee)) {
2161
- return result;
2419
+ return this._attachGroupDispatchModeToPayload(result);
2162
2420
  }
2163
2421
  // replay guard 命中:decrypt 返回了原消息(非 null)但无 e2ee 字段
2164
2422
  // 不是解密失败,不应触发 recover
@@ -2174,7 +2432,7 @@ export class AUNClient {
2174
2432
  if (await this._recoverGroupEpochKey(groupId, epoch, sender, 5000)) {
2175
2433
  const retry = await this._groupE2ee.decrypt(message, opts);
2176
2434
  if (retry !== null && retry.e2ee)
2177
- return retry;
2435
+ return this._attachGroupDispatchModeToPayload(retry);
2178
2436
  }
2179
2437
  }
2180
2438
  catch (exc) {
@@ -2183,6 +2441,21 @@ export class AUNClient {
2183
2441
  }
2184
2442
  return message;
2185
2443
  }
2444
+ _attachGroupDispatchModeToPayload(message) {
2445
+ const payload = message.payload;
2446
+ if (!isJsonObject(payload))
2447
+ return message;
2448
+ const rawMode = String(message.dispatch_mode ?? 'broadcast').trim().toLowerCase();
2449
+ const mode = rawMode === 'mention' || rawMode === 'broadcast' ? rawMode : 'broadcast';
2450
+ return {
2451
+ ...message,
2452
+ dispatch_mode: mode,
2453
+ payload: {
2454
+ ...payload,
2455
+ dispatch_mode: mode,
2456
+ },
2457
+ };
2458
+ }
2186
2459
  /** 批量解密群组消息(用于 group.pull)。跳过防重放检查。 */
2187
2460
  async _decryptGroupMessages(messages) {
2188
2461
  const result = [];
@@ -2194,10 +2467,104 @@ export class AUNClient {
2194
2467
  this._enqueuePendingDecrypt(groupId, msg);
2195
2468
  continue; // R3: 解密失败不入 result,不 publish 密文给应用层
2196
2469
  }
2470
+ if (payload?.type !== 'e2ee.group_encrypted') {
2471
+ result.push(this._attachGroupDispatchModeToPayload(decrypted));
2472
+ continue;
2473
+ }
2197
2474
  result.push(decrypted);
2198
2475
  }
2199
2476
  return result;
2200
2477
  }
2478
+ async _decryptGroupThoughts(result) {
2479
+ if (!result.found) {
2480
+ return { ...result, thoughts: [] };
2481
+ }
2482
+ const items = (Array.isArray(result.thoughts) ? result.thoughts : []).filter(isJsonObject);
2483
+ if (!items.length) {
2484
+ return { ...result, thoughts: [] };
2485
+ }
2486
+ const groupId = String(result.group_id ?? '');
2487
+ const senderAid = String(result.sender_aid ?? '');
2488
+ const thoughts = [];
2489
+ for (const item of items) {
2490
+ const payload = isJsonObject(item.payload) ? item.payload : null;
2491
+ const thoughtId = String(item.thought_id ?? item.message_id ?? '');
2492
+ const message = {
2493
+ group_id: groupId,
2494
+ sender_aid: senderAid,
2495
+ from: senderAid,
2496
+ message_id: thoughtId,
2497
+ payload: payload ?? {},
2498
+ created_at: Number(item.created_at ?? 0),
2499
+ };
2500
+ const decrypted = await this._decryptGroupMessage(message, { skipReplay: true });
2501
+ if (payload?.type === 'e2ee.group_encrypted' && groupId && !decrypted.e2ee) {
2502
+ this._enqueuePendingDecrypt(groupId, message);
2503
+ continue;
2504
+ }
2505
+ thoughts.push({
2506
+ thought_id: thoughtId,
2507
+ message_id: thoughtId,
2508
+ reply_to: item.reply_to,
2509
+ payload: decrypted.payload,
2510
+ created_at: item.created_at,
2511
+ e2ee: decrypted.e2ee,
2512
+ });
2513
+ }
2514
+ return { ...result, thoughts };
2515
+ }
2516
+ async _decryptMessageThoughts(result) {
2517
+ if (!result.found) {
2518
+ return { ...result, thoughts: [] };
2519
+ }
2520
+ const items = (Array.isArray(result.thoughts) ? result.thoughts : []).filter(isJsonObject);
2521
+ if (!items.length) {
2522
+ return { ...result, thoughts: [] };
2523
+ }
2524
+ const senderAid = String(result.sender_aid ?? '');
2525
+ const peerAid = String(result.peer_aid ?? '');
2526
+ const thoughts = [];
2527
+ for (const item of items) {
2528
+ const payload = isJsonObject(item.payload) ? item.payload : null;
2529
+ const thoughtId = String(item.thought_id ?? item.message_id ?? '');
2530
+ const fromAid = String(item.from ?? senderAid);
2531
+ const toAid = String(item.to ?? peerAid);
2532
+ const message = {
2533
+ from: fromAid,
2534
+ to: toAid,
2535
+ message_id: thoughtId,
2536
+ payload: payload ?? {},
2537
+ encrypted: item.encrypted !== false,
2538
+ timestamp: Number(item.created_at ?? 0),
2539
+ };
2540
+ let decrypted = message;
2541
+ if (payload?.type === 'e2ee.encrypted') {
2542
+ const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
2543
+ if (fromAid) {
2544
+ const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
2545
+ if (!certReady) {
2546
+ console.warn('[aun_core] 无法获取发送方证书,跳过 message.thought.get 解密:', fromAid);
2547
+ continue;
2548
+ }
2549
+ }
2550
+ decrypted = await this._e2ee.decryptMessage(message, { skipReplay: true });
2551
+ if (decrypted === null || (isJsonObject(decrypted.payload) && decrypted.payload.type === 'e2ee.encrypted')) {
2552
+ continue;
2553
+ }
2554
+ }
2555
+ thoughts.push({
2556
+ thought_id: thoughtId,
2557
+ message_id: thoughtId,
2558
+ reply_to: item.reply_to,
2559
+ from: fromAid,
2560
+ to: toAid,
2561
+ payload: decrypted.payload,
2562
+ created_at: item.created_at,
2563
+ e2ee: decrypted.e2ee,
2564
+ });
2565
+ }
2566
+ return { ...result, thoughts };
2567
+ }
2201
2568
  /**
2202
2569
  * 尝试处理 P2P 传输的群组密钥消息。
2203
2570
  *
@@ -3292,34 +3659,45 @@ export class AUNClient {
3292
3659
  // 避免 reader 把积压 push 交给空 tracker 的 handler,触发 S2 历史 gap 误补拉。
3293
3660
  this._refreshSeqTrackerContext();
3294
3661
  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 ?? '');
3662
+ try {
3663
+ const challenge = await this._transport.connect(gatewayUrl);
3664
+ this._state = 'authenticating';
3665
+ if (allowReauth) {
3666
+ const authContext = await this._auth.connectSession(this._transport, challenge, gatewayUrl, {
3667
+ accessToken: params.access_token,
3668
+ deviceId: this._deviceId,
3669
+ slotId: this._slotId,
3670
+ deliveryMode: this._connectDeliveryMode,
3671
+ });
3672
+ if (isJsonObject(authContext)) {
3673
+ const auth = authContext;
3674
+ const identity = auth.identity;
3675
+ if (identity && isJsonObject(identity)) {
3676
+ this._identity = identity;
3677
+ this._aid = String(identity.aid ?? this._aid ?? '');
3678
+ if (this._sessionParams) {
3679
+ this._sessionParams.access_token = String(auth.token ?? params.access_token ?? '');
3680
+ }
3312
3681
  }
3313
3682
  }
3314
3683
  }
3684
+ else {
3685
+ await this._auth.initializeWithToken(this._transport, challenge, String(params.access_token), {
3686
+ deviceId: this._deviceId,
3687
+ slotId: this._slotId,
3688
+ deliveryMode: this._connectDeliveryMode,
3689
+ });
3690
+ await this._syncIdentityAfterConnect(String(params.access_token));
3691
+ }
3315
3692
  }
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));
3693
+ catch (err) {
3694
+ // P1-19: 首连失败时重置状态,避免半连接残留
3695
+ this._state = 'disconnected';
3696
+ try {
3697
+ await this._transport.close();
3698
+ }
3699
+ catch { /* 忽略关闭错误 */ }
3700
+ throw err;
3323
3701
  }
3324
3702
  this._state = 'connected';
3325
3703
  await this._dispatcher.publish('connection.state', {
@@ -3666,6 +4044,26 @@ export class AUNClient {
3666
4044
  throw new ValidationError('group.send does not accept delivery_mode; group messages are always fanout');
3667
4045
  }
3668
4046
  }
4047
+ if (method === 'group.thought.put' || method === 'group.thought.get'
4048
+ || method === 'message.thought.put' || method === 'message.thought.get') {
4049
+ const replyTo = isJsonObject(params.reply_to) ? params.reply_to : null;
4050
+ const replyMsgId = String(replyTo?.message_id ?? '').trim();
4051
+ if (!replyMsgId) {
4052
+ throw new ValidationError(`${method} requires reply_to.message_id`);
4053
+ }
4054
+ }
4055
+ if (method === 'group.thought.get' && !String(params.sender_aid ?? '').trim()) {
4056
+ throw new ValidationError('group.thought.get requires sender_aid');
4057
+ }
4058
+ if (method === 'message.thought.put') {
4059
+ this._validateMessageRecipient(params.to);
4060
+ if (!String(params.to ?? '').trim()) {
4061
+ throw new ValidationError('message.thought.put requires to');
4062
+ }
4063
+ }
4064
+ if (method === 'message.thought.get' && !String(params.sender_aid ?? '').trim()) {
4065
+ throw new ValidationError('message.thought.get requires sender_aid');
4066
+ }
3669
4067
  }
3670
4068
  _currentMessageDeliveryMode() {
3671
4069
  return { ...this._connectDeliveryMode };
@@ -3959,6 +4357,8 @@ export class AUNClient {
3959
4357
  this._seqTrackerContext = null;
3960
4358
  this._gapFillDone.clear();
3961
4359
  this._pushedSeqs.clear();
4360
+ this._pendingOrderedMsgs.clear();
4361
+ this._pendingDecryptMsgs.clear();
3962
4362
  this._groupSynced.clear();
3963
4363
  this._p2pSynced = false;
3964
4364
  }
@@ -3969,6 +4369,8 @@ export class AUNClient {
3969
4369
  this._seqTracker = new SeqTracker();
3970
4370
  this._gapFillDone.clear();
3971
4371
  this._pushedSeqs.clear();
4372
+ this._pendingOrderedMsgs.clear();
4373
+ this._pendingDecryptMsgs.clear();
3972
4374
  this._groupSynced.clear();
3973
4375
  this._p2pSynced = false;
3974
4376
  this._seqTrackerContext = nextContext;