@agentunion/fastaun 0.5.10 → 0.5.12

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.
@@ -60,11 +60,16 @@ function deduplicateForwardPageMessages(value, afterSeq) {
60
60
  }
61
61
  return [...bySeq.entries()].sort(([a], [b]) => a - b).map(([, row]) => row);
62
62
  }
63
- function shouldContinueForwardPage({ rawCount, nextAfterSeq, nextAfter, limit, hasMoreField, hasMore, serverHead, pendingAckSeq, }) {
63
+ function contiguousPullSeq(afterSeq, seqs, proof) {
64
+ const pulledMax = seqs.length > 0 ? Math.max(...seqs) : 0;
65
+ const serverProof = safeNonNegativeSeq(proof);
66
+ return Math.max(afterSeq, pulledMax, serverProof ?? 0);
67
+ }
68
+ function shouldContinueForwardPage({ rawCount, nextAfterSeq, nextAfter, limit, hasMoreField, hasMore, serverHead, observedUpper, pendingAckSeq, }) {
64
69
  // 无新 seq 或游标未前进时必须停止,避免错误元数据造成循环。
65
70
  if (rawCount <= 0 || nextAfter <= nextAfterSeq)
66
71
  return false;
67
- if (hasMore || serverHead > nextAfter || pendingAckSeq > 0)
72
+ if (hasMore || serverHead > nextAfter || observedUpper > nextAfter || pendingAckSeq > 0)
68
73
  return true;
69
74
  // 仅旧服务缺失 has_more 时,以满页保留兼容续拉。
70
75
  return !hasMoreField && limit > 0 && rawCount >= limit;
@@ -734,7 +739,7 @@ export class V2E2EECoordinator {
734
739
  let plaintext = null;
735
740
  const retryStatus = {};
736
741
  try {
737
- plaintext = await client._decryptV2Message(entry.msg, false, true, true, true, retryStatus);
742
+ plaintext = await client._decryptV2Message(entry.msg, false, true, true, true, retryStatus, false, 'pending_retry');
738
743
  }
739
744
  catch (exc) {
740
745
  client._clientLog.warn(`V2 sender IK pending retry raised: key=${key} err=${formatE2EEError(exc)}`);
@@ -767,12 +772,14 @@ export class V2E2EECoordinator {
767
772
  pending.delete(key);
768
773
  const seq = Number(entry.msg.seq ?? 0);
769
774
  if (entry.groupId) {
775
+ if (client._delivery.isSelfSentGroupMessage?.(entry.msg))
776
+ continue;
770
777
  plaintext = normalizeGroupMentionMode(plaintext, entry.msg, entry.msg.envelope_json);
771
778
  plaintext.group_id = entry.groupId;
772
- await client._publishPulledMessage('group.message_created', `group:${entry.groupId}`, seq, plaintext);
779
+ await client._publishPulledMessage('group.message_created', `group:${entry.groupId}`, seq, plaintext, false, 'pending_retry');
773
780
  }
774
781
  else {
775
- await client._publishPulledMessage('message.received', `p2p:${client._aid ?? ''}`, seq, plaintext);
782
+ await client._publishPulledMessage('message.received', `p2p:${client._aid ?? ''}`, seq, plaintext, false, 'pending_retry');
776
783
  }
777
784
  client._clientLog.debug(`V2 sender IK pending retry delivered: key=${key}`);
778
785
  }
@@ -1018,10 +1025,10 @@ export class V2E2EECoordinator {
1018
1025
  if (String(msg.version ?? '') === 'v1') {
1019
1026
  const legacy = isJsonObject(msg.legacy_v1)
1020
1027
  ? msg.legacy_v1 : {};
1021
- const payload = legacy.payload;
1028
+ const payload = legacy.payload !== undefined ? legacy.payload : msg.payload;
1022
1029
  const payloadType = isJsonObject(payload)
1023
1030
  ? String(payload.type ?? '').trim() : '';
1024
- if (payload !== undefined && payload !== null && !['e2ee.encrypted', 'e2ee.group_encrypted'].includes(payloadType)) {
1031
+ if (payload !== undefined && payload !== null && !['e2ee.encrypted', 'e2ee.group_encrypted', 'e2ee.p2p_encrypted'].includes(payloadType)) {
1025
1032
  plaintext = {
1026
1033
  message_id: String(msg.message_id ?? ''),
1027
1034
  from: String(msg.from_aid ?? ''),
@@ -1034,9 +1041,14 @@ export class V2E2EECoordinator {
1034
1041
  };
1035
1042
  attachGatewayProximity(plaintext, msg);
1036
1043
  }
1044
+ else if (isJsonObject(payload)
1045
+ && ['e2ee.p2p_encrypted', 'e2ee.encrypted'].includes(payloadType)) {
1046
+ msg = { ...msg, envelope_json: JSON.stringify(payload) };
1047
+ plaintext = await client._decryptV2Message(msg, false, !history, true, true, undefined, false, source);
1048
+ }
1037
1049
  }
1038
1050
  else {
1039
- plaintext = await client._decryptV2Message(msg, false, !history);
1051
+ plaintext = await client._decryptV2Message(msg, false, !history, true, true, undefined, false, source);
1040
1052
  }
1041
1053
  if (!plaintext) {
1042
1054
  if (history)
@@ -1046,12 +1058,7 @@ export class V2E2EECoordinator {
1046
1058
  }
1047
1059
  if (publish) {
1048
1060
  const event = client._delivery.p2pAppEventForMessage(plaintext);
1049
- if (orderedPublish) {
1050
- await client._delivery.publishOrderedMessage(event.event, client._aid ? `p2p:${client._aid}` : '', seq, event.payload, source);
1051
- }
1052
- else {
1053
- await client._publishAppEvent(event.event, event.payload, source);
1054
- }
1061
+ await client._publishAppEvent(event.event, event.payload, source);
1055
1062
  }
1056
1063
  return plaintext;
1057
1064
  }
@@ -1061,35 +1068,52 @@ export class V2E2EECoordinator {
1061
1068
  let plaintext = null;
1062
1069
  if (String(msg.version ?? '') === 'v1') {
1063
1070
  const payload = msg.payload;
1071
+ const recall = typeof client._delivery?.recallEventFromGroupMessage === 'function'
1072
+ ? client._delivery.recallEventFromGroupMessage(msg)
1073
+ : null;
1074
+ if (recall) {
1075
+ const recallPayload = {
1076
+ ...recall,
1077
+ group_id: groupId,
1078
+ group_aid: groupAid,
1079
+ seq,
1080
+ };
1081
+ if (publish) {
1082
+ await client._publishAppEvent('group.message_recalled', recallPayload, source);
1083
+ }
1084
+ return recallPayload;
1085
+ }
1064
1086
  if (payload === undefined || payload === null) {
1065
1087
  if (history)
1066
1088
  return this.historyDecryptFailure(msg, 'legacy payload is missing');
1067
1089
  client._clientLog.warn(`Group Tail 缺少 payload 的 V1 行已跳过,连续性证明仍保留: group=${groupId}, seq=${seq}`);
1068
1090
  return null;
1069
1091
  }
1092
+ const payloadType = isJsonObject(payload)
1093
+ ? String(payload.type ?? '').trim() : '';
1070
1094
  if (isJsonObject(payload)
1071
- && ['e2ee.encrypted', 'e2ee.group_encrypted'].includes(String(payload.type ?? ''))) {
1072
- if (history)
1073
- return this.historyDecryptFailure(msg, 'unsupported legacy encrypted envelope');
1074
- client._clientLog.warn(`Group Tail 不支持的旧加密消息已跳过,连续性证明仍保留: group=${groupId}, seq=${seq}`);
1075
- return null;
1095
+ && ['e2ee.group_encrypted', 'e2ee.p2p_encrypted', 'e2ee.encrypted'].includes(payloadType)) {
1096
+ msg = { ...msg, envelope_json: JSON.stringify(payload) };
1097
+ plaintext = await client._decryptV2Message(msg, false, !history, true, true, undefined, false, source);
1098
+ }
1099
+ else {
1100
+ plaintext = {
1101
+ message_id: String(msg.message_id ?? ''),
1102
+ from: String(msg.from_aid ?? ''),
1103
+ group_id: groupId,
1104
+ group_aid: groupAid,
1105
+ seq,
1106
+ type: String(msg.type ?? ''),
1107
+ timestamp: msg.t_server,
1108
+ payload: payload,
1109
+ encrypted: false,
1110
+ };
1111
+ plaintext = normalizeGroupMentionMode(plaintext, msg, msg.envelope_json);
1112
+ attachGatewayProximity(plaintext, msg);
1076
1113
  }
1077
- plaintext = {
1078
- message_id: String(msg.message_id ?? ''),
1079
- from: String(msg.from_aid ?? ''),
1080
- group_id: groupId,
1081
- group_aid: groupAid,
1082
- seq,
1083
- type: String(msg.type ?? ''),
1084
- timestamp: msg.t_server,
1085
- payload: payload,
1086
- encrypted: false,
1087
- };
1088
- plaintext = normalizeGroupMentionMode(plaintext, msg, msg.envelope_json);
1089
- attachGatewayProximity(plaintext, msg);
1090
1114
  }
1091
1115
  else {
1092
- plaintext = await client._decryptV2Message(msg, false, !history);
1116
+ plaintext = await client._decryptV2Message(msg, false, !history, true, true, undefined, false, source);
1093
1117
  if (plaintext) {
1094
1118
  plaintext.group_id = groupId;
1095
1119
  plaintext.group_aid = groupAid;
@@ -1102,24 +1126,14 @@ export class V2E2EECoordinator {
1102
1126
  client._clientLog.warn(`Group Tail 单条消息解密失败,连续性证明仍保留: group=${groupId}, seq=${seq}`);
1103
1127
  return null;
1104
1128
  }
1129
+ if (publish && !history && client._delivery.isSelfSentGroupMessage?.(msg)) {
1130
+ return plaintext;
1131
+ }
1105
1132
  if (publish) {
1106
- if (orderedPublish) {
1107
- await client._delivery.publishOrderedMessage('group.message_created', `group:${groupId}`, seq, plaintext, source);
1108
- }
1109
- else {
1110
- await client._publishAppEvent('group.message_created', plaintext, source);
1111
- }
1133
+ await client._publishAppEvent('group.message_created', plaintext, source);
1112
1134
  }
1113
1135
  return plaintext;
1114
1136
  }
1115
- async applyTailFloor(ns, floor) {
1116
- const client = this.client;
1117
- if (!ns || floor <= 0 || client._seqTracker.getContiguousSeq(ns) >= floor)
1118
- return;
1119
- client._seqTracker.forceContiguousSeq(ns, floor);
1120
- client._persistSeq(ns);
1121
- await client._saveSeqTrackerState?.();
1122
- }
1123
1137
  async pullV2TailInternal(params, deferPublish = false) {
1124
1138
  const client = this.client;
1125
1139
  if (params.window_mode !== 'tail')
@@ -1129,10 +1143,9 @@ export class V2E2EECoordinator {
1129
1143
  const afterSeq = strictWindowSeq(params.after_seq ?? 0, 'after_seq');
1130
1144
  const limit = strictWindowLimit(params.limit ?? V2_PULL_DEFAULT_PAGE_LIMIT);
1131
1145
  const ns = client._aid ? `p2p:${client._aid}` : '';
1146
+ if (ns)
1147
+ client._delivery.syncStarted(ns);
1132
1148
  const result = await client._callRawV2Rpc('message.v2.pull', { window_mode: 'tail', after_seq: afterSeq, limit, _rpc_foreground: true });
1133
- const floor = Number(result.retention_floor_seq ?? 0);
1134
- const previous = ns ? client._seqTracker.getSyncState(ns) : null;
1135
- await this.applyTailFloor(ns, Number.isSafeInteger(floor) ? floor : 0);
1136
1149
  const page = validateTailPage(result, afterSeq, limit);
1137
1150
  if (ns) {
1138
1151
  client._seqTracker.commitTailWindow(ns, {
@@ -1151,8 +1164,15 @@ export class V2E2EECoordinator {
1151
1164
  decoded.push(plaintext);
1152
1165
  }
1153
1166
  const ack = ns ? client._seqTracker.getContiguousSeq(ns) : 0;
1154
- if (previous && ack > previous.ack)
1155
- await this.ackV2(ack);
1167
+ if (ns)
1168
+ client._delivery.syncProgress(ns, {
1169
+ rawCount: page.messages.length,
1170
+ pulledCount: decoded.length,
1171
+ remaining: result.remaining,
1172
+ hasMore: typeof result.has_more === 'boolean' ? result.has_more : undefined,
1173
+ currentSeq: ack,
1174
+ targetSeq: page.head,
1175
+ });
1156
1176
  return { ...result, messages: decoded, raw_count: page.messages.length };
1157
1177
  }
1158
1178
  async historyV2Internal(params) {
@@ -1169,21 +1189,23 @@ export class V2E2EECoordinator {
1169
1189
  }
1170
1190
  return { ...result, messages: decoded };
1171
1191
  }
1172
- async pullV2(afterSeq = 0, limit = 50, opts) {
1192
+ async pullV2Impl(afterSeq = 0, limit = 50, opts) {
1173
1193
  const client = this.client;
1174
1194
  await client._ensureV2SessionReady('message.pull');
1175
1195
  const ns = client._aid ? `p2p:${client._aid}` : '';
1196
+ if (ns)
1197
+ client._delivery.syncStarted(ns);
1176
1198
  if (opts?.windowMode === 'tail') {
1177
1199
  if (!opts.gateLocked) {
1178
1200
  const key = `${ns}|tail|after=${afterSeq}|limit=${limit}`;
1179
- return (await client._runPullSerialized(key, () => this.pullV2(afterSeq, limit, { ...opts, gateLocked: true }), false));
1201
+ return (await client._runPullSerialized(key, () => this.pullV2Impl(afterSeq, limit, { ...opts, gateLocked: true }), false));
1180
1202
  }
1181
1203
  const result = await this.pullV2TailInternal({ window_mode: 'tail', after_seq: afterSeq, limit });
1182
1204
  return (Array.isArray(result.messages) ? result.messages : []);
1183
1205
  }
1184
1206
  if (ns && !opts?.gateLocked) {
1185
1207
  const key = `${ns}|forward|after=${afterSeq}|force=${String(Boolean(opts?.force))}|limit=${limit}`;
1186
- return await client._runPullSerialized(key, async () => this.pullV2(afterSeq, limit, {
1208
+ return await client._runPullSerialized(key, async () => this.pullV2Impl(afterSeq, limit, {
1187
1209
  ...(opts ?? {}),
1188
1210
  gateLocked: true,
1189
1211
  scheduleFollowup: true,
@@ -1251,8 +1273,10 @@ export class V2E2EECoordinator {
1251
1273
  if (seqs.length > 0) {
1252
1274
  pageMaxSeq = Math.max(...seqs);
1253
1275
  }
1276
+ const pageContiguousSeq = contiguousPullSeq(pageContigBefore, seqs, result.contiguous_seq);
1254
1277
  const deferredKeyFetches = new Map();
1255
1278
  let blockedSeq = 0;
1279
+ const pageDecryptedBefore = decrypted.length;
1256
1280
  for (const msg of messages) {
1257
1281
  const seq = Number(msg.seq ?? 0);
1258
1282
  if (!Number.isFinite(seq) || seq <= 0)
@@ -1260,11 +1284,12 @@ export class V2E2EECoordinator {
1260
1284
  const version = String(msg.version ?? 'v2');
1261
1285
  if (version === 'v1') {
1262
1286
  const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
1263
- const legacyPayload = legacy.payload;
1287
+ const legacyPayload = legacy.payload !== undefined ? legacy.payload : msg.payload;
1264
1288
  const payloadType = isJsonObject(legacyPayload)
1265
1289
  ? String(legacyPayload.type ?? '').trim()
1266
1290
  : '';
1267
- if (legacyPayload !== undefined && legacyPayload !== null && payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
1291
+ if (legacyPayload !== undefined && legacyPayload !== null
1292
+ && !['e2ee.encrypted', 'e2ee.group_encrypted', 'e2ee.p2p_encrypted'].includes(payloadType)) {
1268
1293
  const v1Msg = {
1269
1294
  message_id: String(msg.message_id ?? ''),
1270
1295
  from: String(msg.from_aid ?? ''),
@@ -1286,6 +1311,25 @@ export class V2E2EECoordinator {
1286
1311
  decrypted.push(v1Msg);
1287
1312
  client._clientLog.debug(`message.v2.pull plaintext V1 delivered: seq=${seq}, ns=${ns || '<none>'}`);
1288
1313
  }
1314
+ else if (isJsonObject(legacyPayload)
1315
+ && ['e2ee.p2p_encrypted', 'e2ee.encrypted'].includes(payloadType)) {
1316
+ const deferStatus = {};
1317
+ const plaintext = await client._decryptV2Message({ ...msg, envelope_json: JSON.stringify(legacyPayload) }, true, true, true, true, deferStatus, true, 'pull');
1318
+ if (deferStatus.deferred) {
1319
+ blockedSeq = blockedSeq > 0 ? Math.min(blockedSeq, seq) : seq;
1320
+ if (deferStatus.fromAid) {
1321
+ const key = `${deferStatus.fromAid}\u0000${deferStatus.senderDeviceId ?? ''}\u0000${deferStatus.groupId ?? ''}`;
1322
+ deferredKeyFetches.set(key, { fromAid: deferStatus.fromAid, senderDeviceId: deferStatus.senderDeviceId ?? '', groupId: deferStatus.groupId ?? '' });
1323
+ }
1324
+ }
1325
+ if (plaintext) {
1326
+ if (ns)
1327
+ await client._publishPulledMessage('message.received', ns, seq, plaintext, false);
1328
+ else
1329
+ await client._publishAppEvent('message.received', plaintext, 'pull');
1330
+ decrypted.push(plaintext);
1331
+ }
1332
+ }
1289
1333
  else {
1290
1334
  client._clientLog.debug(`message.v2.pull skipping V1 envelope seq=${seq} payload_type=${payloadType || '<none>'} (V1 E2EE removed)`);
1291
1335
  }
@@ -1300,7 +1344,7 @@ export class V2E2EECoordinator {
1300
1344
  client._v2Session.trackOldSPKMaxSeq(spkId, seq);
1301
1345
  }
1302
1346
  const deferStatus = {};
1303
- const plaintext = await client._decryptV2Message(msg, true, true, true, true, deferStatus, true);
1347
+ const plaintext = await client._decryptV2Message(msg, true, true, true, true, deferStatus, true, 'pull');
1304
1348
  if (deferStatus.deferred) {
1305
1349
  blockedSeq = blockedSeq > 0 ? Math.min(blockedSeq, seq) : seq;
1306
1350
  }
@@ -1330,7 +1374,7 @@ export class V2E2EECoordinator {
1330
1374
  const serverAckSeq = parsedServerAckSeq ?? 0;
1331
1375
  const retentionFloor = Math.max(0, Number(result.retention_floor_seq ?? 0));
1332
1376
  if (ns) {
1333
- const commitTarget = Math.max(pageContigBefore, Number.isFinite(retentionFloor) ? retentionFloor : 0, pageCount === 1 ? deferredServerCursor : 0, blockedSeq > 0 ? blockedSeq - 1 : (seqs.length > 0 ? pageMaxSeq : pageContigBefore));
1377
+ const commitTarget = Math.max(pageContigBefore, Number.isFinite(retentionFloor) ? retentionFloor : 0, pageCount === 1 ? deferredServerCursor : 0, serverAckSeq, blockedSeq > 0 ? blockedSeq - 1 : pageContiguousSeq);
1334
1378
  let ackSeq = pageContigBefore;
1335
1379
  let contigAdvanced = false;
1336
1380
  try {
@@ -1380,7 +1424,7 @@ export class V2E2EECoordinator {
1380
1424
  || (contigAdvanced && ackSeq > knownServerAckSeq));
1381
1425
  if (ackNeeded) {
1382
1426
  this.recordForwardAck(ns, ackSeq);
1383
- const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
1427
+ const nextAfter = Math.max(pageContiguousSeq, nextAfterSeq);
1384
1428
  const canContinuePage = blockedSeq <= 0 && messages.length > 0 && nextAfter > nextAfterSeq;
1385
1429
  if (canContinuePage) {
1386
1430
  pendingAckSeq = Math.max(pendingAckSeq, ackSeq);
@@ -1399,8 +1443,19 @@ export class V2E2EECoordinator {
1399
1443
  this.scheduleSenderIKFetch(fetch.fromAid, fetch.senderDeviceId, fetch.groupId);
1400
1444
  }
1401
1445
  }
1402
- const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
1446
+ if (ns)
1447
+ client._delivery.syncProgress(ns, {
1448
+ rawCount: messages.length,
1449
+ pulledCount: decrypted.length - pageDecryptedBefore,
1450
+ remaining: result.remaining,
1451
+ hasMore: hasMoreField ? hasMore : undefined,
1452
+ currentSeq: client._seqTracker.getContiguousSeq(ns),
1453
+ targetSeq: serverHead,
1454
+ });
1455
+ const nextAfter = Math.max(pageContiguousSeq, nextAfterSeq);
1403
1456
  const rawCount = messages.length;
1457
+ if (blockedSeq > 0)
1458
+ break;
1404
1459
  const shouldContinue = shouldContinueForwardPage({
1405
1460
  rawCount,
1406
1461
  nextAfterSeq,
@@ -1409,6 +1464,7 @@ export class V2E2EECoordinator {
1409
1464
  hasMoreField,
1410
1465
  hasMore,
1411
1466
  serverHead,
1467
+ observedUpper: Math.max(client._seqTracker.getMaxSeenSeq(ns), Number(client._pendingP2pPullUpper?.get?.(ns) ?? 0)),
1412
1468
  pendingAckSeq,
1413
1469
  });
1414
1470
  if (!shouldContinue)
@@ -1434,6 +1490,19 @@ export class V2E2EECoordinator {
1434
1490
  client._clientLog.debug(`message.v2.pull done: requested_after_seq=${afterSeq}, pages=${pageCount}, decrypted=${decrypted.length}, ns=${ns || '<none>'}`);
1435
1491
  return decrypted;
1436
1492
  }
1493
+ async pullV2(afterSeq = 0, limit = 50, opts) {
1494
+ const ns = this.client._aid ? `p2p:${this.client._aid}` : '';
1495
+ if (!ns)
1496
+ return await this.pullV2Impl(afterSeq, limit, opts);
1497
+ this.client._delivery.syncStarted(ns);
1498
+ try {
1499
+ return await this.pullV2Impl(afterSeq, limit, opts);
1500
+ }
1501
+ catch (error) {
1502
+ this.client._delivery.onPullAborted?.();
1503
+ throw error;
1504
+ }
1505
+ }
1437
1506
  async confirmForwardP2PAck(upToSeq) {
1438
1507
  const ns = this.client._aid ? `p2p:${this.client._aid}` : '';
1439
1508
  try {
@@ -1536,22 +1605,8 @@ export class V2E2EECoordinator {
1536
1605
  client._clientLog.debug(`group.v2.send ok: group=${gid}, use_cache=${useCache}, seq=${String((isJsonObject(result) ? result.seq : '') ?? '')}`);
1537
1606
  return result;
1538
1607
  };
1539
- const markSentSeq = async (result) => {
1540
- if (!isJsonObject(result))
1541
- return;
1542
- const obj = result;
1543
- const seq = Number(obj.seq ?? 0);
1544
- if (!Number.isFinite(seq) || seq <= 0)
1545
- return;
1546
- const ns = `group:${gid}`;
1547
- client._seqTracker.onMessageSeq(ns, seq);
1548
- client._markPublishedSeq(ns, seq);
1549
- await client._saveSeqTrackerState();
1550
- client._clientLog.debug(`group.v2.send marked own seq: group=${gid}, ns=${ns}, seq=${seq}`);
1551
- };
1552
1608
  try {
1553
1609
  const result = await attempt(true);
1554
- await markSentSeq(result);
1555
1610
  return client._delivery.attachSendResultEnvelope('group.send', withExplicitGroupAid({
1556
1611
  group_id: gid,
1557
1612
  payload,
@@ -1567,7 +1622,6 @@ export class V2E2EECoordinator {
1567
1622
  client._clientLog.debug(`V2 group speculative send rejected (code=${String(excCode)}), refreshing bootstrap`);
1568
1623
  this.deleteBootstrapCacheEntry(`group:${gid}`);
1569
1624
  const result = await attempt(false);
1570
- await markSentSeq(result);
1571
1625
  return client._delivery.attachSendResultEnvelope('group.send', withExplicitGroupAid({
1572
1626
  group_id: gid,
1573
1627
  payload,
@@ -1739,6 +1793,14 @@ export class V2E2EECoordinator {
1739
1793
  const afterSeq = strictWindowSeq(params.after_seq ?? 0, 'after_seq');
1740
1794
  const limit = strictWindowLimit(params.limit ?? V2_PULL_DEFAULT_PAGE_LIMIT);
1741
1795
  const ns = `group:${groupId}`;
1796
+ const cursorParams = isJsonObject(params._group_cursor_params)
1797
+ ? params._group_cursor_params : params;
1798
+ const requestDeviceId = String(cursorParams.device_id ?? '').trim();
1799
+ const requestSlotId = String(cursorParams.slot_id ?? '').trim();
1800
+ const ownsCursor = (!requestDeviceId || requestDeviceId === String(client._deviceId ?? ''))
1801
+ && (!requestSlotId || requestSlotId === String(client._slotId ?? ''));
1802
+ if (ownsCursor)
1803
+ client._delivery.syncStarted(ns);
1742
1804
  const result = await client._callRawV2Rpc('group.v2.pull', withExplicitGroupAid({
1743
1805
  group_id: groupId,
1744
1806
  window_mode: 'tail',
@@ -1749,9 +1811,6 @@ export class V2E2EECoordinator {
1749
1811
  const resultAid = String(result.group_aid ?? result.groupAid ?? '').trim();
1750
1812
  if (resultAid)
1751
1813
  groupAid = resultAid;
1752
- const floor = Number(result.retention_floor_message_seq ?? result.retention_floor_seq ?? 0);
1753
- const previous = client._seqTracker.getSyncState(ns);
1754
- await this.applyTailFloor(ns, Number.isSafeInteger(floor) ? floor : 0);
1755
1814
  const page = validateTailPage(result, afterSeq, limit);
1756
1815
  client._seqTracker.commitTailWindow(ns, {
1757
1816
  windowStartSeq: page.windowStart,
@@ -1769,8 +1828,15 @@ export class V2E2EECoordinator {
1769
1828
  decoded.push(plaintext);
1770
1829
  }
1771
1830
  const ack = client._seqTracker.getContiguousSeq(ns);
1772
- if (ack > previous.ack)
1773
- await this.ackGroupV2(groupId, ack, groupAid);
1831
+ if (ownsCursor)
1832
+ client._delivery.syncProgress(ns, {
1833
+ rawCount: page.messages.length,
1834
+ pulledCount: decoded.length,
1835
+ remaining: result.remaining,
1836
+ hasMore: typeof result.has_more === 'boolean' ? result.has_more : undefined,
1837
+ currentSeq: ack,
1838
+ targetSeq: page.head,
1839
+ });
1774
1840
  return { ...result, messages: decoded, raw_count: page.messages.length };
1775
1841
  }
1776
1842
  async historyGroupV2Internal(params) {
@@ -1806,7 +1872,7 @@ export class V2E2EECoordinator {
1806
1872
  force: params.force === true,
1807
1873
  });
1808
1874
  }
1809
- async pullGroupV2(groupId, afterSeq = 0, limit = 50, opts) {
1875
+ async pullGroupV2Impl(groupId, afterSeq = 0, limit = 50, opts) {
1810
1876
  const client = this.client;
1811
1877
  await client._ensureV2SessionReady('group.pull');
1812
1878
  const gid = String(groupId ?? '').trim();
@@ -1814,15 +1880,18 @@ export class V2E2EECoordinator {
1814
1880
  throw new ValidationError('group.pull requires group_id');
1815
1881
  let groupAid = String(opts?.groupAid ?? '').trim();
1816
1882
  const ns = `group:${gid}`;
1883
+ if (opts?.ownsCursor !== false)
1884
+ client._delivery.syncStarted(ns);
1817
1885
  if (opts?.windowMode === 'tail') {
1818
1886
  if (!opts.gateLocked) {
1819
1887
  const key = `${ns}|tail|after=${afterSeq}|limit=${limit}`;
1820
- return (await client._runPullSerialized(key, () => this.pullGroupV2(gid, afterSeq, limit, {
1888
+ return (await client._runPullSerialized(key, () => this.pullGroupV2Impl(gid, afterSeq, limit, {
1821
1889
  ...opts,
1822
1890
  gateLocked: true,
1823
1891
  }), false));
1824
1892
  }
1825
1893
  const result = await this.pullGroupV2TailInternal({
1894
+ ...(opts?.cursorParams ?? {}),
1826
1895
  group_id: String(opts.wireGroupId ?? gid),
1827
1896
  group_aid: groupAid || undefined,
1828
1897
  window_mode: 'tail',
@@ -1833,7 +1902,7 @@ export class V2E2EECoordinator {
1833
1902
  }
1834
1903
  if (!opts?.gateLocked) {
1835
1904
  const key = `${ns}|forward|after=${afterSeq}|force=${opts?.force === true}|limit=${limit}`;
1836
- return await client._runPullSerialized(key, async () => this.pullGroupV2(gid, afterSeq, limit, {
1905
+ return await client._runPullSerialized(key, async () => this.pullGroupV2Impl(gid, afterSeq, limit, {
1837
1906
  ...(opts ?? {}),
1838
1907
  gateLocked: true,
1839
1908
  scheduleFollowup: true,
@@ -1914,30 +1983,34 @@ export class V2E2EECoordinator {
1914
1983
  if (seqs.length > 0) {
1915
1984
  pageMaxSeq = Math.max(...seqs);
1916
1985
  }
1986
+ const pageContiguousSeq = contiguousPullSeq(pageContigBefore, seqs, result.contiguous_seq);
1917
1987
  const deferredKeyFetches = new Map();
1918
1988
  let blockedSeq = 0;
1989
+ const pageDecryptedBefore = decrypted.length;
1919
1990
  for (const msg of messages) {
1920
1991
  const seq = Number(msg.seq ?? 0);
1921
1992
  if (!Number.isFinite(seq) || seq <= 0)
1922
1993
  continue;
1923
1994
  const version = String(msg.version ?? 'v2');
1924
1995
  if (version === 'v1') {
1925
- const payload = msg.payload;
1996
+ const legacy = isJsonObject(msg.legacy_v1) ? msg.legacy_v1 : {};
1997
+ const payload = msg.payload !== undefined ? msg.payload : legacy.payload;
1926
1998
  const payloadObj = isJsonObject(payload) ? payload : null;
1999
+ const payloadType = payloadObj ? String(payloadObj.type ?? '').trim() : '';
1927
2000
  // 群撤回 tombstone(占位 / 通知):归一化为 group.message_recalled 事件,仍占 seq。
1928
- if (client._delivery.recallEventFromGroupMessage(msg)) {
2001
+ if (typeof client._delivery?.recallEventFromGroupMessage === 'function'
2002
+ && client._delivery.recallEventFromGroupMessage(msg)) {
1929
2003
  await client._delivery.publishGroupRecallTombstone(gid, seq, {
1930
2004
  ...msg,
1931
2005
  group_id: gid,
1932
2006
  group_aid: eventGroupAid,
1933
- });
2007
+ }, 'pull');
1934
2008
  client._markPublishedSeq(ns, seq);
1935
2009
  client._clientLog.debug(`group.v2.pull recall tombstone delivered: group=${gid}, seq=${seq}`);
1936
2010
  continue;
1937
2011
  }
1938
2012
  if (payloadObj) {
1939
- const payloadType = String(payloadObj.type ?? '').trim();
1940
- if (payloadType !== 'e2ee.encrypted' && payloadType !== 'e2ee.group_encrypted') {
2013
+ if (!['e2ee.encrypted', 'e2ee.group_encrypted', 'e2ee.p2p_encrypted'].includes(payloadType)) {
1941
2014
  let v1Msg = {
1942
2015
  message_id: String(msg.message_id ?? ''),
1943
2016
  from: String(msg.from_aid ?? ''),
@@ -1951,7 +2024,12 @@ export class V2E2EECoordinator {
1951
2024
  };
1952
2025
  v1Msg = normalizeGroupMentionMode(v1Msg, msg, msg.envelope_json);
1953
2026
  attachGatewayProximity(v1Msg, msg);
1954
- await client._publishPulledMessage('group.message_created', ns, seq, v1Msg, false);
2027
+ if (!client._delivery.isSelfSentGroupMessage?.(msg)) {
2028
+ await client._publishPulledMessage('group.message_created', ns, seq, v1Msg, false);
2029
+ }
2030
+ else {
2031
+ client._markPublishedSeq(ns, seq);
2032
+ }
1955
2033
  decrypted.push(v1Msg);
1956
2034
  client._clientLog.debug(`group.v2.pull plaintext V1 delivered: group=${gid}, seq=${seq}`);
1957
2035
  continue;
@@ -1976,7 +2054,31 @@ export class V2E2EECoordinator {
1976
2054
  client._clientLog.debug(`group.v2.pull plaintext V1 delivered: group=${gid}, seq=${seq}`);
1977
2055
  continue;
1978
2056
  }
1979
- client._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
2057
+ if (payloadObj && ['e2ee.group_encrypted', 'e2ee.p2p_encrypted', 'e2ee.encrypted'].includes(payloadType)) {
2058
+ const deferStatus = {};
2059
+ const plaintext = await client._decryptV2Message({ ...msg, envelope_json: JSON.stringify(payloadObj) }, true, true, true, true, deferStatus, true, 'pull');
2060
+ if (deferStatus.deferred) {
2061
+ blockedSeq = blockedSeq > 0 ? Math.min(blockedSeq, seq) : seq;
2062
+ if (deferStatus.fromAid) {
2063
+ const key = `${deferStatus.fromAid}\u0000${deferStatus.senderDeviceId ?? ''}\u0000${deferStatus.groupId ?? ''}`;
2064
+ deferredKeyFetches.set(key, { fromAid: deferStatus.fromAid, senderDeviceId: deferStatus.senderDeviceId ?? '', groupId: deferStatus.groupId ?? '' });
2065
+ }
2066
+ }
2067
+ if (plaintext) {
2068
+ plaintext.group_id = gid;
2069
+ plaintext.group_aid = eventGroupAid;
2070
+ if (!client._delivery.isSelfSentGroupMessage?.(msg)) {
2071
+ await client._publishPulledMessage('group.message_created', ns, seq, plaintext, false);
2072
+ }
2073
+ else {
2074
+ client._markPublishedSeq(ns, seq);
2075
+ }
2076
+ decrypted.push(plaintext);
2077
+ }
2078
+ }
2079
+ else {
2080
+ client._clientLog.debug(`group.v2.pull skipping V1 envelope group=${gid} seq=${seq} payload_type=${payloadObj ? String(payloadObj.type ?? '') : '<none>'} (V1 E2EE removed)`);
2081
+ }
1980
2082
  continue;
1981
2083
  }
1982
2084
  if (version !== 'v2') {
@@ -1984,7 +2086,7 @@ export class V2E2EECoordinator {
1984
2086
  continue;
1985
2087
  }
1986
2088
  const deferStatus = {};
1987
- let plaintext = await client._decryptV2Message(msg, true, true, true, true, deferStatus, true);
2089
+ let plaintext = await client._decryptV2Message(msg, true, true, true, true, deferStatus, true, 'pull');
1988
2090
  if (deferStatus.deferred) {
1989
2091
  blockedSeq = blockedSeq > 0 ? Math.min(blockedSeq, seq) : seq;
1990
2092
  }
@@ -2003,7 +2105,12 @@ export class V2E2EECoordinator {
2003
2105
  plaintext = normalizeGroupMentionMode(plaintext, msg, msg.envelope_json);
2004
2106
  plaintext.group_id = gid;
2005
2107
  plaintext.group_aid = eventGroupAid;
2006
- await client._publishPulledMessage('group.message_created', ns, seq, plaintext, false);
2108
+ if (!client._delivery.isSelfSentGroupMessage?.(msg)) {
2109
+ await client._publishPulledMessage('group.message_created', ns, seq, plaintext, false);
2110
+ }
2111
+ else {
2112
+ client._markPublishedSeq(ns, seq);
2113
+ }
2007
2114
  decrypted.push(plaintext);
2008
2115
  client._logMessageDebug('decrypt-ok', 'group.v2.pull', 'group.message_created', plaintext);
2009
2116
  }
@@ -2013,7 +2120,7 @@ export class V2E2EECoordinator {
2013
2120
  const retentionFloor = Math.max(client._pullRetentionFloor(result, 'retention_floor_message_seq', 'retention_floor_message_seq'));
2014
2121
  const visibilityFloor = Math.max(0, Math.max(0, Number(result.earliest_available_message_seq ?? 1) - 1), Number(cursor?.join_seq ?? 0));
2015
2122
  const effectiveFloor = Math.max(retentionFloor, visibilityFloor);
2016
- const commitTarget = Math.max(pageContigBefore, effectiveFloor, ownsCursor && pageCount === 1 ? deferredServerCursor : 0, blockedSeq > 0 ? blockedSeq - 1 : (seqs.length > 0 ? pageMaxSeq : pageContigBefore));
2123
+ const commitTarget = Math.max(pageContigBefore, effectiveFloor, ownsCursor && pageCount === 1 ? deferredServerCursor : 0, ownsCursor ? cursorCurrentSeq : 0, blockedSeq > 0 ? blockedSeq - 1 : pageContiguousSeq);
2017
2124
  let ackSeq = pageContigBefore;
2018
2125
  let contigAdvanced = false;
2019
2126
  if (ownsCursor) {
@@ -2066,7 +2173,7 @@ export class V2E2EECoordinator {
2066
2173
  || (contigAdvanced && ackSeq > knownServerCursorSeq));
2067
2174
  if (ackNeeded) {
2068
2175
  this.recordForwardAck(ns, ackSeq);
2069
- const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
2176
+ const nextAfter = Math.max(pageContiguousSeq, nextAfterSeq);
2070
2177
  const canContinuePage = blockedSeq <= 0 && messages.length > 0 && nextAfter > nextAfterSeq && ownsCursor;
2071
2178
  if (canContinuePage) {
2072
2179
  pendingAckSeq = Math.max(pendingAckSeq, ackSeq);
@@ -2079,10 +2186,20 @@ export class V2E2EECoordinator {
2079
2186
  lastAutoAckSeq = Math.max(lastAutoAckSeq, ackSeq);
2080
2187
  }
2081
2188
  }
2082
- const nextAfter = Math.max(pageMaxSeq, nextAfterSeq);
2189
+ client._delivery.syncProgress(ns, {
2190
+ rawCount: messages.length,
2191
+ pulledCount: decrypted.length - pageDecryptedBefore,
2192
+ remaining: result.remaining,
2193
+ hasMore: hasMoreField ? hasMore : undefined,
2194
+ currentSeq: client._seqTracker.getContiguousSeq(ns),
2195
+ targetSeq: serverHead,
2196
+ });
2197
+ const nextAfter = Math.max(pageContiguousSeq, nextAfterSeq);
2083
2198
  if (!ownsCursor)
2084
2199
  break;
2085
2200
  const rawCount = messages.length;
2201
+ if (blockedSeq > 0)
2202
+ break;
2086
2203
  const shouldContinue = shouldContinueForwardPage({
2087
2204
  rawCount,
2088
2205
  nextAfterSeq,
@@ -2091,6 +2208,7 @@ export class V2E2EECoordinator {
2091
2208
  hasMoreField,
2092
2209
  hasMore,
2093
2210
  serverHead,
2211
+ observedUpper: Math.max(client._seqTracker.getMaxSeenSeq(ns), Number(client._delivery.pendingPullUpper?.(ns) ?? 0)),
2094
2212
  pendingAckSeq,
2095
2213
  });
2096
2214
  if (!shouldContinue)
@@ -2116,6 +2234,20 @@ export class V2E2EECoordinator {
2116
2234
  client._clientLog.debug(`group.v2.pull done: group=${gid}, requested_after_seq=${afterSeq}, pages=${pageCount}, decrypted=${decrypted.length}, ns=${ns}`);
2117
2235
  return decrypted;
2118
2236
  }
2237
+ async pullGroupV2(groupId, afterSeq = 0, limit = 50, opts) {
2238
+ if (opts?.ownsCursor === false) {
2239
+ return await this.pullGroupV2Impl(groupId, afterSeq, limit, opts);
2240
+ }
2241
+ const ns = `group:${String(groupId ?? '').trim()}`;
2242
+ this.client._delivery.syncStarted(ns);
2243
+ try {
2244
+ return await this.pullGroupV2Impl(groupId, afterSeq, limit, opts);
2245
+ }
2246
+ catch (error) {
2247
+ this.client._delivery.onPullAborted?.();
2248
+ throw error;
2249
+ }
2250
+ }
2119
2251
  async confirmForwardGroupAck(groupId, upToSeq, groupAid) {
2120
2252
  const ns = `group:${String(groupId ?? '').trim()}`;
2121
2253
  try {
@@ -2675,7 +2807,7 @@ export class V2E2EECoordinator {
2675
2807
  async decryptV2PushMessage(data) {
2676
2808
  if (!isJsonObject(data))
2677
2809
  return null;
2678
- return await this.client._decryptV2Message(data, false, false, false, false);
2810
+ return await this.client._decryptV2Message(data, false, false, false, false, undefined, false, 'inline_push');
2679
2811
  }
2680
2812
  handleGroupChangedSpk(data, groupId, action) {
2681
2813
  if (!this.client._v2Session || !groupId || !MEMBERSHIP_ACTIONS.has(action))