@agentunion/fastaun-browser 0.5.4 → 0.5.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/CHANGELOG.md +177 -67
  2. package/_packed_docs/AUN/347/246/273/347/272/277/346/216/250/351/200/201/346/216/245/345/205/245/344/270/216/346/274/224/347/244/272/346/214/207/345/215/227.md +290 -0
  3. package/_packed_docs/AUN/347/246/273/347/272/277/346/216/250/351/200/201/346/234/215/345/212/241/346/236/266/346/236/204/344/270/216/350/257/246/347/273/206/345/256/236/347/216/260/350/256/241/345/210/222-codex.md +994 -0
  4. package/_packed_docs/AUN/347/246/273/347/272/277/346/216/250/351/200/201/346/234/215/345/212/241/350/277/220/347/273/264/344/270/216/345/217/221/345/270/203/346/214/207/345/215/227.md +144 -0
  5. package/_packed_docs/CHANGELOG.md +177 -67
  6. package/_packed_docs/INDEX.md +124 -65
  7. package/_packed_docs/KITE_DOCS_GUIDE.md +65 -30
  8. package/_packed_docs/agent.md/SCHEMA.md +83 -58
  9. package/_packed_docs/agent.md/examples/codeagent-claudecode.md +1 -1
  10. package/_packed_docs/agent.md/examples/openclaw-lobster.md +1 -1
  11. package/_packed_docs/agent.md/examples/signed-openclaw-lobster.md +1 -1
  12. package/_packed_docs/audit/AUN/346/234/215/345/212/241Go/345/214/226/351/207/215/346/236/204/347/262/276/347/273/206/345/214/226/346/272/220/347/240/201/345/256/241/346/237/245-20260718.md +366 -0
  13. package/_packed_docs/aun/345/205/254/347/275/221/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +234 -0
  14. package/_packed_docs/aun/345/210/206/345/270/203/345/274/217/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +1095 -0
  15. package/_packed_docs/aun/346/265/213/350/257/225/350/277/220/350/241/214/346/214/207/345/215/227.md +1199 -0
  16. package/_packed_docs/cli/CLI/346/211/213/345/206/214.md +6 -4
  17. package/_packed_docs/group-message-rpc-alignment-gaps.md +741 -0
  18. package/_packed_docs/message-online-push-alignment.md +572 -0
  19. package/_packed_docs/protocol/06-/346/234/215/345/212/241/345/215/217/350/256/256.md +58 -20
  20. package/_packed_docs/protocol/10-Group-/345/255/220/345/215/217/350/256/256.md +219 -247
  21. package/_packed_docs/protocol/12-Stream-/345/255/220/345/215/217/350/256/256.md +14 -14
  22. package/_packed_docs/protocol/13-Agent/350/241/214/344/270/272/350/247/204/350/214/203.md +3 -3
  23. package/_packed_docs/protocol/15-/347/246/273/347/272/277/346/216/250/351/200/201/351/200/232/347/237/245/345/215/217/350/256/256.md +165 -421
  24. package/_packed_docs/protocol/README.md +1 -0
  25. package/_packed_docs/protocol/aun-docs-guide.md +7 -4
  26. package/_packed_docs/protocol/index.md +25 -19
  27. package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +63 -18
  28. package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +22 -0
  29. package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +93 -84
  30. package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +6 -2
  31. package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +73 -34
  32. package/_packed_docs/sdk/07-/351/224/231/350/257/257/345/244/204/347/220/206.md +3 -3
  33. package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +21 -9
  34. package/_packed_docs/sdk/09-group-rpc-manual.md +319 -285
  35. package/_packed_docs/sdk/09-message-rpc-manual.md +144 -103
  36. package/_packed_docs/sdk/09-payload-reference.md +1 -1
  37. package/_packed_docs/sdk/09-storage-rpc-manual.md +14 -3
  38. package/_packed_docs/sdk/09-stream-rpc-manual.md +8 -8
  39. package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +34 -23
  40. package/_packed_docs/sdk/CHANGELOG-0.5.6.md +73 -0
  41. package/_packed_docs/sdk/INDEX.md +45 -37
  42. package/_packed_docs/sdk/README.md +11 -9
  43. package/_packed_docs//345/217/221/345/270/203/346/212/245/345/221/212-0.5.6.md +260 -0
  44. package/_packed_docs//346/266/210/346/201/257/345/220/214/346/255/245/344/270/216/345/216/206/345/217/262/346/213/211/345/217/226/344/273/243/347/240/201/345/256/241/346/237/245/351/227/256/351/242/230/346/270/205/345/215/225.md +229 -0
  45. package/_packed_docs//346/266/210/346/201/257/345/220/214/346/255/245/344/270/216/345/216/206/345/217/262/346/213/211/345/217/226/346/224/271/351/200/240/346/226/271/346/241/210.md +750 -0
  46. package/dist/agent-md-schema.d.ts +14 -0
  47. package/dist/agent-md-schema.d.ts.map +1 -0
  48. package/dist/agent-md-schema.js +103 -0
  49. package/dist/agent-md-schema.js.map +1 -0
  50. package/dist/agent-md.d.ts +11 -5
  51. package/dist/agent-md.d.ts.map +1 -1
  52. package/dist/agent-md.js +295 -53
  53. package/dist/agent-md.js.map +1 -1
  54. package/dist/aid-store.d.ts +7 -7
  55. package/dist/aid-store.d.ts.map +1 -1
  56. package/dist/aid-store.js +46 -18
  57. package/dist/aid-store.js.map +1 -1
  58. package/dist/aid.d.ts +2 -0
  59. package/dist/aid.d.ts.map +1 -1
  60. package/dist/aid.js +29 -7
  61. package/dist/aid.js.map +1 -1
  62. package/dist/auth.d.ts.map +1 -1
  63. package/dist/auth.js +31 -11
  64. package/dist/auth.js.map +1 -1
  65. package/dist/bundle.js +29920 -19719
  66. package/dist/cert-utils.d.ts +1 -0
  67. package/dist/cert-utils.d.ts.map +1 -1
  68. package/dist/cert-utils.js +36 -16
  69. package/dist/cert-utils.js.map +1 -1
  70. package/dist/client/delivery.d.ts +106 -11
  71. package/dist/client/delivery.d.ts.map +1 -1
  72. package/dist/client/delivery.js +1925 -374
  73. package/dist/client/delivery.js.map +1 -1
  74. package/dist/client/group-state.d.ts.map +1 -1
  75. package/dist/client/group-state.js +27 -24
  76. package/dist/client/group-state.js.map +1 -1
  77. package/dist/client/lifecycle.d.ts +15 -1
  78. package/dist/client/lifecycle.d.ts.map +1 -1
  79. package/dist/client/lifecycle.js +477 -132
  80. package/dist/client/lifecycle.js.map +1 -1
  81. package/dist/client/mention-mode.d.ts +7 -0
  82. package/dist/client/mention-mode.d.ts.map +1 -0
  83. package/dist/client/mention-mode.js +184 -0
  84. package/dist/client/mention-mode.js.map +1 -0
  85. package/dist/client/peers.d.ts +1 -1
  86. package/dist/client/peers.d.ts.map +1 -1
  87. package/dist/client/peers.js +26 -3
  88. package/dist/client/peers.js.map +1 -1
  89. package/dist/client/rpc-pipeline.d.ts +34 -2
  90. package/dist/client/rpc-pipeline.d.ts.map +1 -1
  91. package/dist/client/rpc-pipeline.js +503 -99
  92. package/dist/client/rpc-pipeline.js.map +1 -1
  93. package/dist/client/runtime.d.ts +1 -3
  94. package/dist/client/runtime.d.ts.map +1 -1
  95. package/dist/client/runtime.js +4 -7
  96. package/dist/client/runtime.js.map +1 -1
  97. package/dist/client/v2-e2ee.d.ts +43 -2
  98. package/dist/client/v2-e2ee.d.ts.map +1 -1
  99. package/dist/client/v2-e2ee.js +931 -138
  100. package/dist/client/v2-e2ee.js.map +1 -1
  101. package/dist/client.d.ts +46 -15
  102. package/dist/client.d.ts.map +1 -1
  103. package/dist/client.js +785 -304
  104. package/dist/client.js.map +1 -1
  105. package/dist/errors.d.ts.map +1 -1
  106. package/dist/errors.js +4 -1
  107. package/dist/errors.js.map +1 -1
  108. package/dist/facades.d.ts +26 -1
  109. package/dist/facades.d.ts.map +1 -1
  110. package/dist/facades.js +142 -60
  111. package/dist/facades.js.map +1 -1
  112. package/dist/group-id.d.ts.map +1 -1
  113. package/dist/group-id.js +2 -17
  114. package/dist/group-id.js.map +1 -1
  115. package/dist/group-index.d.ts +6 -1
  116. package/dist/group-index.d.ts.map +1 -1
  117. package/dist/group-index.js +44 -25
  118. package/dist/group-index.js.map +1 -1
  119. package/dist/index.d.ts +5 -4
  120. package/dist/index.d.ts.map +1 -1
  121. package/dist/index.js +3 -2
  122. package/dist/index.js.map +1 -1
  123. package/dist/keystore/index.d.ts +10 -5
  124. package/dist/keystore/index.d.ts.map +1 -1
  125. package/dist/keystore/indexeddb-identity-store.d.ts +0 -12
  126. package/dist/keystore/indexeddb-identity-store.d.ts.map +1 -1
  127. package/dist/keystore/indexeddb-identity-store.js +0 -60
  128. package/dist/keystore/indexeddb-identity-store.js.map +1 -1
  129. package/dist/keystore/indexeddb-shared.d.ts.map +1 -1
  130. package/dist/keystore/indexeddb-shared.js +9 -5
  131. package/dist/keystore/indexeddb-shared.js.map +1 -1
  132. package/dist/keystore/indexeddb-token-store.d.ts +3 -1
  133. package/dist/keystore/indexeddb-token-store.d.ts.map +1 -1
  134. package/dist/keystore/indexeddb-token-store.js +47 -2
  135. package/dist/keystore/indexeddb-token-store.js.map +1 -1
  136. package/dist/register-flow.d.ts.map +1 -1
  137. package/dist/register-flow.js +28 -3
  138. package/dist/register-flow.js.map +1 -1
  139. package/dist/seq-tracker.d.ts +28 -8
  140. package/dist/seq-tracker.d.ts.map +1 -1
  141. package/dist/seq-tracker.js +224 -61
  142. package/dist/seq-tracker.js.map +1 -1
  143. package/dist/storage/vfs.d.ts +1 -0
  144. package/dist/storage/vfs.d.ts.map +1 -1
  145. package/dist/storage/vfs.js +26 -2
  146. package/dist/storage/vfs.js.map +1 -1
  147. package/dist/tools/cross-sdk-agent.js +399 -11
  148. package/dist/tools/cross-sdk-agent.js.map +1 -1
  149. package/dist/transport.d.ts +12 -0
  150. package/dist/transport.d.ts.map +1 -1
  151. package/dist/transport.js +226 -102
  152. package/dist/transport.js.map +1 -1
  153. package/dist/v2/session/session.d.ts.map +1 -1
  154. package/dist/v2/session/session.js +3 -4
  155. package/dist/v2/session/session.js.map +1 -1
  156. package/dist/v2/state/commitment.d.ts.map +1 -1
  157. package/dist/v2/state/commitment.js +1 -2
  158. package/dist/v2/state/commitment.js.map +1 -1
  159. package/dist/validators.d.ts.map +1 -1
  160. package/dist/validators.js +8 -14
  161. package/dist/validators.js.map +1 -1
  162. package/dist/version.d.ts +1 -1
  163. package/dist/version.js +1 -1
  164. package/package.json +11 -9
  165. package/dist/group-resources.d.ts +0 -98
  166. package/dist/group-resources.d.ts.map +0 -1
  167. package/dist/group-resources.js +0 -635
  168. package/dist/group-resources.js.map +0 -1
  169. package/dist/keystore/indexeddb.d.ts +0 -179
  170. package/dist/keystore/indexeddb.d.ts.map +0 -1
  171. package/dist/keystore/indexeddb.js +0 -2031
  172. package/dist/keystore/indexeddb.js.map +0 -1
  173. package/dist/namespaces/auth.d.ts +0 -98
  174. package/dist/namespaces/auth.d.ts.map +0 -1
  175. package/dist/namespaces/auth.js +0 -992
  176. package/dist/namespaces/auth.js.map +0 -1
  177. package/dist/namespaces/custody.d.ts +0 -51
  178. package/dist/namespaces/custody.d.ts.map +0 -1
  179. package/dist/namespaces/custody.js +0 -302
  180. package/dist/namespaces/custody.js.map +0 -1
  181. package/dist/namespaces/meta.d.ts +0 -109
  182. package/dist/namespaces/meta.d.ts.map +0 -1
  183. package/dist/namespaces/meta.js +0 -549
  184. package/dist/namespaces/meta.js.map +0 -1
