@agentunion/fastaun 0.2.13 → 0.2.14
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +2 -2
- package/dist/auth.d.ts +3 -1
- package/dist/auth.js +16 -3
- package/dist/auth.js.map +1 -1
- package/dist/client.d.ts +21 -1
- package/dist/client.js +478 -104
- package/dist/client.js.map +1 -1
- package/dist/seq-tracker.d.ts +3 -0
- package/dist/seq-tracker.js +35 -1
- package/dist/seq-tracker.js.map +1 -1
- package/package.json +1 -1
package/dist/client.js
CHANGED
|
@@ -99,6 +99,21 @@ const RECONNECT_MIN_BASE_DELAY_MS = 1_000;
|
|
|
99
99
|
const RECONNECT_MAX_BASE_DELAY_MS = 64_000;
|
|
100
100
|
const GROUP_ROTATION_LEASE_MS = 120_000;
|
|
101
101
|
const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
|
|
102
|
+
const PENDING_DECRYPT_LIMIT = 100;
|
|
103
|
+
const PUSHED_SEQS_LIMIT = 50_000;
|
|
104
|
+
const PENDING_ORDERED_LIMIT = 50_000;
|
|
105
|
+
// P1-23: 非幂等方法使用更长超时(35s),避免 SDK 10s 超时 < gateway 30s 处理时间
|
|
106
|
+
const NON_IDEMPOTENT_TIMEOUT_MS = 35_000;
|
|
107
|
+
const NON_IDEMPOTENT_METHODS = new Set([
|
|
108
|
+
'message.send', 'group.send', 'group.create', 'group.invite',
|
|
109
|
+
'group.kick', 'group.remove_member', 'group.leave', 'group.dissolve',
|
|
110
|
+
'group.update_name', 'group.update_avatar', 'group.update_announcement',
|
|
111
|
+
'group.update_settings', 'group.rotate_epoch',
|
|
112
|
+
'storage.upload', 'storage.complete_upload', 'storage.delete',
|
|
113
|
+
'auth.create_aid', 'auth.renew_cert', 'auth.rekey',
|
|
114
|
+
'message.thought.put', 'group.thought.put',
|
|
115
|
+
'group.add_member',
|
|
116
|
+
]);
|
|
102
117
|
function clampReconnectDelayMs(value, fallback, upper = RECONNECT_MAX_BASE_DELAY_MS) {
|
|
103
118
|
const parsed = Number(value);
|
|
104
119
|
const ms = Number.isFinite(parsed) ? parsed : fallback;
|
|
@@ -116,6 +131,9 @@ const SIGNED_METHODS = new Set([
|
|
|
116
131
|
'group.transfer_owner', 'group.review_join_request',
|
|
117
132
|
'group.batch_review_join_request',
|
|
118
133
|
'group.request_join', 'group.use_invite_code',
|
|
134
|
+
'group.thought.put',
|
|
135
|
+
'message.thought.put',
|
|
136
|
+
'group.set_settings',
|
|
119
137
|
'group.resources.put', 'group.resources.update',
|
|
120
138
|
'group.resources.delete', 'group.resources.request_add',
|
|
121
139
|
'group.resources.direct_add', 'group.resources.approve_request',
|
|
@@ -276,8 +294,10 @@ export class AUNClient {
|
|
|
276
294
|
_p2pSynced = false;
|
|
277
295
|
/** 补洞去重:已完成/进行中的 key -> 开始时间戳,防止重复 pull 同一区间 */
|
|
278
296
|
_gapFillDone = new Map();
|
|
279
|
-
/**
|
|
297
|
+
/** 已发布到应用层的 seq 集合(按命名空间),补洞路径 publish 前检查以避免重复分发 */
|
|
280
298
|
_pushedSeqs = new Map();
|
|
299
|
+
/** 已解密但因 seq 空洞暂缓发布的应用层消息(按 namespace -> seq) */
|
|
300
|
+
_pendingOrderedMsgs = new Map();
|
|
281
301
|
_pendingDecryptMsgs = new Map();
|
|
282
302
|
_groupEpochRotationInflight = new Set();
|
|
283
303
|
_groupEpochRecoveryInflight = new Map();
|
|
@@ -355,7 +375,7 @@ export class AUNClient {
|
|
|
355
375
|
// 群组变更事件:拦截处理成员变更触发的 epoch 轮换,然后透传
|
|
356
376
|
this._dispatcher.subscribe('_raw.group.changed', (data) => this._onRawGroupChanged(data));
|
|
357
377
|
// 其他事件直接透传
|
|
358
|
-
for (const evt of ['message.recalled', 'message.ack']) {
|
|
378
|
+
for (const evt of ['message.recalled', 'message.ack', 'storage.object_changed']) {
|
|
359
379
|
this._dispatcher.subscribe(`_raw.${evt}`, (data) => this._dispatcher.publish(evt, data));
|
|
360
380
|
}
|
|
361
381
|
// 服务端主动断开通知:记录日志并标记不重连
|
|
@@ -507,11 +527,31 @@ export class AUNClient {
|
|
|
507
527
|
return await this._sendGroupEncrypted(p);
|
|
508
528
|
}
|
|
509
529
|
}
|
|
530
|
+
if (method === 'group.thought.put') {
|
|
531
|
+
const encrypt = p.encrypt ?? true;
|
|
532
|
+
delete p.encrypt;
|
|
533
|
+
if (!encrypt) {
|
|
534
|
+
throw new ValidationError('group.thought.put requires encrypt=true');
|
|
535
|
+
}
|
|
536
|
+
return await this._putGroupThoughtEncrypted(p);
|
|
537
|
+
}
|
|
538
|
+
if (method === 'message.thought.put') {
|
|
539
|
+
const encrypt = p.encrypt ?? true;
|
|
540
|
+
delete p.encrypt;
|
|
541
|
+
if (!encrypt) {
|
|
542
|
+
throw new ValidationError('message.thought.put requires encrypt=true');
|
|
543
|
+
}
|
|
544
|
+
return await this._putMessageThoughtEncrypted(p);
|
|
545
|
+
}
|
|
510
546
|
// 关键操作自动附加客户端签名
|
|
511
547
|
if (SIGNED_METHODS.has(method)) {
|
|
512
548
|
this._signClientOperation(method, p);
|
|
513
549
|
}
|
|
514
|
-
|
|
550
|
+
// P1-23: 非幂等方法使用更长超时
|
|
551
|
+
const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT_MS : undefined;
|
|
552
|
+
let result = callTimeout
|
|
553
|
+
? await this._transport.call(method, p, callTimeout)
|
|
554
|
+
: await this._transport.call(method, p);
|
|
515
555
|
// 自动解密:message.pull 返回的消息
|
|
516
556
|
if (method === 'message.pull' && isJsonObject(result)) {
|
|
517
557
|
const r = result;
|
|
@@ -544,6 +584,7 @@ export class AUNClient {
|
|
|
544
584
|
this._transport.call('message.ack', {
|
|
545
585
|
seq: contig,
|
|
546
586
|
device_id: this._deviceId,
|
|
587
|
+
slot_id: this._slotId,
|
|
547
588
|
}).catch((e) => { _clientLog('debug', 'message.pull auto-ack 失败: %s', formatCaughtError(e)); });
|
|
548
589
|
}
|
|
549
590
|
}
|
|
@@ -590,6 +631,12 @@ export class AUNClient {
|
|
|
590
631
|
}
|
|
591
632
|
}
|
|
592
633
|
}
|
|
634
|
+
if (method === 'group.thought.get' && isJsonObject(result)) {
|
|
635
|
+
result = await this._decryptGroupThoughts(result);
|
|
636
|
+
}
|
|
637
|
+
if (method === 'message.thought.get' && isJsonObject(result)) {
|
|
638
|
+
result = await this._decryptMessageThoughts(result);
|
|
639
|
+
}
|
|
593
640
|
// ── Group E2EE 自动编排(必备能力,始终启用)────────
|
|
594
641
|
{
|
|
595
642
|
// 建群后自动创建 epoch(幂等:已有 secret 时跳过)
|
|
@@ -620,7 +667,10 @@ export class AUNClient {
|
|
|
620
667
|
const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
|
|
621
668
|
if (groupId && this._membershipRotationChanged(method, result)) {
|
|
622
669
|
const expectedEpoch = this._membershipRotationExpectedEpoch(result);
|
|
623
|
-
|
|
670
|
+
// P0-12: await rotation 完成(带超时兜底),确保后续 group.send 使用新 epoch
|
|
671
|
+
const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch);
|
|
672
|
+
const timeoutPromise = new Promise((resolve) => globalThis.setTimeout(resolve, 5000));
|
|
673
|
+
await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => _clientLog('warn', 'membership RPC epoch rotation fallback failed: %s', formatCaughtError(exc)));
|
|
624
674
|
}
|
|
625
675
|
}
|
|
626
676
|
return result;
|
|
@@ -643,6 +693,10 @@ export class AUNClient {
|
|
|
643
693
|
on(event, handler) {
|
|
644
694
|
return this._dispatcher.subscribe(event, handler);
|
|
645
695
|
}
|
|
696
|
+
/** P2-13: 取消订阅事件(对齐 Python/JS off 方法) */
|
|
697
|
+
off(event, handler) {
|
|
698
|
+
this._dispatcher.unsubscribe(event, handler);
|
|
699
|
+
}
|
|
646
700
|
// ── E2EE 加密发送 ────────────────────────────────────────
|
|
647
701
|
/** 自动加密并发送 P2P 消息 */
|
|
648
702
|
async _sendEncrypted(params) {
|
|
@@ -842,49 +896,114 @@ export class AUNClient {
|
|
|
842
896
|
}
|
|
843
897
|
/** 自动加密并发送群组消息 */
|
|
844
898
|
async _sendGroupEncrypted(params) {
|
|
845
|
-
|
|
899
|
+
return await this._callGroupEncryptedRpc('group.send', params, {
|
|
900
|
+
idField: 'message_id',
|
|
901
|
+
idPrefix: 'gm',
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
async _putGroupThoughtEncrypted(params) {
|
|
905
|
+
return await this._callGroupEncryptedRpc('group.thought.put', params, {
|
|
906
|
+
idField: 'thought_id',
|
|
907
|
+
idPrefix: 'gt',
|
|
908
|
+
extraFields: ['reply_to'],
|
|
909
|
+
});
|
|
910
|
+
}
|
|
911
|
+
async _putMessageThoughtEncrypted(params) {
|
|
912
|
+
const toAid = String(params.to ?? '').trim();
|
|
913
|
+
this._validateMessageRecipient(toAid);
|
|
846
914
|
const payload = isJsonObject(params.payload) ? params.payload : null;
|
|
847
|
-
if (!
|
|
848
|
-
throw new ValidationError('
|
|
915
|
+
if (!toAid) {
|
|
916
|
+
throw new ValidationError('message.thought.put requires to');
|
|
849
917
|
}
|
|
850
918
|
if (payload === null) {
|
|
851
|
-
throw new ValidationError('
|
|
852
|
-
}
|
|
853
|
-
// 惰性同步:首次发送该群消息时先 pull 一次
|
|
854
|
-
if (!this._groupSynced.has(groupId)) {
|
|
855
|
-
await this._lazySyncGroup(groupId);
|
|
919
|
+
throw new ValidationError('message.thought.put payload must be an object when encrypt=true');
|
|
856
920
|
}
|
|
857
|
-
|
|
858
|
-
|
|
921
|
+
const thoughtId = String(params.thought_id ?? '') || `mt-${crypto.randomUUID()}`;
|
|
922
|
+
const timestamp = Number(params.timestamp ?? Date.now());
|
|
923
|
+
const prekey = await this._fetchPeerPrekey(toAid);
|
|
924
|
+
const peerCertFingerprint = typeof prekey?.cert_fingerprint === 'string' ? prekey.cert_fingerprint : undefined;
|
|
925
|
+
const peerCertPem = await this._fetchPeerCert(toAid, peerCertFingerprint);
|
|
926
|
+
const [envelope, encryptResult] = this._encryptCopyPayload({
|
|
927
|
+
logicalToAid: toAid,
|
|
928
|
+
payload,
|
|
929
|
+
peerCertPem,
|
|
930
|
+
prekey,
|
|
931
|
+
messageId: thoughtId,
|
|
932
|
+
timestamp,
|
|
933
|
+
});
|
|
934
|
+
this._ensureEncryptResult(toAid, encryptResult);
|
|
935
|
+
const sendParams = {
|
|
936
|
+
to: toAid,
|
|
937
|
+
payload: envelope,
|
|
938
|
+
type: 'e2ee.encrypted',
|
|
939
|
+
encrypted: true,
|
|
940
|
+
thought_id: thoughtId,
|
|
941
|
+
timestamp,
|
|
942
|
+
reply_to: params.reply_to,
|
|
943
|
+
};
|
|
944
|
+
this._signClientOperation('message.thought.put', sendParams);
|
|
945
|
+
return await this._transport.call('message.thought.put', sendParams);
|
|
946
|
+
}
|
|
947
|
+
async _callGroupEncryptedRpc(method, params, options) {
|
|
948
|
+
let { sendParams, groupId } = await this._prepareGroupEncryptedRpcParams(method, params, options);
|
|
859
949
|
for (let attempt = 0; attempt < 2; attempt += 1) {
|
|
860
|
-
const epochResult = await this._committedGroupEpochState(groupId);
|
|
861
|
-
const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
862
|
-
const envelope = committedEpoch > 0
|
|
863
|
-
? this._groupE2ee.encryptWithEpoch(groupId, await this._ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult), payload)
|
|
864
|
-
: await this._groupE2ee.encrypt(groupId, payload);
|
|
865
|
-
const sendParams = {
|
|
866
|
-
group_id: groupId,
|
|
867
|
-
payload: envelope,
|
|
868
|
-
type: 'e2ee.group_encrypted',
|
|
869
|
-
encrypted: true,
|
|
870
|
-
};
|
|
871
|
-
if (this._deviceId && sendParams.device_id === undefined) {
|
|
872
|
-
sendParams.device_id = this._deviceId;
|
|
873
|
-
}
|
|
874
|
-
await this._signClientOperation('group.send', sendParams);
|
|
875
950
|
try {
|
|
876
|
-
return await this._transport.call(
|
|
951
|
+
return await this._transport.call(method, sendParams);
|
|
877
952
|
}
|
|
878
953
|
catch (exc) {
|
|
879
954
|
if (attempt === 0 && this._isRecoverableGroupEpochError(exc)) {
|
|
880
|
-
_clientLog('warn', '群 %s
|
|
881
|
-
await this.
|
|
955
|
+
_clientLog('warn', '群 %s 调用 %s 时 epoch 已过旧,恢复密钥后重加密重试一次: %s', groupId, method, formatCaughtError(exc));
|
|
956
|
+
({ sendParams, groupId } = await this._prepareGroupEncryptedRpcParams(method, params, options, true));
|
|
882
957
|
continue;
|
|
883
958
|
}
|
|
884
959
|
throw exc;
|
|
885
960
|
}
|
|
886
961
|
}
|
|
887
|
-
throw new StateError(
|
|
962
|
+
throw new StateError(`${method} failed after epoch recovery retry: group=${groupId}`);
|
|
963
|
+
}
|
|
964
|
+
async _prepareGroupEncryptedRpcParams(method, params, options, strictEpochReady = false) {
|
|
965
|
+
const groupId = String(params.group_id ?? '');
|
|
966
|
+
const payload = isJsonObject(params.payload) ? params.payload : null;
|
|
967
|
+
if (!groupId) {
|
|
968
|
+
throw new ValidationError(`${method} requires group_id`);
|
|
969
|
+
}
|
|
970
|
+
if (payload === null) {
|
|
971
|
+
throw new ValidationError(`${method} payload must be an object when encrypt=true`);
|
|
972
|
+
}
|
|
973
|
+
if (!this._groupSynced.has(groupId)) {
|
|
974
|
+
await this._lazySyncGroup(groupId);
|
|
975
|
+
}
|
|
976
|
+
await this._ensureGroupEpochReady(groupId, strictEpochReady);
|
|
977
|
+
await this._waitForGroupMembershipEpochFloor(groupId, 2000);
|
|
978
|
+
const epochResult = await this._committedGroupEpochState(groupId);
|
|
979
|
+
const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
980
|
+
const envelope = committedEpoch > 0
|
|
981
|
+
? this._groupE2ee.encryptWithEpoch(groupId, await this._ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult), payload)
|
|
982
|
+
: await this._groupE2ee.encrypt(groupId, payload);
|
|
983
|
+
const operationId = String(params[options.idField] ?? '').trim()
|
|
984
|
+
|| `${options.idPrefix}-${crypto.randomUUID()}`;
|
|
985
|
+
const timestamp = Number(params.timestamp ?? Date.now());
|
|
986
|
+
const sendParams = {
|
|
987
|
+
group_id: groupId,
|
|
988
|
+
payload: envelope,
|
|
989
|
+
type: 'e2ee.group_encrypted',
|
|
990
|
+
encrypted: true,
|
|
991
|
+
[options.idField]: operationId,
|
|
992
|
+
timestamp,
|
|
993
|
+
};
|
|
994
|
+
if (this._deviceId && sendParams.device_id === undefined) {
|
|
995
|
+
sendParams.device_id = this._deviceId;
|
|
996
|
+
}
|
|
997
|
+
if (sendParams.slot_id === undefined) {
|
|
998
|
+
sendParams.slot_id = this._slotId;
|
|
999
|
+
}
|
|
1000
|
+
for (const field of options.extraFields ?? []) {
|
|
1001
|
+
if (params[field] !== undefined) {
|
|
1002
|
+
sendParams[field] = params[field];
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
await this._signClientOperation(method, sendParams);
|
|
1006
|
+
return { sendParams, groupId };
|
|
888
1007
|
}
|
|
889
1008
|
/** 惰性同步:首次激活群时 pull 最近消息,建立 seq 基线 */
|
|
890
1009
|
async _lazySyncGroup(groupId) {
|
|
@@ -1236,17 +1355,20 @@ export class AUNClient {
|
|
|
1236
1355
|
timestamp: data.timestamp,
|
|
1237
1356
|
_decrypt_error: String(exc),
|
|
1238
1357
|
};
|
|
1239
|
-
this.
|
|
1358
|
+
this._publishAppEvent('message.undecryptable', safeEvent).catch(() => { });
|
|
1240
1359
|
}
|
|
1241
1360
|
});
|
|
1242
1361
|
}
|
|
1243
1362
|
/** 实际处理推送消息的异步任务 */
|
|
1244
1363
|
async _processAndPublishMessage(data) {
|
|
1245
1364
|
if (!isJsonObject(data)) {
|
|
1246
|
-
await this.
|
|
1365
|
+
await this._publishAppEvent('message.received', data);
|
|
1247
1366
|
return;
|
|
1248
1367
|
}
|
|
1249
1368
|
const msg = { ...data };
|
|
1369
|
+
if (!this._messageTargetsCurrentInstance(msg)) {
|
|
1370
|
+
return;
|
|
1371
|
+
}
|
|
1250
1372
|
// 拦截 P2P 传输的群组密钥分发/请求/响应消息
|
|
1251
1373
|
if (await this._tryHandleGroupKeyMessage(msg)) {
|
|
1252
1374
|
return;
|
|
@@ -1266,20 +1388,20 @@ export class AUNClient {
|
|
|
1266
1388
|
this._transport.call('message.ack', {
|
|
1267
1389
|
seq: contig,
|
|
1268
1390
|
device_id: this._deviceId,
|
|
1391
|
+
slot_id: this._slotId,
|
|
1269
1392
|
}).catch((e) => { _clientLog('debug', 'P2P auto-ack 失败: %s', formatCaughtError(e)); });
|
|
1270
1393
|
}
|
|
1271
1394
|
// 即时持久化 cursor,异常断连后不回退
|
|
1272
1395
|
this._saveSeqTrackerState();
|
|
1273
1396
|
}
|
|
1274
1397
|
const decrypted = await this._decryptSingleMessage(msg);
|
|
1275
|
-
// 记录已推送的 seq,补洞路径据此去重
|
|
1276
1398
|
if (seq !== undefined && seq !== null && this._aid) {
|
|
1277
1399
|
const ns = `p2p:${this._aid}`;
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1400
|
+
await this._publishOrderedMessage('message.received', ns, seq, decrypted);
|
|
1401
|
+
}
|
|
1402
|
+
else {
|
|
1403
|
+
await this._publishAppEvent('message.received', decrypted);
|
|
1281
1404
|
}
|
|
1282
|
-
await this._dispatcher.publish('message.received', decrypted);
|
|
1283
1405
|
}
|
|
1284
1406
|
/** 处理群组消息推送:自动解密后 re-publish */
|
|
1285
1407
|
async _onRawGroupMessageCreated(data) {
|
|
@@ -1295,7 +1417,7 @@ export class AUNClient {
|
|
|
1295
1417
|
timestamp: data.timestamp,
|
|
1296
1418
|
_decrypt_error: String(exc),
|
|
1297
1419
|
};
|
|
1298
|
-
this.
|
|
1420
|
+
this._publishAppEvent('group.message_undecryptable', safeEvent).catch(() => { });
|
|
1299
1421
|
}
|
|
1300
1422
|
});
|
|
1301
1423
|
}
|
|
@@ -1307,7 +1429,7 @@ export class AUNClient {
|
|
|
1307
1429
|
*/
|
|
1308
1430
|
async _processAndPublishGroupMessage(data) {
|
|
1309
1431
|
if (!isJsonObject(data)) {
|
|
1310
|
-
await this.
|
|
1432
|
+
await this._publishAppEvent('group.message_created', data);
|
|
1311
1433
|
return;
|
|
1312
1434
|
}
|
|
1313
1435
|
const msg = { ...data };
|
|
@@ -1342,19 +1464,12 @@ export class AUNClient {
|
|
|
1342
1464
|
}
|
|
1343
1465
|
this._saveSeqTrackerState();
|
|
1344
1466
|
}
|
|
1345
|
-
// 记录已推送的 seq,补洞路径据此去重
|
|
1346
|
-
if (groupId && seq !== undefined && seq !== null) {
|
|
1347
|
-
const nsKey = `group:${groupId}`;
|
|
1348
|
-
if (!this._pushedSeqs.has(nsKey))
|
|
1349
|
-
this._pushedSeqs.set(nsKey, new Set());
|
|
1350
|
-
this._pushedSeqs.get(nsKey).add(seq);
|
|
1351
|
-
}
|
|
1352
1467
|
// R3: 解密失败 → 不 publish 密文给应用层,入 pending 队列 + 发 undecryptable 事件
|
|
1353
1468
|
const payloadForCheck = isJsonObject(msg.payload) ? msg.payload : null;
|
|
1354
1469
|
if (payloadForCheck?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
|
|
1355
1470
|
if (groupId)
|
|
1356
1471
|
this._enqueuePendingDecrypt(groupId, msg);
|
|
1357
|
-
await this.
|
|
1472
|
+
await this._publishAppEvent('group.message_undecryptable', {
|
|
1358
1473
|
message_id: msg.message_id,
|
|
1359
1474
|
group_id: groupId,
|
|
1360
1475
|
from: msg.from,
|
|
@@ -1364,13 +1479,19 @@ export class AUNClient {
|
|
|
1364
1479
|
});
|
|
1365
1480
|
return;
|
|
1366
1481
|
}
|
|
1367
|
-
|
|
1482
|
+
if (groupId && seq !== undefined && seq !== null) {
|
|
1483
|
+
const nsKey = `group:${groupId}`;
|
|
1484
|
+
await this._publishOrderedMessage('group.message_created', nsKey, seq, decrypted);
|
|
1485
|
+
}
|
|
1486
|
+
else {
|
|
1487
|
+
await this._publishAppEvent('group.message_created', decrypted);
|
|
1488
|
+
}
|
|
1368
1489
|
}
|
|
1369
1490
|
/** 收到不带 payload 的 group.message_created 通知后,自动 pull 最新消息 */
|
|
1370
1491
|
async _autoPullGroupMessages(notification) {
|
|
1371
1492
|
const groupId = (notification.group_id ?? '');
|
|
1372
1493
|
if (!groupId) {
|
|
1373
|
-
await this.
|
|
1494
|
+
await this._publishAppEvent('group.message_created', notification);
|
|
1374
1495
|
return;
|
|
1375
1496
|
}
|
|
1376
1497
|
const ns = `group:${groupId}`;
|
|
@@ -1386,15 +1507,19 @@ export class AUNClient {
|
|
|
1386
1507
|
const messages = result.messages;
|
|
1387
1508
|
if (Array.isArray(messages)) {
|
|
1388
1509
|
// onPullResult 已在 call() 拦截器中调用,此处不再重复
|
|
1389
|
-
// pushedSeqs 去重:跳过已通过推送路径分发的消息
|
|
1390
1510
|
const pushed = this._pushedSeqs.get(ns);
|
|
1391
1511
|
for (const msg of messages) {
|
|
1392
1512
|
if (isJsonObject(msg)) {
|
|
1393
1513
|
const s = msg.seq;
|
|
1394
1514
|
if (pushed && s !== undefined && s !== null && pushed.has(s)) {
|
|
1395
|
-
continue; //
|
|
1515
|
+
continue; // 已发布到应用层,跳过
|
|
1516
|
+
}
|
|
1517
|
+
if (s !== undefined && s !== null) {
|
|
1518
|
+
await this._publishOrderedMessage('group.message_created', ns, s, msg);
|
|
1519
|
+
}
|
|
1520
|
+
else {
|
|
1521
|
+
await this._publishAppEvent('group.message_created', msg);
|
|
1396
1522
|
}
|
|
1397
|
-
await this._dispatcher.publish('group.message_created', msg);
|
|
1398
1523
|
}
|
|
1399
1524
|
}
|
|
1400
1525
|
this._prunePushedSeqs(ns);
|
|
@@ -1405,24 +1530,17 @@ export class AUNClient {
|
|
|
1405
1530
|
catch (exc) {
|
|
1406
1531
|
_clientLog('debug', '自动 pull 群消息失败: %s', formatCaughtError(exc));
|
|
1407
1532
|
}
|
|
1408
|
-
await this.
|
|
1533
|
+
await this._publishAppEvent('group.message_created', notification);
|
|
1409
1534
|
}
|
|
1410
1535
|
/** 后台补齐群消息空洞 */
|
|
1411
1536
|
async _fillGroupGap(groupId) {
|
|
1412
1537
|
const ns = `group:${groupId}`;
|
|
1413
1538
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1414
|
-
// 冷启动(seq=0):服务端推送会带全量消息,SDK 不主动补洞避免重复拉取
|
|
1415
|
-
if (afterSeq === 0) {
|
|
1416
|
-
return;
|
|
1417
|
-
}
|
|
1418
1539
|
// 去重:同一 (group:id:after_seq) 只补一次
|
|
1419
1540
|
const dedupKey = `group_msg:${groupId}:${afterSeq}`;
|
|
1420
1541
|
if (this._gapFillDone.has(dedupKey))
|
|
1421
1542
|
return;
|
|
1422
1543
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1423
|
-
// S1: 成功/失败双路径都需要根据"是否真正前进"决定是否保留 dedup 标记,
|
|
1424
|
-
// 避免 pull 返空、contiguous 未推进时标记被永久污染。
|
|
1425
|
-
let success = false;
|
|
1426
1544
|
try {
|
|
1427
1545
|
const result = await this.call('group.pull', {
|
|
1428
1546
|
group_id: groupId,
|
|
@@ -1440,24 +1558,23 @@ export class AUNClient {
|
|
|
1440
1558
|
const s = msg.seq;
|
|
1441
1559
|
if (pushed && s !== undefined && s !== null && pushed.has(s))
|
|
1442
1560
|
continue;
|
|
1443
|
-
|
|
1561
|
+
if (s !== undefined && s !== null) {
|
|
1562
|
+
await this._publishOrderedMessage('group.message_created', ns, s, msg);
|
|
1563
|
+
}
|
|
1564
|
+
else {
|
|
1565
|
+
await this._publishAppEvent('group.message_created', msg);
|
|
1566
|
+
}
|
|
1444
1567
|
}
|
|
1445
1568
|
}
|
|
1446
1569
|
this._prunePushedSeqs(ns);
|
|
1447
1570
|
}
|
|
1448
1571
|
}
|
|
1449
|
-
success = true;
|
|
1450
1572
|
}
|
|
1451
1573
|
catch (exc) {
|
|
1452
1574
|
_clientLog('warn', '群消息补洞失败: %s', formatCaughtError(exc));
|
|
1453
1575
|
}
|
|
1454
1576
|
finally {
|
|
1455
|
-
|
|
1456
|
-
// 否则(异常 / 空结果)清除,避免后续相同 after_seq 的补洞被跳过。
|
|
1457
|
-
const contigAfter = this._seqTracker.getContiguousSeq(ns);
|
|
1458
|
-
if (!success || contigAfter <= afterSeq) {
|
|
1459
|
-
this._gapFillDone.delete(dedupKey);
|
|
1460
|
-
}
|
|
1577
|
+
this._gapFillDone.delete(dedupKey);
|
|
1461
1578
|
}
|
|
1462
1579
|
}
|
|
1463
1580
|
/** 后台补齐 P2P 消息空洞 */
|
|
@@ -1466,18 +1583,11 @@ export class AUNClient {
|
|
|
1466
1583
|
return;
|
|
1467
1584
|
const ns = `p2p:${this._aid}`;
|
|
1468
1585
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1469
|
-
// 新设备(seq=0)没有历史 prekey,拉旧消息也解不了
|
|
1470
|
-
if (afterSeq === 0) {
|
|
1471
|
-
return;
|
|
1472
|
-
}
|
|
1473
1586
|
// 去重:同一 (type:after_seq) 只补一次
|
|
1474
1587
|
const dedupKey = `p2p:${afterSeq}`;
|
|
1475
1588
|
if (this._gapFillDone.has(dedupKey))
|
|
1476
1589
|
return;
|
|
1477
1590
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1478
|
-
// S1: 成功路径也要判断是否真正前进;pull 返空时必须清除标记,
|
|
1479
|
-
// 否则下一次相同 afterSeq 触发的补洞会被永久跳过。
|
|
1480
|
-
let success = false;
|
|
1481
1591
|
try {
|
|
1482
1592
|
const result = await this.call('message.pull', {
|
|
1483
1593
|
after_seq: afterSeq,
|
|
@@ -1493,23 +1603,23 @@ export class AUNClient {
|
|
|
1493
1603
|
const s = msg.seq;
|
|
1494
1604
|
if (pushed && s !== undefined && s !== null && pushed.has(s))
|
|
1495
1605
|
continue;
|
|
1496
|
-
|
|
1606
|
+
if (s !== undefined && s !== null) {
|
|
1607
|
+
await this._publishOrderedMessage('message.received', ns, s, msg);
|
|
1608
|
+
}
|
|
1609
|
+
else {
|
|
1610
|
+
await this._publishAppEvent('message.received', msg);
|
|
1611
|
+
}
|
|
1497
1612
|
}
|
|
1498
1613
|
}
|
|
1499
1614
|
this._prunePushedSeqs(ns);
|
|
1500
1615
|
}
|
|
1501
1616
|
}
|
|
1502
|
-
success = true;
|
|
1503
1617
|
}
|
|
1504
1618
|
catch (exc) {
|
|
1505
1619
|
_clientLog('warn', 'P2P 消息补洞失败: %s', formatCaughtError(exc));
|
|
1506
1620
|
}
|
|
1507
1621
|
finally {
|
|
1508
|
-
|
|
1509
|
-
const contigAfter = this._seqTracker.getContiguousSeq(ns);
|
|
1510
|
-
if (!success || contigAfter <= afterSeq) {
|
|
1511
|
-
this._gapFillDone.delete(dedupKey);
|
|
1512
|
-
}
|
|
1622
|
+
this._gapFillDone.delete(dedupKey);
|
|
1513
1623
|
}
|
|
1514
1624
|
}
|
|
1515
1625
|
/** 清理 pushedSeqs 中 <= contiguousSeq 的条目,防止无限增长 */
|
|
@@ -1525,21 +1635,128 @@ export class AUNClient {
|
|
|
1525
1635
|
if (pushed.size === 0)
|
|
1526
1636
|
this._pushedSeqs.delete(ns);
|
|
1527
1637
|
}
|
|
1638
|
+
_markPublishedSeq(ns, seq) {
|
|
1639
|
+
let pushed = this._pushedSeqs.get(ns);
|
|
1640
|
+
if (!pushed) {
|
|
1641
|
+
pushed = new Set();
|
|
1642
|
+
this._pushedSeqs.set(ns, pushed);
|
|
1643
|
+
}
|
|
1644
|
+
pushed.add(seq);
|
|
1645
|
+
if (pushed.size > PUSHED_SEQS_LIMIT) {
|
|
1646
|
+
const keep = [...pushed].sort((a, b) => a - b).slice(-PUSHED_SEQS_LIMIT);
|
|
1647
|
+
this._pushedSeqs.set(ns, new Set(keep));
|
|
1648
|
+
}
|
|
1649
|
+
}
|
|
1650
|
+
_enqueueOrderedMessage(ns, event, seq, payload) {
|
|
1651
|
+
let queue = this._pendingOrderedMsgs.get(ns);
|
|
1652
|
+
if (!queue) {
|
|
1653
|
+
queue = new Map();
|
|
1654
|
+
this._pendingOrderedMsgs.set(ns, queue);
|
|
1655
|
+
}
|
|
1656
|
+
queue.set(seq, { event, payload });
|
|
1657
|
+
if (queue.size > PENDING_ORDERED_LIMIT) {
|
|
1658
|
+
const drop = [...queue.keys()].sort((a, b) => a - b).slice(0, queue.size - PENDING_ORDERED_LIMIT);
|
|
1659
|
+
for (const oldSeq of drop)
|
|
1660
|
+
queue.delete(oldSeq);
|
|
1661
|
+
}
|
|
1662
|
+
}
|
|
1663
|
+
_isInstanceScopedMessageEvent(event) {
|
|
1664
|
+
return event === 'message.received'
|
|
1665
|
+
|| event === 'message.undecryptable'
|
|
1666
|
+
|| event === 'group.message_created'
|
|
1667
|
+
|| event === 'group.message_undecryptable';
|
|
1668
|
+
}
|
|
1669
|
+
_attachCurrentInstanceContext(payload) {
|
|
1670
|
+
if (!isJsonObject(payload))
|
|
1671
|
+
return payload;
|
|
1672
|
+
const result = { ...payload };
|
|
1673
|
+
if (this._deviceId && !String(result.device_id ?? '').trim()) {
|
|
1674
|
+
result.device_id = this._deviceId;
|
|
1675
|
+
}
|
|
1676
|
+
if (this._slotId && !String(result.slot_id ?? '').trim()) {
|
|
1677
|
+
result.slot_id = this._slotId;
|
|
1678
|
+
}
|
|
1679
|
+
return result;
|
|
1680
|
+
}
|
|
1681
|
+
_normalizePublishedMessagePayload(event, payload) {
|
|
1682
|
+
if (!this._isInstanceScopedMessageEvent(event))
|
|
1683
|
+
return payload;
|
|
1684
|
+
return this._attachCurrentInstanceContext(payload);
|
|
1685
|
+
}
|
|
1686
|
+
async _publishAppEvent(event, payload) {
|
|
1687
|
+
await this._dispatcher.publish(event, this._normalizePublishedMessagePayload(event, payload));
|
|
1688
|
+
}
|
|
1689
|
+
_messageTargetsCurrentInstance(message) {
|
|
1690
|
+
if (!isJsonObject(message))
|
|
1691
|
+
return true;
|
|
1692
|
+
const targetDeviceId = String(message.device_id ?? '').trim();
|
|
1693
|
+
if (targetDeviceId && this._deviceId && targetDeviceId !== this._deviceId) {
|
|
1694
|
+
return false;
|
|
1695
|
+
}
|
|
1696
|
+
const targetSlotId = String(message.slot_id ?? '').trim();
|
|
1697
|
+
if (targetSlotId && this._slotId && targetSlotId !== this._slotId) {
|
|
1698
|
+
return false;
|
|
1699
|
+
}
|
|
1700
|
+
return true;
|
|
1701
|
+
}
|
|
1702
|
+
async _drainOrderedMessages(ns, beforeSeq) {
|
|
1703
|
+
const queue = this._pendingOrderedMsgs.get(ns);
|
|
1704
|
+
if (!queue || queue.size === 0)
|
|
1705
|
+
return;
|
|
1706
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1707
|
+
const ready = [...queue.keys()]
|
|
1708
|
+
.filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
|
|
1709
|
+
.sort((a, b) => a - b);
|
|
1710
|
+
for (const seq of ready) {
|
|
1711
|
+
const item = queue.get(seq);
|
|
1712
|
+
queue.delete(seq);
|
|
1713
|
+
if (!item || this._pushedSeqs.get(ns)?.has(seq))
|
|
1714
|
+
continue;
|
|
1715
|
+
await this._publishAppEvent(item.event, item.payload);
|
|
1716
|
+
this._markPublishedSeq(ns, seq);
|
|
1717
|
+
}
|
|
1718
|
+
if (queue.size === 0)
|
|
1719
|
+
this._pendingOrderedMsgs.delete(ns);
|
|
1720
|
+
}
|
|
1721
|
+
async _publishOrderedMessage(event, ns, seq, payload) {
|
|
1722
|
+
const seqNum = Number(seq);
|
|
1723
|
+
if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0) {
|
|
1724
|
+
await this._publishAppEvent(event, payload);
|
|
1725
|
+
return true;
|
|
1726
|
+
}
|
|
1727
|
+
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
1728
|
+
const queue = this._pendingOrderedMsgs.get(ns);
|
|
1729
|
+
queue?.delete(seqNum);
|
|
1730
|
+
if (queue && queue.size === 0)
|
|
1731
|
+
this._pendingOrderedMsgs.delete(ns);
|
|
1732
|
+
return false;
|
|
1733
|
+
}
|
|
1734
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1735
|
+
if (seqNum > contig) {
|
|
1736
|
+
this._enqueueOrderedMessage(ns, event, seqNum, payload);
|
|
1737
|
+
return false;
|
|
1738
|
+
}
|
|
1739
|
+
await this._drainOrderedMessages(ns, seqNum);
|
|
1740
|
+
if (this._pushedSeqs.get(ns)?.has(seqNum))
|
|
1741
|
+
return false;
|
|
1742
|
+
const queue = this._pendingOrderedMsgs.get(ns);
|
|
1743
|
+
queue?.delete(seqNum);
|
|
1744
|
+
if (queue && queue.size === 0)
|
|
1745
|
+
this._pendingOrderedMsgs.delete(ns);
|
|
1746
|
+
await this._publishAppEvent(event, payload);
|
|
1747
|
+
this._markPublishedSeq(ns, seqNum);
|
|
1748
|
+
await this._drainOrderedMessages(ns);
|
|
1749
|
+
return true;
|
|
1750
|
+
}
|
|
1528
1751
|
/** 后台补齐群事件空洞 */
|
|
1529
1752
|
async _fillGroupEventGap(groupId) {
|
|
1530
1753
|
const ns = `group_event:${groupId}`;
|
|
1531
1754
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1532
|
-
// 冷启动(seq=0):服务端推送会带全量事件,SDK 不主动补洞避免重复拉取
|
|
1533
|
-
if (afterSeq === 0) {
|
|
1534
|
-
return;
|
|
1535
|
-
}
|
|
1536
1755
|
// 去重:同一 (group_evt:id:after_seq) 只补一次
|
|
1537
1756
|
const dedupKey = `group_evt:${groupId}:${afterSeq}`;
|
|
1538
1757
|
if (this._gapFillDone.has(dedupKey))
|
|
1539
1758
|
return;
|
|
1540
1759
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1541
|
-
// S1: 成功/失败均按"是否真正前进"决定是否保留标记
|
|
1542
|
-
let success = false;
|
|
1543
1760
|
try {
|
|
1544
1761
|
const result = await this.call('group.pull_events', {
|
|
1545
1762
|
group_id: groupId,
|
|
@@ -1551,14 +1768,24 @@ export class AUNClient {
|
|
|
1551
1768
|
const events = result.events;
|
|
1552
1769
|
if (Array.isArray(events)) {
|
|
1553
1770
|
this._seqTracker.onPullResult(ns, events.filter(isJsonObject));
|
|
1771
|
+
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
1772
|
+
const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
|
|
1773
|
+
if (serverAck > 0) {
|
|
1774
|
+
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1775
|
+
if (contigBefore < serverAck) {
|
|
1776
|
+
_clientLog('info', 'group.pull_events retention-floor 推进: ns=%s contiguous=%d -> cursor.current_seq=%d', ns, contigBefore, serverAck);
|
|
1777
|
+
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1554
1780
|
// 持久化 cursor + ack_events(与 Python 对齐)
|
|
1555
1781
|
this._saveSeqTrackerState();
|
|
1556
1782
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1557
|
-
if (contig > 0) {
|
|
1783
|
+
if (contig > 0 && (events.length > 0 || serverAck > 0)) {
|
|
1558
1784
|
this._transport.call('group.ack_events', {
|
|
1559
1785
|
group_id: groupId,
|
|
1560
1786
|
event_seq: contig,
|
|
1561
1787
|
device_id: this._deviceId,
|
|
1788
|
+
slot_id: this._slotId,
|
|
1562
1789
|
}).catch((e) => { _clientLog('debug', '群事件 auto-ack 失败: group=%s %s', groupId, formatCaughtError(e)); });
|
|
1563
1790
|
}
|
|
1564
1791
|
for (const evt of events) {
|
|
@@ -1574,16 +1801,12 @@ export class AUNClient {
|
|
|
1574
1801
|
}
|
|
1575
1802
|
}
|
|
1576
1803
|
}
|
|
1577
|
-
success = true;
|
|
1578
1804
|
}
|
|
1579
1805
|
catch (exc) {
|
|
1580
1806
|
_clientLog('warn', '群事件补洞失败: %s', formatCaughtError(exc));
|
|
1581
1807
|
}
|
|
1582
1808
|
finally {
|
|
1583
|
-
|
|
1584
|
-
if (!success || contigAfter <= afterSeq) {
|
|
1585
|
-
this._gapFillDone.delete(dedupKey);
|
|
1586
|
-
}
|
|
1809
|
+
this._gapFillDone.delete(dedupKey);
|
|
1587
1810
|
}
|
|
1588
1811
|
}
|
|
1589
1812
|
/**
|
|
@@ -1767,6 +1990,7 @@ export class AUNClient {
|
|
|
1767
1990
|
group_id: groupId,
|
|
1768
1991
|
event_seq: contig,
|
|
1769
1992
|
device_id: this._deviceId,
|
|
1993
|
+
slot_id: this._slotId,
|
|
1770
1994
|
}).catch((e) => { _clientLog('debug', '群事件推送 auto-ack 失败: group=%s %s', groupId, formatCaughtError(e)); });
|
|
1771
1995
|
}
|
|
1772
1996
|
}
|
|
@@ -1941,6 +2165,8 @@ export class AUNClient {
|
|
|
1941
2165
|
// 4. 清理推送 seq 去重缓存
|
|
1942
2166
|
this._pushedSeqs.delete(`group:${groupId}`);
|
|
1943
2167
|
this._pushedSeqs.delete(`group_event:${groupId}`);
|
|
2168
|
+
this._pendingOrderedMsgs.delete(`group:${groupId}`);
|
|
2169
|
+
this._pendingDecryptMsgs.delete(`group:${groupId}`);
|
|
1944
2170
|
_clientLog('info', '已清理解散群组 %s 的本地状态', groupId);
|
|
1945
2171
|
}
|
|
1946
2172
|
/** 同步验签群事件 client_signature。返回 true/false/"pending"。 */
|
|
@@ -1988,11 +2214,20 @@ export class AUNClient {
|
|
|
1988
2214
|
const ok = crypto.verify('SHA256', signData, pubKey, Buffer.from(sigB64, 'base64'));
|
|
1989
2215
|
if (!ok) {
|
|
1990
2216
|
_clientLog('warn', '群事件验签失败 aid=%s method=%s', sigAid, method);
|
|
2217
|
+
// P1-16: 签名失败统一发布事件
|
|
2218
|
+
this._dispatcher.publish('signature.verification_failed', {
|
|
2219
|
+
aid: sigAid, method, error: 'ECDSA verification failed',
|
|
2220
|
+
}).catch(() => { });
|
|
1991
2221
|
}
|
|
1992
2222
|
return ok;
|
|
1993
2223
|
}
|
|
1994
2224
|
catch (exc) {
|
|
1995
2225
|
_clientLog('warn', '群事件验签异常: %s', formatCaughtError(exc));
|
|
2226
|
+
// P1-16: 签名失败统一发布事件
|
|
2227
|
+
this._dispatcher.publish('signature.verification_failed', {
|
|
2228
|
+
aid: String(cs.aid ?? ''), method: String(cs._method ?? ''),
|
|
2229
|
+
error: formatCaughtError(exc),
|
|
2230
|
+
}).catch(() => { });
|
|
1996
2231
|
return false;
|
|
1997
2232
|
}
|
|
1998
2233
|
}
|
|
@@ -2395,7 +2630,7 @@ export class AUNClient {
|
|
|
2395
2630
|
const ns = `group:${groupId}`;
|
|
2396
2631
|
const queue = this._pendingDecryptMsgs.get(ns) ?? [];
|
|
2397
2632
|
queue.push(msg);
|
|
2398
|
-
this._pendingDecryptMsgs.set(ns, queue.slice(-
|
|
2633
|
+
this._pendingDecryptMsgs.set(ns, queue.slice(-PENDING_DECRYPT_LIMIT));
|
|
2399
2634
|
}
|
|
2400
2635
|
async _retryPendingDecryptMsgs(groupId) {
|
|
2401
2636
|
const ns = `group:${groupId}`;
|
|
@@ -2412,14 +2647,20 @@ export class AUNClient {
|
|
|
2412
2647
|
stillPending.push(msg);
|
|
2413
2648
|
continue;
|
|
2414
2649
|
}
|
|
2415
|
-
|
|
2650
|
+
const seq = msg.seq;
|
|
2651
|
+
if (seq !== undefined && seq !== null) {
|
|
2652
|
+
await this._publishOrderedMessage('group.message_created', ns, seq, decrypted);
|
|
2653
|
+
}
|
|
2654
|
+
else {
|
|
2655
|
+
await this._publishAppEvent('group.message_created', decrypted);
|
|
2656
|
+
}
|
|
2416
2657
|
}
|
|
2417
2658
|
catch {
|
|
2418
2659
|
stillPending.push(msg);
|
|
2419
2660
|
}
|
|
2420
2661
|
}
|
|
2421
2662
|
const queuedDuringRetry = this._pendingDecryptMsgs.get(ns) ?? [];
|
|
2422
|
-
const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-
|
|
2663
|
+
const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-PENDING_DECRYPT_LIMIT);
|
|
2423
2664
|
if (mergedPending.length)
|
|
2424
2665
|
this._pendingDecryptMsgs.set(ns, mergedPending);
|
|
2425
2666
|
else
|
|
@@ -2509,10 +2750,10 @@ export class AUNClient {
|
|
|
2509
2750
|
async _decryptGroupMessage(message, opts) {
|
|
2510
2751
|
const payload = message.payload;
|
|
2511
2752
|
if (!isJsonObject(payload))
|
|
2512
|
-
return message;
|
|
2753
|
+
return this._attachGroupDispatchModeToPayload(message);
|
|
2513
2754
|
const payloadObj = payload;
|
|
2514
2755
|
if (payloadObj.type !== 'e2ee.group_encrypted')
|
|
2515
|
-
return message;
|
|
2756
|
+
return this._attachGroupDispatchModeToPayload(message);
|
|
2516
2757
|
// 确保发送方证书已缓存(签名验证需要)
|
|
2517
2758
|
const senderAid = String(message.from ?? message.sender_aid ?? '');
|
|
2518
2759
|
if (senderAid) {
|
|
@@ -2525,7 +2766,7 @@ export class AUNClient {
|
|
|
2525
2766
|
// 先尝试直接解密
|
|
2526
2767
|
const result = this._groupE2ee.decrypt(message, opts);
|
|
2527
2768
|
if (result !== null && result.e2ee) {
|
|
2528
|
-
return result;
|
|
2769
|
+
return this._attachGroupDispatchModeToPayload(result);
|
|
2529
2770
|
}
|
|
2530
2771
|
// replay guard 命中:decrypt 返回了原消息(非 null)但无 e2ee 字段
|
|
2531
2772
|
// 不是解密失败,不应触发 recover
|
|
@@ -2541,7 +2782,7 @@ export class AUNClient {
|
|
|
2541
2782
|
if (await this._recoverGroupEpochKey(groupId, epoch, sender, 5000)) {
|
|
2542
2783
|
const retry = await this._groupE2ee.decrypt(message, opts);
|
|
2543
2784
|
if (retry !== null && retry.e2ee)
|
|
2544
|
-
return retry;
|
|
2785
|
+
return this._attachGroupDispatchModeToPayload(retry);
|
|
2545
2786
|
}
|
|
2546
2787
|
}
|
|
2547
2788
|
catch (exc) {
|
|
@@ -2550,6 +2791,21 @@ export class AUNClient {
|
|
|
2550
2791
|
}
|
|
2551
2792
|
return message;
|
|
2552
2793
|
}
|
|
2794
|
+
_attachGroupDispatchModeToPayload(message) {
|
|
2795
|
+
const payload = message.payload;
|
|
2796
|
+
if (!isJsonObject(payload))
|
|
2797
|
+
return message;
|
|
2798
|
+
const rawMode = String(message.dispatch_mode ?? 'broadcast').trim().toLowerCase();
|
|
2799
|
+
const mode = rawMode === 'mention' || rawMode === 'broadcast' ? rawMode : 'broadcast';
|
|
2800
|
+
return {
|
|
2801
|
+
...message,
|
|
2802
|
+
dispatch_mode: mode,
|
|
2803
|
+
payload: {
|
|
2804
|
+
...payload,
|
|
2805
|
+
dispatch_mode: mode,
|
|
2806
|
+
},
|
|
2807
|
+
};
|
|
2808
|
+
}
|
|
2553
2809
|
/** 批量解密群组消息(用于 group.pull,跳过防重放) */
|
|
2554
2810
|
async _decryptGroupMessages(messages) {
|
|
2555
2811
|
const result = [];
|
|
@@ -2561,10 +2817,104 @@ export class AUNClient {
|
|
|
2561
2817
|
this._enqueuePendingDecrypt(groupId, msg);
|
|
2562
2818
|
continue; // R3: 解密失败不入 result,不 publish 密文给应用层
|
|
2563
2819
|
}
|
|
2820
|
+
if (payload?.type !== 'e2ee.group_encrypted') {
|
|
2821
|
+
result.push(this._attachGroupDispatchModeToPayload(decrypted));
|
|
2822
|
+
continue;
|
|
2823
|
+
}
|
|
2564
2824
|
result.push(decrypted);
|
|
2565
2825
|
}
|
|
2566
2826
|
return result;
|
|
2567
2827
|
}
|
|
2828
|
+
async _decryptGroupThoughts(result) {
|
|
2829
|
+
if (!result.found) {
|
|
2830
|
+
return { ...result, thoughts: [] };
|
|
2831
|
+
}
|
|
2832
|
+
const items = Array.isArray(result.thoughts) ? result.thoughts.filter(isJsonObject) : [];
|
|
2833
|
+
if (items.length === 0) {
|
|
2834
|
+
return { ...result, thoughts: [] };
|
|
2835
|
+
}
|
|
2836
|
+
const groupId = String(result.group_id ?? '');
|
|
2837
|
+
const senderAid = String(result.sender_aid ?? '');
|
|
2838
|
+
const thoughts = [];
|
|
2839
|
+
for (const item of items) {
|
|
2840
|
+
const payload = isJsonObject(item.payload) ? item.payload : null;
|
|
2841
|
+
const thoughtId = String(item.thought_id ?? item.message_id ?? '');
|
|
2842
|
+
const message = {
|
|
2843
|
+
group_id: groupId,
|
|
2844
|
+
sender_aid: senderAid,
|
|
2845
|
+
from: senderAid,
|
|
2846
|
+
message_id: thoughtId,
|
|
2847
|
+
payload: payload ?? {},
|
|
2848
|
+
created_at: Number(item.created_at ?? 0),
|
|
2849
|
+
};
|
|
2850
|
+
const decrypted = await this._decryptGroupMessage(message, { skipReplay: true });
|
|
2851
|
+
if (payload?.type === 'e2ee.group_encrypted' && groupId && !decrypted.e2ee) {
|
|
2852
|
+
this._enqueuePendingDecrypt(groupId, message);
|
|
2853
|
+
continue;
|
|
2854
|
+
}
|
|
2855
|
+
thoughts.push({
|
|
2856
|
+
thought_id: thoughtId,
|
|
2857
|
+
message_id: thoughtId,
|
|
2858
|
+
reply_to: item.reply_to,
|
|
2859
|
+
payload: decrypted.payload,
|
|
2860
|
+
created_at: item.created_at,
|
|
2861
|
+
e2ee: decrypted.e2ee,
|
|
2862
|
+
});
|
|
2863
|
+
}
|
|
2864
|
+
return { ...result, thoughts };
|
|
2865
|
+
}
|
|
2866
|
+
async _decryptMessageThoughts(result) {
|
|
2867
|
+
if (!result.found) {
|
|
2868
|
+
return { ...result, thoughts: [] };
|
|
2869
|
+
}
|
|
2870
|
+
const items = Array.isArray(result.thoughts) ? result.thoughts.filter(isJsonObject) : [];
|
|
2871
|
+
if (items.length === 0) {
|
|
2872
|
+
return { ...result, thoughts: [] };
|
|
2873
|
+
}
|
|
2874
|
+
const senderAid = String(result.sender_aid ?? '');
|
|
2875
|
+
const peerAid = String(result.peer_aid ?? '');
|
|
2876
|
+
const thoughts = [];
|
|
2877
|
+
for (const item of items) {
|
|
2878
|
+
const payload = isJsonObject(item.payload) ? item.payload : null;
|
|
2879
|
+
const thoughtId = String(item.thought_id ?? item.message_id ?? '');
|
|
2880
|
+
const fromAid = String(item.from ?? senderAid);
|
|
2881
|
+
const toAid = String(item.to ?? peerAid);
|
|
2882
|
+
const message = {
|
|
2883
|
+
from: fromAid,
|
|
2884
|
+
to: toAid,
|
|
2885
|
+
message_id: thoughtId,
|
|
2886
|
+
payload: payload ?? {},
|
|
2887
|
+
encrypted: item.encrypted !== false,
|
|
2888
|
+
timestamp: Number(item.created_at ?? 0),
|
|
2889
|
+
};
|
|
2890
|
+
let decrypted = message;
|
|
2891
|
+
if (payload?.type === 'e2ee.encrypted') {
|
|
2892
|
+
const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
|
|
2893
|
+
if (fromAid) {
|
|
2894
|
+
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2895
|
+
if (!certReady) {
|
|
2896
|
+
_clientLog('warn', '无法获取发送方 %s 的证书,跳过 message.thought.get 解密', fromAid);
|
|
2897
|
+
continue;
|
|
2898
|
+
}
|
|
2899
|
+
}
|
|
2900
|
+
decrypted = this._e2ee._decryptMessage(message);
|
|
2901
|
+
if (decrypted === null || (isJsonObject(decrypted.payload) && decrypted.payload.type === 'e2ee.encrypted')) {
|
|
2902
|
+
continue;
|
|
2903
|
+
}
|
|
2904
|
+
}
|
|
2905
|
+
thoughts.push({
|
|
2906
|
+
thought_id: thoughtId,
|
|
2907
|
+
message_id: thoughtId,
|
|
2908
|
+
reply_to: item.reply_to,
|
|
2909
|
+
from: fromAid,
|
|
2910
|
+
to: toAid,
|
|
2911
|
+
payload: decrypted.payload,
|
|
2912
|
+
created_at: item.created_at,
|
|
2913
|
+
e2ee: decrypted.e2ee,
|
|
2914
|
+
});
|
|
2915
|
+
}
|
|
2916
|
+
return { ...result, thoughts };
|
|
2917
|
+
}
|
|
2568
2918
|
// ── Group E2EE 编排 ───────────────────────────────────────
|
|
2569
2919
|
_attachRotationId(info, rotationId) {
|
|
2570
2920
|
if (!rotationId || !Array.isArray(info.distributions))
|
|
@@ -3200,6 +3550,8 @@ export class AUNClient {
|
|
|
3200
3550
|
this._seqTrackerContext = null;
|
|
3201
3551
|
this._gapFillDone.clear();
|
|
3202
3552
|
this._pushedSeqs.clear();
|
|
3553
|
+
this._pendingOrderedMsgs.clear();
|
|
3554
|
+
this._pendingDecryptMsgs.clear();
|
|
3203
3555
|
this._groupSynced.clear();
|
|
3204
3556
|
this._p2pSynced = false;
|
|
3205
3557
|
}
|
|
@@ -3210,6 +3562,8 @@ export class AUNClient {
|
|
|
3210
3562
|
this._seqTracker = new SeqTracker();
|
|
3211
3563
|
this._gapFillDone.clear();
|
|
3212
3564
|
this._pushedSeqs.clear();
|
|
3565
|
+
this._pendingOrderedMsgs.clear();
|
|
3566
|
+
this._pendingDecryptMsgs.clear();
|
|
3213
3567
|
this._groupSynced.clear();
|
|
3214
3568
|
this._p2pSynced = false;
|
|
3215
3569
|
this._seqTrackerContext = nextContext;
|
|
@@ -3641,6 +3995,26 @@ export class AUNClient {
|
|
|
3641
3995
|
throw new ValidationError('group.send does not accept delivery_mode; group messages are always fanout');
|
|
3642
3996
|
}
|
|
3643
3997
|
}
|
|
3998
|
+
if (method === 'group.thought.put' || method === 'group.thought.get'
|
|
3999
|
+
|| method === 'message.thought.put' || method === 'message.thought.get') {
|
|
4000
|
+
const replyTo = isJsonObject(params.reply_to) ? params.reply_to : null;
|
|
4001
|
+
const replyMsgId = String(replyTo?.message_id ?? '').trim();
|
|
4002
|
+
if (!replyMsgId) {
|
|
4003
|
+
throw new ValidationError(`${method} requires reply_to.message_id`);
|
|
4004
|
+
}
|
|
4005
|
+
}
|
|
4006
|
+
if (method === 'group.thought.get' && !String(params.sender_aid ?? '').trim()) {
|
|
4007
|
+
throw new ValidationError('group.thought.get requires sender_aid');
|
|
4008
|
+
}
|
|
4009
|
+
if (method === 'message.thought.put') {
|
|
4010
|
+
this._validateMessageRecipient(params.to);
|
|
4011
|
+
if (!String(params.to ?? '').trim()) {
|
|
4012
|
+
throw new ValidationError('message.thought.put requires to');
|
|
4013
|
+
}
|
|
4014
|
+
}
|
|
4015
|
+
if (method === 'message.thought.get' && !String(params.sender_aid ?? '').trim()) {
|
|
4016
|
+
throw new ValidationError('message.thought.get requires sender_aid');
|
|
4017
|
+
}
|
|
3644
4018
|
}
|
|
3645
4019
|
_currentMessageDeliveryMode() {
|
|
3646
4020
|
return { ...this._connectDeliveryMode };
|