@agentunion/fastaun 0.2.13 → 0.2.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/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 +25 -1
- package/dist/client.js +600 -124
- package/dist/client.js.map +1 -1
- package/dist/e2ee-group.d.ts +23 -2
- package/dist/e2ee-group.js +194 -23
- package/dist/e2ee-group.js.map +1 -1
- package/dist/e2ee.d.ts +20 -1
- package/dist/e2ee.js +258 -35
- package/dist/e2ee.js.map +1 -1
- package/dist/index.d.ts +3 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/keystore/file.d.ts +10 -0
- package/dist/keystore/file.js +73 -0
- package/dist/keystore/file.js.map +1 -1
- package/dist/keystore/index.d.ts +12 -0
- package/dist/namespaces/auth.d.ts +19 -0
- package/dist/namespaces/auth.js +185 -0
- package/dist/namespaces/auth.js.map +1 -1
- package/dist/namespaces/meta.d.ts +75 -0
- package/dist/namespaces/meta.js +464 -0
- package/dist/namespaces/meta.js.map +1 -0
- 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
|
@@ -27,6 +27,7 @@ import { AUNLogger } from './logger.js';
|
|
|
27
27
|
import { SQLiteBackup } from './keystore/sqlite-backup.js';
|
|
28
28
|
import { AuthNamespace } from './namespaces/auth.js';
|
|
29
29
|
import { CustodyNamespace } from './namespaces/custody.js';
|
|
30
|
+
import { MetaNamespace } from './namespaces/meta.js';
|
|
30
31
|
import { RPCTransport } from './transport.js';
|
|
31
32
|
import { AuthFlow } from './auth.js';
|
|
32
33
|
import { SeqTracker } from './seq-tracker.js';
|
|
@@ -99,6 +100,21 @@ const RECONNECT_MIN_BASE_DELAY_MS = 1_000;
|
|
|
99
100
|
const RECONNECT_MAX_BASE_DELAY_MS = 64_000;
|
|
100
101
|
const GROUP_ROTATION_LEASE_MS = 120_000;
|
|
101
102
|
const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
|
|
103
|
+
const PENDING_DECRYPT_LIMIT = 100;
|
|
104
|
+
const PUSHED_SEQS_LIMIT = 50_000;
|
|
105
|
+
const PENDING_ORDERED_LIMIT = 50_000;
|
|
106
|
+
// P1-23: 非幂等方法使用更长超时(35s),避免 SDK 10s 超时 < gateway 30s 处理时间
|
|
107
|
+
const NON_IDEMPOTENT_TIMEOUT_MS = 35_000;
|
|
108
|
+
const NON_IDEMPOTENT_METHODS = new Set([
|
|
109
|
+
'message.send', 'group.send', 'group.create', 'group.invite',
|
|
110
|
+
'group.kick', 'group.remove_member', 'group.leave', 'group.dissolve',
|
|
111
|
+
'group.update_name', 'group.update_avatar', 'group.update_announcement',
|
|
112
|
+
'group.update_settings', 'group.rotate_epoch',
|
|
113
|
+
'storage.upload', 'storage.complete_upload', 'storage.delete',
|
|
114
|
+
'auth.create_aid', 'auth.renew_cert', 'auth.rekey',
|
|
115
|
+
'message.thought.put', 'group.thought.put',
|
|
116
|
+
'group.add_member',
|
|
117
|
+
]);
|
|
102
118
|
function clampReconnectDelayMs(value, fallback, upper = RECONNECT_MAX_BASE_DELAY_MS) {
|
|
103
119
|
const parsed = Number(value);
|
|
104
120
|
const ms = Number.isFinite(parsed) ? parsed : fallback;
|
|
@@ -116,6 +132,9 @@ const SIGNED_METHODS = new Set([
|
|
|
116
132
|
'group.transfer_owner', 'group.review_join_request',
|
|
117
133
|
'group.batch_review_join_request',
|
|
118
134
|
'group.request_join', 'group.use_invite_code',
|
|
135
|
+
'group.thought.put',
|
|
136
|
+
'message.thought.put',
|
|
137
|
+
'group.set_settings',
|
|
119
138
|
'group.resources.put', 'group.resources.update',
|
|
120
139
|
'group.resources.delete', 'group.resources.request_add',
|
|
121
140
|
'group.resources.direct_add', 'group.resources.approve_request',
|
|
@@ -123,6 +142,7 @@ const SIGNED_METHODS = new Set([
|
|
|
123
142
|
]);
|
|
124
143
|
/** peer 证书缓存 TTL(10 分钟) */
|
|
125
144
|
const PEER_CERT_CACHE_TTL = 600;
|
|
145
|
+
const PREKEY_FALLBACK_DEVICE_ID = 'aun_device_id';
|
|
126
146
|
function isGroupServiceAid(value) {
|
|
127
147
|
const text = String(value ?? '').trim();
|
|
128
148
|
if (!text.includes('.'))
|
|
@@ -147,6 +167,52 @@ function isPeerPrekeyResponse(value) {
|
|
|
147
167
|
return false;
|
|
148
168
|
return candidate.prekey === undefined || isPeerPrekeyMaterial(candidate.prekey);
|
|
149
169
|
}
|
|
170
|
+
function normalizePeerPrekeys(prekeys) {
|
|
171
|
+
const normalized = [];
|
|
172
|
+
for (const item of prekeys) {
|
|
173
|
+
if (!isPeerPrekeyMaterial(item))
|
|
174
|
+
continue;
|
|
175
|
+
const prekeyId = item.prekey_id.trim();
|
|
176
|
+
const publicKey = item.public_key.trim();
|
|
177
|
+
const signature = item.signature.trim();
|
|
178
|
+
if (!prekeyId || !publicKey || !signature)
|
|
179
|
+
continue;
|
|
180
|
+
const deviceId = String(item.device_id ?? '').trim();
|
|
181
|
+
const certFingerprint = String(item.cert_fingerprint ?? '').trim().toLowerCase();
|
|
182
|
+
const candidate = {
|
|
183
|
+
...item,
|
|
184
|
+
prekey_id: prekeyId,
|
|
185
|
+
public_key: publicKey,
|
|
186
|
+
signature,
|
|
187
|
+
device_id: deviceId,
|
|
188
|
+
};
|
|
189
|
+
if (certFingerprint) {
|
|
190
|
+
candidate.cert_fingerprint = certFingerprint;
|
|
191
|
+
}
|
|
192
|
+
else {
|
|
193
|
+
delete candidate.cert_fingerprint;
|
|
194
|
+
}
|
|
195
|
+
normalized.push(candidate);
|
|
196
|
+
}
|
|
197
|
+
if (normalized.length === 0)
|
|
198
|
+
return [];
|
|
199
|
+
if (normalized.length === 1) {
|
|
200
|
+
if (!String(normalized[0].device_id ?? '').trim()) {
|
|
201
|
+
normalized[0].device_id = PREKEY_FALLBACK_DEVICE_ID;
|
|
202
|
+
}
|
|
203
|
+
return normalized;
|
|
204
|
+
}
|
|
205
|
+
const seen = new Set();
|
|
206
|
+
const filtered = [];
|
|
207
|
+
for (const item of normalized) {
|
|
208
|
+
const deviceId = String(item.device_id ?? '').trim();
|
|
209
|
+
if (!deviceId || deviceId === PREKEY_FALLBACK_DEVICE_ID || seen.has(deviceId))
|
|
210
|
+
continue;
|
|
211
|
+
seen.add(deviceId);
|
|
212
|
+
filtered.push(item);
|
|
213
|
+
}
|
|
214
|
+
return filtered;
|
|
215
|
+
}
|
|
150
216
|
function formatCaughtError(error) {
|
|
151
217
|
return error instanceof Error ? error : String(error);
|
|
152
218
|
}
|
|
@@ -253,6 +319,8 @@ export class AUNClient {
|
|
|
253
319
|
auth;
|
|
254
320
|
/** AID 托管命名空间 */
|
|
255
321
|
custody;
|
|
322
|
+
/** Meta 命名空间(心跳、状态、信任根管理) */
|
|
323
|
+
meta;
|
|
256
324
|
/** 会话参数(重连用) */
|
|
257
325
|
_sessionParams = null;
|
|
258
326
|
/** 会话选项 */
|
|
@@ -276,8 +344,10 @@ export class AUNClient {
|
|
|
276
344
|
_p2pSynced = false;
|
|
277
345
|
/** 补洞去重:已完成/进行中的 key -> 开始时间戳,防止重复 pull 同一区间 */
|
|
278
346
|
_gapFillDone = new Map();
|
|
279
|
-
/**
|
|
347
|
+
/** 已发布到应用层的 seq 集合(按命名空间),补洞路径 publish 前检查以避免重复分发 */
|
|
280
348
|
_pushedSeqs = new Map();
|
|
349
|
+
/** 已解密但因 seq 空洞暂缓发布的应用层消息(按 namespace -> seq) */
|
|
350
|
+
_pendingOrderedMsgs = new Map();
|
|
281
351
|
_pendingDecryptMsgs = new Map();
|
|
282
352
|
_groupEpochRotationInflight = new Set();
|
|
283
353
|
_groupEpochRecoveryInflight = new Map();
|
|
@@ -348,6 +418,7 @@ export class AUNClient {
|
|
|
348
418
|
});
|
|
349
419
|
this.auth = new AuthNamespace(this);
|
|
350
420
|
this.custody = new CustodyNamespace(this);
|
|
421
|
+
this.meta = new MetaNamespace(this);
|
|
351
422
|
// 内部订阅:推送消息自动解密后 re-publish 给用户
|
|
352
423
|
this._dispatcher.subscribe('_raw.message.received', (data) => this._onRawMessageReceived(data));
|
|
353
424
|
// 群组消息推送:自动解密后 re-publish
|
|
@@ -355,7 +426,7 @@ export class AUNClient {
|
|
|
355
426
|
// 群组变更事件:拦截处理成员变更触发的 epoch 轮换,然后透传
|
|
356
427
|
this._dispatcher.subscribe('_raw.group.changed', (data) => this._onRawGroupChanged(data));
|
|
357
428
|
// 其他事件直接透传
|
|
358
|
-
for (const evt of ['message.recalled', 'message.ack']) {
|
|
429
|
+
for (const evt of ['message.recalled', 'message.ack', 'storage.object_changed']) {
|
|
359
430
|
this._dispatcher.subscribe(`_raw.${evt}`, (data) => this._dispatcher.publish(evt, data));
|
|
360
431
|
}
|
|
361
432
|
// 服务端主动断开通知:记录日志并标记不重连
|
|
@@ -498,6 +569,8 @@ export class AUNClient {
|
|
|
498
569
|
if (encrypt) {
|
|
499
570
|
return await this._sendEncrypted(p);
|
|
500
571
|
}
|
|
572
|
+
delete p.protected_headers;
|
|
573
|
+
delete p.headers;
|
|
501
574
|
}
|
|
502
575
|
// 自动加密:group.send 默认加密(encrypt 默认 True)
|
|
503
576
|
if (method === 'group.send') {
|
|
@@ -506,12 +579,34 @@ export class AUNClient {
|
|
|
506
579
|
if (encrypt) {
|
|
507
580
|
return await this._sendGroupEncrypted(p);
|
|
508
581
|
}
|
|
582
|
+
delete p.protected_headers;
|
|
583
|
+
delete p.headers;
|
|
584
|
+
}
|
|
585
|
+
if (method === 'group.thought.put') {
|
|
586
|
+
const encrypt = p.encrypt ?? true;
|
|
587
|
+
delete p.encrypt;
|
|
588
|
+
if (!encrypt) {
|
|
589
|
+
throw new ValidationError('group.thought.put requires encrypt=true');
|
|
590
|
+
}
|
|
591
|
+
return await this._putGroupThoughtEncrypted(p);
|
|
592
|
+
}
|
|
593
|
+
if (method === 'message.thought.put') {
|
|
594
|
+
const encrypt = p.encrypt ?? true;
|
|
595
|
+
delete p.encrypt;
|
|
596
|
+
if (!encrypt) {
|
|
597
|
+
throw new ValidationError('message.thought.put requires encrypt=true');
|
|
598
|
+
}
|
|
599
|
+
return await this._putMessageThoughtEncrypted(p);
|
|
509
600
|
}
|
|
510
601
|
// 关键操作自动附加客户端签名
|
|
511
602
|
if (SIGNED_METHODS.has(method)) {
|
|
512
603
|
this._signClientOperation(method, p);
|
|
513
604
|
}
|
|
514
|
-
|
|
605
|
+
// P1-23: 非幂等方法使用更长超时
|
|
606
|
+
const callTimeout = NON_IDEMPOTENT_METHODS.has(method) ? NON_IDEMPOTENT_TIMEOUT_MS : undefined;
|
|
607
|
+
let result = callTimeout
|
|
608
|
+
? await this._transport.call(method, p, callTimeout)
|
|
609
|
+
: await this._transport.call(method, p);
|
|
515
610
|
// 自动解密:message.pull 返回的消息
|
|
516
611
|
if (method === 'message.pull' && isJsonObject(result)) {
|
|
517
612
|
const r = result;
|
|
@@ -544,6 +639,7 @@ export class AUNClient {
|
|
|
544
639
|
this._transport.call('message.ack', {
|
|
545
640
|
seq: contig,
|
|
546
641
|
device_id: this._deviceId,
|
|
642
|
+
slot_id: this._slotId,
|
|
547
643
|
}).catch((e) => { _clientLog('debug', 'message.pull auto-ack 失败: %s', formatCaughtError(e)); });
|
|
548
644
|
}
|
|
549
645
|
}
|
|
@@ -590,6 +686,12 @@ export class AUNClient {
|
|
|
590
686
|
}
|
|
591
687
|
}
|
|
592
688
|
}
|
|
689
|
+
if (method === 'group.thought.get' && isJsonObject(result)) {
|
|
690
|
+
result = await this._decryptGroupThoughts(result);
|
|
691
|
+
}
|
|
692
|
+
if (method === 'message.thought.get' && isJsonObject(result)) {
|
|
693
|
+
result = await this._decryptMessageThoughts(result);
|
|
694
|
+
}
|
|
593
695
|
// ── Group E2EE 自动编排(必备能力,始终启用)────────
|
|
594
696
|
{
|
|
595
697
|
// 建群后自动创建 epoch(幂等:已有 secret 时跳过)
|
|
@@ -620,7 +722,10 @@ export class AUNClient {
|
|
|
620
722
|
const groupId = this._extractGroupIdFromResult(result) || String(p.group_id ?? '');
|
|
621
723
|
if (groupId && this._membershipRotationChanged(method, result)) {
|
|
622
724
|
const expectedEpoch = this._membershipRotationExpectedEpoch(result);
|
|
623
|
-
|
|
725
|
+
// P0-12: await rotation 完成(带超时兜底),确保后续 group.send 使用新 epoch
|
|
726
|
+
const rotationPromise = this._maybeLeadRotateGroupEpoch(groupId, this._membershipRotationTriggerId(groupId, result), expectedEpoch);
|
|
727
|
+
const timeoutPromise = new Promise((resolve) => globalThis.setTimeout(resolve, 5000));
|
|
728
|
+
await Promise.race([rotationPromise, timeoutPromise]).catch((exc) => _clientLog('warn', 'membership RPC epoch rotation fallback failed: %s', formatCaughtError(exc)));
|
|
624
729
|
}
|
|
625
730
|
}
|
|
626
731
|
return result;
|
|
@@ -643,7 +748,22 @@ export class AUNClient {
|
|
|
643
748
|
on(event, handler) {
|
|
644
749
|
return this._dispatcher.subscribe(event, handler);
|
|
645
750
|
}
|
|
751
|
+
/** P2-13: 取消订阅事件(对齐 Python/JS off 方法) */
|
|
752
|
+
off(event, handler) {
|
|
753
|
+
this._dispatcher.unsubscribe(event, handler);
|
|
754
|
+
}
|
|
646
755
|
// ── E2EE 加密发送 ────────────────────────────────────────
|
|
756
|
+
_protectedHeadersFromParams(params) {
|
|
757
|
+
const value = params.protected_headers ?? params.headers;
|
|
758
|
+
if (value == null)
|
|
759
|
+
return null;
|
|
760
|
+
if (isJsonObject(value))
|
|
761
|
+
return value;
|
|
762
|
+
if (typeof value === 'object' && typeof value.toObject === 'function') {
|
|
763
|
+
return value;
|
|
764
|
+
}
|
|
765
|
+
return null;
|
|
766
|
+
}
|
|
647
767
|
/** 自动加密并发送 P2P 消息 */
|
|
648
768
|
async _sendEncrypted(params) {
|
|
649
769
|
const toAid = String(params.to ?? '');
|
|
@@ -655,6 +775,7 @@ export class AUNClient {
|
|
|
655
775
|
throw new ValidationError('message.send payload must be an object when encrypt=true');
|
|
656
776
|
}
|
|
657
777
|
const persistRequired = Boolean(params.persist_required || params.durable);
|
|
778
|
+
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
658
779
|
// 惰性同步:首次发送 P2P 消息时先 pull 一次
|
|
659
780
|
if (!this._p2pSynced) {
|
|
660
781
|
await this._lazySyncP2p();
|
|
@@ -665,6 +786,7 @@ export class AUNClient {
|
|
|
665
786
|
payload,
|
|
666
787
|
messageId,
|
|
667
788
|
timestamp,
|
|
789
|
+
protectedHeaders,
|
|
668
790
|
});
|
|
669
791
|
if (recipientPrekeys.length <= 1 && selfSyncCopies.length === 0) {
|
|
670
792
|
return await this._sendEncryptedSingle({
|
|
@@ -674,6 +796,7 @@ export class AUNClient {
|
|
|
674
796
|
timestamp,
|
|
675
797
|
prekey: recipientPrekeys[0],
|
|
676
798
|
persistRequired,
|
|
799
|
+
protectedHeaders,
|
|
677
800
|
});
|
|
678
801
|
}
|
|
679
802
|
const recipientCopies = await this._buildRecipientDeviceCopies({
|
|
@@ -682,6 +805,7 @@ export class AUNClient {
|
|
|
682
805
|
messageId,
|
|
683
806
|
timestamp,
|
|
684
807
|
prekeys: recipientPrekeys,
|
|
808
|
+
protectedHeaders,
|
|
685
809
|
});
|
|
686
810
|
const sendParams = {
|
|
687
811
|
to: toAid,
|
|
@@ -715,6 +839,7 @@ export class AUNClient {
|
|
|
715
839
|
prekey,
|
|
716
840
|
messageId: opts.messageId,
|
|
717
841
|
timestamp: opts.timestamp,
|
|
842
|
+
protectedHeaders: opts.protectedHeaders,
|
|
718
843
|
});
|
|
719
844
|
this._ensureEncryptResult(opts.toAid, encryptResult);
|
|
720
845
|
const sendParams = {
|
|
@@ -733,7 +858,7 @@ export class AUNClient {
|
|
|
733
858
|
async _buildRecipientDeviceCopies(opts) {
|
|
734
859
|
const recipientCopies = [];
|
|
735
860
|
const certCache = new Map();
|
|
736
|
-
for (const prekey of opts.prekeys) {
|
|
861
|
+
for (const prekey of normalizePeerPrekeys(opts.prekeys)) {
|
|
737
862
|
const deviceId = String(prekey.device_id ?? '').trim();
|
|
738
863
|
const peerCertFingerprint = String(prekey.cert_fingerprint ?? '').trim().toLowerCase();
|
|
739
864
|
const cacheKey = peerCertFingerprint || '__default__';
|
|
@@ -749,6 +874,7 @@ export class AUNClient {
|
|
|
749
874
|
prekey,
|
|
750
875
|
messageId: opts.messageId,
|
|
751
876
|
timestamp: opts.timestamp,
|
|
877
|
+
protectedHeaders: opts.protectedHeaders,
|
|
752
878
|
});
|
|
753
879
|
this._ensureEncryptResult(opts.toAid, encryptResult);
|
|
754
880
|
recipientCopies.push({
|
|
@@ -791,7 +917,7 @@ export class AUNClient {
|
|
|
791
917
|
if (!myAid) {
|
|
792
918
|
return [];
|
|
793
919
|
}
|
|
794
|
-
const prekeys = await this._fetchPeerPrekeys(myAid);
|
|
920
|
+
const prekeys = normalizePeerPrekeys(await this._fetchPeerPrekeys(myAid));
|
|
795
921
|
if (prekeys.length === 0) {
|
|
796
922
|
return [];
|
|
797
923
|
}
|
|
@@ -809,6 +935,7 @@ export class AUNClient {
|
|
|
809
935
|
prekey,
|
|
810
936
|
messageId: opts.messageId,
|
|
811
937
|
timestamp: opts.timestamp,
|
|
938
|
+
protectedHeaders: opts.protectedHeaders,
|
|
812
939
|
});
|
|
813
940
|
this._ensureEncryptResult(myAid, encryptResult);
|
|
814
941
|
copies.push({
|
|
@@ -819,7 +946,7 @@ export class AUNClient {
|
|
|
819
946
|
return copies;
|
|
820
947
|
}
|
|
821
948
|
_encryptCopyPayload(opts) {
|
|
822
|
-
const [envelope, encryptResult] = this._e2ee.encryptOutbound(opts.logicalToAid, opts.payload, opts.peerCertPem, opts.prekey ?? null, opts.messageId, opts.timestamp);
|
|
949
|
+
const [envelope, encryptResult] = this._e2ee.encryptOutbound(opts.logicalToAid, opts.payload, opts.peerCertPem, opts.prekey ?? null, opts.messageId, opts.timestamp, opts.protectedHeaders, opts.context ?? null);
|
|
823
950
|
return [envelope, encryptResult];
|
|
824
951
|
}
|
|
825
952
|
_ensureEncryptResult(toAid, encryptResult) {
|
|
@@ -842,49 +969,131 @@ export class AUNClient {
|
|
|
842
969
|
}
|
|
843
970
|
/** 自动加密并发送群组消息 */
|
|
844
971
|
async _sendGroupEncrypted(params) {
|
|
845
|
-
|
|
972
|
+
return await this._callGroupEncryptedRpc('group.send', params, {
|
|
973
|
+
idField: 'message_id',
|
|
974
|
+
idPrefix: 'gm',
|
|
975
|
+
});
|
|
976
|
+
}
|
|
977
|
+
async _putGroupThoughtEncrypted(params) {
|
|
978
|
+
return await this._callGroupEncryptedRpc('group.thought.put', params, {
|
|
979
|
+
idField: 'thought_id',
|
|
980
|
+
idPrefix: 'gt',
|
|
981
|
+
extraFields: ['context'],
|
|
982
|
+
});
|
|
983
|
+
}
|
|
984
|
+
async _putMessageThoughtEncrypted(params) {
|
|
985
|
+
const toAid = String(params.to ?? '').trim();
|
|
986
|
+
this._validateMessageRecipient(toAid);
|
|
846
987
|
const payload = isJsonObject(params.payload) ? params.payload : null;
|
|
847
|
-
if (!
|
|
848
|
-
throw new ValidationError('
|
|
988
|
+
if (!toAid) {
|
|
989
|
+
throw new ValidationError('message.thought.put requires to');
|
|
849
990
|
}
|
|
850
991
|
if (payload === null) {
|
|
851
|
-
throw new ValidationError('
|
|
852
|
-
}
|
|
853
|
-
// 惰性同步:首次发送该群消息时先 pull 一次
|
|
854
|
-
if (!this._groupSynced.has(groupId)) {
|
|
855
|
-
await this._lazySyncGroup(groupId);
|
|
992
|
+
throw new ValidationError('message.thought.put payload must be an object when encrypt=true');
|
|
856
993
|
}
|
|
857
|
-
|
|
858
|
-
|
|
994
|
+
const thoughtId = String(params.thought_id ?? '') || `mt-${crypto.randomUUID()}`;
|
|
995
|
+
const timestamp = Number(params.timestamp ?? Date.now());
|
|
996
|
+
const prekey = await this._fetchPeerPrekey(toAid);
|
|
997
|
+
const peerCertFingerprint = typeof prekey?.cert_fingerprint === 'string' ? prekey.cert_fingerprint : undefined;
|
|
998
|
+
const peerCertPem = await this._fetchPeerCert(toAid, peerCertFingerprint);
|
|
999
|
+
const [envelope, encryptResult] = this._encryptCopyPayload({
|
|
1000
|
+
logicalToAid: toAid,
|
|
1001
|
+
payload,
|
|
1002
|
+
peerCertPem,
|
|
1003
|
+
prekey,
|
|
1004
|
+
messageId: thoughtId,
|
|
1005
|
+
timestamp,
|
|
1006
|
+
protectedHeaders: this._protectedHeadersFromParams(params),
|
|
1007
|
+
context: isJsonObject(params.context) ? params.context : null,
|
|
1008
|
+
});
|
|
1009
|
+
this._ensureEncryptResult(toAid, encryptResult);
|
|
1010
|
+
const sendParams = {
|
|
1011
|
+
to: toAid,
|
|
1012
|
+
payload: envelope,
|
|
1013
|
+
type: 'e2ee.encrypted',
|
|
1014
|
+
encrypted: true,
|
|
1015
|
+
thought_id: thoughtId,
|
|
1016
|
+
timestamp,
|
|
1017
|
+
};
|
|
1018
|
+
if ('context' in params)
|
|
1019
|
+
sendParams.context = params.context;
|
|
1020
|
+
this._signClientOperation('message.thought.put', sendParams);
|
|
1021
|
+
return await this._transport.call('message.thought.put', sendParams);
|
|
1022
|
+
}
|
|
1023
|
+
async _callGroupEncryptedRpc(method, params, options) {
|
|
1024
|
+
let { sendParams, groupId } = await this._prepareGroupEncryptedRpcParams(method, params, options);
|
|
859
1025
|
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
1026
|
try {
|
|
876
|
-
return await this._transport.call(
|
|
1027
|
+
return await this._transport.call(method, sendParams);
|
|
877
1028
|
}
|
|
878
1029
|
catch (exc) {
|
|
879
1030
|
if (attempt === 0 && this._isRecoverableGroupEpochError(exc)) {
|
|
880
|
-
_clientLog('warn', '群 %s
|
|
881
|
-
await this.
|
|
1031
|
+
_clientLog('warn', '群 %s 调用 %s 时 epoch 已过旧,恢复密钥后重加密重试一次: %s', groupId, method, formatCaughtError(exc));
|
|
1032
|
+
({ sendParams, groupId } = await this._prepareGroupEncryptedRpcParams(method, params, options, true));
|
|
882
1033
|
continue;
|
|
883
1034
|
}
|
|
884
1035
|
throw exc;
|
|
885
1036
|
}
|
|
886
1037
|
}
|
|
887
|
-
throw new StateError(
|
|
1038
|
+
throw new StateError(`${method} failed after epoch recovery retry: group=${groupId}`);
|
|
1039
|
+
}
|
|
1040
|
+
async _prepareGroupEncryptedRpcParams(method, params, options, strictEpochReady = false) {
|
|
1041
|
+
const groupId = String(params.group_id ?? '');
|
|
1042
|
+
const payload = isJsonObject(params.payload) ? params.payload : null;
|
|
1043
|
+
if (!groupId) {
|
|
1044
|
+
throw new ValidationError(`${method} requires group_id`);
|
|
1045
|
+
}
|
|
1046
|
+
if (payload === null) {
|
|
1047
|
+
throw new ValidationError(`${method} payload must be an object when encrypt=true`);
|
|
1048
|
+
}
|
|
1049
|
+
if (!this._groupSynced.has(groupId)) {
|
|
1050
|
+
await this._lazySyncGroup(groupId);
|
|
1051
|
+
}
|
|
1052
|
+
await this._ensureGroupEpochReady(groupId, strictEpochReady);
|
|
1053
|
+
await this._waitForGroupMembershipEpochFloor(groupId, 2000);
|
|
1054
|
+
const epochResult = await this._committedGroupEpochState(groupId);
|
|
1055
|
+
const committedEpoch = Number(epochResult.committed_epoch ?? epochResult.epoch ?? 0);
|
|
1056
|
+
const operationId = String(params[options.idField] ?? '').trim()
|
|
1057
|
+
|| `${options.idPrefix}-${crypto.randomUUID()}`;
|
|
1058
|
+
const timestamp = Number(params.timestamp ?? Date.now());
|
|
1059
|
+
const protectedHeaders = this._protectedHeadersFromParams(params);
|
|
1060
|
+
const context = method === 'group.thought.put' && isJsonObject(params.context)
|
|
1061
|
+
? params.context
|
|
1062
|
+
: null;
|
|
1063
|
+
const envelope = committedEpoch > 0
|
|
1064
|
+
? this._groupE2ee.encryptWithEpoch(groupId, await this._ensureCommittedGroupSecretForSend(groupId, committedEpoch, epochResult), payload, {
|
|
1065
|
+
messageId: operationId,
|
|
1066
|
+
timestamp,
|
|
1067
|
+
protectedHeaders,
|
|
1068
|
+
context,
|
|
1069
|
+
})
|
|
1070
|
+
: await this._groupE2ee.encrypt(groupId, payload, {
|
|
1071
|
+
messageId: operationId,
|
|
1072
|
+
timestamp,
|
|
1073
|
+
protectedHeaders,
|
|
1074
|
+
context,
|
|
1075
|
+
});
|
|
1076
|
+
const sendParams = {
|
|
1077
|
+
group_id: groupId,
|
|
1078
|
+
payload: envelope,
|
|
1079
|
+
type: 'e2ee.group_encrypted',
|
|
1080
|
+
encrypted: true,
|
|
1081
|
+
[options.idField]: operationId,
|
|
1082
|
+
timestamp,
|
|
1083
|
+
};
|
|
1084
|
+
if (this._deviceId && sendParams.device_id === undefined) {
|
|
1085
|
+
sendParams.device_id = this._deviceId;
|
|
1086
|
+
}
|
|
1087
|
+
if (sendParams.slot_id === undefined) {
|
|
1088
|
+
sendParams.slot_id = this._slotId;
|
|
1089
|
+
}
|
|
1090
|
+
for (const field of options.extraFields ?? []) {
|
|
1091
|
+
if (params[field] !== undefined) {
|
|
1092
|
+
sendParams[field] = params[field];
|
|
1093
|
+
}
|
|
1094
|
+
}
|
|
1095
|
+
await this._signClientOperation(method, sendParams);
|
|
1096
|
+
return { sendParams, groupId };
|
|
888
1097
|
}
|
|
889
1098
|
/** 惰性同步:首次激活群时 pull 最近消息,建立 seq 基线 */
|
|
890
1099
|
async _lazySyncGroup(groupId) {
|
|
@@ -1236,17 +1445,20 @@ export class AUNClient {
|
|
|
1236
1445
|
timestamp: data.timestamp,
|
|
1237
1446
|
_decrypt_error: String(exc),
|
|
1238
1447
|
};
|
|
1239
|
-
this.
|
|
1448
|
+
this._publishAppEvent('message.undecryptable', safeEvent).catch(() => { });
|
|
1240
1449
|
}
|
|
1241
1450
|
});
|
|
1242
1451
|
}
|
|
1243
1452
|
/** 实际处理推送消息的异步任务 */
|
|
1244
1453
|
async _processAndPublishMessage(data) {
|
|
1245
1454
|
if (!isJsonObject(data)) {
|
|
1246
|
-
await this.
|
|
1455
|
+
await this._publishAppEvent('message.received', data);
|
|
1247
1456
|
return;
|
|
1248
1457
|
}
|
|
1249
1458
|
const msg = { ...data };
|
|
1459
|
+
if (!this._messageTargetsCurrentInstance(msg)) {
|
|
1460
|
+
return;
|
|
1461
|
+
}
|
|
1250
1462
|
// 拦截 P2P 传输的群组密钥分发/请求/响应消息
|
|
1251
1463
|
if (await this._tryHandleGroupKeyMessage(msg)) {
|
|
1252
1464
|
return;
|
|
@@ -1266,20 +1478,20 @@ export class AUNClient {
|
|
|
1266
1478
|
this._transport.call('message.ack', {
|
|
1267
1479
|
seq: contig,
|
|
1268
1480
|
device_id: this._deviceId,
|
|
1481
|
+
slot_id: this._slotId,
|
|
1269
1482
|
}).catch((e) => { _clientLog('debug', 'P2P auto-ack 失败: %s', formatCaughtError(e)); });
|
|
1270
1483
|
}
|
|
1271
1484
|
// 即时持久化 cursor,异常断连后不回退
|
|
1272
1485
|
this._saveSeqTrackerState();
|
|
1273
1486
|
}
|
|
1274
1487
|
const decrypted = await this._decryptSingleMessage(msg);
|
|
1275
|
-
// 记录已推送的 seq,补洞路径据此去重
|
|
1276
1488
|
if (seq !== undefined && seq !== null && this._aid) {
|
|
1277
1489
|
const ns = `p2p:${this._aid}`;
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1490
|
+
await this._publishOrderedMessage('message.received', ns, seq, decrypted);
|
|
1491
|
+
}
|
|
1492
|
+
else {
|
|
1493
|
+
await this._publishAppEvent('message.received', decrypted);
|
|
1281
1494
|
}
|
|
1282
|
-
await this._dispatcher.publish('message.received', decrypted);
|
|
1283
1495
|
}
|
|
1284
1496
|
/** 处理群组消息推送:自动解密后 re-publish */
|
|
1285
1497
|
async _onRawGroupMessageCreated(data) {
|
|
@@ -1295,7 +1507,7 @@ export class AUNClient {
|
|
|
1295
1507
|
timestamp: data.timestamp,
|
|
1296
1508
|
_decrypt_error: String(exc),
|
|
1297
1509
|
};
|
|
1298
|
-
this.
|
|
1510
|
+
this._publishAppEvent('group.message_undecryptable', safeEvent).catch(() => { });
|
|
1299
1511
|
}
|
|
1300
1512
|
});
|
|
1301
1513
|
}
|
|
@@ -1307,7 +1519,7 @@ export class AUNClient {
|
|
|
1307
1519
|
*/
|
|
1308
1520
|
async _processAndPublishGroupMessage(data) {
|
|
1309
1521
|
if (!isJsonObject(data)) {
|
|
1310
|
-
await this.
|
|
1522
|
+
await this._publishAppEvent('group.message_created', data);
|
|
1311
1523
|
return;
|
|
1312
1524
|
}
|
|
1313
1525
|
const msg = { ...data };
|
|
@@ -1342,19 +1554,12 @@ export class AUNClient {
|
|
|
1342
1554
|
}
|
|
1343
1555
|
this._saveSeqTrackerState();
|
|
1344
1556
|
}
|
|
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
1557
|
// R3: 解密失败 → 不 publish 密文给应用层,入 pending 队列 + 发 undecryptable 事件
|
|
1353
1558
|
const payloadForCheck = isJsonObject(msg.payload) ? msg.payload : null;
|
|
1354
1559
|
if (payloadForCheck?.type === 'e2ee.group_encrypted' && !decrypted.e2ee) {
|
|
1355
1560
|
if (groupId)
|
|
1356
1561
|
this._enqueuePendingDecrypt(groupId, msg);
|
|
1357
|
-
await this.
|
|
1562
|
+
await this._publishAppEvent('group.message_undecryptable', {
|
|
1358
1563
|
message_id: msg.message_id,
|
|
1359
1564
|
group_id: groupId,
|
|
1360
1565
|
from: msg.from,
|
|
@@ -1364,13 +1569,19 @@ export class AUNClient {
|
|
|
1364
1569
|
});
|
|
1365
1570
|
return;
|
|
1366
1571
|
}
|
|
1367
|
-
|
|
1572
|
+
if (groupId && seq !== undefined && seq !== null) {
|
|
1573
|
+
const nsKey = `group:${groupId}`;
|
|
1574
|
+
await this._publishOrderedMessage('group.message_created', nsKey, seq, decrypted);
|
|
1575
|
+
}
|
|
1576
|
+
else {
|
|
1577
|
+
await this._publishAppEvent('group.message_created', decrypted);
|
|
1578
|
+
}
|
|
1368
1579
|
}
|
|
1369
1580
|
/** 收到不带 payload 的 group.message_created 通知后,自动 pull 最新消息 */
|
|
1370
1581
|
async _autoPullGroupMessages(notification) {
|
|
1371
1582
|
const groupId = (notification.group_id ?? '');
|
|
1372
1583
|
if (!groupId) {
|
|
1373
|
-
await this.
|
|
1584
|
+
await this._publishAppEvent('group.message_created', notification);
|
|
1374
1585
|
return;
|
|
1375
1586
|
}
|
|
1376
1587
|
const ns = `group:${groupId}`;
|
|
@@ -1386,15 +1597,19 @@ export class AUNClient {
|
|
|
1386
1597
|
const messages = result.messages;
|
|
1387
1598
|
if (Array.isArray(messages)) {
|
|
1388
1599
|
// onPullResult 已在 call() 拦截器中调用,此处不再重复
|
|
1389
|
-
// pushedSeqs 去重:跳过已通过推送路径分发的消息
|
|
1390
1600
|
const pushed = this._pushedSeqs.get(ns);
|
|
1391
1601
|
for (const msg of messages) {
|
|
1392
1602
|
if (isJsonObject(msg)) {
|
|
1393
1603
|
const s = msg.seq;
|
|
1394
1604
|
if (pushed && s !== undefined && s !== null && pushed.has(s)) {
|
|
1395
|
-
continue; //
|
|
1605
|
+
continue; // 已发布到应用层,跳过
|
|
1606
|
+
}
|
|
1607
|
+
if (s !== undefined && s !== null) {
|
|
1608
|
+
await this._publishOrderedMessage('group.message_created', ns, s, msg);
|
|
1609
|
+
}
|
|
1610
|
+
else {
|
|
1611
|
+
await this._publishAppEvent('group.message_created', msg);
|
|
1396
1612
|
}
|
|
1397
|
-
await this._dispatcher.publish('group.message_created', msg);
|
|
1398
1613
|
}
|
|
1399
1614
|
}
|
|
1400
1615
|
this._prunePushedSeqs(ns);
|
|
@@ -1405,24 +1620,17 @@ export class AUNClient {
|
|
|
1405
1620
|
catch (exc) {
|
|
1406
1621
|
_clientLog('debug', '自动 pull 群消息失败: %s', formatCaughtError(exc));
|
|
1407
1622
|
}
|
|
1408
|
-
await this.
|
|
1623
|
+
await this._publishAppEvent('group.message_created', notification);
|
|
1409
1624
|
}
|
|
1410
1625
|
/** 后台补齐群消息空洞 */
|
|
1411
1626
|
async _fillGroupGap(groupId) {
|
|
1412
1627
|
const ns = `group:${groupId}`;
|
|
1413
1628
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1414
|
-
// 冷启动(seq=0):服务端推送会带全量消息,SDK 不主动补洞避免重复拉取
|
|
1415
|
-
if (afterSeq === 0) {
|
|
1416
|
-
return;
|
|
1417
|
-
}
|
|
1418
1629
|
// 去重:同一 (group:id:after_seq) 只补一次
|
|
1419
1630
|
const dedupKey = `group_msg:${groupId}:${afterSeq}`;
|
|
1420
1631
|
if (this._gapFillDone.has(dedupKey))
|
|
1421
1632
|
return;
|
|
1422
1633
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1423
|
-
// S1: 成功/失败双路径都需要根据"是否真正前进"决定是否保留 dedup 标记,
|
|
1424
|
-
// 避免 pull 返空、contiguous 未推进时标记被永久污染。
|
|
1425
|
-
let success = false;
|
|
1426
1634
|
try {
|
|
1427
1635
|
const result = await this.call('group.pull', {
|
|
1428
1636
|
group_id: groupId,
|
|
@@ -1440,24 +1648,23 @@ export class AUNClient {
|
|
|
1440
1648
|
const s = msg.seq;
|
|
1441
1649
|
if (pushed && s !== undefined && s !== null && pushed.has(s))
|
|
1442
1650
|
continue;
|
|
1443
|
-
|
|
1651
|
+
if (s !== undefined && s !== null) {
|
|
1652
|
+
await this._publishOrderedMessage('group.message_created', ns, s, msg);
|
|
1653
|
+
}
|
|
1654
|
+
else {
|
|
1655
|
+
await this._publishAppEvent('group.message_created', msg);
|
|
1656
|
+
}
|
|
1444
1657
|
}
|
|
1445
1658
|
}
|
|
1446
1659
|
this._prunePushedSeqs(ns);
|
|
1447
1660
|
}
|
|
1448
1661
|
}
|
|
1449
|
-
success = true;
|
|
1450
1662
|
}
|
|
1451
1663
|
catch (exc) {
|
|
1452
1664
|
_clientLog('warn', '群消息补洞失败: %s', formatCaughtError(exc));
|
|
1453
1665
|
}
|
|
1454
1666
|
finally {
|
|
1455
|
-
|
|
1456
|
-
// 否则(异常 / 空结果)清除,避免后续相同 after_seq 的补洞被跳过。
|
|
1457
|
-
const contigAfter = this._seqTracker.getContiguousSeq(ns);
|
|
1458
|
-
if (!success || contigAfter <= afterSeq) {
|
|
1459
|
-
this._gapFillDone.delete(dedupKey);
|
|
1460
|
-
}
|
|
1667
|
+
this._gapFillDone.delete(dedupKey);
|
|
1461
1668
|
}
|
|
1462
1669
|
}
|
|
1463
1670
|
/** 后台补齐 P2P 消息空洞 */
|
|
@@ -1466,18 +1673,11 @@ export class AUNClient {
|
|
|
1466
1673
|
return;
|
|
1467
1674
|
const ns = `p2p:${this._aid}`;
|
|
1468
1675
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1469
|
-
// 新设备(seq=0)没有历史 prekey,拉旧消息也解不了
|
|
1470
|
-
if (afterSeq === 0) {
|
|
1471
|
-
return;
|
|
1472
|
-
}
|
|
1473
1676
|
// 去重:同一 (type:after_seq) 只补一次
|
|
1474
1677
|
const dedupKey = `p2p:${afterSeq}`;
|
|
1475
1678
|
if (this._gapFillDone.has(dedupKey))
|
|
1476
1679
|
return;
|
|
1477
1680
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1478
|
-
// S1: 成功路径也要判断是否真正前进;pull 返空时必须清除标记,
|
|
1479
|
-
// 否则下一次相同 afterSeq 触发的补洞会被永久跳过。
|
|
1480
|
-
let success = false;
|
|
1481
1681
|
try {
|
|
1482
1682
|
const result = await this.call('message.pull', {
|
|
1483
1683
|
after_seq: afterSeq,
|
|
@@ -1493,23 +1693,23 @@ export class AUNClient {
|
|
|
1493
1693
|
const s = msg.seq;
|
|
1494
1694
|
if (pushed && s !== undefined && s !== null && pushed.has(s))
|
|
1495
1695
|
continue;
|
|
1496
|
-
|
|
1696
|
+
if (s !== undefined && s !== null) {
|
|
1697
|
+
await this._publishOrderedMessage('message.received', ns, s, msg);
|
|
1698
|
+
}
|
|
1699
|
+
else {
|
|
1700
|
+
await this._publishAppEvent('message.received', msg);
|
|
1701
|
+
}
|
|
1497
1702
|
}
|
|
1498
1703
|
}
|
|
1499
1704
|
this._prunePushedSeqs(ns);
|
|
1500
1705
|
}
|
|
1501
1706
|
}
|
|
1502
|
-
success = true;
|
|
1503
1707
|
}
|
|
1504
1708
|
catch (exc) {
|
|
1505
1709
|
_clientLog('warn', 'P2P 消息补洞失败: %s', formatCaughtError(exc));
|
|
1506
1710
|
}
|
|
1507
1711
|
finally {
|
|
1508
|
-
|
|
1509
|
-
const contigAfter = this._seqTracker.getContiguousSeq(ns);
|
|
1510
|
-
if (!success || contigAfter <= afterSeq) {
|
|
1511
|
-
this._gapFillDone.delete(dedupKey);
|
|
1512
|
-
}
|
|
1712
|
+
this._gapFillDone.delete(dedupKey);
|
|
1513
1713
|
}
|
|
1514
1714
|
}
|
|
1515
1715
|
/** 清理 pushedSeqs 中 <= contiguousSeq 的条目,防止无限增长 */
|
|
@@ -1525,21 +1725,128 @@ export class AUNClient {
|
|
|
1525
1725
|
if (pushed.size === 0)
|
|
1526
1726
|
this._pushedSeqs.delete(ns);
|
|
1527
1727
|
}
|
|
1728
|
+
_markPublishedSeq(ns, seq) {
|
|
1729
|
+
let pushed = this._pushedSeqs.get(ns);
|
|
1730
|
+
if (!pushed) {
|
|
1731
|
+
pushed = new Set();
|
|
1732
|
+
this._pushedSeqs.set(ns, pushed);
|
|
1733
|
+
}
|
|
1734
|
+
pushed.add(seq);
|
|
1735
|
+
if (pushed.size > PUSHED_SEQS_LIMIT) {
|
|
1736
|
+
const keep = [...pushed].sort((a, b) => a - b).slice(-PUSHED_SEQS_LIMIT);
|
|
1737
|
+
this._pushedSeqs.set(ns, new Set(keep));
|
|
1738
|
+
}
|
|
1739
|
+
}
|
|
1740
|
+
_enqueueOrderedMessage(ns, event, seq, payload) {
|
|
1741
|
+
let queue = this._pendingOrderedMsgs.get(ns);
|
|
1742
|
+
if (!queue) {
|
|
1743
|
+
queue = new Map();
|
|
1744
|
+
this._pendingOrderedMsgs.set(ns, queue);
|
|
1745
|
+
}
|
|
1746
|
+
queue.set(seq, { event, payload });
|
|
1747
|
+
if (queue.size > PENDING_ORDERED_LIMIT) {
|
|
1748
|
+
const drop = [...queue.keys()].sort((a, b) => a - b).slice(0, queue.size - PENDING_ORDERED_LIMIT);
|
|
1749
|
+
for (const oldSeq of drop)
|
|
1750
|
+
queue.delete(oldSeq);
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
_isInstanceScopedMessageEvent(event) {
|
|
1754
|
+
return event === 'message.received'
|
|
1755
|
+
|| event === 'message.undecryptable'
|
|
1756
|
+
|| event === 'group.message_created'
|
|
1757
|
+
|| event === 'group.message_undecryptable';
|
|
1758
|
+
}
|
|
1759
|
+
_attachCurrentInstanceContext(payload) {
|
|
1760
|
+
if (!isJsonObject(payload))
|
|
1761
|
+
return payload;
|
|
1762
|
+
const result = { ...payload };
|
|
1763
|
+
if (this._deviceId && !String(result.device_id ?? '').trim()) {
|
|
1764
|
+
result.device_id = this._deviceId;
|
|
1765
|
+
}
|
|
1766
|
+
if (this._slotId && !String(result.slot_id ?? '').trim()) {
|
|
1767
|
+
result.slot_id = this._slotId;
|
|
1768
|
+
}
|
|
1769
|
+
return result;
|
|
1770
|
+
}
|
|
1771
|
+
_normalizePublishedMessagePayload(event, payload) {
|
|
1772
|
+
if (!this._isInstanceScopedMessageEvent(event))
|
|
1773
|
+
return payload;
|
|
1774
|
+
return this._attachCurrentInstanceContext(payload);
|
|
1775
|
+
}
|
|
1776
|
+
async _publishAppEvent(event, payload) {
|
|
1777
|
+
await this._dispatcher.publish(event, this._normalizePublishedMessagePayload(event, payload));
|
|
1778
|
+
}
|
|
1779
|
+
_messageTargetsCurrentInstance(message) {
|
|
1780
|
+
if (!isJsonObject(message))
|
|
1781
|
+
return true;
|
|
1782
|
+
const targetDeviceId = String(message.device_id ?? '').trim();
|
|
1783
|
+
if (targetDeviceId && this._deviceId && targetDeviceId !== this._deviceId) {
|
|
1784
|
+
return false;
|
|
1785
|
+
}
|
|
1786
|
+
const targetSlotId = String(message.slot_id ?? '').trim();
|
|
1787
|
+
if (targetSlotId && this._slotId && targetSlotId !== this._slotId) {
|
|
1788
|
+
return false;
|
|
1789
|
+
}
|
|
1790
|
+
return true;
|
|
1791
|
+
}
|
|
1792
|
+
async _drainOrderedMessages(ns, beforeSeq) {
|
|
1793
|
+
const queue = this._pendingOrderedMsgs.get(ns);
|
|
1794
|
+
if (!queue || queue.size === 0)
|
|
1795
|
+
return;
|
|
1796
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1797
|
+
const ready = [...queue.keys()]
|
|
1798
|
+
.filter((seq) => seq <= contig && (beforeSeq === undefined || seq < beforeSeq))
|
|
1799
|
+
.sort((a, b) => a - b);
|
|
1800
|
+
for (const seq of ready) {
|
|
1801
|
+
const item = queue.get(seq);
|
|
1802
|
+
queue.delete(seq);
|
|
1803
|
+
if (!item || this._pushedSeqs.get(ns)?.has(seq))
|
|
1804
|
+
continue;
|
|
1805
|
+
await this._publishAppEvent(item.event, item.payload);
|
|
1806
|
+
this._markPublishedSeq(ns, seq);
|
|
1807
|
+
}
|
|
1808
|
+
if (queue.size === 0)
|
|
1809
|
+
this._pendingOrderedMsgs.delete(ns);
|
|
1810
|
+
}
|
|
1811
|
+
async _publishOrderedMessage(event, ns, seq, payload) {
|
|
1812
|
+
const seqNum = Number(seq);
|
|
1813
|
+
if (!Number.isFinite(seqNum) || !Number.isInteger(seqNum) || seqNum <= 0) {
|
|
1814
|
+
await this._publishAppEvent(event, payload);
|
|
1815
|
+
return true;
|
|
1816
|
+
}
|
|
1817
|
+
if (this._pushedSeqs.get(ns)?.has(seqNum)) {
|
|
1818
|
+
const queue = this._pendingOrderedMsgs.get(ns);
|
|
1819
|
+
queue?.delete(seqNum);
|
|
1820
|
+
if (queue && queue.size === 0)
|
|
1821
|
+
this._pendingOrderedMsgs.delete(ns);
|
|
1822
|
+
return false;
|
|
1823
|
+
}
|
|
1824
|
+
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1825
|
+
if (seqNum > contig) {
|
|
1826
|
+
this._enqueueOrderedMessage(ns, event, seqNum, payload);
|
|
1827
|
+
return false;
|
|
1828
|
+
}
|
|
1829
|
+
await this._drainOrderedMessages(ns, seqNum);
|
|
1830
|
+
if (this._pushedSeqs.get(ns)?.has(seqNum))
|
|
1831
|
+
return false;
|
|
1832
|
+
const queue = this._pendingOrderedMsgs.get(ns);
|
|
1833
|
+
queue?.delete(seqNum);
|
|
1834
|
+
if (queue && queue.size === 0)
|
|
1835
|
+
this._pendingOrderedMsgs.delete(ns);
|
|
1836
|
+
await this._publishAppEvent(event, payload);
|
|
1837
|
+
this._markPublishedSeq(ns, seqNum);
|
|
1838
|
+
await this._drainOrderedMessages(ns);
|
|
1839
|
+
return true;
|
|
1840
|
+
}
|
|
1528
1841
|
/** 后台补齐群事件空洞 */
|
|
1529
1842
|
async _fillGroupEventGap(groupId) {
|
|
1530
1843
|
const ns = `group_event:${groupId}`;
|
|
1531
1844
|
const afterSeq = this._seqTracker.getContiguousSeq(ns);
|
|
1532
|
-
// 冷启动(seq=0):服务端推送会带全量事件,SDK 不主动补洞避免重复拉取
|
|
1533
|
-
if (afterSeq === 0) {
|
|
1534
|
-
return;
|
|
1535
|
-
}
|
|
1536
1845
|
// 去重:同一 (group_evt:id:after_seq) 只补一次
|
|
1537
1846
|
const dedupKey = `group_evt:${groupId}:${afterSeq}`;
|
|
1538
1847
|
if (this._gapFillDone.has(dedupKey))
|
|
1539
1848
|
return;
|
|
1540
1849
|
this._gapFillDone.set(dedupKey, Date.now());
|
|
1541
|
-
// S1: 成功/失败均按"是否真正前进"决定是否保留标记
|
|
1542
|
-
let success = false;
|
|
1543
1850
|
try {
|
|
1544
1851
|
const result = await this.call('group.pull_events', {
|
|
1545
1852
|
group_id: groupId,
|
|
@@ -1551,14 +1858,24 @@ export class AUNClient {
|
|
|
1551
1858
|
const events = result.events;
|
|
1552
1859
|
if (Array.isArray(events)) {
|
|
1553
1860
|
this._seqTracker.onPullResult(ns, events.filter(isJsonObject));
|
|
1861
|
+
const cursor = isJsonObject(result.cursor) ? result.cursor : null;
|
|
1862
|
+
const serverAck = cursor ? Number(cursor.current_seq ?? 0) : 0;
|
|
1863
|
+
if (serverAck > 0) {
|
|
1864
|
+
const contigBefore = this._seqTracker.getContiguousSeq(ns);
|
|
1865
|
+
if (contigBefore < serverAck) {
|
|
1866
|
+
_clientLog('info', 'group.pull_events retention-floor 推进: ns=%s contiguous=%d -> cursor.current_seq=%d', ns, contigBefore, serverAck);
|
|
1867
|
+
this._seqTracker.forceContiguousSeq(ns, serverAck);
|
|
1868
|
+
}
|
|
1869
|
+
}
|
|
1554
1870
|
// 持久化 cursor + ack_events(与 Python 对齐)
|
|
1555
1871
|
this._saveSeqTrackerState();
|
|
1556
1872
|
const contig = this._seqTracker.getContiguousSeq(ns);
|
|
1557
|
-
if (contig > 0) {
|
|
1873
|
+
if (contig > 0 && (events.length > 0 || serverAck > 0)) {
|
|
1558
1874
|
this._transport.call('group.ack_events', {
|
|
1559
1875
|
group_id: groupId,
|
|
1560
1876
|
event_seq: contig,
|
|
1561
1877
|
device_id: this._deviceId,
|
|
1878
|
+
slot_id: this._slotId,
|
|
1562
1879
|
}).catch((e) => { _clientLog('debug', '群事件 auto-ack 失败: group=%s %s', groupId, formatCaughtError(e)); });
|
|
1563
1880
|
}
|
|
1564
1881
|
for (const evt of events) {
|
|
@@ -1574,16 +1891,12 @@ export class AUNClient {
|
|
|
1574
1891
|
}
|
|
1575
1892
|
}
|
|
1576
1893
|
}
|
|
1577
|
-
success = true;
|
|
1578
1894
|
}
|
|
1579
1895
|
catch (exc) {
|
|
1580
1896
|
_clientLog('warn', '群事件补洞失败: %s', formatCaughtError(exc));
|
|
1581
1897
|
}
|
|
1582
1898
|
finally {
|
|
1583
|
-
|
|
1584
|
-
if (!success || contigAfter <= afterSeq) {
|
|
1585
|
-
this._gapFillDone.delete(dedupKey);
|
|
1586
|
-
}
|
|
1899
|
+
this._gapFillDone.delete(dedupKey);
|
|
1587
1900
|
}
|
|
1588
1901
|
}
|
|
1589
1902
|
/**
|
|
@@ -1767,6 +2080,7 @@ export class AUNClient {
|
|
|
1767
2080
|
group_id: groupId,
|
|
1768
2081
|
event_seq: contig,
|
|
1769
2082
|
device_id: this._deviceId,
|
|
2083
|
+
slot_id: this._slotId,
|
|
1770
2084
|
}).catch((e) => { _clientLog('debug', '群事件推送 auto-ack 失败: group=%s %s', groupId, formatCaughtError(e)); });
|
|
1771
2085
|
}
|
|
1772
2086
|
}
|
|
@@ -1941,6 +2255,8 @@ export class AUNClient {
|
|
|
1941
2255
|
// 4. 清理推送 seq 去重缓存
|
|
1942
2256
|
this._pushedSeqs.delete(`group:${groupId}`);
|
|
1943
2257
|
this._pushedSeqs.delete(`group_event:${groupId}`);
|
|
2258
|
+
this._pendingOrderedMsgs.delete(`group:${groupId}`);
|
|
2259
|
+
this._pendingDecryptMsgs.delete(`group:${groupId}`);
|
|
1944
2260
|
_clientLog('info', '已清理解散群组 %s 的本地状态', groupId);
|
|
1945
2261
|
}
|
|
1946
2262
|
/** 同步验签群事件 client_signature。返回 true/false/"pending"。 */
|
|
@@ -1988,11 +2304,20 @@ export class AUNClient {
|
|
|
1988
2304
|
const ok = crypto.verify('SHA256', signData, pubKey, Buffer.from(sigB64, 'base64'));
|
|
1989
2305
|
if (!ok) {
|
|
1990
2306
|
_clientLog('warn', '群事件验签失败 aid=%s method=%s', sigAid, method);
|
|
2307
|
+
// P1-16: 签名失败统一发布事件
|
|
2308
|
+
this._dispatcher.publish('signature.verification_failed', {
|
|
2309
|
+
aid: sigAid, method, error: 'ECDSA verification failed',
|
|
2310
|
+
}).catch(() => { });
|
|
1991
2311
|
}
|
|
1992
2312
|
return ok;
|
|
1993
2313
|
}
|
|
1994
2314
|
catch (exc) {
|
|
1995
2315
|
_clientLog('warn', '群事件验签异常: %s', formatCaughtError(exc));
|
|
2316
|
+
// P1-16: 签名失败统一发布事件
|
|
2317
|
+
this._dispatcher.publish('signature.verification_failed', {
|
|
2318
|
+
aid: String(cs.aid ?? ''), method: String(cs._method ?? ''),
|
|
2319
|
+
error: formatCaughtError(exc),
|
|
2320
|
+
}).catch(() => { });
|
|
1996
2321
|
return false;
|
|
1997
2322
|
}
|
|
1998
2323
|
}
|
|
@@ -2181,11 +2506,17 @@ export class AUNClient {
|
|
|
2181
2506
|
async _fetchPeerPrekeys(peerAid) {
|
|
2182
2507
|
const cachedList = this._peerPrekeysCache.get(peerAid);
|
|
2183
2508
|
if (cachedList && Date.now() / 1000 < cachedList.expireAt) {
|
|
2184
|
-
|
|
2509
|
+
const normalized = normalizePeerPrekeys(cachedList.items);
|
|
2510
|
+
if (normalized.length > 0) {
|
|
2511
|
+
return normalized.map((item) => ({ ...item }));
|
|
2512
|
+
}
|
|
2185
2513
|
}
|
|
2186
2514
|
const cached = this._e2ee.getCachedPrekey(peerAid);
|
|
2187
|
-
if (cached !== null)
|
|
2188
|
-
|
|
2515
|
+
if (cached !== null) {
|
|
2516
|
+
const normalized = normalizePeerPrekeys([cached]);
|
|
2517
|
+
if (normalized.length > 0)
|
|
2518
|
+
return normalized.map((item) => ({ ...item }));
|
|
2519
|
+
}
|
|
2189
2520
|
try {
|
|
2190
2521
|
const result = await this._transport.call('message.e2ee.get_prekey', { aid: peerAid });
|
|
2191
2522
|
if (!isJsonObject(result)) {
|
|
@@ -2196,13 +2527,7 @@ export class AUNClient {
|
|
|
2196
2527
|
}
|
|
2197
2528
|
const devicePrekeys = Array.isArray(result.device_prekeys) ? result.device_prekeys : null;
|
|
2198
2529
|
if (devicePrekeys) {
|
|
2199
|
-
const normalized =
|
|
2200
|
-
for (const item of devicePrekeys) {
|
|
2201
|
-
if (!isPeerPrekeyMaterial(item)) {
|
|
2202
|
-
continue;
|
|
2203
|
-
}
|
|
2204
|
-
normalized.push({ ...item });
|
|
2205
|
-
}
|
|
2530
|
+
const normalized = normalizePeerPrekeys(devicePrekeys);
|
|
2206
2531
|
if (normalized.length > 0) {
|
|
2207
2532
|
this._peerPrekeysCache.set(peerAid, {
|
|
2208
2533
|
items: normalized.map((item) => ({ ...item })),
|
|
@@ -2217,12 +2542,15 @@ export class AUNClient {
|
|
|
2217
2542
|
}
|
|
2218
2543
|
const prekey = result.prekey;
|
|
2219
2544
|
if (prekey) {
|
|
2220
|
-
|
|
2221
|
-
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2545
|
+
const normalized = normalizePeerPrekeys([prekey]);
|
|
2546
|
+
if (normalized.length > 0) {
|
|
2547
|
+
this._peerPrekeysCache.set(peerAid, {
|
|
2548
|
+
items: normalized.map((item) => ({ ...item })),
|
|
2549
|
+
expireAt: Date.now() / 1000 + 300,
|
|
2550
|
+
});
|
|
2551
|
+
this._e2ee.cachePrekey(peerAid, normalized[0]);
|
|
2552
|
+
return normalized.map((item) => ({ ...item }));
|
|
2553
|
+
}
|
|
2226
2554
|
}
|
|
2227
2555
|
if (result.found) {
|
|
2228
2556
|
throw new ValidationError(`invalid prekey response for ${peerAid}`);
|
|
@@ -2240,7 +2568,10 @@ export class AUNClient {
|
|
|
2240
2568
|
async _fetchPeerPrekey(peerAid) {
|
|
2241
2569
|
const cachedList = this._peerPrekeysCache.get(peerAid);
|
|
2242
2570
|
if (cachedList && Date.now() / 1000 < cachedList.expireAt && cachedList.items.length > 0) {
|
|
2243
|
-
|
|
2571
|
+
const normalized = normalizePeerPrekeys(cachedList.items);
|
|
2572
|
+
if (normalized.length > 0) {
|
|
2573
|
+
return { ...normalized[0] };
|
|
2574
|
+
}
|
|
2244
2575
|
}
|
|
2245
2576
|
const prekeys = await this._fetchPeerPrekeys(peerAid);
|
|
2246
2577
|
if (prekeys.length === 0) {
|
|
@@ -2395,7 +2726,7 @@ export class AUNClient {
|
|
|
2395
2726
|
const ns = `group:${groupId}`;
|
|
2396
2727
|
const queue = this._pendingDecryptMsgs.get(ns) ?? [];
|
|
2397
2728
|
queue.push(msg);
|
|
2398
|
-
this._pendingDecryptMsgs.set(ns, queue.slice(-
|
|
2729
|
+
this._pendingDecryptMsgs.set(ns, queue.slice(-PENDING_DECRYPT_LIMIT));
|
|
2399
2730
|
}
|
|
2400
2731
|
async _retryPendingDecryptMsgs(groupId) {
|
|
2401
2732
|
const ns = `group:${groupId}`;
|
|
@@ -2412,14 +2743,20 @@ export class AUNClient {
|
|
|
2412
2743
|
stillPending.push(msg);
|
|
2413
2744
|
continue;
|
|
2414
2745
|
}
|
|
2415
|
-
|
|
2746
|
+
const seq = msg.seq;
|
|
2747
|
+
if (seq !== undefined && seq !== null) {
|
|
2748
|
+
await this._publishOrderedMessage('group.message_created', ns, seq, decrypted);
|
|
2749
|
+
}
|
|
2750
|
+
else {
|
|
2751
|
+
await this._publishAppEvent('group.message_created', decrypted);
|
|
2752
|
+
}
|
|
2416
2753
|
}
|
|
2417
2754
|
catch {
|
|
2418
2755
|
stillPending.push(msg);
|
|
2419
2756
|
}
|
|
2420
2757
|
}
|
|
2421
2758
|
const queuedDuringRetry = this._pendingDecryptMsgs.get(ns) ?? [];
|
|
2422
|
-
const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-
|
|
2759
|
+
const mergedPending = [...stillPending, ...queuedDuringRetry].slice(-PENDING_DECRYPT_LIMIT);
|
|
2423
2760
|
if (mergedPending.length)
|
|
2424
2761
|
this._pendingDecryptMsgs.set(ns, mergedPending);
|
|
2425
2762
|
else
|
|
@@ -2509,10 +2846,10 @@ export class AUNClient {
|
|
|
2509
2846
|
async _decryptGroupMessage(message, opts) {
|
|
2510
2847
|
const payload = message.payload;
|
|
2511
2848
|
if (!isJsonObject(payload))
|
|
2512
|
-
return message;
|
|
2849
|
+
return this._attachGroupDispatchModeToPayload(message);
|
|
2513
2850
|
const payloadObj = payload;
|
|
2514
2851
|
if (payloadObj.type !== 'e2ee.group_encrypted')
|
|
2515
|
-
return message;
|
|
2852
|
+
return this._attachGroupDispatchModeToPayload(message);
|
|
2516
2853
|
// 确保发送方证书已缓存(签名验证需要)
|
|
2517
2854
|
const senderAid = String(message.from ?? message.sender_aid ?? '');
|
|
2518
2855
|
if (senderAid) {
|
|
@@ -2525,7 +2862,7 @@ export class AUNClient {
|
|
|
2525
2862
|
// 先尝试直接解密
|
|
2526
2863
|
const result = this._groupE2ee.decrypt(message, opts);
|
|
2527
2864
|
if (result !== null && result.e2ee) {
|
|
2528
|
-
return result;
|
|
2865
|
+
return this._attachGroupDispatchModeToPayload(result);
|
|
2529
2866
|
}
|
|
2530
2867
|
// replay guard 命中:decrypt 返回了原消息(非 null)但无 e2ee 字段
|
|
2531
2868
|
// 不是解密失败,不应触发 recover
|
|
@@ -2541,7 +2878,7 @@ export class AUNClient {
|
|
|
2541
2878
|
if (await this._recoverGroupEpochKey(groupId, epoch, sender, 5000)) {
|
|
2542
2879
|
const retry = await this._groupE2ee.decrypt(message, opts);
|
|
2543
2880
|
if (retry !== null && retry.e2ee)
|
|
2544
|
-
return retry;
|
|
2881
|
+
return this._attachGroupDispatchModeToPayload(retry);
|
|
2545
2882
|
}
|
|
2546
2883
|
}
|
|
2547
2884
|
catch (exc) {
|
|
@@ -2550,6 +2887,21 @@ export class AUNClient {
|
|
|
2550
2887
|
}
|
|
2551
2888
|
return message;
|
|
2552
2889
|
}
|
|
2890
|
+
_attachGroupDispatchModeToPayload(message) {
|
|
2891
|
+
const payload = message.payload;
|
|
2892
|
+
if (!isJsonObject(payload))
|
|
2893
|
+
return message;
|
|
2894
|
+
const rawMode = String(message.dispatch_mode ?? 'broadcast').trim().toLowerCase();
|
|
2895
|
+
const mode = rawMode === 'mention' || rawMode === 'broadcast' ? rawMode : 'broadcast';
|
|
2896
|
+
return {
|
|
2897
|
+
...message,
|
|
2898
|
+
dispatch_mode: mode,
|
|
2899
|
+
payload: {
|
|
2900
|
+
...payload,
|
|
2901
|
+
dispatch_mode: mode,
|
|
2902
|
+
},
|
|
2903
|
+
};
|
|
2904
|
+
}
|
|
2553
2905
|
/** 批量解密群组消息(用于 group.pull,跳过防重放) */
|
|
2554
2906
|
async _decryptGroupMessages(messages) {
|
|
2555
2907
|
const result = [];
|
|
@@ -2561,10 +2913,108 @@ export class AUNClient {
|
|
|
2561
2913
|
this._enqueuePendingDecrypt(groupId, msg);
|
|
2562
2914
|
continue; // R3: 解密失败不入 result,不 publish 密文给应用层
|
|
2563
2915
|
}
|
|
2916
|
+
if (payload?.type !== 'e2ee.group_encrypted') {
|
|
2917
|
+
result.push(this._attachGroupDispatchModeToPayload(decrypted));
|
|
2918
|
+
continue;
|
|
2919
|
+
}
|
|
2564
2920
|
result.push(decrypted);
|
|
2565
2921
|
}
|
|
2566
2922
|
return result;
|
|
2567
2923
|
}
|
|
2924
|
+
async _decryptGroupThoughts(result) {
|
|
2925
|
+
if (!result.found) {
|
|
2926
|
+
return { ...result, thoughts: [] };
|
|
2927
|
+
}
|
|
2928
|
+
const items = Array.isArray(result.thoughts) ? result.thoughts.filter(isJsonObject) : [];
|
|
2929
|
+
if (items.length === 0) {
|
|
2930
|
+
return { ...result, thoughts: [] };
|
|
2931
|
+
}
|
|
2932
|
+
const groupId = String(result.group_id ?? '');
|
|
2933
|
+
const senderAid = String(result.sender_aid ?? '');
|
|
2934
|
+
const thoughts = [];
|
|
2935
|
+
for (const item of items) {
|
|
2936
|
+
const payload = isJsonObject(item.payload) ? item.payload : null;
|
|
2937
|
+
const thoughtId = String(item.thought_id ?? item.message_id ?? '');
|
|
2938
|
+
const message = {
|
|
2939
|
+
group_id: groupId,
|
|
2940
|
+
sender_aid: senderAid,
|
|
2941
|
+
from: senderAid,
|
|
2942
|
+
message_id: thoughtId,
|
|
2943
|
+
payload: payload ?? {},
|
|
2944
|
+
created_at: Number(item.created_at ?? 0),
|
|
2945
|
+
};
|
|
2946
|
+
const decrypted = await this._decryptGroupMessage(message, { skipReplay: true });
|
|
2947
|
+
if (payload?.type === 'e2ee.group_encrypted' && groupId && !decrypted.e2ee) {
|
|
2948
|
+
this._enqueuePendingDecrypt(groupId, message);
|
|
2949
|
+
continue;
|
|
2950
|
+
}
|
|
2951
|
+
const thought = {
|
|
2952
|
+
thought_id: thoughtId,
|
|
2953
|
+
message_id: thoughtId,
|
|
2954
|
+
payload: decrypted.payload,
|
|
2955
|
+
created_at: item.created_at,
|
|
2956
|
+
e2ee: decrypted.e2ee,
|
|
2957
|
+
};
|
|
2958
|
+
if ('context' in item)
|
|
2959
|
+
thought.context = item.context;
|
|
2960
|
+
thoughts.push(thought);
|
|
2961
|
+
}
|
|
2962
|
+
return { ...result, thoughts };
|
|
2963
|
+
}
|
|
2964
|
+
async _decryptMessageThoughts(result) {
|
|
2965
|
+
if (!result.found) {
|
|
2966
|
+
return { ...result, thoughts: [] };
|
|
2967
|
+
}
|
|
2968
|
+
const items = Array.isArray(result.thoughts) ? result.thoughts.filter(isJsonObject) : [];
|
|
2969
|
+
if (items.length === 0) {
|
|
2970
|
+
return { ...result, thoughts: [] };
|
|
2971
|
+
}
|
|
2972
|
+
const senderAid = String(result.sender_aid ?? '');
|
|
2973
|
+
const peerAid = String(result.peer_aid ?? '');
|
|
2974
|
+
const thoughts = [];
|
|
2975
|
+
for (const item of items) {
|
|
2976
|
+
const payload = isJsonObject(item.payload) ? item.payload : null;
|
|
2977
|
+
const thoughtId = String(item.thought_id ?? item.message_id ?? '');
|
|
2978
|
+
const fromAid = String(item.from ?? senderAid);
|
|
2979
|
+
const toAid = String(item.to ?? peerAid);
|
|
2980
|
+
const message = {
|
|
2981
|
+
from: fromAid,
|
|
2982
|
+
to: toAid,
|
|
2983
|
+
message_id: thoughtId,
|
|
2984
|
+
payload: payload ?? {},
|
|
2985
|
+
encrypted: item.encrypted !== false,
|
|
2986
|
+
timestamp: Number(item.created_at ?? 0),
|
|
2987
|
+
};
|
|
2988
|
+
let decrypted = message;
|
|
2989
|
+
if (payload?.type === 'e2ee.encrypted') {
|
|
2990
|
+
const senderCertFingerprint = String(payload.sender_cert_fingerprint ?? payload.aad?.sender_cert_fingerprint ?? '').trim().toLowerCase();
|
|
2991
|
+
if (fromAid) {
|
|
2992
|
+
const certReady = await this._ensureSenderCertCached(fromAid, senderCertFingerprint || undefined);
|
|
2993
|
+
if (!certReady) {
|
|
2994
|
+
_clientLog('warn', '无法获取发送方 %s 的证书,跳过 message.thought.get 解密', fromAid);
|
|
2995
|
+
continue;
|
|
2996
|
+
}
|
|
2997
|
+
}
|
|
2998
|
+
decrypted = this._e2ee._decryptMessage(message);
|
|
2999
|
+
if (decrypted === null || (isJsonObject(decrypted.payload) && decrypted.payload.type === 'e2ee.encrypted')) {
|
|
3000
|
+
continue;
|
|
3001
|
+
}
|
|
3002
|
+
}
|
|
3003
|
+
const thought = {
|
|
3004
|
+
thought_id: thoughtId,
|
|
3005
|
+
message_id: thoughtId,
|
|
3006
|
+
from: fromAid,
|
|
3007
|
+
to: toAid,
|
|
3008
|
+
payload: decrypted.payload,
|
|
3009
|
+
created_at: item.created_at,
|
|
3010
|
+
e2ee: decrypted.e2ee,
|
|
3011
|
+
};
|
|
3012
|
+
if ('context' in item)
|
|
3013
|
+
thought.context = item.context;
|
|
3014
|
+
thoughts.push(thought);
|
|
3015
|
+
}
|
|
3016
|
+
return { ...result, thoughts };
|
|
3017
|
+
}
|
|
2568
3018
|
// ── Group E2EE 编排 ───────────────────────────────────────
|
|
2569
3019
|
_attachRotationId(info, rotationId) {
|
|
2570
3020
|
if (!rotationId || !Array.isArray(info.distributions))
|
|
@@ -3200,6 +3650,8 @@ export class AUNClient {
|
|
|
3200
3650
|
this._seqTrackerContext = null;
|
|
3201
3651
|
this._gapFillDone.clear();
|
|
3202
3652
|
this._pushedSeqs.clear();
|
|
3653
|
+
this._pendingOrderedMsgs.clear();
|
|
3654
|
+
this._pendingDecryptMsgs.clear();
|
|
3203
3655
|
this._groupSynced.clear();
|
|
3204
3656
|
this._p2pSynced = false;
|
|
3205
3657
|
}
|
|
@@ -3210,6 +3662,8 @@ export class AUNClient {
|
|
|
3210
3662
|
this._seqTracker = new SeqTracker();
|
|
3211
3663
|
this._gapFillDone.clear();
|
|
3212
3664
|
this._pushedSeqs.clear();
|
|
3665
|
+
this._pendingOrderedMsgs.clear();
|
|
3666
|
+
this._pendingDecryptMsgs.clear();
|
|
3213
3667
|
this._groupSynced.clear();
|
|
3214
3668
|
this._p2pSynced = false;
|
|
3215
3669
|
this._seqTrackerContext = nextContext;
|
|
@@ -3641,6 +4095,28 @@ export class AUNClient {
|
|
|
3641
4095
|
throw new ValidationError('group.send does not accept delivery_mode; group messages are always fanout');
|
|
3642
4096
|
}
|
|
3643
4097
|
}
|
|
4098
|
+
if (method === 'group.thought.put' || method === 'group.thought.get'
|
|
4099
|
+
|| method === 'message.thought.put' || method === 'message.thought.get') {
|
|
4100
|
+
const context = isJsonObject(params.context) ? params.context : null;
|
|
4101
|
+
const contextType = String(context?.type ?? '').trim();
|
|
4102
|
+
const contextId = String(context?.id ?? '').trim();
|
|
4103
|
+
const hasContext = contextType.length > 0 && contextId.length > 0;
|
|
4104
|
+
if (!hasContext) {
|
|
4105
|
+
throw new ValidationError(`${method} requires context.type + context.id`);
|
|
4106
|
+
}
|
|
4107
|
+
}
|
|
4108
|
+
if (method === 'group.thought.get' && !String(params.sender_aid ?? '').trim()) {
|
|
4109
|
+
throw new ValidationError('group.thought.get requires sender_aid');
|
|
4110
|
+
}
|
|
4111
|
+
if (method === 'message.thought.put') {
|
|
4112
|
+
this._validateMessageRecipient(params.to);
|
|
4113
|
+
if (!String(params.to ?? '').trim()) {
|
|
4114
|
+
throw new ValidationError('message.thought.put requires to');
|
|
4115
|
+
}
|
|
4116
|
+
}
|
|
4117
|
+
if (method === 'message.thought.get' && !String(params.sender_aid ?? '').trim()) {
|
|
4118
|
+
throw new ValidationError('message.thought.get requires sender_aid');
|
|
4119
|
+
}
|
|
3644
4120
|
}
|
|
3645
4121
|
_currentMessageDeliveryMode() {
|
|
3646
4122
|
return { ...this._connectDeliveryMode };
|