@@ -1,15 +1,133 @@
1
1
  import { importCertPublicKeyEcdsa } from '../crypto.js';
2
2
  import { E2EEError, StateError, ValidationError } from '../errors.js';
3
- import { normalizeGroupId } from '../group-id.js';
4
3
  import { decryptMessage, encryptGroupMessage, encryptP2PMessage, } from '../v2/e2ee/index.js';
5
4
  import { V2Session, V2KeyStore } from '../v2/session/index.js';
6
5
  import { ConnectionState, isJsonObject } from '../types.js';
6
+ import { normalizeGroupMentionMode } from './mention-mode.js';
7
7
  const V2_BOOTSTRAP_TTL_MS = 60 * 60 * 1000;
8
8
  const V2_RETRYABLE_CODES = new Set([-33011, -33012, -33050, -33052, -33054]);
9
9
  const V2_GROUP_STALE_BOOTSTRAP_CODE = -33054;
10
+ const V2_PULL_DEFAULT_PAGE_LIMIT = 50;
11
+ const V2_PULL_MAX_PAGE_LIMIT = 50;
12
+ const V2_SENDER_IK_SYNC_FETCH_TIMEOUT_MS = 3_000;
10
13
  const V2_SESSION_INIT_RETRY_LIMIT = 12;
11
14
  const V2_SESSION_INIT_RETRY_BASE_MS = 1000;
12
15
  const V2_SESSION_INIT_RETRY_MAX_MS = 5000;
