@imweapp/openclaw-imwe 2026.4.12-alpha.1
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/LICENSE +21 -0
- package/README.md +172 -0
- package/index.ts +16 -0
- package/openclaw.plugin.json +58 -0
- package/package.json +73 -0
- package/proto/PbBoxPullProto.proto +43 -0
- package/proto/PbChatAudioContent.proto +23 -0
- package/proto/PbChatDeliverMsg.proto +38 -0
- package/proto/PbChatFileMeta.proto +34 -0
- package/proto/PbChatMsg.proto +93 -0
- package/proto/PbChatRichMediaContent.proto +31 -0
- package/proto/PbChatTextContent.proto +38 -0
- package/proto/PbMarkdownContent.proto +18 -0
- package/proto/PbMsgReadStampContent.proto +11 -0
- package/proto/PbPacket.proto +61 -0
- package/proto/PbSingleChatMsg.proto +60 -0
- package/setup-entry.ts +17 -0
- package/src/accounts.ts +109 -0
- package/src/api-client.ts +740 -0
- package/src/bot-info-cache.ts +49 -0
- package/src/channel.runtime.ts +29 -0
- package/src/channel.ts +456 -0
- package/src/config-schema.ts +26 -0
- package/src/e2ee/api.ts +261 -0
- package/src/e2ee/canonical.ts +59 -0
- package/src/e2ee/errors.ts +103 -0
- package/src/e2ee/index.ts +8 -0
- package/src/e2ee/proper-lockfile.d.ts +61 -0
- package/src/e2ee/service.ts +1273 -0
- package/src/e2ee/store.ts +174 -0
- package/src/e2ee/types.ts +113 -0
- package/src/e2ee/vodozemac.ts +373 -0
- package/src/file-transfer/api.ts +364 -0
- package/src/file-transfer/concurrency.ts +77 -0
- package/src/file-transfer/download.ts +261 -0
- package/src/file-transfer/file-crypto.ts +93 -0
- package/src/file-transfer/index.ts +18 -0
- package/src/file-transfer/scheduler.ts +185 -0
- package/src/file-transfer/types.ts +195 -0
- package/src/file-transfer/upload.ts +656 -0
- package/src/markdown-detect.ts +119 -0
- package/src/media-upload.ts +338 -0
- package/src/media-utils.ts +110 -0
- package/src/monitor.ts +838 -0
- package/src/proto/codec.ts +54 -0
- package/src/proto/inbound.codec.ts +624 -0
- package/src/proto/proto-types.ts +291 -0
- package/src/proto/registry.ts +226 -0
- package/src/proto/send.codec.ts +535 -0
- package/src/recent-message-cache.ts +350 -0
- package/src/send.ts +792 -0
- package/src/setup-core.ts +62 -0
- package/src/types.ts +153 -0
- package/src/vodozemackit/index.ts +297 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm.d.ts +138 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm.js +24 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm_bg.js +1172 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm +0 -0
- package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm.d.ts +109 -0
|
@@ -0,0 +1,1273 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* e2ee/service.ts — E2EE Service 编排层
|
|
3
|
+
*
|
|
4
|
+
* 对应设计:design.md §3.1;requirements R15。
|
|
5
|
+
*
|
|
6
|
+
* 职责:
|
|
7
|
+
* - 组合 VodozemacBinding + E2eeStore + E2eeApi + ContactSingleFlight
|
|
8
|
+
* - 暴露 E2eeService 接口(ensureLocalDevice / encryptSingle / decryptSingle 等)
|
|
9
|
+
* - dispose() 释放 WASM handle
|
|
10
|
+
*
|
|
11
|
+
* 本文件首期仅搭建骨架与依赖注入;各方法的具体实现由后续任务(8.2~8.9)逐步落地。
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { randomUUID } from 'node:crypto';
|
|
15
|
+
|
|
16
|
+
import { createE2eeApi, type E2eeApi, type CreateDeviceRequest } from './api.js';
|
|
17
|
+
import { E2eeEncryptFailedError, SingleNoAvailableRecipientOrSession } from './errors.js';
|
|
18
|
+
import { createE2eeStore, type E2eeStore } from './store.js';
|
|
19
|
+
import {
|
|
20
|
+
E2EE_DEVICE_STATUS,
|
|
21
|
+
type PreKeyBatch,
|
|
22
|
+
type PreKeyEntry,
|
|
23
|
+
type Session as SessionEntry,
|
|
24
|
+
} from './types.js';
|
|
25
|
+
import {
|
|
26
|
+
createVodozemacBinding,
|
|
27
|
+
type VodozemacBinding,
|
|
28
|
+
type AccountHandle,
|
|
29
|
+
type SessionHandle,
|
|
30
|
+
} from './vodozemac.js';
|
|
31
|
+
import { getRegistry } from '../proto/registry.js';
|
|
32
|
+
import {
|
|
33
|
+
buildDecryptErrorEnvelopeBytes,
|
|
34
|
+
encodeSingleChatReqPacket,
|
|
35
|
+
genClientMsgId,
|
|
36
|
+
} from '../proto/send.codec.js';
|
|
37
|
+
import { postProto } from '../api-client.js';
|
|
38
|
+
import { resolveQuotedMessage } from '../recent-message-cache.js';
|
|
39
|
+
import type { DecodedChatOperationEnvelope } from '../proto/proto-types.js';
|
|
40
|
+
|
|
41
|
+
// ─── Logger 类型(与项目既有风格一致) ───────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
type Logger = {
|
|
44
|
+
info?: (...args: unknown[]) => void;
|
|
45
|
+
warn?: (...args: unknown[]) => void;
|
|
46
|
+
error?: (...args: unknown[]) => void;
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
// ─── ContactSingleFlight ────────────────────────────────────────────────────
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* per-contact 并发去重工具。
|
|
53
|
+
*
|
|
54
|
+
* 同一 contactId 的并发调用共享同一次执行结果(single-flight 语义);
|
|
55
|
+
* 不同 contactId 之间互不阻塞。错误不阻塞后续排队。
|
|
56
|
+
*/
|
|
57
|
+
class ContactSingleFlight {
|
|
58
|
+
private map = new Map<string, Promise<unknown>>();
|
|
59
|
+
|
|
60
|
+
async run<T>(contactId: string, fn: () => Promise<T>): Promise<T> {
|
|
61
|
+
const prev = this.map.get(contactId);
|
|
62
|
+
const next = (async () => {
|
|
63
|
+
if (prev) await prev.catch(() => {});
|
|
64
|
+
return fn();
|
|
65
|
+
})();
|
|
66
|
+
this.map.set(contactId, next);
|
|
67
|
+
try {
|
|
68
|
+
return await next;
|
|
69
|
+
} finally {
|
|
70
|
+
if (this.map.get(contactId) === next) this.map.delete(contactId);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// ─── E2eeService 接口 ───────────────────────────────────────────────────────
|
|
76
|
+
|
|
77
|
+
/** bootstrap 所需的机器人信息 */
|
|
78
|
+
export interface BotInfo {
|
|
79
|
+
botAcctId: string;
|
|
80
|
+
e2eeEnabled?: boolean;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** E2EE Service 公共接口 */
|
|
84
|
+
export interface E2eeService {
|
|
85
|
+
/**
|
|
86
|
+
* 幂等的本地设备 bootstrap。
|
|
87
|
+
* - 无 local → create 路径
|
|
88
|
+
* - 有 local 且与服务端 active 匹配但缺 OTK → replenish
|
|
89
|
+
* - 有 local 但服务端 active 不匹配 → 重置后 create(切设备语义)
|
|
90
|
+
* - 启动恢复:扫 preKeyBatches 把 uploading 回退 pending 并续传原 uploadId
|
|
91
|
+
*/
|
|
92
|
+
ensureLocalDevice(botInfo: BotInfo): Promise<void>;
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* 出站加密。对 `to` 的活跃设备加密,返回可直接塞进
|
|
96
|
+
* PbSingleChatMsgReqBody.envelope 的 PbSingleChatMsgRecipients bytes。
|
|
97
|
+
*
|
|
98
|
+
* - 缺 session 时走 getPreKeyBundle(按设备单目标)
|
|
99
|
+
* - 发送前 peer-must-exist 校验
|
|
100
|
+
* - per-contact single-flight
|
|
101
|
+
*/
|
|
102
|
+
encryptSingle(params: {
|
|
103
|
+
to: string;
|
|
104
|
+
plainBytes: Uint8Array;
|
|
105
|
+
}): Promise<{ recipientsBytes: Uint8Array; senderE2eeId: string }>;
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* 入站解密。由 inbound.codec 注入回调调用。
|
|
109
|
+
* 解不开返回 null(上层丢弃,不投递给 agent)。
|
|
110
|
+
* 解密失败路径内部会尝试发送 decryptError 信令给对端(尽力而为,失败仅 WARN)。
|
|
111
|
+
*/
|
|
112
|
+
decryptSingle(params: {
|
|
113
|
+
fromId: string;
|
|
114
|
+
fromE2eeId: string;
|
|
115
|
+
toE2eeId: string;
|
|
116
|
+
e2eeSid: string;
|
|
117
|
+
encryptedBody: Uint8Array;
|
|
118
|
+
clientMsgId: string;
|
|
119
|
+
envelopeType: number;
|
|
120
|
+
}): Promise<Uint8Array | null>;
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* DEVICE_NOT_MATCH 修正。调用后上层单次重试 encryptSingle + send。
|
|
124
|
+
*/
|
|
125
|
+
handleDeviceNotMatch(data: {
|
|
126
|
+
extraDevices?: Array<{ imAcctId: string; e2eeId: string }>;
|
|
127
|
+
missDevices?: Array<{ imAcctId: string; e2eeId: string }>;
|
|
128
|
+
extraImAcctIds?: string[];
|
|
129
|
+
missImAcctIds?: string[];
|
|
130
|
+
}): Promise<void>;
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* 分发入站 envelopeType=2 操作消息。
|
|
134
|
+
* - decryptError → 内部调 handleRemoteDecryptError
|
|
135
|
+
* - botThinking → 既有 typing 路径
|
|
136
|
+
* - 其它 case → 首期不处理,返回 skip
|
|
137
|
+
*/
|
|
138
|
+
dispatchOperation(params: {
|
|
139
|
+
fromId: string;
|
|
140
|
+
fromE2eeId: string;
|
|
141
|
+
plainBytes: Uint8Array;
|
|
142
|
+
}): Promise<void>;
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* 收到对端 decryptError 信令后的处理。
|
|
146
|
+
* - 校验 content.otherE2eeId == local.e2eeId,否则忽略
|
|
147
|
+
* - 清对应 peerE2eeId 的 recipient + session
|
|
148
|
+
* - recent-message-cache 命中文本 → 用新 clientMsgId 调 sendImweText 重发
|
|
149
|
+
* - 非文本或缓存未命中 → WARN 不重发
|
|
150
|
+
*/
|
|
151
|
+
handleRemoteDecryptError(params: {
|
|
152
|
+
peerImAcctId: string;
|
|
153
|
+
content: {
|
|
154
|
+
clientMsgId: string;
|
|
155
|
+
e2eeId: string;
|
|
156
|
+
otherE2eeId: string;
|
|
157
|
+
};
|
|
158
|
+
}): Promise<void>;
|
|
159
|
+
|
|
160
|
+
/** 清理单个对端的 recipient + sessions(运维接口,首期不主动调用)。 */
|
|
161
|
+
clearPeer(peerImAcctId: string): Promise<void>;
|
|
162
|
+
|
|
163
|
+
/** 释放 WASM handle(stopAccount 调用)。 */
|
|
164
|
+
dispose(): Promise<void>;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// ─── createE2eeService 工厂 ─────────────────────────────────────────────────
|
|
168
|
+
|
|
169
|
+
export function createE2eeService(opts: {
|
|
170
|
+
accountId: string;
|
|
171
|
+
botAcctId: string;
|
|
172
|
+
apiBaseUrl: string;
|
|
173
|
+
auth: { apiKey: string; apiSecret: string };
|
|
174
|
+
stateDir: string;
|
|
175
|
+
log?: Logger;
|
|
176
|
+
}): E2eeService {
|
|
177
|
+
const { accountId, botAcctId, apiBaseUrl, auth, stateDir, log } = opts;
|
|
178
|
+
|
|
179
|
+
// 组合内部依赖
|
|
180
|
+
const binding: VodozemacBinding = createVodozemacBinding();
|
|
181
|
+
const store: E2eeStore = createE2eeStore({ accountId, botAcctId, stateDir, log });
|
|
182
|
+
const api: E2eeApi = createE2eeApi({ apiBaseUrl, auth });
|
|
183
|
+
const singleFlight = new ContactSingleFlight();
|
|
184
|
+
|
|
185
|
+
// 当前活跃的 AccountHandle(bootstrap 后赋值,dispose 时清空)
|
|
186
|
+
let accountHandle: AccountHandle | null = null;
|
|
187
|
+
|
|
188
|
+
/** 启动抖动保护窗口:60 秒 */
|
|
189
|
+
const JITTER_PROTECTION_WINDOW_MS = 60_000;
|
|
190
|
+
|
|
191
|
+
/** OTK 首批生成数量 */
|
|
192
|
+
const INITIAL_OTK_COUNT = 50;
|
|
193
|
+
|
|
194
|
+
// ── 内部辅助:上传一个 pending 批次 ──────────────────────────────────────
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* 上传单个 preKeyBatch(create 或 replenish),并在成功后
|
|
198
|
+
* 同一事务内 markKeysAsPublished + 批次 synced + 新 accountPickle。
|
|
199
|
+
*/
|
|
200
|
+
async function uploadBatch(
|
|
201
|
+
e2eeId: string,
|
|
202
|
+
identityKey: string,
|
|
203
|
+
uploadId: string,
|
|
204
|
+
mode: 'create' | 'replenish',
|
|
205
|
+
keys: PreKeyEntry[],
|
|
206
|
+
): Promise<void> {
|
|
207
|
+
const prefix = `[e2ee][${accountId}]`;
|
|
208
|
+
|
|
209
|
+
// 标记为 uploading
|
|
210
|
+
await store.update((prev) => {
|
|
211
|
+
const batch = prev.preKeyBatches[uploadId];
|
|
212
|
+
if (!batch) return { next: prev, result: undefined };
|
|
213
|
+
return {
|
|
214
|
+
next: {
|
|
215
|
+
...prev,
|
|
216
|
+
preKeyBatches: {
|
|
217
|
+
...prev.preKeyBatches,
|
|
218
|
+
[uploadId]: { ...batch, state: 'uploading' as const, updatedAt: Date.now() },
|
|
219
|
+
},
|
|
220
|
+
},
|
|
221
|
+
result: undefined,
|
|
222
|
+
};
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
// 构造请求参数
|
|
226
|
+
const oneTimeKeys = keys.filter((k) => k.keyType === 'otk').map((k) => k.publicKey);
|
|
227
|
+
const fallbackKeys = keys.filter((k) => k.keyType === 'fallback').map((k) => k.publicKey);
|
|
228
|
+
const params: CreateDeviceRequest = {
|
|
229
|
+
imAcctId: botAcctId,
|
|
230
|
+
e2eeId,
|
|
231
|
+
identityKey,
|
|
232
|
+
oneTimeKeys,
|
|
233
|
+
fallbackKeys,
|
|
234
|
+
};
|
|
235
|
+
|
|
236
|
+
log?.info?.(`${prefix}: uploadBatch mode=${mode}, params=${JSON.stringify(params)}`);
|
|
237
|
+
|
|
238
|
+
// 发起 HTTP 请求
|
|
239
|
+
if (mode === 'create') {
|
|
240
|
+
await api.createDevice(params);
|
|
241
|
+
} else {
|
|
242
|
+
await api.replenish(params);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
log?.info?.(`${prefix}: uploadBatch 成功: mode=${mode}, uploadId=${uploadId}`);
|
|
246
|
+
|
|
247
|
+
// 成功后:markKeysAsPublished + 批次 synced + 新 accountPickle
|
|
248
|
+
await store.update((prev) => {
|
|
249
|
+
if (!prev.local) return { next: prev, result: undefined };
|
|
250
|
+
|
|
251
|
+
// 恢复 account 以 markKeysAsPublished
|
|
252
|
+
const handle = binding.restoreAccount(prev.local.e2eeId, prev.local.accountPickle);
|
|
253
|
+
handle.markKeysAsPublished();
|
|
254
|
+
const newPickle = handle.pickle();
|
|
255
|
+
handle.dispose();
|
|
256
|
+
|
|
257
|
+
// 更新 accountHandle 引用
|
|
258
|
+
accountHandle?.dispose();
|
|
259
|
+
accountHandle = binding.restoreAccount(prev.local.e2eeId, newPickle);
|
|
260
|
+
|
|
261
|
+
return {
|
|
262
|
+
next: {
|
|
263
|
+
...prev,
|
|
264
|
+
local: { ...prev.local, accountPickle: newPickle, updatedAt: Date.now() },
|
|
265
|
+
preKeyBatches: {
|
|
266
|
+
...prev.preKeyBatches,
|
|
267
|
+
[uploadId]: {
|
|
268
|
+
...prev.preKeyBatches[uploadId]!,
|
|
269
|
+
state: 'synced' as const,
|
|
270
|
+
updatedAt: Date.now(),
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
result: undefined,
|
|
275
|
+
};
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ── 内部辅助:create 路径 ─────────────────────────────────────────────────
|
|
280
|
+
|
|
281
|
+
async function doCreate(): Promise<void> {
|
|
282
|
+
const prefix = `[e2ee][${accountId}]`;
|
|
283
|
+
log?.info?.(`${prefix}: doCreate 开始`);
|
|
284
|
+
|
|
285
|
+
// 1. newAccount → 归一化
|
|
286
|
+
await binding.ready();
|
|
287
|
+
const handle = binding.newAccount();
|
|
288
|
+
const e2eeId = handle.e2eeId;
|
|
289
|
+
const identityKey = handle.identityKey();
|
|
290
|
+
|
|
291
|
+
// 2. 生成 OTK + FBK
|
|
292
|
+
const { created: otkList } = handle.generateOneTimeKeysWithInfo(INITIAL_OTK_COUNT);
|
|
293
|
+
const fbkInfo = handle.generateFallbackKey();
|
|
294
|
+
log?.info?.(
|
|
295
|
+
`${prefix}: doCreate: 生成密钥完成, otkCount=${otkList.length}, fbkKeyId=${fbkInfo.keyId}`,
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
// 3. pickle
|
|
299
|
+
const accountPickle = handle.pickle();
|
|
300
|
+
|
|
301
|
+
// 4. 构造 preKeyBatch keys
|
|
302
|
+
const keys: PreKeyEntry[] = [
|
|
303
|
+
...otkList.map((k) => ({
|
|
304
|
+
keyType: 'otk' as const,
|
|
305
|
+
keyId: k.keyId,
|
|
306
|
+
publicKey: k.publicKey,
|
|
307
|
+
})),
|
|
308
|
+
{ keyType: 'fallback' as const, keyId: fbkInfo.keyId, publicKey: fbkInfo.publicKey },
|
|
309
|
+
];
|
|
310
|
+
|
|
311
|
+
const uploadId = randomUUID();
|
|
312
|
+
const now = Date.now();
|
|
313
|
+
|
|
314
|
+
// 5. 写 local + preKeyBatches[uploadId]=pending
|
|
315
|
+
await store.update((prev) => ({
|
|
316
|
+
next: {
|
|
317
|
+
...prev,
|
|
318
|
+
local: {
|
|
319
|
+
e2eeId,
|
|
320
|
+
identityKey,
|
|
321
|
+
accountPickle,
|
|
322
|
+
createdAt: now,
|
|
323
|
+
updatedAt: now,
|
|
324
|
+
},
|
|
325
|
+
preKeyBatches: {
|
|
326
|
+
...prev.preKeyBatches,
|
|
327
|
+
[uploadId]: {
|
|
328
|
+
uploadId,
|
|
329
|
+
mode: 'create' as const,
|
|
330
|
+
state: 'pending' as const,
|
|
331
|
+
keys,
|
|
332
|
+
retryCount: 0,
|
|
333
|
+
createdAt: now,
|
|
334
|
+
updatedAt: now,
|
|
335
|
+
},
|
|
336
|
+
},
|
|
337
|
+
},
|
|
338
|
+
result: undefined,
|
|
339
|
+
}));
|
|
340
|
+
log?.info?.(`${prefix}: doCreate: 已写入 local state, e2eeId=${e2eeId}, uploadId=${uploadId}`);
|
|
341
|
+
|
|
342
|
+
// 6. 上传(内部会 markKeysAsPublished + synced + 新 accountPickle)
|
|
343
|
+
await uploadBatch(e2eeId, identityKey, uploadId, 'create', keys);
|
|
344
|
+
|
|
345
|
+
// 7. 设置 accountHandle
|
|
346
|
+
accountHandle?.dispose();
|
|
347
|
+
const finalState = await store.snapshot();
|
|
348
|
+
if (finalState.local) {
|
|
349
|
+
accountHandle = binding.restoreAccount(
|
|
350
|
+
finalState.local.e2eeId,
|
|
351
|
+
finalState.local.accountPickle,
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
handle.dispose();
|
|
356
|
+
log?.info?.(`${prefix}: create 完成: e2eeId=${e2eeId}`);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// ── 内部辅助:replenish 路径 ──────────────────────────────────────────────
|
|
360
|
+
|
|
361
|
+
async function doReplenish(
|
|
362
|
+
e2eeId: string,
|
|
363
|
+
currentPickle: string,
|
|
364
|
+
requireNum: number,
|
|
365
|
+
): Promise<void> {
|
|
366
|
+
const prefix = `[e2ee][${accountId}]`;
|
|
367
|
+
log?.info?.(`${prefix}: doReplenish 开始, e2eeId=${e2eeId}, requireNum=${requireNum}`);
|
|
368
|
+
|
|
369
|
+
// 恢复 account
|
|
370
|
+
await binding.ready();
|
|
371
|
+
const handle = binding.restoreAccount(e2eeId, currentPickle);
|
|
372
|
+
|
|
373
|
+
// 生成补充的 OTK(至少补 requireNum 个,但不少于 1)
|
|
374
|
+
const otkCount = Math.max(requireNum, 1);
|
|
375
|
+
const { created: otkList } = handle.generateOneTimeKeysWithInfo(otkCount);
|
|
376
|
+
const fbkInfo = handle.generateFallbackKey();
|
|
377
|
+
log?.info?.(
|
|
378
|
+
`${prefix}: doReplenish: 生成密钥完成, otkCount=${otkList.length}, fbkKeyId=${fbkInfo.keyId}`,
|
|
379
|
+
);
|
|
380
|
+
|
|
381
|
+
// pickle
|
|
382
|
+
const accountPickle = handle.pickle();
|
|
383
|
+
|
|
384
|
+
// 构造 keys
|
|
385
|
+
const keys: PreKeyEntry[] = [
|
|
386
|
+
...otkList.map((k) => ({
|
|
387
|
+
keyType: 'otk' as const,
|
|
388
|
+
keyId: k.keyId,
|
|
389
|
+
publicKey: k.publicKey,
|
|
390
|
+
})),
|
|
391
|
+
{ keyType: 'fallback' as const, keyId: fbkInfo.keyId, publicKey: fbkInfo.publicKey },
|
|
392
|
+
];
|
|
393
|
+
|
|
394
|
+
const uploadId = randomUUID();
|
|
395
|
+
const now = Date.now();
|
|
396
|
+
|
|
397
|
+
// 写 preKeyBatches + 更新 accountPickle
|
|
398
|
+
await store.update((prev) => ({
|
|
399
|
+
next: {
|
|
400
|
+
...prev,
|
|
401
|
+
local: prev.local ? { ...prev.local, accountPickle, updatedAt: now } : undefined,
|
|
402
|
+
preKeyBatches: {
|
|
403
|
+
...prev.preKeyBatches,
|
|
404
|
+
[uploadId]: {
|
|
405
|
+
uploadId,
|
|
406
|
+
mode: 'replenish' as const,
|
|
407
|
+
state: 'pending' as const,
|
|
408
|
+
keys,
|
|
409
|
+
retryCount: 0,
|
|
410
|
+
createdAt: now,
|
|
411
|
+
updatedAt: now,
|
|
412
|
+
},
|
|
413
|
+
},
|
|
414
|
+
},
|
|
415
|
+
result: undefined,
|
|
416
|
+
}));
|
|
417
|
+
|
|
418
|
+
// 上传
|
|
419
|
+
await uploadBatch(e2eeId, e2eeId, uploadId, 'replenish', keys);
|
|
420
|
+
|
|
421
|
+
// 更新 accountHandle
|
|
422
|
+
accountHandle?.dispose();
|
|
423
|
+
const finalState = await store.snapshot();
|
|
424
|
+
if (finalState.local) {
|
|
425
|
+
accountHandle = binding.restoreAccount(
|
|
426
|
+
finalState.local.e2eeId,
|
|
427
|
+
finalState.local.accountPickle,
|
|
428
|
+
);
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
handle.dispose();
|
|
432
|
+
log?.info?.(`${prefix}: replenish 完成: e2eeId=${e2eeId}, 补充 ${otkCount} OTK`);
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
const service: E2eeService = {
|
|
436
|
+
async ensureLocalDevice(_botInfo: BotInfo): Promise<void> {
|
|
437
|
+
const prefix = `[e2ee][${accountId}]`;
|
|
438
|
+
log?.info?.(`${prefix}: ensureLocalDevice 开始`);
|
|
439
|
+
if (_botInfo.e2eeEnabled === false) {
|
|
440
|
+
throw new Error(`${prefix} Bot 未启用 E2EE`);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
// ── 启动恢复:uploading → pending ──────────────────────────────────
|
|
444
|
+
let recoveredBatchCount = 0;
|
|
445
|
+
await store.update((prev) => {
|
|
446
|
+
let changed = false;
|
|
447
|
+
const nextBatches: Record<string, PreKeyBatch> = { ...prev.preKeyBatches };
|
|
448
|
+
for (const id of Object.keys(nextBatches)) {
|
|
449
|
+
const batch = nextBatches[id]!;
|
|
450
|
+
if (batch.state === 'uploading') {
|
|
451
|
+
nextBatches[id] = { ...batch, state: 'pending' as const, updatedAt: Date.now() };
|
|
452
|
+
changed = true;
|
|
453
|
+
}
|
|
454
|
+
}
|
|
455
|
+
if (!changed) return { next: prev, result: undefined };
|
|
456
|
+
recoveredBatchCount = Object.keys(nextBatches).filter(
|
|
457
|
+
(id) =>
|
|
458
|
+
nextBatches[id]!.state === 'pending' && prev.preKeyBatches[id]?.state === 'uploading',
|
|
459
|
+
).length;
|
|
460
|
+
return { next: { ...prev, preKeyBatches: nextBatches }, result: undefined };
|
|
461
|
+
});
|
|
462
|
+
if (recoveredBatchCount > 0) {
|
|
463
|
+
log?.info?.(
|
|
464
|
+
`${prefix}: ensureLocalDevice: 恢复 ${recoveredBatchCount} 个 uploading→pending 批次`,
|
|
465
|
+
);
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ── 续传所有 pending 批次(以原 uploadId 幂等重投) ─────────────────
|
|
469
|
+
const snap = await store.snapshot();
|
|
470
|
+
for (const batchId of Object.keys(snap.preKeyBatches)) {
|
|
471
|
+
const batch = snap.preKeyBatches[batchId]!;
|
|
472
|
+
if (batch.state !== 'pending') continue;
|
|
473
|
+
if (!snap.local) continue; // 无 local 时 pending 批次无法续传,后续 create 会重建
|
|
474
|
+
log?.info?.(
|
|
475
|
+
`${prefix}: ensureLocalDevice: 续传 pending 批次, uploadId=${batch.uploadId}, mode=${batch.mode}`,
|
|
476
|
+
);
|
|
477
|
+
await uploadBatch(
|
|
478
|
+
snap.local.e2eeId,
|
|
479
|
+
snap.local.identityKey,
|
|
480
|
+
batch.uploadId,
|
|
481
|
+
batch.mode,
|
|
482
|
+
batch.keys,
|
|
483
|
+
);
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// ── 判断三支路 ─────────────────────────────────────────────────────
|
|
487
|
+
const state = await store.snapshot();
|
|
488
|
+
|
|
489
|
+
if (!state.local) {
|
|
490
|
+
// 分支 A:无 local → create
|
|
491
|
+
log?.info?.(`${prefix}: bootstrap: 无 local,进入 create 路径`);
|
|
492
|
+
await doCreate();
|
|
493
|
+
log?.info?.(`${prefix}: ensureLocalDevice 完成`);
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
// 有 local,查服务端密钥状态;querySupplement 是新 open-bot 接口的主入口。
|
|
498
|
+
const supplement = await api.querySupplement(state.local.e2eeId);
|
|
499
|
+
if (supplement.status === E2EE_DEVICE_STATUS.NORMAL) {
|
|
500
|
+
log?.info?.(`${prefix}: bootstrap: ready`);
|
|
501
|
+
accountHandle?.dispose();
|
|
502
|
+
accountHandle = binding.restoreAccount(state.local.e2eeId, state.local.accountPickle);
|
|
503
|
+
log?.info?.(`${prefix}: ensureLocalDevice 完成`);
|
|
504
|
+
return;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
if (supplement.status === E2EE_DEVICE_STATUS.NEEDS_SUPPLEMENT) {
|
|
508
|
+
const requireNum =
|
|
509
|
+
(supplement.oneTimeKeyRequireNum ?? 0) + (supplement.fallbackKeyRequireNum ?? 0);
|
|
510
|
+
log?.info?.(`${prefix}: bootstrap: 需补充 ${requireNum} 个密钥,进入 replenish 路径`);
|
|
511
|
+
await doReplenish(state.local.e2eeId, state.local.accountPickle, requireNum);
|
|
512
|
+
log?.info?.(`${prefix}: ensureLocalDevice 完成`);
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// UNINITIALIZED 表示服务端占位密钥或本地 e2eeId 已不匹配 → 重建设备。
|
|
517
|
+
const now = Date.now();
|
|
518
|
+
const lastBootstrap = state.local.createdAt;
|
|
519
|
+
if (now - lastBootstrap < JITTER_PROTECTION_WINDOW_MS) {
|
|
520
|
+
throw new Error(
|
|
521
|
+
`${prefix} 启动抖动保护:服务端 E2EE 设备未初始化或与本地不一致,` +
|
|
522
|
+
`距上次 create 不足 ${Math.floor(JITTER_PROTECTION_WINDOW_MS / 1000)}s,中止 bootstrap`,
|
|
523
|
+
);
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
log?.info?.(`${prefix}: bootstrap: 服务端 UNINITIALIZED 且超过抖动窗口,进入 create 路径`);
|
|
527
|
+
await doCreate();
|
|
528
|
+
log?.info?.(`${prefix}: ensureLocalDevice 完成`);
|
|
529
|
+
},
|
|
530
|
+
|
|
531
|
+
async encryptSingle(params: {
|
|
532
|
+
to: string;
|
|
533
|
+
plainBytes: Uint8Array;
|
|
534
|
+
}): Promise<{ recipientsBytes: Uint8Array; senderE2eeId: string }> {
|
|
535
|
+
const { to, plainBytes } = params;
|
|
536
|
+
const prefix = `[e2ee][${accountId}]`;
|
|
537
|
+
log?.info?.(
|
|
538
|
+
`${prefix}: encryptSingle 开始, to=${to}, plainBytes.length=${plainBytes.length}`,
|
|
539
|
+
);
|
|
540
|
+
|
|
541
|
+
return singleFlight.run(to, async () => {
|
|
542
|
+
if (!accountHandle) {
|
|
543
|
+
throw new Error(
|
|
544
|
+
`${prefix} encryptSingle: accountHandle 未初始化,请先调用 ensureLocalDevice`,
|
|
545
|
+
);
|
|
546
|
+
}
|
|
547
|
+
const localE2eeId = accountHandle.e2eeId;
|
|
548
|
+
|
|
549
|
+
// ── 检查是否已有活跃 session,缺则按设备走 getPreKeyBundle ───────
|
|
550
|
+
let snap = await store.snapshot();
|
|
551
|
+
const findActiveSessions = (state: typeof snap): SessionEntry[] => {
|
|
552
|
+
const deviceIds = state.recipients[to]?.deviceIds ?? [];
|
|
553
|
+
return (Object.values(state.sessions) as SessionEntry[]).filter(
|
|
554
|
+
(s) =>
|
|
555
|
+
s.contactId === to &&
|
|
556
|
+
!s.staleAt &&
|
|
557
|
+
(deviceIds.length === 0 || deviceIds.includes(s.peerE2eeId)),
|
|
558
|
+
);
|
|
559
|
+
};
|
|
560
|
+
|
|
561
|
+
let activeSessions = findActiveSessions(snap);
|
|
562
|
+
const missingSessionDeviceIds = (state: typeof snap): string[] => {
|
|
563
|
+
const deviceIds = state.recipients[to]?.deviceIds ?? [];
|
|
564
|
+
if (deviceIds.length === 0) return activeSessions.length === 0 ? [''] : [];
|
|
565
|
+
const activeDeviceIds = new Set(findActiveSessions(state).map((s) => s.peerE2eeId));
|
|
566
|
+
return deviceIds.filter((deviceId) => !activeDeviceIds.has(deviceId));
|
|
567
|
+
};
|
|
568
|
+
|
|
569
|
+
const missingDeviceIds = missingSessionDeviceIds(snap);
|
|
570
|
+
for (const missingDeviceId of missingDeviceIds) {
|
|
571
|
+
// 调 getPreKeyBundle 单目标申请;otherE2eeId 为空时由服务端按账号选择设备。
|
|
572
|
+
log?.info?.(
|
|
573
|
+
`${prefix}: encryptSingle: 缺 session,向 ${to}/${missingDeviceId || '<auto>'} 申请 PreKeyBundle`,
|
|
574
|
+
);
|
|
575
|
+
const secret = await api.getPreKeyBundle({
|
|
576
|
+
otherImAcctId: to,
|
|
577
|
+
otherE2eeId: missingDeviceId,
|
|
578
|
+
});
|
|
579
|
+
log?.info?.(
|
|
580
|
+
`${prefix}: encryptSingle: getPreKeyBundle 成功, e2eeId=${secret.e2eeId}, identityKey=${secret.identityKey.slice(0, 8)}...`,
|
|
581
|
+
);
|
|
582
|
+
|
|
583
|
+
// createOutboundSession 并在一次 update 事务内写 session + recipients
|
|
584
|
+
const sessionHandle = accountHandle.createOutboundSession(
|
|
585
|
+
secret.identityKey,
|
|
586
|
+
secret.oneTimeKey,
|
|
587
|
+
);
|
|
588
|
+
log?.info?.(
|
|
589
|
+
`${prefix}: encryptSingle: createOutboundSession 成功, sessionId=${sessionHandle.sessionId}`,
|
|
590
|
+
);
|
|
591
|
+
|
|
592
|
+
const now = Date.now();
|
|
593
|
+
const sessionKey = `${to}:${secret.e2eeId}:${sessionHandle.sessionId}`;
|
|
594
|
+
|
|
595
|
+
await store.update((prev) => {
|
|
596
|
+
const existingRecipient = prev.recipients[to];
|
|
597
|
+
const deviceIds = existingRecipient?.deviceIds ?? [];
|
|
598
|
+
const nextDeviceIds = deviceIds.includes(secret.e2eeId)
|
|
599
|
+
? deviceIds
|
|
600
|
+
: [...deviceIds, secret.e2eeId];
|
|
601
|
+
|
|
602
|
+
return {
|
|
603
|
+
next: {
|
|
604
|
+
...prev,
|
|
605
|
+
recipients: {
|
|
606
|
+
...prev.recipients,
|
|
607
|
+
[to]: {
|
|
608
|
+
contactId: to,
|
|
609
|
+
deviceIds: nextDeviceIds,
|
|
610
|
+
updatedAt: now,
|
|
611
|
+
},
|
|
612
|
+
},
|
|
613
|
+
sessions: {
|
|
614
|
+
...prev.sessions,
|
|
615
|
+
[sessionKey]: {
|
|
616
|
+
contactId: to,
|
|
617
|
+
peerE2eeId: secret.e2eeId,
|
|
618
|
+
sessionId: sessionHandle.sessionId,
|
|
619
|
+
sessionPickle: sessionHandle.pickle(),
|
|
620
|
+
isActive: true,
|
|
621
|
+
createdAt: now,
|
|
622
|
+
updatedAt: now,
|
|
623
|
+
},
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
result: undefined,
|
|
627
|
+
};
|
|
628
|
+
});
|
|
629
|
+
|
|
630
|
+
sessionHandle.dispose();
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
// ── peer-must-exist 校验 ─────────────────────────────────────────
|
|
634
|
+
snap = await store.snapshot();
|
|
635
|
+
activeSessions = findActiveSessions(snap);
|
|
636
|
+
|
|
637
|
+
if (activeSessions.length === 0) {
|
|
638
|
+
throw new SingleNoAvailableRecipientOrSession(
|
|
639
|
+
`${prefix} encryptSingle: 协商后仍无活跃 session (to=${to})`,
|
|
640
|
+
to,
|
|
641
|
+
);
|
|
642
|
+
}
|
|
643
|
+
|
|
644
|
+
// ── 对每个活跃 session 加密 ─────────────────────────────────────
|
|
645
|
+
const deviceMsgs: Array<{ toE2eeId: string; e2eeSid: string; envelope: Uint8Array }> = [];
|
|
646
|
+
const failedSessionKeys: string[] = [];
|
|
647
|
+
|
|
648
|
+
for (const sess of activeSessions) {
|
|
649
|
+
let handle: SessionHandle | null = null;
|
|
650
|
+
log?.info?.(
|
|
651
|
+
`${prefix}: encryptSingle: 加密 session, peerE2eeId=${sess.peerE2eeId}, sessionId=${sess.sessionId}`,
|
|
652
|
+
);
|
|
653
|
+
try {
|
|
654
|
+
handle = binding.restoreSession(sess.sessionId, sess.sessionPickle);
|
|
655
|
+
const encrypted = handle.encrypt(plainBytes);
|
|
656
|
+
deviceMsgs.push({
|
|
657
|
+
toE2eeId: sess.peerE2eeId,
|
|
658
|
+
e2eeSid: sess.sessionId,
|
|
659
|
+
envelope: encrypted.body,
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
// 加密成功后回写新 pickle
|
|
663
|
+
const newPickle = handle.pickle();
|
|
664
|
+
const sessionKey = `${sess.contactId}:${sess.peerE2eeId}:${sess.sessionId}`;
|
|
665
|
+
await store.update((prev) => {
|
|
666
|
+
const existing = prev.sessions[sessionKey];
|
|
667
|
+
if (!existing) return { next: prev, result: undefined };
|
|
668
|
+
return {
|
|
669
|
+
next: {
|
|
670
|
+
...prev,
|
|
671
|
+
sessions: {
|
|
672
|
+
...prev.sessions,
|
|
673
|
+
[sessionKey]: { ...existing, sessionPickle: newPickle, updatedAt: Date.now() },
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
result: undefined,
|
|
677
|
+
};
|
|
678
|
+
});
|
|
679
|
+
} catch {
|
|
680
|
+
// encrypt 抛错 → 标 staleAt
|
|
681
|
+
const sessionKey = `${sess.contactId}:${sess.peerE2eeId}:${sess.sessionId}`;
|
|
682
|
+
failedSessionKeys.push(sessionKey);
|
|
683
|
+
log?.warn?.(
|
|
684
|
+
`${prefix}: encryptSingle: session ${sess.sessionId} encrypt 失败,标记 stale`,
|
|
685
|
+
);
|
|
686
|
+
} finally {
|
|
687
|
+
handle?.dispose();
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// 标记失败的 session 为 stale
|
|
692
|
+
if (failedSessionKeys.length > 0) {
|
|
693
|
+
await store.update((prev) => {
|
|
694
|
+
const nextSessions = { ...prev.sessions };
|
|
695
|
+
const now = Date.now();
|
|
696
|
+
for (const key of failedSessionKeys) {
|
|
697
|
+
const s = nextSessions[key];
|
|
698
|
+
if (s) {
|
|
699
|
+
nextSessions[key] = { ...s, staleAt: now, updatedAt: now };
|
|
700
|
+
}
|
|
701
|
+
}
|
|
702
|
+
return { next: { ...prev, sessions: nextSessions }, result: undefined };
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
// 全部失败 → 抛 E2eeEncryptFailedError
|
|
707
|
+
if (deviceMsgs.length === 0) {
|
|
708
|
+
log?.error?.(`${prefix}: encryptSingle: 所有活跃 session encrypt 均失败, to=${to}`);
|
|
709
|
+
throw new E2eeEncryptFailedError(
|
|
710
|
+
`${prefix} encryptSingle: 所有活跃 session encrypt 均失败 (to=${to})`,
|
|
711
|
+
to,
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
log?.info?.(
|
|
715
|
+
`${prefix}: encryptSingle: 加密结果汇总, 成功=${deviceMsgs.length}, 失败=${failedSessionKeys.length}`,
|
|
716
|
+
);
|
|
717
|
+
|
|
718
|
+
// ── 编码 PbSingleChatMsgRecipients bytes ─────────────────────────
|
|
719
|
+
const registry = await getRegistry();
|
|
720
|
+
const recipientsPayload = {
|
|
721
|
+
recipients: [
|
|
722
|
+
{
|
|
723
|
+
acctId: to,
|
|
724
|
+
msgs: deviceMsgs,
|
|
725
|
+
},
|
|
726
|
+
],
|
|
727
|
+
};
|
|
728
|
+
const errMsg = registry.PbSingleChatMsgRecipients.verify(recipientsPayload);
|
|
729
|
+
if (errMsg) {
|
|
730
|
+
throw new Error(`${prefix} encryptSingle: proto verify 失败: ${errMsg}`);
|
|
731
|
+
}
|
|
732
|
+
const message = registry.PbSingleChatMsgRecipients.create(recipientsPayload);
|
|
733
|
+
const recipientsBytes = registry.PbSingleChatMsgRecipients.encode(message).finish();
|
|
734
|
+
log?.info?.(
|
|
735
|
+
`${prefix}: encryptSingle 完成, recipientsBytes.length=${recipientsBytes.length}, senderE2eeId=${localE2eeId}`,
|
|
736
|
+
);
|
|
737
|
+
|
|
738
|
+
return { recipientsBytes, senderE2eeId: localE2eeId };
|
|
739
|
+
});
|
|
740
|
+
},
|
|
741
|
+
|
|
742
|
+
async decryptSingle(params: {
|
|
743
|
+
fromId: string;
|
|
744
|
+
fromE2eeId: string;
|
|
745
|
+
toE2eeId: string;
|
|
746
|
+
e2eeSid: string;
|
|
747
|
+
encryptedBody: Uint8Array;
|
|
748
|
+
clientMsgId: string;
|
|
749
|
+
envelopeType: number;
|
|
750
|
+
}): Promise<Uint8Array | null> {
|
|
751
|
+
const prefix = `[e2ee][${accountId}]`;
|
|
752
|
+
const { fromId, fromE2eeId, toE2eeId, e2eeSid, encryptedBody } = params;
|
|
753
|
+
log?.info?.(
|
|
754
|
+
`${prefix}: decryptSingle 开始, fromId=${fromId}, fromE2eeId=${fromE2eeId}, toE2eeId=${toE2eeId}, e2eeSid=${e2eeSid}`,
|
|
755
|
+
);
|
|
756
|
+
|
|
757
|
+
// 1. 校验 toE2eeId == local.e2eeId,不匹配则返回 null(非本设备消息)
|
|
758
|
+
const snap = await store.snapshot();
|
|
759
|
+
if (!snap.local || toE2eeId !== snap.local.e2eeId) {
|
|
760
|
+
log?.warn?.(`${prefix}: decryptSingle: toE2eeId 不匹配,忽略`);
|
|
761
|
+
return null;
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
const msg = { body: encryptedBody };
|
|
765
|
+
|
|
766
|
+
// 2. 尝试命中已有 session(按 e2eeSid 查找)
|
|
767
|
+
const sessionKey = Object.keys(snap.sessions).find((k) => {
|
|
768
|
+
const s = snap.sessions[k]!;
|
|
769
|
+
return s.sessionId === e2eeSid && s.contactId === fromId && s.peerE2eeId === fromE2eeId;
|
|
770
|
+
});
|
|
771
|
+
|
|
772
|
+
if (sessionKey) {
|
|
773
|
+
// ── 路径 A:命中 sessionPickle ──────────────────────────────────
|
|
774
|
+
log?.info?.(`${prefix}: decryptSingle: 路径A 命中已有 session, sessionKey=${sessionKey}`);
|
|
775
|
+
const sessionEntry = snap.sessions[sessionKey]!;
|
|
776
|
+
try {
|
|
777
|
+
const sessionHandle = binding.restoreSession(
|
|
778
|
+
sessionEntry.sessionId,
|
|
779
|
+
sessionEntry.sessionPickle,
|
|
780
|
+
);
|
|
781
|
+
const plaintext = sessionHandle.decrypt(msg);
|
|
782
|
+
const newPickle = sessionHandle.pickle();
|
|
783
|
+
sessionHandle.dispose();
|
|
784
|
+
log?.info?.(
|
|
785
|
+
`${prefix}: decryptSingle: 路径A 解密成功, plaintext.length=${plaintext.length}`,
|
|
786
|
+
);
|
|
787
|
+
|
|
788
|
+
// 一次 update 写新 pickle + isActive=true
|
|
789
|
+
await store.update((prev) => ({
|
|
790
|
+
next: {
|
|
791
|
+
...prev,
|
|
792
|
+
sessions: {
|
|
793
|
+
...prev.sessions,
|
|
794
|
+
[sessionKey]: {
|
|
795
|
+
...prev.sessions[sessionKey]!,
|
|
796
|
+
sessionPickle: newPickle,
|
|
797
|
+
isActive: true,
|
|
798
|
+
updatedAt: Date.now(),
|
|
799
|
+
},
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
result: undefined,
|
|
803
|
+
}));
|
|
804
|
+
|
|
805
|
+
return plaintext;
|
|
806
|
+
} catch {
|
|
807
|
+
// 解密失败 → 标 staleAt + 发 decryptError 信令
|
|
808
|
+
log?.warn?.(`${prefix}: decryptSingle: session 命中但解密失败, sessionId=${e2eeSid}`);
|
|
809
|
+
await store.update((prev) => {
|
|
810
|
+
if (!prev.sessions[sessionKey]) return { next: prev, result: undefined };
|
|
811
|
+
return {
|
|
812
|
+
next: {
|
|
813
|
+
...prev,
|
|
814
|
+
sessions: {
|
|
815
|
+
...prev.sessions,
|
|
816
|
+
[sessionKey]: {
|
|
817
|
+
...prev.sessions[sessionKey]!,
|
|
818
|
+
staleAt: Date.now(),
|
|
819
|
+
updatedAt: Date.now(),
|
|
820
|
+
},
|
|
821
|
+
},
|
|
822
|
+
},
|
|
823
|
+
result: undefined,
|
|
824
|
+
};
|
|
825
|
+
});
|
|
826
|
+
// 尽力发送 decryptError 信令(失败仅 WARN,不阻塞主路径)
|
|
827
|
+
await sendDecryptErrorSignaling({ fromId, fromE2eeId, clientMsgId: params.clientMsgId });
|
|
828
|
+
return null;
|
|
829
|
+
}
|
|
830
|
+
}
|
|
831
|
+
|
|
832
|
+
// ── 路径 B:未命中,尝试 PreKey 新建 inbound session ────────────────
|
|
833
|
+
log?.info?.(`${prefix}: decryptSingle: 路径B 尝试 PreKey 新建 inbound session`);
|
|
834
|
+
try {
|
|
835
|
+
const acctHandle = binding.restoreAccount(snap.local.e2eeId, snap.local.accountPickle);
|
|
836
|
+
const { session: newSession, plaintext, oneTimeKey } = acctHandle.createInboundSession(msg);
|
|
837
|
+
log?.info?.(
|
|
838
|
+
`${prefix}: decryptSingle: 路径B createInboundSession 成功, newSessionId=${newSession.sessionId}, plaintext.length=${plaintext.length}`,
|
|
839
|
+
);
|
|
840
|
+
|
|
841
|
+
// 隐式确认:检查 oneTimeKey 是否仍未发布 → markKeysAsPublished
|
|
842
|
+
const unpublished = acctHandle.unpublishedOneTimeKeyCount();
|
|
843
|
+
if (unpublished > 0) {
|
|
844
|
+
// 检查返回的 oneTimeKey 是否在未发布列表中
|
|
845
|
+
const unpublishedKeys = acctHandle.oneTimeKeysWithInfo();
|
|
846
|
+
const stillUnpublished = unpublishedKeys.some((k) => k.publicKey === oneTimeKey);
|
|
847
|
+
if (stillUnpublished) {
|
|
848
|
+
acctHandle.markKeysAsPublished();
|
|
849
|
+
log?.info?.(
|
|
850
|
+
`${prefix}: decryptSingle: 路径B OTK 确认, oneTimeKey=${oneTimeKey.slice(0, 8)}..., markKeysAsPublished`,
|
|
851
|
+
);
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
// 重新 pickle account
|
|
856
|
+
const newAccountPickle = acctHandle.pickle();
|
|
857
|
+
acctHandle.dispose();
|
|
858
|
+
|
|
859
|
+
// pickle 新 session
|
|
860
|
+
const newSessionId = newSession.sessionId;
|
|
861
|
+
const newSessionPickle = newSession.pickle();
|
|
862
|
+
newSession.dispose();
|
|
863
|
+
|
|
864
|
+
// 查 preKeyBatches:oneTimeKey 命中某 pending/uploading 批次 → 标 synced
|
|
865
|
+
// 陌生 OTK 不制造幽灵批次
|
|
866
|
+
const now = Date.now();
|
|
867
|
+
|
|
868
|
+
// 同一 update 事务内写 account + session + recipients + preKeyBatches
|
|
869
|
+
await store.update((prev) => {
|
|
870
|
+
if (!prev.local) return { next: prev, result: undefined };
|
|
871
|
+
|
|
872
|
+
// 更新 preKeyBatches:查找含该 oneTimeKey 的 pending/uploading 批次
|
|
873
|
+
const nextBatches = { ...prev.preKeyBatches };
|
|
874
|
+
for (const batchId of Object.keys(nextBatches)) {
|
|
875
|
+
const batch = nextBatches[batchId]!;
|
|
876
|
+
if (batch.state !== 'pending' && batch.state !== 'uploading') continue;
|
|
877
|
+
const hasKey = batch.keys.some(
|
|
878
|
+
(k) => k.keyType === 'otk' && k.publicKey === oneTimeKey,
|
|
879
|
+
);
|
|
880
|
+
if (hasKey) {
|
|
881
|
+
nextBatches[batchId] = { ...batch, state: 'synced' as const, updatedAt: now };
|
|
882
|
+
}
|
|
883
|
+
}
|
|
884
|
+
|
|
885
|
+
// 新 session key
|
|
886
|
+
const sessKey = `${fromId}:${fromE2eeId}:${newSessionId}`;
|
|
887
|
+
|
|
888
|
+
// 更新 recipients
|
|
889
|
+
const existingRecipient = prev.recipients[fromId];
|
|
890
|
+
const deviceIds = existingRecipient?.deviceIds ?? [];
|
|
891
|
+
const updatedDeviceIds = deviceIds.includes(fromE2eeId)
|
|
892
|
+
? deviceIds
|
|
893
|
+
: [...deviceIds, fromE2eeId];
|
|
894
|
+
return {
|
|
895
|
+
next: {
|
|
896
|
+
...prev,
|
|
897
|
+
local: { ...prev.local, accountPickle: newAccountPickle, updatedAt: now },
|
|
898
|
+
sessions: {
|
|
899
|
+
...prev.sessions,
|
|
900
|
+
[sessKey]: {
|
|
901
|
+
contactId: fromId,
|
|
902
|
+
peerE2eeId: fromE2eeId,
|
|
903
|
+
sessionId: newSessionId,
|
|
904
|
+
sessionPickle: newSessionPickle,
|
|
905
|
+
isActive: true,
|
|
906
|
+
createdAt: now,
|
|
907
|
+
updatedAt: now,
|
|
908
|
+
},
|
|
909
|
+
},
|
|
910
|
+
recipients: {
|
|
911
|
+
...prev.recipients,
|
|
912
|
+
[fromId]: {
|
|
913
|
+
contactId: fromId,
|
|
914
|
+
deviceIds: updatedDeviceIds,
|
|
915
|
+
updatedAt: now,
|
|
916
|
+
},
|
|
917
|
+
},
|
|
918
|
+
preKeyBatches: nextBatches,
|
|
919
|
+
},
|
|
920
|
+
result: undefined,
|
|
921
|
+
};
|
|
922
|
+
});
|
|
923
|
+
|
|
924
|
+
// 更新内存中的 accountHandle
|
|
925
|
+
accountHandle?.dispose();
|
|
926
|
+
accountHandle = binding.restoreAccount(snap.local.e2eeId, newAccountPickle);
|
|
927
|
+
|
|
928
|
+
log?.info?.(
|
|
929
|
+
`${prefix}: decryptSingle: 路径B 返回明文, plaintext.length=${plaintext.length}`,
|
|
930
|
+
);
|
|
931
|
+
return plaintext;
|
|
932
|
+
} catch {
|
|
933
|
+
// createInboundSession 失败 → 发 decryptError 信令
|
|
934
|
+
log?.warn?.(
|
|
935
|
+
`${prefix}: decryptSingle: createInboundSession 失败, fromE2eeId=${fromE2eeId}`,
|
|
936
|
+
);
|
|
937
|
+
// 尽力发送 decryptError 信令(失败仅 WARN,不阻塞主路径)
|
|
938
|
+
await sendDecryptErrorSignaling({ fromId, fromE2eeId, clientMsgId: params.clientMsgId });
|
|
939
|
+
return null;
|
|
940
|
+
}
|
|
941
|
+
},
|
|
942
|
+
|
|
943
|
+
async handleDeviceNotMatch(data: {
|
|
944
|
+
extraDevices?: Array<{ imAcctId: string; e2eeId: string }>;
|
|
945
|
+
missDevices?: Array<{ imAcctId: string; e2eeId: string }>;
|
|
946
|
+
extraImAcctIds?: string[];
|
|
947
|
+
missImAcctIds?: string[];
|
|
948
|
+
}): Promise<void> {
|
|
949
|
+
const prefix = `[e2ee][${accountId}]`;
|
|
950
|
+
const { extraDevices = [], missDevices = [] } = data;
|
|
951
|
+
// 单聊场景忽略账号级 extraImAcctIds / missImAcctIds
|
|
952
|
+
log?.info?.(
|
|
953
|
+
`${prefix}: handleDeviceNotMatch 开始, extraDevices=[${extraDevices.map((d) => `${d.imAcctId}:${d.e2eeId}`).join(',')}], missDevices=[${missDevices.map((d) => `${d.imAcctId}:${d.e2eeId}`).join(',')}]`,
|
|
954
|
+
);
|
|
955
|
+
|
|
956
|
+
if (extraDevices.length === 0 && missDevices.length === 0) {
|
|
957
|
+
log?.info?.(`${prefix}: handleDeviceNotMatch: 无 device 级变更,跳过`);
|
|
958
|
+
return;
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
await store.update((prev) => {
|
|
962
|
+
const now = Date.now();
|
|
963
|
+
const nextRecipients = { ...prev.recipients };
|
|
964
|
+
const nextSessions = { ...prev.sessions };
|
|
965
|
+
|
|
966
|
+
// 按 imAcctId 分组处理
|
|
967
|
+
const contactIds = new Set([
|
|
968
|
+
...extraDevices.map((d) => d.imAcctId),
|
|
969
|
+
...missDevices.map((d) => d.imAcctId),
|
|
970
|
+
]);
|
|
971
|
+
|
|
972
|
+
for (const contactId of contactIds) {
|
|
973
|
+
const oldDeviceIds = new Set(nextRecipients[contactId]?.deviceIds ?? []);
|
|
974
|
+
const missForContact = missDevices
|
|
975
|
+
.filter((d) => d.imAcctId === contactId)
|
|
976
|
+
.map((d) => d.e2eeId);
|
|
977
|
+
const extraForContact = extraDevices
|
|
978
|
+
.filter((d) => d.imAcctId === contactId)
|
|
979
|
+
.map((d) => d.e2eeId);
|
|
980
|
+
|
|
981
|
+
// (old ∪ miss) \ extra
|
|
982
|
+
for (const id of missForContact) oldDeviceIds.add(id);
|
|
983
|
+
for (const id of extraForContact) oldDeviceIds.delete(id);
|
|
984
|
+
|
|
985
|
+
// 更新 recipients
|
|
986
|
+
const newDeviceIds = [...oldDeviceIds];
|
|
987
|
+
log?.info?.(
|
|
988
|
+
`${prefix}: handleDeviceNotMatch: contactId=${contactId}, 变更前deviceIds=[${nextRecipients[contactId]?.deviceIds?.join(',') ?? ''}], 变更后deviceIds=[${newDeviceIds.join(',')}]`,
|
|
989
|
+
);
|
|
990
|
+
if (newDeviceIds.length > 0) {
|
|
991
|
+
nextRecipients[contactId] = {
|
|
992
|
+
...(nextRecipients[contactId] ?? { contactId, updatedAt: now }),
|
|
993
|
+
contactId,
|
|
994
|
+
deviceIds: newDeviceIds,
|
|
995
|
+
updatedAt: now,
|
|
996
|
+
};
|
|
997
|
+
} else {
|
|
998
|
+
delete nextRecipients[contactId];
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
// extra 对应 sessions 标 staleAt
|
|
1002
|
+
const extraE2eeIds = new Set(extraForContact);
|
|
1003
|
+
for (const sessionKey of Object.keys(nextSessions)) {
|
|
1004
|
+
const session = nextSessions[sessionKey]!;
|
|
1005
|
+
if (session.contactId === contactId && extraE2eeIds.has(session.peerE2eeId)) {
|
|
1006
|
+
nextSessions[sessionKey] = { ...session, staleAt: now, updatedAt: now };
|
|
1007
|
+
}
|
|
1008
|
+
}
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
return {
|
|
1012
|
+
next: { ...prev, recipients: nextRecipients, sessions: nextSessions },
|
|
1013
|
+
result: undefined,
|
|
1014
|
+
};
|
|
1015
|
+
});
|
|
1016
|
+
|
|
1017
|
+
log?.info?.(
|
|
1018
|
+
`${prefix}: handleDeviceNotMatch 完成: extra=${extraDevices.length}, miss=${missDevices.length}`,
|
|
1019
|
+
);
|
|
1020
|
+
},
|
|
1021
|
+
|
|
1022
|
+
async dispatchOperation(params: {
|
|
1023
|
+
fromId: string;
|
|
1024
|
+
fromE2eeId: string;
|
|
1025
|
+
plainBytes: Uint8Array;
|
|
1026
|
+
}): Promise<void> {
|
|
1027
|
+
const prefix = `[e2ee][${accountId}]`;
|
|
1028
|
+
const { fromId, plainBytes } = params;
|
|
1029
|
+
log?.info?.(
|
|
1030
|
+
`${prefix}: dispatchOperation 开始, fromId=${fromId}, plainBytes.length=${plainBytes.length}`,
|
|
1031
|
+
);
|
|
1032
|
+
|
|
1033
|
+
// 解码 PbChatOperationEnvelope
|
|
1034
|
+
const registry = await getRegistry();
|
|
1035
|
+
let envelope: DecodedChatOperationEnvelope;
|
|
1036
|
+
try {
|
|
1037
|
+
const decoded = registry.PbChatOperationEnvelope.decode(plainBytes);
|
|
1038
|
+
envelope = decoded as unknown as DecodedChatOperationEnvelope;
|
|
1039
|
+
log?.info?.(
|
|
1040
|
+
`${prefix}: dispatchOperation: 解码成功, oneof case=${envelope.decryptError ? 'decryptError' : envelope.botThinking ? 'botThinking' : envelope.msgRead ? 'msgRead' : 'unknown'}`,
|
|
1041
|
+
);
|
|
1042
|
+
} catch (err) {
|
|
1043
|
+
log?.warn?.(`${prefix}: dispatchOperation: PbChatOperationEnvelope 解码失败: ${err}`);
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
// 按 oneof case 分发
|
|
1048
|
+
if (envelope.decryptError) {
|
|
1049
|
+
// decryptError → handleRemoteDecryptError
|
|
1050
|
+
const content = envelope.decryptError;
|
|
1051
|
+
await this.handleRemoteDecryptError({
|
|
1052
|
+
peerImAcctId: fromId,
|
|
1053
|
+
content: {
|
|
1054
|
+
clientMsgId: content.clientMsgId,
|
|
1055
|
+
e2eeId: content.e2eeId,
|
|
1056
|
+
otherE2eeId: content.otherE2eeId,
|
|
1057
|
+
},
|
|
1058
|
+
});
|
|
1059
|
+
} else if (envelope.botThinking) {
|
|
1060
|
+
// botThinking → 首期仅日志,复用既有 typing 路径由上层处理
|
|
1061
|
+
log?.info?.(
|
|
1062
|
+
`${prefix}: dispatchOperation: 收到 botThinking 信号, status=${envelope.botThinking.status}`,
|
|
1063
|
+
);
|
|
1064
|
+
} else if (envelope.msgRead) {
|
|
1065
|
+
log?.info?.(
|
|
1066
|
+
`${prefix}: dispatchOperation: 收到 msgRead, clientMsgIds=${(envelope.msgRead as { clientMsgIds?: string[] }).clientMsgIds?.join(',')}`,
|
|
1067
|
+
);
|
|
1068
|
+
} else {
|
|
1069
|
+
log?.warn?.(`${prefix}: dispatchOperation: 未知操作类型,忽略`);
|
|
1070
|
+
}
|
|
1071
|
+
},
|
|
1072
|
+
|
|
1073
|
+
async handleRemoteDecryptError(params: {
|
|
1074
|
+
peerImAcctId: string;
|
|
1075
|
+
content: {
|
|
1076
|
+
clientMsgId: string;
|
|
1077
|
+
e2eeId: string;
|
|
1078
|
+
otherE2eeId: string;
|
|
1079
|
+
};
|
|
1080
|
+
}): Promise<void> {
|
|
1081
|
+
const prefix = `[e2ee][${accountId}]`;
|
|
1082
|
+
const { peerImAcctId, content } = params;
|
|
1083
|
+
log?.info?.(
|
|
1084
|
+
`${prefix}: handleRemoteDecryptError 开始, peerImAcctId=${peerImAcctId}, clientMsgId=${content.clientMsgId}, e2eeId=${content.e2eeId}, otherE2eeId=${content.otherE2eeId}`,
|
|
1085
|
+
);
|
|
1086
|
+
|
|
1087
|
+
// 方向校验:content.otherE2eeId 应等于本地 e2eeId
|
|
1088
|
+
const snap = await store.snapshot();
|
|
1089
|
+
if (!snap.local) {
|
|
1090
|
+
log?.warn?.(`${prefix}: handleRemoteDecryptError: local 未初始化,忽略`);
|
|
1091
|
+
return;
|
|
1092
|
+
}
|
|
1093
|
+
if (content.otherE2eeId !== snap.local.e2eeId) {
|
|
1094
|
+
log?.info?.(
|
|
1095
|
+
`${prefix}: handleRemoteDecryptError: 方向校验失败 otherE2eeId=${content.otherE2eeId} != local.e2eeId=${snap.local.e2eeId},忽略`,
|
|
1096
|
+
);
|
|
1097
|
+
return;
|
|
1098
|
+
}
|
|
1099
|
+
|
|
1100
|
+
// 在一次 update 事务内:标 staleAt + 移除 recipient deviceId
|
|
1101
|
+
await store.update((prev) => {
|
|
1102
|
+
const now = Date.now();
|
|
1103
|
+
const nextSessions = { ...prev.sessions };
|
|
1104
|
+
const nextRecipients = { ...prev.recipients };
|
|
1105
|
+
|
|
1106
|
+
// 把 sessions["${peerImAcctId}:${content.e2eeId}:*"] 标 staleAt
|
|
1107
|
+
for (const sessionKey of Object.keys(nextSessions)) {
|
|
1108
|
+
if (sessionKey.startsWith(`${peerImAcctId}:${content.e2eeId}:`)) {
|
|
1109
|
+
const session = nextSessions[sessionKey]!;
|
|
1110
|
+
nextSessions[sessionKey] = { ...session, staleAt: now, updatedAt: now };
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
|
|
1114
|
+
// 把 recipients[peerImAcctId].deviceIds 中对应 content.e2eeId 移除
|
|
1115
|
+
const recipient = nextRecipients[peerImAcctId];
|
|
1116
|
+
if (recipient) {
|
|
1117
|
+
const nextDeviceIds = recipient.deviceIds.filter((id) => id !== content.e2eeId);
|
|
1118
|
+
if (nextDeviceIds.length > 0) {
|
|
1119
|
+
nextRecipients[peerImAcctId] = {
|
|
1120
|
+
...recipient,
|
|
1121
|
+
deviceIds: nextDeviceIds,
|
|
1122
|
+
updatedAt: now,
|
|
1123
|
+
};
|
|
1124
|
+
} else {
|
|
1125
|
+
delete nextRecipients[peerImAcctId];
|
|
1126
|
+
}
|
|
1127
|
+
}
|
|
1128
|
+
|
|
1129
|
+
return {
|
|
1130
|
+
next: { ...prev, sessions: nextSessions, recipients: nextRecipients },
|
|
1131
|
+
result: undefined,
|
|
1132
|
+
};
|
|
1133
|
+
});
|
|
1134
|
+
|
|
1135
|
+
log?.info?.(
|
|
1136
|
+
`${prefix}: handleRemoteDecryptError: 已清理 peer=${peerImAcctId} e2eeId=${content.e2eeId} 的 session/recipient`,
|
|
1137
|
+
);
|
|
1138
|
+
|
|
1139
|
+
// 查 recent-message-cache 命中原文本 → 重发
|
|
1140
|
+
const cached = resolveQuotedMessage(peerImAcctId, content.clientMsgId);
|
|
1141
|
+
if (!cached) {
|
|
1142
|
+
log?.warn?.(
|
|
1143
|
+
`${prefix}: handleRemoteDecryptError: 缓存未命中 clientMsgId=${content.clientMsgId},不重发`,
|
|
1144
|
+
);
|
|
1145
|
+
return;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// 仅文本消息重发;非文本(如 [media]、[markdown])不重发
|
|
1149
|
+
if (
|
|
1150
|
+
cached.body.startsWith('[') &&
|
|
1151
|
+
(cached.body === '[media]' || cached.body === '[markdown]')
|
|
1152
|
+
) {
|
|
1153
|
+
log?.warn?.(
|
|
1154
|
+
`${prefix}: handleRemoteDecryptError: 原消息非文本(body=${cached.body}),不重发`,
|
|
1155
|
+
);
|
|
1156
|
+
return;
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
// 用新 clientMsgId 调 sendTextToPeer 重发(动态导入避免循环依赖)
|
|
1160
|
+
const newClientMsgId = randomUUID();
|
|
1161
|
+
log?.info?.(
|
|
1162
|
+
`${prefix}: handleRemoteDecryptError: 开始重发, peer=${peerImAcctId}, oldClientMsgId=${content.clientMsgId}, newClientMsgId=${newClientMsgId}`,
|
|
1163
|
+
);
|
|
1164
|
+
try {
|
|
1165
|
+
const { sendTextToPeer } = await import('../send.js');
|
|
1166
|
+
await sendTextToPeer(
|
|
1167
|
+
{ apiBaseUrl, auth, accountId, fromId: botAcctId, e2eeService: service, log },
|
|
1168
|
+
{ to: peerImAcctId, text: cached.body, clientMsgId: newClientMsgId },
|
|
1169
|
+
);
|
|
1170
|
+
log?.info?.(
|
|
1171
|
+
`${prefix}: handleRemoteDecryptError: 重发成功 peer=${peerImAcctId} newClientMsgId=${newClientMsgId}`,
|
|
1172
|
+
);
|
|
1173
|
+
} catch (err) {
|
|
1174
|
+
log?.warn?.(
|
|
1175
|
+
`${prefix}: handleRemoteDecryptError: 重发失败 peer=${peerImAcctId} clientMsgId=${content.clientMsgId}, err: ${err}`,
|
|
1176
|
+
);
|
|
1177
|
+
}
|
|
1178
|
+
},
|
|
1179
|
+
|
|
1180
|
+
async clearPeer(peerImAcctId: string): Promise<void> {
|
|
1181
|
+
const prefix = `[e2ee][${accountId}]`;
|
|
1182
|
+
|
|
1183
|
+
await store.update((prev) => {
|
|
1184
|
+
const now = Date.now();
|
|
1185
|
+
const nextRecipients = { ...prev.recipients };
|
|
1186
|
+
const nextSessions = { ...prev.sessions };
|
|
1187
|
+
|
|
1188
|
+
// 删除 recipients[peerImAcctId]
|
|
1189
|
+
delete nextRecipients[peerImAcctId];
|
|
1190
|
+
|
|
1191
|
+
// 删除所有 key 以 `${peerImAcctId}:` 开头的 session
|
|
1192
|
+
for (const sessionKey of Object.keys(nextSessions)) {
|
|
1193
|
+
if (sessionKey.startsWith(`${peerImAcctId}:`)) {
|
|
1194
|
+
delete nextSessions[sessionKey];
|
|
1195
|
+
}
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
return {
|
|
1199
|
+
next: { ...prev, recipients: nextRecipients, sessions: nextSessions },
|
|
1200
|
+
result: undefined,
|
|
1201
|
+
};
|
|
1202
|
+
});
|
|
1203
|
+
|
|
1204
|
+
log?.info?.(`${prefix}: clearPeer 完成: peer=${peerImAcctId}`);
|
|
1205
|
+
},
|
|
1206
|
+
|
|
1207
|
+
async dispose(): Promise<void> {
|
|
1208
|
+
// 释放 WASM handle
|
|
1209
|
+
if (accountHandle) {
|
|
1210
|
+
accountHandle.dispose();
|
|
1211
|
+
accountHandle = null;
|
|
1212
|
+
}
|
|
1213
|
+
},
|
|
1214
|
+
};
|
|
1215
|
+
|
|
1216
|
+
// ── 内部辅助:解密失败后发送 decryptError 信令(尽力而为) ────────────────
|
|
1217
|
+
|
|
1218
|
+
/**
|
|
1219
|
+
* 解密失败后向原发送方回发 decryptError 信令(明文,不经过 session 加密)。
|
|
1220
|
+
* 整个流程 try/catch 包裹:失败仅 WARN,不重试,不阻塞主路径。
|
|
1221
|
+
*
|
|
1222
|
+
* 与 iOS 对齐:decryptError 信令走明文通道(createGroupSignalPlainMsgReqBody),
|
|
1223
|
+
* 因为解密失败时 session 可能已损坏,无法再用于加密。
|
|
1224
|
+
*/
|
|
1225
|
+
async function sendDecryptErrorSignaling(params: {
|
|
1226
|
+
fromId: string;
|
|
1227
|
+
fromE2eeId: string;
|
|
1228
|
+
clientMsgId: string;
|
|
1229
|
+
}): Promise<void> {
|
|
1230
|
+
const prefix = `[e2ee][${accountId}]`;
|
|
1231
|
+
try {
|
|
1232
|
+
const snap = await store.snapshot();
|
|
1233
|
+
if (!snap.local) return;
|
|
1234
|
+
|
|
1235
|
+
log?.info?.(
|
|
1236
|
+
`${prefix}: sendDecryptErrorSignaling: 发送前, fromId=${params.fromId}, clientMsgId=${params.clientMsgId}`,
|
|
1237
|
+
);
|
|
1238
|
+
|
|
1239
|
+
// 1. 构造 PbMessageDecryptErrorSendContent
|
|
1240
|
+
const content = {
|
|
1241
|
+
clientMsgId: params.clientMsgId,
|
|
1242
|
+
e2eeId: snap.local.e2eeId,
|
|
1243
|
+
otherE2eeId: params.fromE2eeId,
|
|
1244
|
+
};
|
|
1245
|
+
|
|
1246
|
+
// 2. 构造 PbChatOperationEnvelope(decryptError) bytes
|
|
1247
|
+
const opEnvelopeBytes = await buildDecryptErrorEnvelopeBytes(content);
|
|
1248
|
+
|
|
1249
|
+
// 3. 明文发送:直接把 opEnvelopeBytes 作为 envelope,不经过 encryptSingle
|
|
1250
|
+
const signalingClientMsgId = genClientMsgId();
|
|
1251
|
+
const packetBytes = await encodeSingleChatReqPacket({
|
|
1252
|
+
fromId: botAcctId,
|
|
1253
|
+
to: params.fromId,
|
|
1254
|
+
clientMsgId: signalingClientMsgId,
|
|
1255
|
+
envelopeBytes: opEnvelopeBytes,
|
|
1256
|
+
e2eeFlag: true,
|
|
1257
|
+
fromE2eeId: snap.local.e2eeId,
|
|
1258
|
+
envelopeType: 2,
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
await postProto(apiBaseUrl, '/api/im/open/bot/sendMessage', packetBytes, auth);
|
|
1262
|
+
log?.info?.(
|
|
1263
|
+
`${prefix}: sendDecryptErrorSignaling: 发送成功, fromId=${params.fromId}, clientMsgId=${params.clientMsgId}`,
|
|
1264
|
+
);
|
|
1265
|
+
} catch (err) {
|
|
1266
|
+
log?.warn?.(
|
|
1267
|
+
`${prefix}: decryptError 信令发送失败 (peerImAcctId=${params.fromId}, clientMsgId=${params.clientMsgId}): ${err}`,
|
|
1268
|
+
);
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
return service;
|
|
1273
|
+
}
|