16
+ function strictWindowSeq(value, field, positive = false) {
17
+ if (!Number.isSafeInteger(value))
18
+ throw new ValidationError(`${field} must be an integer`);
19
+ const minimum = positive ? 1 : 0;
20
+ if (value < minimum)
21
+ throw new ValidationError(`${field} must be >= ${minimum}`);
22
+ return value;
23
+ }
24
+ function safeNonNegativeSeq(value) {
25
+ return typeof value === 'number' && Number.isSafeInteger(value) && value >= 0 ? value : null;
26
+ }
27
+ function strictWindowLimit(value) {
28
+ const limit = strictWindowSeq(value, 'limit', true);
29
+ if (limit > V2_PULL_MAX_PAGE_LIMIT)
30
+ throw new ValidationError(`limit must be <= ${V2_PULL_MAX_PAGE_LIMIT}`);
31
+ return limit;
32
+ }
33
+ function deduplicateWindowMessages(value, lowerSeq, upperSeq, boundary) {
34
+ if (!Array.isArray(value))
35
+ throw new ValidationError('messages must be an array');
36
+ const bySeq = new Map();
37
+ for (const raw of value) {
38
+ if (!isJsonObject(raw))
39
+ throw new ValidationError('messages entries must be objects');
40
+ const row = raw;
41
+ const seq = strictWindowSeq(row.seq, 'messages[].seq', true);
42
+ if (seq < lowerSeq || seq > upperSeq)
43
+ throw new ValidationError(`messages[].seq is outside ${boundary}`);
44
+ const previous = bySeq.get(seq);
45
+ if (previous && JSON.stringify(previous) !== JSON.stringify(row))
46
+ throw new ValidationError('messages contains conflicting duplicate seq');
47
+ bySeq.set(seq, row);
48
+ }
49
+ return [...bySeq.entries()].sort(([a], [b]) => a - b).map(([, row]) => row);
50
+ }
51
+ function deduplicateForwardPageMessages(value, afterSeq) {
52
+ if (!Array.isArray(value))
53
+ return [];
54
+ const bySeq = new Map();
55
+ for (const raw of value) {
56
+ if (!isJsonObject(raw))
57
+ continue;
58
+ const row = raw;
59
+ const seq = safeNonNegativeSeq(row.seq);
60
+ if (seq === null || seq <= 0 || seq <= afterSeq || bySeq.has(seq))
61
+ continue;
62
+ bySeq.set(seq, row);
63
+ }
64
+ return [...bySeq.entries()].sort(([a], [b]) => a - b).map(([, row]) => row);
65
+ }
66
+ function shouldContinueForwardPage({ rawCount, nextAfterSeq, nextAfter, limit, hasMoreField, hasMore, serverHead, pendingAckSeq, }) {
67
+ // 无新 seq 或游标未前进时必须停止,避免错误元数据造成循环。
68
+ if (rawCount <= 0 || nextAfter <= nextAfterSeq)
69
+ return false;
70
+ if (hasMore || serverHead > nextAfter || pendingAckSeq > 0)
71
+ return true;
72
+ // 仅旧服务缺失 has_more 时,以满页保留兼容续拉。
73
+ return !hasMoreField && limit > 0 && rawCount >= limit;
74
+ }
75
+ function validateTailPage(result, afterSeq, pageLimit) {
76
+ if (!isJsonObject(result))
77
+ throw new ValidationError('tail response must be an object');
78
+ const row = result;
79
+ if (row.window_mode !== 'tail')
80
+ throw new ValidationError('tail response window_mode must equal tail');
81
+ strictWindowSeq(afterSeq, 'after_seq');
82
+ const windowStart = strictWindowSeq(row.window_start_seq, 'window_start_seq');
83
+ const head = strictWindowSeq(row.head_seq, 'head_seq');
84
+ const covered = strictWindowSeq(row.covered_through_seq, 'covered_through_seq');
85
+ if (windowStart > head || covered !== head || head - windowStart + 1 > pageLimit) {
86
+ throw new ValidationError('tail response proof is invalid');
87
+ }
88
+ let lowerSeq = windowStart;
89
+ if (head <= afterSeq) {
90
+ if (windowStart !== head)
91
+ throw new ValidationError('empty tail window must end at head_seq');
92
+ lowerSeq = head + 1;
93
+ }
94
+ else {
95
+ if (windowStart < afterSeq + 1)
96
+ throw new ValidationError('tail window_start_seq is below after_seq');
97
+ lowerSeq = Math.max(windowStart, afterSeq + 1);
98
+ }
99
+ const messages = deduplicateWindowMessages(row.messages, lowerSeq, head, 'the proven tail window');
100
+ if (head <= afterSeq && messages.length > 0)
101
+ throw new ValidationError('tail response must not include messages at or below after_seq');
102
+ return { windowStart, head, messages };
103
+ }
104
+ function validateHistoryPage(result, beforeSeq) {
105
+ if (!isJsonObject(result))
106
+ throw new ValidationError('history response must be an object');
107
+ const row = result;
108
+ const rawStart = row.window_start_seq;
109
+ const rawNext = row.next_before_seq;
110
+ if ((rawStart === undefined) !== (rawNext === undefined))
111
+ throw new ValidationError('history pagination boundaries must both be present or absent');
112
+ let lowerSeq = 1;
113
+ let windowStart;
114
+ if (rawStart !== undefined) {
115
+ windowStart = strictWindowSeq(rawStart, 'window_start_seq', true);
116
+ const nextBefore = strictWindowSeq(rawNext, 'next_before_seq', true);
117
+ if (windowStart !== nextBefore || windowStart >= beforeSeq)
118
+ throw new ValidationError('history boundary is invalid');
119
+ lowerSeq = windowStart;
120
+ }
121
+ const messages = deduplicateWindowMessages(row.messages, lowerSeq, beforeSeq - 1, 'the history page');
122
+ if (messages.length > 0 && (windowStart === undefined || Number(messages[0].seq) !== windowStart)) {
123
+ throw new ValidationError('history messages require exact pagination boundaries');
124
+ }
125
+ if (messages.length === 0 && windowStart !== undefined)
126
+ throw new ValidationError('empty history page must not include pagination boundaries');
127
+ if (typeof row.has_older !== 'boolean' || (messages.length === 0 && row.has_older))
128
+ throw new ValidationError('has_older is invalid');
129
+ return messages;
130
+ }
13
131
  const MEMBERSHIP_ACTIONS = new Set([
14
132
  'member_added',
15
133
  'member_left',
@@ -205,6 +323,12 @@ function v2WrapCapabilities() {
205
323
  per_device_wrap: true,
206
324
  };
207
325
  }
326
+ function withExplicitGroupAid(params, groupAid) {
327
+ const aid = String(groupAid ?? '').trim();
328
+ if (aid)
329
+ params.group_aid = aid;
330
+ return params;
331
+ }
208
332
  function applyV2WrapPolicyToTargets(targets, policy) {
209
333
  if (!policy)
210
334
  return targets;
@@ -231,6 +355,10 @@ function applyV2WrapPolicyToTargets(targets, policy) {
231
355
  export class V2E2EECoordinator {
232
356
  runtime;
233
357
  targetSetCache = new Map();
358
+ senderIKSyncFetches = new Map();
359
+ /** 服务端 ACK cursor 高于本地 A 时,延迟到下一次 Forward Pull 再恢复。 */
360
+ pendingForwardCursors = new Map();
361
+ pendingForwardAcks = new Map();
234
362
  v2SessionInitRetryTimer = null;
235
363
  v2SessionInitRetryBackgroundSync = false;
236
364
  v2SessionInitRetryIdentityKey = null;
@@ -240,6 +368,51 @@ export class V2E2EECoordinator {
240
368
  get client() {
241
369
  return this.runtime.client;
242
370
  }
371
+ resetForwardCursorState() {
372
+ this.pendingForwardCursors.clear();
373
+ this.pendingForwardAcks.clear();
374
+ }
375
+ pendingForwardCursor(ns, localAck) {
376
+ if (!ns)
377
+ return 0;
378
+ const pending = this.pendingForwardCursors.get(ns) ?? 0;
379
+ if (pending <= localAck) {
380
+ if (pending > 0)
381
+ this.pendingForwardCursors.delete(ns);
382
+ return 0;
383
+ }
384
+ return pending;
385
+ }
386
+ recordForwardCursor(ns, cursor, localAck) {
387
+ if (!ns || !Number.isSafeInteger(cursor) || cursor <= localAck)
388
+ return;
389
+ this.pendingForwardCursors.set(ns, Math.max(this.pendingForwardCursors.get(ns) ?? 0, cursor));
390
+ }
391
+ clearForwardCursor(ns, committedAck) {
392
+ const pending = this.pendingForwardCursors.get(ns) ?? 0;
393
+ if (pending > 0 && committedAck >= pending)
394
+ this.pendingForwardCursors.delete(ns);
395
+ }
396
+ pendingForwardAck(ns, localAck) {
397
+ if (!ns)
398
+ return 0;
399
+ const pending = this.pendingForwardAcks.get(ns) ?? 0;
400
+ if (!Number.isSafeInteger(pending) || pending <= 0 || localAck <= 0 || pending > localAck) {
401
+ this.pendingForwardAcks.delete(ns);
402
+ return 0;
403
+ }
404
+ return pending;
405
+ }
406
+ recordForwardAck(ns, ackSeq) {
407
+ if (!ns || !Number.isSafeInteger(ackSeq) || ackSeq <= 0)
408
+ return;
409
+ this.pendingForwardAcks.set(ns, Math.max(this.pendingForwardAcks.get(ns) ?? 0, ackSeq));
410
+ }
411
+ clearForwardAck(ns, confirmedSeq) {
412
+ const pending = this.pendingForwardAcks.get(ns) ?? 0;
413
+ if (pending > 0 && confirmedSeq >= pending)
414
+ this.pendingForwardAcks.delete(ns);
415
+ }
243
416
  get bootstrapCache() {
244
417
  const client = this.client;
245
418
  if (!(client._v2BootstrapCache instanceof Map)) {
@@ -253,6 +426,18 @@ export class V2E2EECoordinator {
253
426
  setBootstrapCacheEntry(key, entry) {
254
427
  this.bootstrapCache.set(key, entry);
255
428
  }
429
+ cacheBundledSelfDevices(bootstrap, wrapPolicy) {
430
+ const client = this.client;
431
+ if (!client._aid || !Object.prototype.hasOwnProperty.call(bootstrap, 'self_devices'))
432
+ return;
433
+ const selfDevices = (Array.isArray(bootstrap.self_devices) ? bootstrap.self_devices : []);
434
+ this.setBootstrapCacheEntry(client._aid, {
435
+ devices: selfDevices,
436
+ auditRecipients: [],
437
+ cachedAt: Date.now(),
438
+ wrapPolicy,
439
+ });
440
+ }
256
441
  deleteBootstrapCacheEntry(key) {
257
442
  this.bootstrapCache.delete(key);
258
443
  this.targetSetCache.delete(key);
@@ -555,6 +740,84 @@ export class V2E2EECoordinator {
555
740
  pendingSenderIKFetchKey(fromAid, senderDeviceId, groupId) {
556
741
  return `${fromAid}#${senderDeviceId}#${groupId || ''}`;
557
742
  }
743
+ async fetchSenderIKOnce(fromAid, senderDeviceId, groupId, certFingerprint = '') {
744
+ const client = this.client;
745
+ const peerAid = String(fromAid ?? '').trim();
746
+ const deviceId = String(senderDeviceId ?? '').trim();
747
+ const gid = String(groupId ?? '').trim();
748
+ const fingerprint = String(certFingerprint ?? '').trim().toLowerCase();
749
+ if (!peerAid)
750
+ return null;
751
+ const fetchKey = [peerAid, deviceId, gid, fingerprint].join('#');
752
+ const existing = this.senderIKSyncFetches.get(fetchKey);
753
+ if (existing)
754
+ return await existing;
755
+ const task = (async () => {
756
+ const started = Date.now();
757
+ let timer;
758
+ const timedOut = Symbol('sender-ik-timeout');
759
+ const fetch = async () => {
760
+ try {
761
+ const bootstrap = await client.call('message.v2.bootstrap', {
762
+ peer_aid: peerAid,
763
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
764
+ });
765
+ const peers = (Array.isArray(bootstrap?.peer_devices) ? bootstrap.peer_devices : []);
766
+ for (const dev of peers)
767
+ client._cacheV2PeerIKFromDevice(dev, peerAid);
768
+ }
769
+ catch (exc) {
770
+ client._clientLog.warn(`V2 sender IK 同步 bootstrap 失败: peer=${peerAid} device=${deviceId || '-'} err=${String(formatE2EEError(exc))}`);
771
+ }
772
+ let senderPubDer = await this.getV2SenderPubDer(peerAid, deviceId, fingerprint);
773
+ if (senderPubDer || !gid)
774
+ return senderPubDer;
775
+ try {
776
+ const bootstrap = await client.call('group.v2.bootstrap', {
777
+ group_id: gid,
778
+ e2ee_wrap_capabilities: v2WrapCapabilities(),
779
+ });
780
+ const devices = (Array.isArray(bootstrap?.devices) ? bootstrap.devices : []);
781
+ const audit = (Array.isArray(bootstrap?.audit_recipients) ? bootstrap.audit_recipients : []);
782
+ for (const dev of devices)
783
+ client._cacheV2PeerIKFromDevice(dev);
784
+ for (const dev of audit)
785
+ client._cacheV2PeerIKFromDevice(dev);
786
+ }
787
+ catch (exc) {
788
+ client._clientLog.warn(`V2 sender IK 同步 group bootstrap 失败: group=${gid} peer=${peerAid} err=${String(formatE2EEError(exc))}`);
789
+ }
790
+ senderPubDer = await this.getV2SenderPubDer(peerAid, deviceId, fingerprint);
791
+ return senderPubDer;
792
+ };
793
+ try {
794
+ const result = await Promise.race([
795
+ fetch(),
796
+ new Promise((resolve) => {
797
+ timer = setTimeout(() => resolve(timedOut), V2_SENDER_IK_SYNC_FETCH_TIMEOUT_MS);
798
+ }),
799
+ ]);
800
+ if (result === timedOut) {
801
+ client._clientLog.warn(`V2 sender IK 同步获取超时: peer=${peerAid} device=${deviceId || '-'} group=${gid || '<p2p>'} timeout_ms=${V2_SENDER_IK_SYNC_FETCH_TIMEOUT_MS}`);
802
+ return null;
803
+ }
804
+ return result;
805
+ }
806
+ finally {
807
+ if (timer !== undefined)
808
+ clearTimeout(timer);
809
+ client._clientLog.debug(`V2 sender IK 同步获取完成: peer=${peerAid} device=${deviceId || '-'} group=${gid || '<p2p>'} elapsed_ms=${Date.now() - started}`);
810
+ }
811
+ })();
812
+ this.senderIKSyncFetches.set(fetchKey, task);
813
+ try {
814
+ return await task;
815
+ }
816
+ finally {
817
+ if (this.senderIKSyncFetches.get(fetchKey) === task)
818
+ this.senderIKSyncFetches.delete(fetchKey);
819
+ }
820
+ }
558
821
  scheduleSenderIKPending(args) {
559
822
  const client = this.client;
560
823
  const fromAid = String(args.fromAid ?? '').trim();
@@ -571,7 +834,8 @@ export class V2E2EECoordinator {
571
834
  createdAt: Date.now(),
572
835
  });
573
836
  client._clientLog.debug(`V2 decrypt pending sender IK: key=${messageKey} from=${fromAid} device=${senderDeviceId || '-'} group=${groupId || '<p2p>'} pending=${client._v2SenderIKPending.size}`);
574
- this.scheduleSenderIKFetch(fromAid, senderDeviceId, groupId);
837
+ if (args.startFetch !== false)
838
+ this.scheduleSenderIKFetch(fromAid, senderDeviceId, groupId);
575
839
  }
576
840
  scheduleSenderIKFetch(fromAid, senderDeviceId, groupId) {
577
841
  const client = this.client;
@@ -622,26 +886,33 @@ export class V2E2EECoordinator {
622
886
  const pendingItems = [...client._v2SenderIKPending.entries()].filter(([, entry]) => entry.fromAid === fromAid && entry.senderDeviceId === senderDeviceId && entry.groupId === groupId);
623
887
  for (const [key, entry] of pendingItems) {
624
888
  let plaintext = null;
889
+ const retryStatus = {};
625
890
  try {
626
- plaintext = await client._decryptV2Message(entry.msg, false);
891
+ plaintext = await client._decryptV2Message(entry.msg, false, true, true, true, retryStatus);
627
892
  }
628
893
  catch (exc) {
629
894
  client._clientLog.warn(`V2 sender IK pending retry raised: key=${key} err=${String(formatE2EEError(exc))}`);
895
+ client._v2SenderIKPending.delete(key);
896
+ continue;
630
897
  }
631
- client._v2SenderIKPending.delete(key);
632
898
  if (plaintext === null) {
633
- client._clientLog.debug(`V2 sender IK pending retry failed: key=${key}`);
634
- client.emit('message.undecryptable', {
635
- from: entry.fromAid,
636
- sender_device_id: entry.senderDeviceId,
637
- group_id: entry.groupId || undefined,
638
- seq: Number(entry.msg.seq ?? 0),
639
- reason: 'sender_ik_retry_failed',
640
- });
899
+ if (retryStatus.deferred) {
900
+ client._clientLog.warn(`V2 pending retry still missing key material: key=${key}`);
901
+ continue;
902
+ }
903
+ client._clientLog.warn(`V2 sender IK pending retry still undecryptable: key=${key}`);
904
+ const isGroup = Boolean(entry.groupId);
905
+ const eventName = isGroup ? 'group.message_undecryptable' : 'message.undecryptable';
906
+ const safeEvent = this.safeUndecryptablePushEvent(entry.msg, isGroup);
907
+ safeEvent._decrypt_error = 'sender IK pending retry failed';
908
+ safeEvent._decrypt_stage = 'sender_ik_pending_retry';
909
+ await client._publishAppEvent(eventName, safeEvent, 'pending_retry');
641
910
  continue;
642
911
  }
912
+ client._v2SenderIKPending.delete(key);
643
913
  const seq = Number(entry.msg.seq ?? 0);
644
914
  if (entry.groupId) {
915
+ plaintext = normalizeGroupMentionMode(plaintext, entry.msg, entry.msg.envelope_json);
645
916
  plaintext.group_id = entry.groupId;
646
917
  await client._publishPulledMessage('group.message_created', `group:${entry.groupId}`, seq, plaintext);
647
918
  }
@@ -709,19 +980,211 @@ export class V2E2EECoordinator {
709
980
  throw exc;
710
981
  }
711
982
  }
983
+ historyDecryptFailure(msg, reason) {
984
+ return {
985
+ message_id: String(msg.message_id ?? ''),
986
+ seq: Number(msg.seq ?? 0),
987
+ encrypted: true,
988
+ history_error: { code: 'decrypt_failed', message: reason },
989
+ };
990
+ }
991
+ async decodeP2PWindowMessage(msg, publish, history, source, orderedPublish = false) {
992
+ const client = this.client;
993
+ const seq = strictWindowSeq(msg.seq, 'messages[].seq', true);
994
+ let plaintext = null;
995
+ if (String(msg.version ?? '') === 'v1') {
996
+ const legacy = isJsonObject(msg.legacy_v1)
997
+ ? msg.legacy_v1 : {};
998
+ const payload = legacy.payload;
999
+ const payloadType = isJsonObject(payload)
1000
+ ? String(payload.type ?? '').trim() : '';
1001
+ if (payload !== undefined && payload !== null && !['e2ee.encrypted', 'e2ee.group_encrypted'].includes(payloadType)) {
1002
+ plaintext = {
1003
+ message_id: String(msg.message_id ?? ''), from: String(msg.from_aid ?? ''),
1004
+ to: String(legacy.to ?? client._aid ?? ''), seq, type: String(msg.type ?? ''),
1005
+ timestamp: msg.t_server, payload: payload, encrypted: false,
1006
+ };
1007
+ attachGatewayProximity(plaintext, msg);
1008
+ }
1009
+ }
1010
+ else {
1011
+ plaintext = source === 'inline_push'
1012
+ ? await client._decryptV2Message(msg, false, false, false, false)
1013
+ : await client._decryptV2Message(msg, false);
1014
+ }
1015
+ if (!plaintext) {
1016
+ if (history)
1017
+ return this.historyDecryptFailure(msg, 'message could not be decrypted');
1018
+ client._clientLog.warn(`P2P Tail 单条消息解密失败,连续性证明仍保留: seq=${seq}`);
1019
+ return null;
1020
+ }
1021
+ if (publish) {
1022
+ const event = client._delivery.p2pAppEventForMessage(plaintext);
1023
+ if (orderedPublish) {
1024
+ await client._delivery.publishOrderedMessage(event.event, client._aid ? `p2p:${client._aid}` : '', seq, event.payload);
1025
+ }
1026
+ else {
1027
+ await client._publishAppEvent(event.event, event.payload, source);
1028
+ }
1029
+ }
1030
+ return plaintext;
1031
+ }
1032
+ async decodeGroupWindowMessage(msg, groupId, groupAid, publish, history, source, orderedPublish = false) {
1033
+ const client = this.client;
1034
+ const seq = strictWindowSeq(msg.seq, 'messages[].seq', true);
1035
+ let plaintext = null;
1036
+ if (String(msg.version ?? '') === 'v1') {
1037
+ const payload = msg.payload;
1038
+ if (payload === undefined || payload === null) {
1039
+ if (history)
1040
+ return this.historyDecryptFailure(msg, 'legacy payload is missing');
1041
+ client._clientLog.warn(`Group Tail 缺少 payload 的 V1 行已跳过,连续性证明仍保留: group=${groupId}, seq=${seq}`);
1042
+ return null;
1043
+ }
1044
+ if (isJsonObject(payload)
1045
+ && ['e2ee.encrypted', 'e2ee.group_encrypted'].includes(String(payload.type ?? ''))) {
1046
+ if (history)
1047
+ return this.historyDecryptFailure(msg, 'unsupported legacy encrypted envelope');
1048
+ client._clientLog.warn(`Group Tail 不支持的旧加密消息已跳过,连续性证明仍保留: group=${groupId}, seq=${seq}`);
1049
+ return null;
1050
+ }
1051
+ plaintext = {
1052
+ message_id: String(msg.message_id ?? ''), from: String(msg.from_aid ?? ''),
1053
+ group_id: groupId, group_aid: groupAid, seq, type: String(msg.type ?? ''),
1054
+ timestamp: msg.t_server, payload: payload, encrypted: false,
1055
+ };
1056
+ plaintext = normalizeGroupMentionMode(plaintext, msg, msg.envelope_json);
1057
+ attachGatewayProximity(plaintext, msg);
1058
+ }
1059
+ else {
1060
+ plaintext = source === 'inline_push'
1061
+ ? await client._decryptV2Message(msg, false, false, false, false)
1062
+ : await client._decryptV2Message(msg, false);
1063
+ if (plaintext) {
1064
+ // Inline Head 已绑定群身份;解密结果若声称群身份,必须与其严格一致。
1065
+ if (source === 'inline_push' && Object.prototype.hasOwnProperty.call(plaintext, 'group_id')
1066
+ && (typeof plaintext.group_id !== 'string' || !plaintext.group_id || plaintext.group_id !== groupId)) {
1067
+ return null;
1068
+ }
1069
+ plaintext.group_id = groupId;
1070
+ plaintext.group_aid = groupAid;
1071
+ plaintext = normalizeGroupMentionMode(plaintext, msg, msg.envelope_json);
1072
+ }
1073
+ }
1074
+ if (!plaintext) {
1075
+ if (history)
1076
+ return this.historyDecryptFailure(msg, 'message could not be decrypted');
1077
+ client._clientLog.warn(`Group Tail 单条消息解密失败,连续性证明仍保留: group=${groupId}, seq=${seq}`);
1078
+ return null;
1079
+ }
1080
+ if (publish) {
1081
+ if (orderedPublish) {
1082
+ await client._delivery.publishOrderedMessage('group.message_created', `group:${groupId}`, seq, plaintext);
1083
+ }
1084
+ else {
1085
+ await client._publishAppEvent('group.message_created', plaintext, source);
1086
+ }
1087
+ }
1088
+ return plaintext;
1089
+ }
1090
+ async decodeInlineP2PMessage(msg) {
1091
+ return await this.decodeP2PWindowMessage(msg, false, false, 'inline_push');
1092
+ }
1093
+ async decodeInlineGroupMessage(msg, groupId, groupAid) {
1094
+ return await this.decodeGroupWindowMessage(msg, groupId, groupAid, false, false, 'inline_push');
1095
+ }
1096
+ async applyTailFloor(ns, floor) {
1097
+ const client = this.client;
1098
+ if (!ns || floor <= 0 || client._seqTracker.getContiguousSeq(ns) >= floor)
1099
+ return;
1100
+ client._seqTracker.forceContiguousSeq(ns, floor);
1101
+ client._persistSeq(ns);
1102
+ await client._saveSeqTrackerState?.();
1103
+ }
1104
+ async pullV2TailInternal(params, deferPublish = false) {
1105
+ const client = this.client;
1106
+ if (params.window_mode !== 'tail')
1107
+ throw new ValidationError('window_mode must equal tail');
1108
+ if ('ack_up_to_seq' in params || params.force === true)
1109
+ throw new ValidationError('Tail does not accept ack_up_to_seq or force');
1110
+ const afterSeq = strictWindowSeq(params.after_seq ?? 0, 'after_seq');
1111
+ const limit = strictWindowLimit(params.limit ?? V2_PULL_DEFAULT_PAGE_LIMIT);
1112
+ const ns = client._aid ? `p2p:${client._aid}` : '';
1113
+ const result = await client._callRawV2Rpc('message.v2.pull', { window_mode: 'tail', after_seq: afterSeq, limit, _rpc_foreground: true });
1114
+ const floor = Number(result.retention_floor_seq ?? 0);
1115
+ const previous = ns ? client._seqTracker.getSyncState(ns) : null;
1116
+ await this.applyTailFloor(ns, Number.isSafeInteger(floor) ? floor : 0);
1117
+ const page = validateTailPage(result, afterSeq, limit);
1118
+ if (ns) {
1119
+ client._seqTracker.commitTailWindow(ns, {
1120
+ windowStartSeq: page.windowStart, headSeq: page.head, coveredThroughSeq: page.head, pageLimit: limit,
1121
+ });
1122
+ client._persistSeq(ns);
1123
+ await client._saveSeqTrackerState?.();
1124
+ }
1125
+ const decoded = [];
1126
+ for (const msg of page.messages) {
1127
+ const plaintext = await this.decodeP2PWindowMessage(msg, true, false, 'tail', deferPublish);
1128
+ if (plaintext)
1129
+ decoded.push(plaintext);
1130
+ }
1131
+ const ack = ns ? client._seqTracker.getContiguousSeq(ns) : 0;
1132
+ if (previous && ack > previous.ack)
1133
+ await this.ackV2(ack);
1134
+ return { ...result, messages: decoded, raw_count: page.messages.length };
1135
+ }
1136
+ async historyV2Internal(params) {
1137
+ const beforeSeq = strictWindowSeq(params.before_seq, 'before_seq', true);
1138
+ const limit = strictWindowLimit(params.limit ?? V2_PULL_DEFAULT_PAGE_LIMIT);
1139
+ const result = await this.client._callRawV2Rpc('message.history', { before_seq: beforeSeq, limit, _rpc_foreground: true });
1140
+ const messages = validateHistoryPage(result, beforeSeq);
1141
+ const decoded = [];
1142
+ for (const msg of messages) {
1143
+ const plaintext = await this.decodeP2PWindowMessage(msg, false, true, 'history');
1144
+ if (plaintext)
1145
+ decoded.push(plaintext);
1146
+ }
1147
+ return { ...result, messages: decoded };
1148
+ }
712
1149
  async pullV2(afterSeq = 0, limit = 50, opts) {
713
1150
  const client = this.client;
714
1151
  await client._ensureV2SessionReady('message.pull');
715
1152
  const ns = client._aid ? `p2p:${client._aid}` : '';
1153
+ if (opts?.windowMode === 'tail') {
1154
+ if (!opts.gateLocked) {
1155
+ const key = `${ns}|tail|after=${afterSeq}|limit=${limit}`;
1156
+ return await client._runPullSerialized(key, () => this.pullV2(afterSeq, limit, {
1157
+ ...(opts ?? {}),
1158
+ gateLocked: true,
1159
+ }), false);
1160
+ }
1161
+ const result = await this.pullV2TailInternal({ window_mode: 'tail', after_seq: afterSeq, limit });
1162
+ return Array.isArray(result.messages) ? result.messages : [];
1163
+ }
1164
+ if (ns && !opts?.gateLocked) {
1165
+ const key = `${ns}|forward|after=${afterSeq}|force=${String(Boolean(opts?.force))}|limit=${limit}`;
1166
+ return await client._runPullSerialized(key, () => this.pullV2(afterSeq, limit, {
1167
+ ...(opts ?? {}),
1168
+ gateLocked: true,
1169
+ }), true);
1170
+ }
716
1171
  const decrypted = [];
717
1172
  let nextAfterSeq = opts?.force ? afterSeq : (afterSeq || (ns ? client._seqTracker.getContiguousSeq(ns) : 0));
1173
+ let pullGateKey = ns
1174
+ ? `${ns}|forward|after=${afterSeq}|force=${String(Boolean(opts?.force))}|limit=${limit}`
1175
+ : '';
1176
+ const deferredServerCursor = ns ? this.pendingForwardCursor(ns, client._seqTracker.getContiguousSeq(ns)) : 0;
1177
+ const deferredForwardAck = ns ? this.pendingForwardAck(ns, client._seqTracker.getContiguousSeq(ns)) : 0;
718
1178
  let pageCount = 0;
719
1179
  let lastAutoAckSeq = 0;
720
- let pendingAckSeq = 0;
721
- const maxPages = 100;
1180
+ let pendingAckSeq = deferredForwardAck;
1181
+ const maxPages = Math.max(1, Math.min(100, Math.trunc(Number(opts?.maxPages ?? 100) || 100)));
722
1182
  while (pageCount < maxPages) {
723
1183
  pageCount += 1;
724
1184
  const ackUpToSeq = pendingAckSeq;
1185
+ const pendingInlineGeneration = ackUpToSeq > 0
1186
+ ? client._delivery?.pendingP2PInlineAckGeneration?.(ns) ?? null
1187
+ : null;
725
1188
  const result = await client._callRawV2Rpc('message.v2.pull', {
726
1189
  after_seq: nextAfterSeq,
727
1190
  limit,
@@ -729,23 +1192,38 @@ export class V2E2EECoordinator {
729
1192
  ...(ackUpToSeq > 0 ? { ack_up_to_seq: ackUpToSeq } : {}),
730
1193
  });
731
1194
  if (ackUpToSeq > 0) {
732
- pendingAckSeq = 0;
733
- lastAutoAckSeq = Math.max(lastAutoAckSeq, ackUpToSeq);
1195
+ const actualAckSeq = client._delivery?.resolveP2PPullAckSeq?.(result, ackUpToSeq) ?? 0;
1196
+ if (actualAckSeq >= ackUpToSeq) {
1197
+ if (pendingInlineGeneration !== null) {
1198
+ client._delivery?.completePendingP2PInlineAckUpTo?.(ns, actualAckSeq, pendingInlineGeneration);
1199
+ }
1200
+ pendingAckSeq = 0;
1201
+ lastAutoAckSeq = Math.max(lastAutoAckSeq, ackUpToSeq);
1202
+ this.clearForwardAck(ns, ackUpToSeq);
1203
+ }
1204
+ else {
1205
+ client._clientLog.warn(`message.v2.pull piggyback ACK below target: requested=${ackUpToSeq}, actual=${actualAckSeq}; fallback terminal ACK`);
1206
+ await this.confirmForwardP2PAck(ackUpToSeq, pendingInlineGeneration ?? undefined);
1207
+ pendingAckSeq = 0;
1208
+ lastAutoAckSeq = Math.max(lastAutoAckSeq, ackUpToSeq);
1209
+ }
734
1210
  }
735
1211
  const pageMessages = (Array.isArray(result?.messages) ? result.messages : []);
736
- const messages = pageMessages.filter((msg) => {
737
- const seq = Number(msg.seq ?? 0);
738
- return Number.isFinite(seq) && seq > nextAfterSeq;
739
- }).sort((a, b) => Number(a.seq ?? 0) - Number(b.seq ?? 0));
740
- client._clientLog.debug(`message.v2.pull page response: page=${pageCount}, raw_count=${messages.length}, stale_count=${Math.max(0, pageMessages.length - messages.length)}, has_more=${String(result.has_more ?? '')}, server_ack_seq=${String(result.server_ack_seq ?? '')}`);
1212
+ const messages = deduplicateForwardPageMessages(pageMessages, nextAfterSeq);
1213
+ const cursor = isJsonObject(result.cursor) ? result.cursor : null;
1214
+ const hasMoreField = Object.prototype.hasOwnProperty.call(result, 'has_more');
1215
+ const hasMore = result.has_more === true;
1216
+ const serverHead = safeNonNegativeSeq(cursor?.latest_seq) ?? 0;
1217
+ client._clientLog.debug(`message.v2.pull page response: page=${pageCount}, raw_count=${messages.length}, stale_count=${Math.max(0, pageMessages.length - messages.length)}, has_more=${String(result.has_more ?? '')}, server_head=${serverHead}, server_ack_seq=${String(result.server_ack_seq ?? '')}`);
741
1218
  const seqs = messages
742
1219
  .map((msg) => Number(msg.seq ?? 0))
743
1220
  .filter((seq) => Number.isFinite(seq) && seq > 0);
744
1221
  const pageContigBefore = ns ? client._seqTracker.getContiguousSeq(ns) : 0;
1222
+ const pageTrackerSnapshot = ns && typeof client._seqTracker.snapshotNamespace === 'function'
1223
+ ? client._seqTracker.snapshotNamespace(ns)
1224
+ : null;
745
1225
  const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
746
- if (ns && seqs.length > 0) {
747
- client._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
748
- }
1226
+ const deferredKeyFetches = new Map();
749
1227
  for (const msg of messages) {
750
1228
  const seq = Number(msg.seq ?? 0);
751
1229
  if (!Number.isFinite(seq) || seq <= 0)
@@ -772,7 +1250,7 @@ export class V2E2EECoordinator {
772
1250
  attachGatewayProximity(v1Msg, msg);
773
1251
  const appEvent = client._delivery.p2pAppEventForMessage(v1Msg);
774
1252
  if (ns)
775
- await client._publishPulledMessage(appEvent.event, ns, seq, appEvent.payload);
1253
+ await client._publishPulledMessage(appEvent.event, ns, seq, appEvent.payload, false);
776
1254
  else
777
1255
  await client._publishAppEvent(appEvent.event, appEvent.payload);
778
1256
  decrypted.push(v1Msg);
@@ -790,11 +1268,20 @@ export class V2E2EECoordinator {
790
1268
  if (msgSpkId && client._v2Session && !client._v2Session.isCurrentSPK(msgSpkId)) {
791
1269
  client._v2Session.trackOldSPKMaxSeq(msgSpkId, seq);
792
1270
  }
793
- const plaintext = await client._decryptV2Message(msg);
1271
+ const deferStatus = {};
1272
+ const plaintext = await client._decryptV2Message(msg, true, true, true, true, deferStatus, true);
1273
+ if (deferStatus.deferred && deferStatus.fromAid) {
1274
+ const key = `${deferStatus.fromAid}\u0000${deferStatus.senderDeviceId ?? ''}\u0000${deferStatus.groupId ?? ''}`;
1275
+ deferredKeyFetches.set(key, {
1276
+ fromAid: deferStatus.fromAid,
1277
+ senderDeviceId: deferStatus.senderDeviceId ?? '',
1278
+ groupId: deferStatus.groupId ?? '',
1279
+ });
1280
+ }
794
1281
  if (plaintext === null)
795
1282
  continue;
796
1283
  if (ns) {
797
- await client._publishPulledMessage('message.received', ns, seq, plaintext);
1284
+ await client._publishPulledMessage('message.received', ns, seq, plaintext, false);
798
1285
  decrypted.push(plaintext);
799
1286
  }
800
1287
  else {
@@ -803,26 +1290,60 @@ export class V2E2EECoordinator {
803
1290
  }
804
1291
  }
805
1292
  const hasServerAckSeq = Object.prototype.hasOwnProperty.call(result, 'server_ack_seq');
806
- const serverAckSeq = Number(result.server_ack_seq ?? 0);
807
- if (ns && Number.isFinite(serverAckSeq) && serverAckSeq > 0) {
808
- const contig = client._seqTracker.getContiguousSeq(ns);
809
- if (contig < serverAckSeq) {
810
- client._clientLog.info(`message.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> server_ack_seq=${serverAckSeq}`);
811
- client._seqTracker.forceContiguousSeq(ns, serverAckSeq);
812
- }
813
- }
1293
+ const parsedServerAckSeq = hasServerAckSeq ? safeNonNegativeSeq(result.server_ack_seq) : null;
1294
+ const serverAckSeq = parsedServerAckSeq ?? 0;
1295
+ const retentionFloor = Math.max(0, Number(result.retention_floor_seq ?? 0));
814
1296
  if (ns) {
815
- const ackSeq = client._seqTracker.getContiguousSeq(ns);
816
- const contigAdvanced = ackSeq !== pageContigBefore;
817
- if (contigAdvanced) {
818
- await client._drainOrderedMessages(ns);
819
- client._persistSeq(ns);
1297
+ const commitTarget = Math.max(pageContigBefore, Number.isFinite(retentionFloor) ? retentionFloor : 0, pageCount === 1 ? deferredServerCursor : 0, seqs.length > 0 ? pageMaxSeq : pageContigBefore);
1298
+ let ackSeq = pageContigBefore;
1299
+ let contigAdvanced = false;
1300
+ try {
1301
+ if (commitTarget > pageContigBefore) {
1302
+ client._seqTracker.forceContiguousSeq(ns, commitTarget);
1303
+ if (retentionFloor > pageContigBefore) {
1304
+ client._clientLog.info(`message.v2.pull retention-floor advance: ns=${ns} contiguous=${pageContigBefore} -> retention_floor_seq=${retentionFloor}`);
1305
+ }
1306
+ if (deferredServerCursor > pageContigBefore && pageCount === 1) {
1307
+ client._clientLog.info(`message.v2.pull restore local A from deferred server ACK cursor: ns=${ns}, contiguous=${pageContigBefore} -> server_ack_seq=${deferredServerCursor}`);
1308
+ }
1309
+ if (seqs.length > 0) {
1310
+ client._clientLog.debug(`message.v2.pull force contiguous: ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
1311
+ }
1312
+ }
1313
+ ackSeq = client._seqTracker.getContiguousSeq(ns);
1314
+ contigAdvanced = ackSeq !== pageContigBefore;
1315
+ if (contigAdvanced) {
1316
+ await client._commitSeqTrackerState(ns);
1317
+ if (pageCount === 1 && deferredServerCursor > 0)
1318
+ this.clearForwardCursor(ns, ackSeq);
1319
+ }
820
1320
  }
821
- const ackNeeded = pageMessages.length > 0
822
- && ackSeq > 0
1321
+ catch (exc) {
1322
+ if (typeof client._seqTracker.restoreNamespaceSnapshot === 'function') {
1323
+ client._seqTracker.restoreNamespaceSnapshot(ns, pageTrackerSnapshot);
1324
+ }
1325
+ client._clientLog.error?.(`message.v2.pull Forward commit rolled back: ns=${ns} target=${commitTarget} error=${formatE2EEError(exc)}`);
1326
+ throw exc;
1327
+ }
1328
+ if (contigAdvanced)
1329
+ await client._drainOrderedMessages(ns, undefined, false, false);
1330
+ for (const fetch of deferredKeyFetches.values()) {
1331
+ this.scheduleSenderIKFetch(fetch.fromAid, fetch.senderDeviceId, fetch.groupId);
1332
+ }
1333
+ if (parsedServerAckSeq !== null && serverAckSeq > ackSeq) {
1334
+ this.recordForwardCursor(ns, serverAckSeq, ackSeq);
1335
+ }
1336
+ // 无本页服务端水位时,只有本页成功提交了比已知服务端更高的 A
1337
+ // 才新增 ACK 义务。空页不能仅因本地已有 A 而重复 ACK。
1338
+ const knownServerAckSeq = hasServerAckSeq
1339
+ ? serverAckSeq
1340
+ : (pageCount === 1 ? deferredServerCursor : 0);
1341
+ const ackNeeded = ackSeq > 0
823
1342
  && ackSeq > lastAutoAckSeq
824
- && (contigAdvanced || (hasServerAckSeq && ackSeq > serverAckSeq));
1343
+ && ((hasServerAckSeq && ackSeq > serverAckSeq)
1344
+ || (contigAdvanced && ackSeq > knownServerAckSeq));
825
1345
  if (ackNeeded) {
1346
+ this.recordForwardAck(ns, ackSeq);
826
1347
  const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
827
1348
  const canContinuePage = messages.length > 0 && nextAfter > nextAfterSeq;
828
1349
  if (canContinuePage) {
@@ -830,18 +1351,30 @@ export class V2E2EECoordinator {
830
1351
  lastAutoAckSeq = Math.max(lastAutoAckSeq, ackSeq);
831
1352
  }
832
1353
  else {
833
- await this.ackV2(ackSeq);
1354
+ await this.confirmForwardP2PAck(ackSeq);
834
1355
  lastAutoAckSeq = Math.max(lastAutoAckSeq, ackSeq);
835
1356
  }
836
1357
  }
837
1358
  }
838
1359
  const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
839
1360
  const rawCount = messages.length;
840
- const canContinuePage = rawCount > 0 && nextAfter > nextAfterSeq;
841
- const fullPage = canContinuePage && limit > 0 && rawCount >= limit;
842
- const shouldContinue = canContinuePage && (fullPage || pendingAckSeq > 0);
1361
+ const shouldContinue = shouldContinueForwardPage({
1362
+ rawCount,
1363
+ nextAfterSeq,
1364
+ nextAfter,
1365
+ limit,
1366
+ hasMoreField,
1367
+ hasMore,
1368
+ serverHead,
1369
+ pendingAckSeq,
1370
+ });
843
1371
  if (!shouldContinue)
844
1372
  break;
1373
+ if (pullGateKey && opts?.gateLocked && client._rpcPipeline?.yieldPullGate) {
1374
+ const nextKey = `${ns}|forward|after=${nextAfter}|force=${String(Boolean(opts?.force))}|limit=${limit}`;
1375
+ await client._rpcPipeline.yieldPullGate(pullGateKey, nextKey, true);
1376
+ pullGateKey = nextKey;
1377
+ }
845
1378
  const tailDelayMs = v2PullTailDelayMs(rawCount, limit);
846
1379
  if (tailDelayMs > 0)
847
1380
  await v2Sleep(tailDelayMs);
@@ -850,40 +1383,69 @@ export class V2E2EECoordinator {
850
1383
  if (pageCount >= maxPages) {
851
1384
  client._clientLog.warn(`message.v2.pull reached max_pages=${maxPages} after_seq=${nextAfterSeq}`);
852
1385
  if (pendingAckSeq > 0) {
853
- await this.ackV2(pendingAckSeq);
1386
+ await this.confirmForwardP2PAck(pendingAckSeq);
854
1387
  pendingAckSeq = 0;
855
1388
  }
856
1389
  }
857
1390
  return decrypted;
858
1391
  }
859
- async ackV2(upToSeq) {
1392
+ async confirmForwardP2PAck(upToSeq, inlineGeneration) {
1393
+ const ns = this.client._aid ? `p2p:${this.client._aid}` : '';
1394
+ try {
1395
+ const result = await this.ackV2ForForward(upToSeq, inlineGeneration);
1396
+ const actualAckSeq = this.client._delivery?.resolveForwardAckSeq?.(result, upToSeq) ?? 0;
1397
+ if (actualAckSeq < upToSeq) {
1398
+ throw new E2EEError(`message.v2.ack server ACK watermark ${actualAckSeq} is below requested ${upToSeq}`);
1399
+ }
1400
+ this.clearForwardAck(ns, upToSeq);
1401
+ }
1402
+ catch (exc) {
1403
+ this.recordForwardAck(ns, upToSeq);
1404
+ throw exc;
1405
+ }
1406
+ }
1407
+ async ackV2(upToSeq, inlineGeneration) {
1408
+ return this.ackV2WithConfirmation(upToSeq, inlineGeneration, false);
1409
+ }
1410
+ async ackV2ForForward(upToSeq, inlineGeneration) {
1411
+ return this.ackV2WithConfirmation(upToSeq, inlineGeneration, true);
1412
+ }
1413
+ async ackV2WithConfirmation(upToSeq, inlineGeneration, requireServerWatermark) {
860
1414
  const client = this.client;
861
1415
  const ns = client._aid ? `p2p:${client._aid}` : '';
862
1416
  let seq = upToSeq ?? (ns ? client._seqTracker.getContiguousSeq(ns) : 0);
863
- if (seq <= 0)
1417
+ if (typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq <= 0)
864
1418
  return { acked: 0 };
865
1419
  seq = client._clampAckSeq('message.v2.ack', 'up_to_seq', ns, seq);
1420
+ if (!Number.isSafeInteger(seq) || seq <= 0)
1421
+ return { acked: 0 };
1422
+ // ACK 发出前绑定当时的 inline pending;迟到响应不得结算重置后新建的 pending。
1423
+ const ackGeneration = inlineGeneration ?? client._delivery?.pendingP2PInlineAckGeneration?.(ns) ?? null;
1424
+ const v2SessionAtStart = client._v2Session;
1425
+ const v2RuntimeGenerationAtStart = Number(client._v2RuntimeGeneration ?? 0);
866
1426
  const raw = await client._callRawV2Rpc('message.v2.ack', { up_to_seq: seq });
867
1427
  const result = isJsonObject(raw)
868
1428
  ? { ...raw }
869
1429
  : { result: raw };
870
- let actualAckSeq = seq;
871
- if ('effective_ack_seq' in result)
872
- actualAckSeq = Number(result.effective_ack_seq ?? 0);
873
- else if ('ack_seq' in result)
874
- actualAckSeq = Number(result.ack_seq ?? 0);
875
- else if ('cursor' in result)
876
- actualAckSeq = Number(result.cursor ?? 0);
877
- if (!Number.isFinite(actualAckSeq))
878
- actualAckSeq = seq;
1430
+ const actualAckSeq = requireServerWatermark
1431
+ ? (client._delivery?.resolveForwardAckSeq?.(result, seq) ?? 0)
1432
+ : (client._delivery?.resolveActualAckSeq?.(result, seq) ?? seq);
1433
+ if (requireServerWatermark && actualAckSeq < seq) {
1434
+ throw new E2EEError(`message.v2.ack server ACK watermark ${actualAckSeq} is below requested ${seq}`);
1435
+ }
879
1436
  result.ack_seq = actualAckSeq;
880
1437
  result.success = true;
881
- if (Number(result.acked ?? 0) === 0)
882
- result.acked = actualAckSeq;
883
- if (client._v2Session) {
1438
+ if (ackGeneration !== null) {
1439
+ client._delivery?.completePendingP2PInlineAckUpTo?.(ns, actualAckSeq, ackGeneration);
1440
+ }
1441
+ if (v2SessionAtStart
1442
+ && client._v2Session === v2SessionAtStart
1443
+ && Number(client._v2RuntimeGeneration ?? 0) === v2RuntimeGenerationAtStart) {
884
1444
  try {
885
- const destroyed = await client._v2Session.maybeDestroyOldSPKs(actualAckSeq);
886
- if (destroyed.length > 0) {
1445
+ const destroyed = await v2SessionAtStart.maybeDestroyOldSPKs(actualAckSeq);
1446
+ if (client._v2Session === v2SessionAtStart
1447
+ && Number(client._v2RuntimeGeneration ?? 0) === v2RuntimeGenerationAtStart
1448
+ && destroyed.length > 0) {
887
1449
  client._clientLog.info(`V2 destroyed old SPKs after ack: ${destroyed.slice(0, 3)} (PFS)`);
888
1450
  }
889
1451
  }
@@ -896,9 +1458,10 @@ export class V2E2EECoordinator {
896
1458
  async sendGroupV2(groupId, payload, opts) {
897
1459
  const client = this.client;
898
1460
  await client._ensureV2SessionReady('group.send', 'V2 session not initialized; encrypted group.send requires V2 (V1 E2EE removed)');
899
- const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
1461
+ const gid = String(groupId ?? '').trim();
900
1462
  if (!gid)
901
1463
  throw new ValidationError("group.send requires 'group_id'");
1464
+ const groupAid = String(opts?.groupAid ?? '').trim();
902
1465
  if (!isJsonObject(payload))
903
1466
  throw new ValidationError('group.send payload must be a dict for V2 encryption');
904
1467
  const attempt = async (useCache) => {
@@ -911,10 +1474,10 @@ export class V2E2EECoordinator {
911
1474
  context: opts?.context,
912
1475
  useCache,
913
1476
  });
914
- return client.call('group.v2.send', {
1477
+ return client.call('group.v2.send', withExplicitGroupAid({
915
1478
  group_id: gid,
916
1479
  envelope,
917
- });
1480
+ }, groupAid));
918
1481
  };
919
1482
  try {
920
1483
  const result = await attempt(true);
@@ -927,14 +1490,14 @@ export class V2E2EECoordinator {
927
1490
  await client._saveSeqTrackerState();
928
1491
  }
929
1492
  }
930
- return client._delivery.attachSendResultEnvelope('group.send', {
1493
+ return client._delivery.attachSendResultEnvelope('group.send', withExplicitGroupAid({
931
1494
  group_id: gid,
932
1495
  payload,
933
1496
  message_id: opts?.messageId,
934
1497
  timestamp: opts?.timestamp,
935
1498
  protected_headers: opts?.protectedHeaders,
936
1499
  context: opts?.context,
937
- }, result, true);
1500
+ }, groupAid), result, true);
938
1501
  }
939
1502
  catch (exc) {
940
1503
  const excCode = exc?.code;
@@ -951,65 +1514,184 @@ export class V2E2EECoordinator {
951
1514
  await client._saveSeqTrackerState();
952
1515
  }
953
1516
  }
954
- return client._delivery.attachSendResultEnvelope('group.send', {
1517
+ return client._delivery.attachSendResultEnvelope('group.send', withExplicitGroupAid({
955
1518
  group_id: gid,
956
1519
  payload,
957
1520
  message_id: opts?.messageId,
958
1521
  timestamp: opts?.timestamp,
959
1522
  protected_headers: opts?.protectedHeaders,
960
1523
  context: opts?.context,
961
- }, result, true);
1524
+ }, groupAid), result, true);
962
1525
  }
963
1526
  throw exc;
964
1527
  }
965
1528
  }
1529
+ async pullGroupV2TailInternal(params, deferPublish = false) {
1530
+ const client = this.client;
1531
+ if (params.window_mode !== 'tail')
1532
+ throw new ValidationError('window_mode must equal tail');
1533
+ if ('ack_up_to_seq' in params || params.force === true)
1534
+ throw new ValidationError('Tail does not accept ack_up_to_seq or force');
1535
+ const groupId = String(params.group_id ?? params.group_aid ?? '').trim();
1536
+ if (!groupId)
1537
+ throw new ValidationError('group_id is required');
1538
+ let groupAid = String(params.group_aid ?? '').trim();
1539
+ const afterSeq = strictWindowSeq(params.after_seq ?? 0, 'after_seq');
1540
+ const limit = strictWindowLimit(params.limit ?? V2_PULL_DEFAULT_PAGE_LIMIT);
1541
+ const ns = `group:${groupId}`;
1542
+ const result = await client._callRawV2Rpc('group.v2.pull', withExplicitGroupAid({
1543
+ group_id: groupId, window_mode: 'tail', after_seq: afterSeq, limit, _rpc_foreground: true,
1544
+ }, groupAid));
1545
+ const resultAid = String(result.group_aid ?? result.groupAid ?? '').trim();
1546
+ if (resultAid)
1547
+ groupAid = resultAid;
1548
+ const floor = Number(result.retention_floor_message_seq ?? result.retention_floor_seq ?? 0);
1549
+ const previous = client._seqTracker.getSyncState(ns);
1550
+ await this.applyTailFloor(ns, Number.isSafeInteger(floor) ? floor : 0);
1551
+ const page = validateTailPage(result, afterSeq, limit);
1552
+ client._seqTracker.commitTailWindow(ns, {
1553
+ windowStartSeq: page.windowStart, headSeq: page.head, coveredThroughSeq: page.head, pageLimit: limit,
1554
+ });
1555
+ client._persistSeq(ns);
1556
+ await client._saveSeqTrackerState?.();
1557
+ const decoded = [];
1558
+ for (const msg of page.messages) {
1559
+ client._logMessageDebug?.('tail-raw', 'group.v2.pull', 'group.message_created', msg);
1560
+ const plaintext = await this.decodeGroupWindowMessage(msg, groupId, groupAid, true, false, 'tail', deferPublish);
1561
+ if (plaintext)
1562
+ decoded.push(plaintext);
1563
+ }
1564
+ const ack = client._seqTracker.getContiguousSeq(ns);
1565
+ if (ack > previous.ack)
1566
+ await this.ackGroupV2(groupId, ack, groupAid);
1567
+ return { ...result, messages: decoded, raw_count: page.messages.length };
1568
+ }
1569
+ async historyGroupV2Internal(params) {
1570
+ const groupId = String(params.group_id ?? params.group_aid ?? '').trim();
1571
+ if (!groupId)
1572
+ throw new ValidationError('group_id is required');
1573
+ let groupAid = String(params.group_aid ?? '').trim();
1574
+ const beforeSeq = strictWindowSeq(params.before_seq, 'before_seq', true);
1575
+ const limit = strictWindowLimit(params.limit ?? V2_PULL_DEFAULT_PAGE_LIMIT);
1576
+ const result = await this.client._callRawV2Rpc('group.history', withExplicitGroupAid({
1577
+ group_id: groupId, before_seq: beforeSeq, limit, _rpc_foreground: true,
1578
+ }, groupAid));
1579
+ const resultAid = String(result.group_aid ?? result.groupAid ?? '').trim();
1580
+ if (resultAid)
1581
+ groupAid = resultAid;
1582
+ const messages = validateHistoryPage(result, beforeSeq);
1583
+ const decoded = [];
1584
+ for (const msg of messages) {
1585
+ const plaintext = await this.decodeGroupWindowMessage(msg, groupId, groupAid, false, true, 'history');
1586
+ if (plaintext)
1587
+ decoded.push(plaintext);
1588
+ }
1589
+ return { ...result, messages: decoded };
1590
+ }
966
1591
  async pullGroupV2Internal(params) {
967
- await this.pullGroupV2(params.group_id, params.after_seq, params.limit);
1592
+ await this.pullGroupV2(params.group_id, params.after_seq, params.limit, {
1593
+ gateLocked: true,
1594
+ explicitAfterSeq: true,
1595
+ force: params.force === true,
1596
+ });
968
1597
  }
969
1598
  async pullGroupV2(groupId, afterSeq = 0, limit = 50, opts) {
970
1599
  const client = this.client;
971
1600
  await client._ensureV2SessionReady('group.pull');
972
- const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
1601
+ const gid = String(groupId ?? '').trim();
973
1602
  if (!gid)
974
1603
  throw new ValidationError('group.pull requires group_id');
1604
+ let groupAid = String(opts?.groupAid ?? '').trim();
975
1605
  const ns = `group:${gid}`;
1606
+ if (opts?.windowMode === 'tail') {
1607
+ if (!opts.gateLocked) {
1608
+ const key = `${ns}|tail|after=${afterSeq}|limit=${limit}`;
1609
+ return await client._runPullSerialized(key, () => this.pullGroupV2(gid, afterSeq, limit, {
1610
+ ...(opts ?? {}),
1611
+ gateLocked: true,
1612
+ }), false);
1613
+ }
1614
+ const result = await this.pullGroupV2TailInternal({
1615
+ group_id: String(opts.wireGroupId ?? gid), group_aid: groupAid || undefined,
1616
+ window_mode: 'tail', after_seq: afterSeq, limit,
1617
+ });
1618
+ return Array.isArray(result.messages) ? result.messages : [];
1619
+ }
1620
+ if (!opts?.gateLocked) {
1621
+ const key = `${ns}|forward|after=${afterSeq}|force=${String(Boolean(opts?.force))}|limit=${limit}`;
1622
+ return await client._runPullSerialized(key, () => this.pullGroupV2(gid, afterSeq, limit, {
1623
+ ...(opts ?? {}),
1624
+ gateLocked: true,
1625
+ }), true);
1626
+ }
976
1627
  const decrypted = [];
977
1628
  const wireGroupId = String(opts?.wireGroupId ?? groupId ?? '').trim() || gid;
978
1629
  const cursorParams = opts?.cursorParams ?? {};
979
1630
  const ownsCursor = opts?.ownsCursor !== false;
980
- let nextAfterSeq = opts?.explicitAfterSeq ? afterSeq : (afterSeq || client._seqTracker.getContiguousSeq(ns));
1631
+ let pullGateKey = ownsCursor
1632
+ ? `${ns}|forward|after=${afterSeq}|force=${String(Boolean(opts?.force))}|limit=${limit}`
1633
+ : '';
1634
+ let nextAfterSeq = (opts?.explicitAfterSeq || opts?.force)
1635
+ ? afterSeq
1636
+ : (afterSeq || client._seqTracker.getContiguousSeq(ns));
1637
+ const deferredServerCursor = ownsCursor
1638
+ ? this.pendingForwardCursor(ns, client._seqTracker.getContiguousSeq(ns))
1639
+ : 0;
1640
+ const deferredForwardAck = ownsCursor ? this.pendingForwardAck(ns, client._seqTracker.getContiguousSeq(ns)) : 0;
981
1641
  let pageCount = 0;
982
1642
  let lastAutoAckSeq = 0;
983
- let pendingAckSeq = 0;
984
- const maxPages = 100;
1643
+ let pendingAckSeq = deferredForwardAck;
1644
+ const maxPages = Math.max(1, Math.min(100, Math.trunc(Number(opts?.maxPages ?? 100) || 100)));
985
1645
  while (pageCount < maxPages) {
986
1646
  pageCount += 1;
987
1647
  const ackUpToSeq = ownsCursor ? pendingAckSeq : 0;
988
- const result = await client._callRawV2Rpc('group.v2.pull', {
1648
+ const pendingInlineGeneration = ackUpToSeq > 0
1649
+ ? client._delivery?.pendingGroupInlineAckGeneration?.(ns) ?? null
1650
+ : null;
1651
+ const result = await client._callRawV2Rpc('group.v2.pull', withExplicitGroupAid({
989
1652
  group_id: wireGroupId,
990
- group_aid: gid,
991
1653
  after_seq: nextAfterSeq,
992
1654
  limit,
993
1655
  ...cursorParams,
1656
+ ...(opts?.force ? { force: true } : {}),
994
1657
  ...(ackUpToSeq > 0 ? { ack_up_to_seq: ackUpToSeq } : {}),
995
- });
1658
+ }, groupAid));
996
1659
  if (ackUpToSeq > 0) {
997
- pendingAckSeq = 0;
998
- lastAutoAckSeq = Math.max(lastAutoAckSeq, ackUpToSeq);
1660
+ const actualAckSeq = client._delivery?.resolveGroupPullAckSeq?.(result, ackUpToSeq) ?? 0;
1661
+ if (actualAckSeq >= ackUpToSeq) {
1662
+ if (pendingInlineGeneration !== null) {
1663
+ client._delivery?.completePendingGroupInlineAckUpTo?.(ns, actualAckSeq, pendingInlineGeneration);
1664
+ }
1665
+ pendingAckSeq = 0;
1666
+ lastAutoAckSeq = Math.max(lastAutoAckSeq, ackUpToSeq);
1667
+ this.clearForwardAck(ns, ackUpToSeq);
1668
+ }
1669
+ else {
1670
+ client._clientLog.warn(`group.v2.pull piggyback ACK below target: group=${gid}, requested=${ackUpToSeq}, actual=${actualAckSeq}; fallback terminal ACK`);
1671
+ await this.confirmForwardGroupAck(gid, ackUpToSeq, groupAid);
1672
+ pendingAckSeq = 0;
1673
+ lastAutoAckSeq = Math.max(lastAutoAckSeq, ackUpToSeq);
1674
+ }
999
1675
  }
1676
+ const resultGroupAid = String(result.group_aid ?? result.groupAid ?? '').trim();
1677
+ if (resultGroupAid)
1678
+ groupAid = resultGroupAid;
1679
+ const eventGroupAid = resultGroupAid || groupAid || gid;
1000
1680
  const pageMessages = (Array.isArray(result?.messages) ? result.messages : []);
1001
- const messages = pageMessages.filter((msg) => {
1002
- const seq = Number(msg.seq ?? 0);
1003
- return Number.isFinite(seq) && seq > nextAfterSeq;
1004
- }).sort((a, b) => Number(a.seq ?? 0) - Number(b.seq ?? 0));
1681
+ const messages = deduplicateForwardPageMessages(pageMessages, nextAfterSeq);
1682
+ const cursor = isJsonObject(result.cursor) ? result.cursor : null;
1683
+ const hasMoreField = Object.prototype.hasOwnProperty.call(result, 'has_more');
1684
+ const hasMore = result.has_more === true;
1685
+ const serverHead = Math.max(safeNonNegativeSeq(result.latest_message_seq) ?? 0, safeNonNegativeSeq(cursor?.latest_seq) ?? 0);
1005
1686
  const seqs = messages
1006
1687
  .map((msg) => Number(msg.seq ?? 0))
1007
1688
  .filter((seq) => Number.isFinite(seq) && seq > 0);
1008
1689
  const pageContigBefore = client._seqTracker.getContiguousSeq(ns);
1690
+ const pageTrackerSnapshot = typeof client._seqTracker.snapshotNamespace === 'function'
1691
+ ? client._seqTracker.snapshotNamespace(ns)
1692
+ : null;
1009
1693
  const pageMaxSeq = seqs.length > 0 ? Math.max(...seqs) : nextAfterSeq;
1010
- if (seqs.length > 0) {
1011
- client._seqTracker.forceContiguousSeq(ns, pageMaxSeq);
1012
- }
1694
+ const deferredKeyFetches = new Map();
1013
1695
  for (const msg of messages) {
1014
1696
  const seq = Number(msg.seq ?? 0);
1015
1697
  if (!Number.isFinite(seq) || seq <= 0)
@@ -1020,7 +1702,11 @@ export class V2E2EECoordinator {
1020
1702
  const payloadObj = isJsonObject(payload) ? payload : null;
1021
1703
  // 群撤回 tombstone(占位 / 通知):归一化为 group.message_recalled 事件,仍占 seq。
1022
1704
  if (client._delivery.recallEventFromGroupMessage(msg)) {
1023
- await client._delivery.publishGroupRecallTombstone(gid, seq, msg);
1705
+ await client._delivery.publishGroupRecallTombstone(gid, seq, {
1706
+ ...msg,
1707
+ group_id: gid,
1708
+ group_aid: eventGroupAid,
1709
+ });
1024
1710
  client._markPublishedSeq(ns, seq);
1025
1711
  client._clientLog.debug(`group.v2.pull recall tombstone delivered: group=${gid}, seq=${seq}`);
1026
1712
  continue;
@@ -1028,35 +1714,39 @@ export class V2E2EECoordinator {
1028
1714
  if (payloadObj) {
1029
1715
  const payloadType = String(payloadObj.type ?? '').trim();
1030
1716
  if (payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
1031
- const v1Msg = {
1717
+ let v1Msg = {
1032
1718
  message_id: String(msg.message_id ?? ''),
1033
1719
  from: String(msg.from_aid ?? ''),
1034
1720
  group_id: gid,
1721
+ group_aid: eventGroupAid,
1035
1722
  seq: msg.seq,
1036
1723
  type: String(msg.type ?? ''),
1037
1724
  timestamp: msg.t_server,
1038
1725
  payload,
1039
1726
  encrypted: false,
1040
1727
  };
1728
+ v1Msg = normalizeGroupMentionMode(v1Msg, msg, msg.envelope_json);
1041
1729
  attachGatewayProximity(v1Msg, msg);
1042
- await client._publishPulledMessage('group.message_created', ns, seq, v1Msg);
1730
+ await client._publishPulledMessage('group.message_created', ns, seq, v1Msg, false);
1043
1731
  decrypted.push(v1Msg);
1044
1732
  continue;
1045
1733
  }
1046
1734
  }
1047
1735
  else if (payload !== undefined && payload !== null) {
1048
- const v1Msg = {
1736
+ let v1Msg = {
1049
1737
  message_id: String(msg.message_id ?? ''),
1050
1738
  from: String(msg.from_aid ?? ''),
1051
1739
  group_id: gid,
1740
+ group_aid: eventGroupAid,
1052
1741
  seq: msg.seq,
1053
1742
  type: String(msg.type ?? ''),
1054
1743
  timestamp: msg.t_server,
1055
1744
  payload,
1056
1745
  encrypted: false,
1057
1746
  };
1747
+ v1Msg = normalizeGroupMentionMode(v1Msg, msg, msg.envelope_json);
1058
1748
  attachGatewayProximity(v1Msg, msg);
1059
- await client._publishPulledMessage('group.message_created', ns, seq, v1Msg);
1749
+ await client._publishPulledMessage('group.message_created', ns, seq, v1Msg, false);
1060
1750
  decrypted.push(v1Msg);
1061
1751
  continue;
1062
1752
  }
@@ -1067,35 +1757,83 @@ export class V2E2EECoordinator {
1067
1757
  client._clientLog.debug(`group.v2.pull skipping non-V2 row group=${gid} seq=${seq} version=${String(msg.version ?? '')}`);
1068
1758
  continue;
1069
1759
  }
1070
- const plaintext = await client._decryptV2Message(msg);
1760
+ const deferStatus = {};
1761
+ let plaintext = await client._decryptV2Message(msg, true, true, true, true, deferStatus, true);
1762
+ if (deferStatus.deferred && deferStatus.fromAid) {
1763
+ const key = `${deferStatus.fromAid}\u0000${deferStatus.senderDeviceId ?? ''}\u0000${deferStatus.groupId ?? ''}`;
1764
+ deferredKeyFetches.set(key, {
1765
+ fromAid: deferStatus.fromAid,
1766
+ senderDeviceId: deferStatus.senderDeviceId ?? '',
1767
+ groupId: deferStatus.groupId ?? '',
1768
+ });
1769
+ }
1071
1770
  if (plaintext === null)
1072
1771
  continue;
1772
+ plaintext = normalizeGroupMentionMode(plaintext, msg, msg.envelope_json);
1073
1773
  plaintext.group_id = gid;
1074
- await client._publishPulledMessage('group.message_created', ns, seq, plaintext);
1774
+ plaintext.group_aid = eventGroupAid;
1775
+ await client._publishPulledMessage('group.message_created', ns, seq, plaintext, false);
1075
1776
  decrypted.push(plaintext);
1076
1777
  }
1077
- const cursor = isJsonObject(result.cursor) ? result.cursor : null;
1078
- const cursorCurrentSeq = Number(cursor?.current_seq ?? 0);
1079
- if (Number.isFinite(cursorCurrentSeq) && cursorCurrentSeq > 0) {
1080
- const contig = client._seqTracker.getContiguousSeq(ns);
1081
- if (contig < cursorCurrentSeq) {
1082
- client._clientLog.info(`group.v2.pull retention-floor advance: ns=${ns} contiguous=${contig} -> cursor.current_seq=${cursorCurrentSeq}`);
1083
- client._seqTracker.forceContiguousSeq(ns, cursorCurrentSeq);
1778
+ const hasServerCursor = cursor !== null && Object.prototype.hasOwnProperty.call(cursor, 'current_seq');
1779
+ const parsedCursorCurrentSeq = hasServerCursor ? safeNonNegativeSeq(cursor?.current_seq) : null;
1780
+ const cursorCurrentSeq = parsedCursorCurrentSeq ?? 0;
1781
+ const retentionFloor = Math.max(Number(result.retention_floor_message_seq ?? result.retention_floor_seq ?? 0), Number(cursor?.retention_floor_seq ?? 0));
1782
+ const visibilityFloor = Math.max(0, Math.max(0, Number(result.earliest_available_message_seq ?? 1) - 1), Number(cursor?.join_seq ?? 0));
1783
+ const effectiveFloor = Math.max(retentionFloor, visibilityFloor);
1784
+ const commitTarget = Math.max(pageContigBefore, Number.isFinite(effectiveFloor) ? effectiveFloor : 0, ownsCursor && pageCount === 1 ? deferredServerCursor : 0, seqs.length > 0 ? pageMaxSeq : pageContigBefore);
1785
+ let ackSeq = pageContigBefore;
1786
+ let contigAdvanced = false;
1787
+ if (ownsCursor) {
1788
+ try {
1789
+ if (commitTarget > pageContigBefore) {
1790
+ client._seqTracker.forceContiguousSeq(ns, commitTarget);
1791
+ if (effectiveFloor > pageContigBefore) {
1792
+ client._clientLog.info(`group.v2.pull floor advance: ns=${ns} contiguous=${pageContigBefore} -> effective_floor=${effectiveFloor} retention_floor=${retentionFloor} visibility_floor=${visibilityFloor}`);
1793
+ }
1794
+ if (deferredServerCursor > pageContigBefore && pageCount === 1) {
1795
+ client._clientLog.info(`group.v2.pull restore local A from deferred server ACK cursor: group=${gid}, ns=${ns}, contiguous=${pageContigBefore} -> current_seq=${deferredServerCursor}`);
1796
+ }
1797
+ if (seqs.length > 0) {
1798
+ client._clientLog.debug(`group.v2.pull force contiguous: group=${gid}, ns=${ns}, page_max_seq=${pageMaxSeq}, previous=${pageContigBefore}`);
1799
+ }
1800
+ }
1801
+ ackSeq = client._seqTracker.getContiguousSeq(ns);
1802
+ contigAdvanced = ackSeq !== pageContigBefore;
1803
+ if (contigAdvanced) {
1804
+ await client._commitSeqTrackerState(ns);
1805
+ if (pageCount === 1 && deferredServerCursor > 0)
1806
+ this.clearForwardCursor(ns, ackSeq);
1807
+ }
1808
+ }
1809
+ catch (exc) {
1810
+ if (typeof client._seqTracker.restoreNamespaceSnapshot === 'function') {
1811
+ client._seqTracker.restoreNamespaceSnapshot(ns, pageTrackerSnapshot);
1812
+ }
1813
+ client._clientLog.error?.(`group.v2.pull Forward commit rolled back: group=${gid} ns=${ns} target=${commitTarget} error=${formatE2EEError(exc)}`);
1814
+ throw exc;
1084
1815
  }
1816
+ if (contigAdvanced)
1817
+ await client._drainOrderedMessages(ns, undefined, false, false);
1085
1818
  }
1086
- const ackSeq = client._seqTracker.getContiguousSeq(ns);
1087
- const contigAdvanced = ackSeq !== pageContigBefore;
1088
- if (contigAdvanced) {
1089
- await client._drainOrderedMessages(ns);
1090
- client._persistSeq(ns);
1819
+ for (const fetch of deferredKeyFetches.values()) {
1820
+ this.scheduleSenderIKFetch(fetch.fromAid, fetch.senderDeviceId, fetch.groupId);
1091
1821
  }
1092
- const hasServerCursor = cursor !== null && Object.prototype.hasOwnProperty.call(cursor, 'current_seq');
1093
- const ackNeeded = pageMessages.length > 0
1094
- && ackSeq > 0
1822
+ if (ownsCursor && parsedCursorCurrentSeq !== null && cursorCurrentSeq > ackSeq) {
1823
+ this.recordForwardCursor(ns, cursorCurrentSeq, ackSeq);
1824
+ }
1825
+ // 无本页 cursor.current_seq 时,只有本页成功提交了比已知 cursor
1826
+ // 更高的 A 才新增 ACK 义务;空页不得因已有本地 A 重复确认。
1827
+ const knownServerCursorSeq = hasServerCursor
1828
+ ? cursorCurrentSeq
1829
+ : (ownsCursor && pageCount === 1 ? deferredServerCursor : 0);
1830
+ const ackNeeded = ackSeq > 0
1095
1831
  && ackSeq > lastAutoAckSeq
1096
1832
  && ownsCursor
1097
- && (contigAdvanced || (hasServerCursor && ackSeq > cursorCurrentSeq));
1833
+ && ((hasServerCursor && ackSeq > cursorCurrentSeq)
1834
+ || (contigAdvanced && ackSeq > knownServerCursorSeq));
1098
1835
  if (ackNeeded) {
1836
+ this.recordForwardAck(ns, ackSeq);
1099
1837
  const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
1100
1838
  const canContinuePage = messages.length > 0 && nextAfter > nextAfterSeq && ownsCursor;
1101
1839
  if (canContinuePage) {
@@ -1103,7 +1841,7 @@ export class V2E2EECoordinator {
1103
1841
  lastAutoAckSeq = Math.max(lastAutoAckSeq, ackSeq);
1104
1842
  }
1105
1843
  else {
1106
- await this.ackGroupV2(gid, ackSeq);
1844
+ await this.confirmForwardGroupAck(gid, ackSeq, groupAid);
1107
1845
  lastAutoAckSeq = Math.max(lastAutoAckSeq, ackSeq);
1108
1846
  }
1109
1847
  }
@@ -1111,11 +1849,23 @@ export class V2E2EECoordinator {
1111
1849
  if (!ownsCursor)
1112
1850
  break;
1113
1851
  const rawCount = messages.length;
1114
- const canContinuePage = rawCount > 0 && nextAfter > nextAfterSeq;
1115
- const fullPage = canContinuePage && limit > 0 && rawCount >= limit;
1116
- const shouldContinue = canContinuePage && (fullPage || pendingAckSeq > 0);
1852
+ const shouldContinue = shouldContinueForwardPage({
1853
+ rawCount,
1854
+ nextAfterSeq,
1855
+ nextAfter,
1856
+ limit,
1857
+ hasMoreField,
1858
+ hasMore,
1859
+ serverHead,
1860
+ pendingAckSeq,
1861
+ });
1117
1862
  if (!shouldContinue)
1118
1863
  break;
1864
+ if (pullGateKey && opts?.gateLocked && client._rpcPipeline?.yieldPullGate) {
1865
+ const nextKey = `${ns}|forward|after=${nextAfter}|force=${String(Boolean(opts?.force))}|limit=${limit}`;
1866
+ await client._rpcPipeline.yieldPullGate(pullGateKey, nextKey, true);
1867
+ pullGateKey = nextKey;
1868
+ }
1119
1869
  const tailDelayMs = v2PullTailDelayMs(rawCount, limit);
1120
1870
  if (tailDelayMs > 0)
1121
1871
  await v2Sleep(tailDelayMs);
@@ -1124,23 +1874,66 @@ export class V2E2EECoordinator {
1124
1874
  if (pageCount >= maxPages) {
1125
1875
  client._clientLog.warn(`group.v2.pull reached max_pages=${maxPages} group=${gid} after_seq=${nextAfterSeq}`);
1126
1876
  if (pendingAckSeq > 0) {
1127
- await this.ackGroupV2(gid, pendingAckSeq);
1877
+ await this.confirmForwardGroupAck(gid, pendingAckSeq, groupAid);
1128
1878
  pendingAckSeq = 0;
1129
1879
  }
1130
1880
  }
1131
1881
  return decrypted;
1132
1882
  }
1133
- async ackGroupV2(groupId, upToSeq) {
1883
+ async confirmForwardGroupAck(groupId, upToSeq, groupAid) {
1884
+ const ns = `group:${String(groupId ?? '').trim()}`;
1885
+ try {
1886
+ const result = await this.ackGroupV2ForForward(groupId, upToSeq, groupAid);
1887
+ const actualAckSeq = this.client._delivery?.resolveForwardAckSeq?.(result, upToSeq) ?? 0;
1888
+ if (actualAckSeq < upToSeq) {
1889
+ throw new E2EEError(`group.v2.ack server ACK watermark ${actualAckSeq} is below requested ${upToSeq}`);
1890
+ }
1891
+ this.clearForwardAck(ns, upToSeq);
1892
+ }
1893
+ catch (exc) {
1894
+ this.recordForwardAck(ns, upToSeq);
1895
+ throw exc;
1896
+ }
1897
+ }
1898
+ async ackGroupV2(groupId, upToSeq, groupAid, inlineGeneration) {
1899
+ return this.ackGroupV2WithConfirmation(groupId, upToSeq, groupAid, inlineGeneration, false);
1900
+ }
1901
+ async ackGroupV2ForForward(groupId, upToSeq, groupAid) {
1902
+ return this.ackGroupV2WithConfirmation(groupId, upToSeq, groupAid, undefined, true);
1903
+ }
1904
+ async ackGroupV2WithConfirmation(groupId, upToSeq, groupAid, inlineGeneration, requireServerWatermark) {
1134
1905
  const client = this.client;
1135
- const gid = normalizeGroupId(groupId) || String(groupId ?? '').trim();
1906
+ const gid = typeof groupId === 'string' ? groupId.trim() : '';
1136
1907
  if (!gid)
1137
1908
  throw new ValidationError('group.ack_messages requires group_id');
1138
- const ns = `group:${gid}`;
1139
- let seq = Number(upToSeq ?? client._seqTracker.getContiguousSeq(ns));
1140
- if (!Number.isFinite(seq) || seq <= 0)
1909
+ if (groupAid !== undefined && typeof groupAid !== 'string') {
1910
+ throw new ValidationError('group.ack_messages group_aid must be a string');
1911
+ }
1912
+ const explicitGroupAid = groupAid?.trim() ?? '';
1913
+ const wireNs = `group:${gid}`;
1914
+ const ns = client._delivery?.resolvePendingGroupInlineAckNamespace?.(wireNs) ?? wireNs;
1915
+ let seq = upToSeq ?? client._seqTracker.getContiguousSeq(ns);
1916
+ if (typeof upToSeq !== 'undefined' && (typeof upToSeq !== 'number' || !Number.isSafeInteger(upToSeq))) {
1917
+ return { acked: 0 };
1918
+ }
1919
+ if (!Number.isSafeInteger(seq) || seq <= 0)
1141
1920
  return { acked: 0 };
1142
1921
  seq = client._clampAckSeq('group.v2.ack', 'up_to_seq', ns, seq);
1143
- return client._callRawV2Rpc('group.v2.ack', { group_id: gid, group_aid: gid, up_to_seq: seq });
1922
+ if (!Number.isSafeInteger(seq) || seq <= 0)
1923
+ return { acked: 0 };
1924
+ // 与 P2P 一致,必须将 ACK 响应限定到请求开始时对应的 inline generation。
1925
+ const ackGeneration = inlineGeneration ?? client._delivery?.pendingGroupInlineAckGeneration?.(ns) ?? null;
1926
+ const raw = await client._callRawV2Rpc('group.v2.ack', withExplicitGroupAid({ group_id: gid, up_to_seq: seq }, explicitGroupAid));
1927
+ const actualAckSeq = requireServerWatermark
1928
+ ? (client._delivery?.resolveForwardAckSeq?.(raw, seq) ?? 0)
1929
+ : (client._delivery?.resolveActualAckSeq?.(raw, seq) ?? seq);
1930
+ if (requireServerWatermark && actualAckSeq < seq) {
1931
+ throw new E2EEError(`group.v2.ack server ACK watermark ${actualAckSeq} is below requested ${seq}`);
1932
+ }
1933
+ if (ackGeneration !== null) {
1934
+ client._delivery?.completePendingGroupInlineAckUpTo?.(ns, actualAckSeq, ackGeneration);
1935
+ }
1936
+ return raw;
1144
1937
  }
1145
1938
  async putMessageThoughtEncryptedV2(params) {
1146
1939
  const client = this.client;
@@ -1318,7 +2111,7 @@ export class V2E2EECoordinator {
1318
2111
  let e2ee;
1319
2112
  if (payload?.type === 'e2ee.group_encrypted' && String(payload.version ?? '') === 'v2') {
1320
2113
  e2ee = v2E2eeMeta(payload);
1321
- const plain = await client._decryptV2EnvelopeForThought({ envelope: payload, fromAid });
2114
+ const plain = await client._decryptV2EnvelopeForThought({ envelope: payload, fromAid, fetchSenderIK: true });
1322
2115
  if (plain === null) {
1323
2116
  decryptFailed = true;
1324
2117
  client._clientLog.debug(`group.thought.get decrypt returned null: group=${groupId}, thought_id=${thoughtId}, from=${fromAid}`);
@@ -1388,7 +2181,7 @@ export class V2E2EECoordinator {
1388
2181
  let e2ee;
1389
2182
  if (payload?.type === 'e2ee.p2p_encrypted' && String(payload.version ?? '') === 'v2') {
1390
2183
  e2ee = v2E2eeMeta(payload);
1391
- const plain = await client._decryptV2EnvelopeForThought({ envelope: payload, fromAid });
2184
+ const plain = await client._decryptV2EnvelopeForThought({ envelope: payload, fromAid, fetchSenderIK: true });
1392
2185
  if (plain === null) {
1393
2186
  decryptFailed = true;
1394
2187
  client._clientLog.debug(`message.thought.get decrypt returned null: thought_id=${thoughtId}, from=${fromAid}, to=${toAid}`);
@@ -1475,7 +2268,10 @@ export class V2E2EECoordinator {
1475
2268
  const fromAid = String(opts.fromAid || aad.from || '').trim();
1476
2269
  const senderDeviceId = String(aad.from_device ?? '');
1477
2270
  const senderCertFingerprint = String(envelope.sender_cert_fingerprint ?? '').trim().toLowerCase();
1478
- const senderPubDer = await this.getV2SenderPubDer(fromAid, senderDeviceId, senderCertFingerprint);
2271
+ let senderPubDer = await this.getV2SenderPubDer(fromAid, senderDeviceId, senderCertFingerprint);
2272
+ if (!senderPubDer && opts.fetchSenderIK) {
2273
+ senderPubDer = await this.fetchSenderIKOnce(fromAid, senderDeviceId, groupIdForKeys, senderCertFingerprint);
2274
+ }
1479
2275
  if (!senderPubDer) {
1480
2276
  client._clientLog.warn(`V2 thought decrypt: no sender IK for ${fromAid} device=${senderDeviceId}`);
1481
2277
  this.scheduleSenderIKFetch(fromAid, senderDeviceId, groupIdForKeys);
@@ -1543,7 +2339,7 @@ export class V2E2EECoordinator {
1543
2339
  return String(envelope.version ?? '').trim().toLowerCase() === 'v2' && payloadType.startsWith('e2ee.');
1544
2340
  }
1545
2341
  safeUndecryptablePushEvent(msg, group) {
1546
- const event = {
2342
+ let event = {
1547
2343
  message_id: msg.message_id ?? null,
1548
2344
  from: msg.from ?? null,
1549
2345
  seq: msg.seq ?? null,
@@ -1567,6 +2363,10 @@ export class V2E2EECoordinator {
1567
2363
  attachV2EnvelopeMetadata(event, v2E2eeMeta(envelope));
1568
2364
  }
1569
2365
  }
2366
+ if (group) {
2367
+ const modeSource = envelope ? { ...msg, envelope } : msg;
2368
+ event = normalizeGroupMentionMode(event, modeSource);
2369
+ }
1570
2370
  return event;
1571
2371
  }
1572
2372
  async decryptEncryptedPushPayload(msg, group) {
@@ -1580,7 +2380,7 @@ export class V2E2EECoordinator {
1580
2380
  if (!plaintext)
1581
2381
  return null;
1582
2382
  const e2eeMeta = v2E2eeMeta(envelope);
1583
- const result = {
2383
+ let result = {
1584
2384
  message_id: String(msg.message_id ?? ''),
1585
2385
  from: fromAid,
1586
2386
  seq: msg.seq ?? null,
@@ -1599,6 +2399,7 @@ export class V2E2EECoordinator {
1599
2399
  attachGatewayProximity(result, msg);
1600
2400
  if (group) {
1601
2401
  result.group_id = msg.group_id ?? aad.group_id ?? envelope.group_id ?? null;
2402
+ result = normalizeGroupMentionMode(result, msg, envelope);
1602
2403
  }
1603
2404
  else {
1604
2405
  result.to = msg.to ?? client._aid ?? '';
@@ -1659,6 +2460,7 @@ export class V2E2EECoordinator {
1659
2460
  peerDevices = (Array.isArray(bs?.peer_devices) ? bs.peer_devices : []);
1660
2461
  auditRaw = (Array.isArray(bs?.audit_recipients) ? bs.audit_recipients : []);
1661
2462
  wrapPolicy = normalizeV2WrapPolicy(bs?.e2ee_wrap_policy);
2463
+ this.cacheBundledSelfDevices(bs, wrapPolicy);
1662
2464
  if (peerDevices.length > 0) {
1663
2465
  this.setBootstrapCacheEntry(to, {
1664
2466
  devices: peerDevices,
@@ -1758,8 +2560,7 @@ export class V2E2EECoordinator {
1758
2560
  throw new StateError('V2 session not initialized');
1759
2561
  }
1760
2562
  const session = client._v2Session;
1761
- const rawGroupId = String(opts.groupId ?? '').trim();
1762
- const groupId = normalizeGroupId(rawGroupId) || rawGroupId;
2563
+ const groupId = String(opts.groupId ?? '').trim();
1763
2564
  if (!groupId)
1764
2565
  throw new ValidationError("group.send requires 'group_id'");
1765
2566
  const useCache = opts.useCache !== false;
@@ -1770,15 +2571,7 @@ export class V2E2EECoordinator {
1770
2571
  let auditRecipientsRaw = [];
1771
2572
  let wrapPolicy;
1772
2573
  let usedCachedBootstrap = false;
1773
- let cached = useCache ? this.getBootstrapCacheEntry(cacheKey) : undefined;
1774
- if (!cached && useCache && rawGroupId && rawGroupId !== groupId) {
1775
- const legacyCacheKey = `group:${rawGroupId}`;
1776
- cached = this.getBootstrapCacheEntry(legacyCacheKey);
1777
- if (cached) {
1778
- this.setBootstrapCacheEntry(cacheKey, cached);
1779
- this.deleteBootstrapCacheEntry(legacyCacheKey);
1780
- }
1781
- }
2574
+ const cached = useCache ? this.getBootstrapCacheEntry(cacheKey) : undefined;
1782
2575
  if (cached && (Date.now() - Number(cached.cachedAt ?? 0)) < V2_BOOTSTRAP_TTL_MS) {
1783
2576
  allDevices = (cached.devices ?? []);
1784
2577
  epoch = Number(cached.epoch ?? 0) || 0;