@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.
Files changed (59) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +172 -0
  3. package/index.ts +16 -0
  4. package/openclaw.plugin.json +58 -0
  5. package/package.json +73 -0
  6. package/proto/PbBoxPullProto.proto +43 -0
  7. package/proto/PbChatAudioContent.proto +23 -0
  8. package/proto/PbChatDeliverMsg.proto +38 -0
  9. package/proto/PbChatFileMeta.proto +34 -0
  10. package/proto/PbChatMsg.proto +93 -0
  11. package/proto/PbChatRichMediaContent.proto +31 -0
  12. package/proto/PbChatTextContent.proto +38 -0
  13. package/proto/PbMarkdownContent.proto +18 -0
  14. package/proto/PbMsgReadStampContent.proto +11 -0
  15. package/proto/PbPacket.proto +61 -0
  16. package/proto/PbSingleChatMsg.proto +60 -0
  17. package/setup-entry.ts +17 -0
  18. package/src/accounts.ts +109 -0
  19. package/src/api-client.ts +740 -0
  20. package/src/bot-info-cache.ts +49 -0
  21. package/src/channel.runtime.ts +29 -0
  22. package/src/channel.ts +456 -0
  23. package/src/config-schema.ts +26 -0
  24. package/src/e2ee/api.ts +261 -0
  25. package/src/e2ee/canonical.ts +59 -0
  26. package/src/e2ee/errors.ts +103 -0
  27. package/src/e2ee/index.ts +8 -0
  28. package/src/e2ee/proper-lockfile.d.ts +61 -0
  29. package/src/e2ee/service.ts +1273 -0
  30. package/src/e2ee/store.ts +174 -0
  31. package/src/e2ee/types.ts +113 -0
  32. package/src/e2ee/vodozemac.ts +373 -0
  33. package/src/file-transfer/api.ts +364 -0
  34. package/src/file-transfer/concurrency.ts +77 -0
  35. package/src/file-transfer/download.ts +261 -0
  36. package/src/file-transfer/file-crypto.ts +93 -0
  37. package/src/file-transfer/index.ts +18 -0
  38. package/src/file-transfer/scheduler.ts +185 -0
  39. package/src/file-transfer/types.ts +195 -0
  40. package/src/file-transfer/upload.ts +656 -0
  41. package/src/markdown-detect.ts +119 -0
  42. package/src/media-upload.ts +338 -0
  43. package/src/media-utils.ts +110 -0
  44. package/src/monitor.ts +838 -0
  45. package/src/proto/codec.ts +54 -0
  46. package/src/proto/inbound.codec.ts +624 -0
  47. package/src/proto/proto-types.ts +291 -0
  48. package/src/proto/registry.ts +226 -0
  49. package/src/proto/send.codec.ts +535 -0
  50. package/src/recent-message-cache.ts +350 -0
  51. package/src/send.ts +792 -0
  52. package/src/setup-core.ts +62 -0
  53. package/src/types.ts +153 -0
  54. package/src/vodozemackit/index.ts +297 -0
  55. package/src/vodozemackit/pkg/vodozemackit_wasm.d.ts +138 -0
  56. package/src/vodozemackit/pkg/vodozemackit_wasm.js +24 -0
  57. package/src/vodozemackit/pkg/vodozemackit_wasm_bg.js +1172 -0
  58. package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm +0 -0
  59. package/src/vodozemackit/pkg/vodozemackit_wasm_bg.wasm.d.ts +109 -0
@@ -0,0 +1,54 @@
1
+ /**
2
+ * codec.ts — Protobuf 编解码统一入口
3
+ *
4
+ * 基于 protobufjs 运行时动态加载 .proto 文件,无需代码生成步骤。
5
+ * .proto 文件(proto/ 目录)是唯一的结构定义来源,修改后重启进程即生效。
6
+ *
7
+ * 入站(inbound)解包链路:
8
+ * decodeInboundPacket(packetBytes)
9
+ * → PbPacket → PbRequest.body → PbChatMsgDeliverBody
10
+ * → PbChatMsgEnvelope → PbChatTextContent
11
+ * → InboundTextMessage(OpenClaw ctx 所需格式)
12
+ *
13
+ * 出站(outbound)编码链路:
14
+ * encodeSendRequest(to, text)
15
+ * → PbChatTextContent → PbChatMsgEnvelope → PbSingleChatMsgReqBody
16
+ * → PbRequest → PbPacket → Uint8Array
17
+ *
18
+ * 所有公开函数均为 async,首次调用时触发 .proto 文件加载(约几毫秒),
19
+ * 后续调用使用缓存,无额外开销。
20
+ */
21
+
22
+ // ── 入站:完整四层解包 ────────────────────────────────────────────────────────
23
+ export { decodeInboundPacket } from './inbound.codec.js';
24
+ export type {
25
+ InboundTextMessage,
26
+ InboundDecodeResult,
27
+ InboundDecodeSkip,
28
+ InboundAttachment,
29
+ } from './inbound.codec.js';
30
+
31
+ // ── 出站:发送消息编码 + 发送响应解码 ────────────────────────────────────────
32
+ export {
33
+ genClientMsgId,
34
+ encodeSendRequest,
35
+ encodeTypingSignalRequest,
36
+ encodeMediaSendRequest,
37
+ encodeMarkdownSendRequest,
38
+ decodeSendResponse,
39
+ } from './send.codec.js';
40
+ export type { OutboundMediaParams, OutboundMarkdownParams } from './send.codec.js';
41
+
42
+ // ── 类型注册表(供高级用途,如自定义解码场景) ────────────────────────────────
43
+ export { getRegistry } from './registry.js';
44
+ export type { ProtoRegistry } from './registry.js';
45
+
46
+ // ── Proto 字段类型接口(供测试和类型断言使用) ────────────────────────────────
47
+ export type {
48
+ DecodedPbPacket,
49
+ DecodedPbRequest,
50
+ DecodedDeliverBody,
51
+ DecodedChatMsgEnvelope,
52
+ DecodedTextContent,
53
+ DecodedBodyRange,
54
+ } from './proto-types.js';
@@ -0,0 +1,624 @@
1
+ /**
2
+ * inbound.codec.ts — 入站消息完整解包
3
+ *
4
+ * 使用 protobufjs 运行时动态加载 .proto 文件进行解码,
5
+ * .proto 文件是唯一的结构定义来源(proto/ 目录)。
6
+ *
7
+ * 四层解包链路:
8
+ *
9
+ * 第1层 PbPacket(传输容器,PbPacket.proto)
10
+ * ↓ type=SERVER_REQ(2) → request
11
+ * 第2层 PbRequest(请求体,PbPacket.proto)
12
+ * ↓ body[bytes] 按 path 反序列化
13
+ * 第3层 PbChatMsgDeliverBody(统一投递体,PbChatDeliverMsg.proto)
14
+ * ↓ envelope[bytes] 按 envelopeType 反序列化
15
+ * 第4层 PbChatMsgEnvelope(信封,PbChatMsg.proto)
16
+ * ↓ oneof textContent(字段3)
17
+ * PbChatTextContent(文本内容,PbChatTextContent.proto)
18
+ *
19
+ * 当前处理:
20
+ * - envelopeType=1(聊天消息信封)
21
+ * - textContent(文本消息,oneof 字段 3)
22
+ * - richMediaContent(图片/视频消息,oneof 字段 9)
23
+ * - fileContent(文件消息,oneof 字段 13)
24
+ * - audioContent(音频消息,oneof 字段 4)
25
+ * 其他类型返回 skip,由 monitor.ts 跳过。
26
+ */
27
+
28
+ import { getRegistry } from './registry.js';
29
+ import type {
30
+ DecodedPbPacket,
31
+ DecodedDeliverBody,
32
+ DecodedChatMsgEnvelope,
33
+ DecodedBodyRange,
34
+ DecodedRichMediaContent,
35
+ DecodedAudioContent,
36
+ DecodedFileMeta,
37
+ } from './proto-types.js';
38
+ import type { ImweInboundMessage } from '../types.js';
39
+
40
+ // ─────────────────────────────────────────────────────────────────────────────
41
+ // 常量
42
+ // ─────────────────────────────────────────────────────────────────────────────
43
+
44
+ /** PbType 枚举值(对应 PbPacket.proto) */
45
+ const PB_TYPE_SERVER_REQ = 2;
46
+
47
+ /** 信封类型枚举值(对应 PbChatDeliverMsg.proto envelopeType 字段注释) */
48
+ const ENVELOPE_TYPE_CHAT = 1;
49
+
50
+ // ─────────────────────────────────────────────────────────────────────────────
51
+ // 附件类型
52
+ // ─────────────────────────────────────────────────────────────────────────────
53
+
54
+ /** 入站消息附件(从 protobuf 解码后的标准化结构) */
55
+ export type InboundAttachment = {
56
+ /** 附件类型:image / video / audio / file */
57
+ type: 'image' | 'video' | 'audio' | 'file';
58
+ /** 文件下载 URL(来自 PbChatFileMeta.url) */
59
+ url: string;
60
+ /** 文件 MIME 类型(从 fileType 推断) */
61
+ mimeType?: string;
62
+ /** 文件名 */
63
+ fileName?: string;
64
+ /** 文件大小(字节) */
65
+ fileSize?: number;
66
+ /** 图片/视频宽度(像素) */
67
+ width?: number;
68
+ /** 图片/视频高度(像素) */
69
+ height?: number;
70
+ /** 音频/视频时长(秒) */
71
+ duration?: number;
72
+ /** 图片/视频文字描述 */
73
+ caption?: string;
74
+ /** AES-256 文件加密密钥(32 字节,E2EE 文件传输时存在) */
75
+ fileKey?: Uint8Array;
76
+ /** AES-CTR 初始向量(16 字节,E2EE 文件传输时存在) */
77
+ fileIv?: Uint8Array;
78
+ /** SHA256(plaintext) 小写十六进制摘要 */
79
+ fileDigest?: string;
80
+ /** 明文长度(字节) */
81
+ plaintextLength?: number;
82
+ /** 操作凭证(下载回调用) */
83
+ opCreds?: string;
84
+ };
85
+
86
+ // ─────────────────────────────────────────────────────────────────────────────
87
+ // PbChatFileMetaType 枚举映射
88
+ // ─────────────────────────────────────────────────────────────────────────────
89
+
90
+ /**
91
+ * 将 PbChatFileMetaType 枚举值映射为 TypeScript 字符串类型。
92
+ * 0=UNKNOWN→file, 1=IMAGE→image, 2=VIDEO→video, 3=AUDIO→audio, 4=FILE→file
93
+ * 未知值安全回退为 'file'。
94
+ */
95
+ export function mapFileMetaType(protoType: number): 'image' | 'video' | 'audio' | 'file' {
96
+ switch (protoType) {
97
+ case 1:
98
+ return 'image';
99
+ case 2:
100
+ return 'video';
101
+ case 3:
102
+ return 'audio';
103
+ default:
104
+ return 'file';
105
+ }
106
+ }
107
+
108
+ // ─────────────────────────────────────────────────────────────────────────────
109
+ // 解包结果类型
110
+ // ─────────────────────────────────────────────────────────────────────────────
111
+
112
+ /** 解包成功,得到文本消息 */
113
+ export type InboundTextMessage = ImweInboundMessage & {
114
+ /** 服务端消息 ID(用于 ACK) */
115
+ serverMsgId: string;
116
+ /** 会话类型:1=P2P 单聊,2=CHATROOM 群聊(对应 PbContactType) */
117
+ convType: number;
118
+ /** 接收方 ID(单聊=账户ID,群聊=群组ID) */
119
+ toId: string;
120
+ };
121
+
122
+ /** 解包失败或不支持的消息类型,由调用方决定是否记录日志 */
123
+ export type InboundDecodeSkip = {
124
+ reason:
125
+ | 'not-server-req' // PbPacket.type 不是 SERVER_REQ
126
+ | 'no-body' // PbRequest.body 为空
127
+ | 'unsupported-envelope-type' // envelopeType 不是 1(聊天消息)
128
+ | 'unsupported-content-type' // oneof 不是 textContent(非文本消息)
129
+ | 'empty-envelope'; // envelope 字节为空
130
+ detail?: string;
131
+ };
132
+
133
+ export type InboundDecodeResult =
134
+ | { ok: true; message: InboundTextMessage }
135
+ | { ok: false; skip: InboundDecodeSkip };
136
+
137
+ // ─────────────────────────────────────────────────────────────────────────────
138
+ // 多媒体内容解码辅助函数
139
+ // ─────────────────────────────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * 当 fileMeta.mediaType 为 UNKNOWN(0) 或缺失时,从 MIME type 或 URL 后缀辅助判断媒体类型。
143
+ * 无法判断时回退到调用方指定的 defaultType。
144
+ */
145
+ function guessMediaTypeFallback(
146
+ fileMeta: DecodedFileMeta,
147
+ defaultType: 'image' | 'video' | 'file',
148
+ ): 'image' | 'video' | 'audio' | 'file' {
149
+ // MIME type 辅助判断
150
+ if (fileMeta.fileType) {
151
+ if (fileMeta.fileType.startsWith('video/')) return 'video';
152
+ if (fileMeta.fileType.startsWith('image/')) return 'image';
153
+ if (fileMeta.fileType.startsWith('audio/')) return 'audio';
154
+ }
155
+ // URL 后缀辅助判断
156
+ if (fileMeta.url) {
157
+ const lower = fileMeta.url.toLowerCase().split('?')[0];
158
+ if (/\.(mp4|mov|avi|mkv|webm|flv)$/.test(lower)) return 'video';
159
+ if (/\.(jpe?g|png|gif|webp|bmp|svg|heic|heif)$/.test(lower)) return 'image';
160
+ if (/\.(mp3|wav|ogg|aac|m4a|flac|opus|amr)$/.test(lower)) return 'audio';
161
+ }
162
+ return defaultType;
163
+ }
164
+
165
+ /**
166
+ * 从 DecodedFileMeta 中提取 E2EE 文件加密相关字段。
167
+ * key/iv 为空(长度为 0)时视为不存在,返回 undefined(向后兼容)。
168
+ */
169
+ function extractFileEncryptionFields(
170
+ fileMeta: DecodedFileMeta,
171
+ ): Pick<InboundAttachment, 'fileKey' | 'fileIv' | 'fileDigest' | 'plaintextLength' | 'opCreds'> {
172
+ const fileKey = fileMeta.key && fileMeta.key.length > 0 ? fileMeta.key : undefined;
173
+ const fileIv = fileMeta.iv && fileMeta.iv.length > 0 ? fileMeta.iv : undefined;
174
+ const fileDigest = fileMeta.digest || undefined;
175
+ const plaintextLength =
176
+ fileMeta.plaintextLength != null && Number(fileMeta.plaintextLength) > 0
177
+ ? Number(fileMeta.plaintextLength)
178
+ : undefined;
179
+ const opCreds = fileMeta.opCreds || undefined;
180
+
181
+ return {
182
+ ...(fileKey ? { fileKey } : {}),
183
+ ...(fileIv ? { fileIv } : {}),
184
+ ...(fileDigest ? { fileDigest } : {}),
185
+ ...(plaintextLength != null ? { plaintextLength } : {}),
186
+ ...(opCreds ? { opCreds } : {}),
187
+ };
188
+ }
189
+
190
+ /**
191
+ * 从 DecodedRichMediaContent 提取 InboundAttachment 数组。
192
+ * 过滤掉无 fileMeta 或 fileMeta.url 为空的条目。
193
+ *
194
+ * @param content 解码后的富媒体内容
195
+ * @param defaultType 默认附件类型(当 fileMeta.mediaType 为 0/UNKNOWN 时使用)
196
+ */
197
+ function decodeRichMediaContent(
198
+ content: DecodedRichMediaContent,
199
+ defaultType: 'image' | 'video' | 'file',
200
+ ): InboundAttachment[] {
201
+ const attachments: InboundAttachment[] = [];
202
+ for (const meta of content.meta) {
203
+ if (!meta.fileMeta || !meta.fileMeta.url) continue;
204
+
205
+ const mapped = mapFileMetaType(meta.fileMeta.mediaType);
206
+ // 当映射结果为 'file'(即 UNKNOWN/FILE)时,尝试从 MIME type 或 URL 后缀辅助判断
207
+ const type = mapped === 'file' ? guessMediaTypeFallback(meta.fileMeta, defaultType) : mapped;
208
+
209
+ attachments.push({
210
+ type,
211
+ url: meta.fileMeta.url,
212
+ ...(meta.fileMeta.fileType ? { mimeType: meta.fileMeta.fileType } : {}),
213
+ ...(meta.fileName ? { fileName: meta.fileName } : {}),
214
+ ...(meta.fileSize != null ? { fileSize: Number(meta.fileSize) } : {}),
215
+ ...(meta.width != null ? { width: Number(meta.width) } : {}),
216
+ ...(meta.height != null ? { height: Number(meta.height) } : {}),
217
+ ...(meta.duration != null ? { duration: Number(meta.duration) } : {}),
218
+ ...((meta.caption ?? content.caption) ? { caption: meta.caption ?? content.caption } : {}),
219
+ ...extractFileEncryptionFields(meta.fileMeta),
220
+ });
221
+ }
222
+ return attachments;
223
+ }
224
+
225
+ /**
226
+ * 从 DecodedAudioContent 提取单个 InboundAttachment。
227
+ * 若 meta 或 fileMeta.url 为空则返回 null。
228
+ */
229
+ function decodeAudioContent(content: DecodedAudioContent): InboundAttachment | null {
230
+ if (!content.meta?.fileMeta?.url) return null;
231
+
232
+ return {
233
+ type: 'audio',
234
+ url: content.meta.fileMeta.url,
235
+ ...(content.meta.fileMeta.fileType ? { mimeType: content.meta.fileMeta.fileType } : {}),
236
+ ...(content.meta.duration != null ? { duration: Number(content.meta.duration) } : {}),
237
+ ...extractFileEncryptionFields(content.meta.fileMeta),
238
+ };
239
+ }
240
+
241
+ // ─────────────────────────────────────────────────────────────────────────────
242
+ // 主解包函数
243
+ // ─────────────────────────────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * 将平台推送的 PbPacket 二进制四层解包为 OpenClaw 可用的消息对象。
247
+ *
248
+ * 首次调用时会触发 .proto 文件加载(约几毫秒),后续调用使用缓存。
249
+ *
250
+ * @param packetBytes 平台推送的原始 protobuf 二进制(最外层 PbPacket)
251
+ * @returns 解包结果:成功返回 InboundTextMessage,失败返回跳过原因
252
+ */
253
+ export async function decodeInboundPacket(packetBytes: Uint8Array): Promise<InboundDecodeResult> {
254
+ const reg = await getRegistry();
255
+
256
+ // ── 第1层:PbPacket.decode() ──────────────────────────────────────────────
257
+ const packet = reg.PbPacket.decode(packetBytes) as unknown as DecodedPbPacket;
258
+
259
+ if (packet.type !== PB_TYPE_SERVER_REQ || !packet.request) {
260
+ return {
261
+ ok: false,
262
+ skip: { reason: 'not-server-req', detail: `type=${packet.type}` },
263
+ };
264
+ }
265
+
266
+ // ── 第2层:PbChatMsgDeliverBody.decode(PbRequest.body) ───────────────────
267
+ const body = packet.request.body;
268
+ if (!body || body.length === 0) {
269
+ return {
270
+ ok: false,
271
+ skip: { reason: 'no-body', detail: `path=${packet.request.path}` },
272
+ };
273
+ }
274
+
275
+ const deliver = reg.PbChatMsgDeliverBody.decode(body) as unknown as DecodedDeliverBody;
276
+
277
+ // 只处理聊天消息信封(envelopeType=1)
278
+ if (Number(deliver.envelopeType) !== ENVELOPE_TYPE_CHAT) {
279
+ return {
280
+ ok: false,
281
+ skip: {
282
+ reason: 'unsupported-envelope-type',
283
+ detail: `envelopeType=${deliver.envelopeType}`,
284
+ },
285
+ };
286
+ }
287
+
288
+ // ── 第3层:PbChatMsgEnvelope.decode(envelope) ────────────────────────────
289
+ if (!deliver.envelope || deliver.envelope.length === 0) {
290
+ return { ok: false, skip: { reason: 'empty-envelope' } };
291
+ }
292
+
293
+ const envelope = reg.PbChatMsgEnvelope.decode(
294
+ deliver.envelope,
295
+ ) as unknown as DecodedChatMsgEnvelope;
296
+
297
+ // ── 第4层:oneof 内容分支 ─────────────────────────────────────────────────
298
+ // protobufjs 对 oneof 的处理:只有当前激活的字段有值,其余为 undefined
299
+
300
+ if (envelope.textContent) {
301
+ // ── 文本消息(字段3)────────────────────────────────────────────────────
302
+ const textContent = envelope.textContent;
303
+ const bodyRanges = textContent.bodyRanges ?? [];
304
+
305
+ const message: InboundTextMessage = {
306
+ msgId: deliver.clientMsgId,
307
+ senderId: deliver.fromId,
308
+ content: textContent.text,
309
+ timestampMs: Number(deliver.serverStamp),
310
+ serverMsgId: deliver.serverMsgId,
311
+ convType: deliver.convType,
312
+ toId: deliver.toId,
313
+ ...(bodyRanges.length > 0
314
+ ? {
315
+ bodyRanges: bodyRanges.map((r) => ({
316
+ key: r.key as 0 | 1 | 2,
317
+ payload: r.payload,
318
+ ...(r.start !== undefined ? { start: r.start } : {}),
319
+ ...(r.length !== undefined ? { length: r.length } : {}),
320
+ })),
321
+ }
322
+ : {}),
323
+ ...(textContent.referenceClientMsgId
324
+ ? { referenceClientMsgId: textContent.referenceClientMsgId }
325
+ : {}),
326
+ };
327
+
328
+ return { ok: true, message };
329
+ }
330
+
331
+ if (envelope.richMediaContent) {
332
+ // ── 图片/视频消息(字段9)──────────────────────────────────────────────
333
+ const attachments = decodeRichMediaContent(envelope.richMediaContent, 'image');
334
+ const message: InboundTextMessage = {
335
+ msgId: deliver.clientMsgId,
336
+ senderId: deliver.fromId,
337
+ content: envelope.richMediaContent.caption ?? '',
338
+ timestampMs: Number(deliver.serverStamp),
339
+ serverMsgId: deliver.serverMsgId,
340
+ convType: deliver.convType,
341
+ toId: deliver.toId,
342
+ ...(attachments.length > 0 ? { attachments } : {}),
343
+ };
344
+ return { ok: true, message };
345
+ }
346
+
347
+ if (envelope.fileContent) {
348
+ // ── 文件消息(字段13,结构同 richMediaContent)─────────────────────────
349
+ const attachments = decodeRichMediaContent(envelope.fileContent, 'file');
350
+ const message: InboundTextMessage = {
351
+ msgId: deliver.clientMsgId,
352
+ senderId: deliver.fromId,
353
+ content: envelope.fileContent.caption ?? '',
354
+ timestampMs: Number(deliver.serverStamp),
355
+ serverMsgId: deliver.serverMsgId,
356
+ convType: deliver.convType,
357
+ toId: deliver.toId,
358
+ ...(attachments.length > 0 ? { attachments } : {}),
359
+ };
360
+ return { ok: true, message };
361
+ }
362
+
363
+ if (envelope.audioContent) {
364
+ // ── 音频消息(字段4)──────────────────────────────────────────────────
365
+ const attachment = decodeAudioContent(envelope.audioContent);
366
+ const attachments = attachment ? [attachment] : [];
367
+ const message: InboundTextMessage = {
368
+ msgId: deliver.clientMsgId,
369
+ senderId: deliver.fromId,
370
+ content: envelope.audioContent.speech ?? '',
371
+ timestampMs: Number(deliver.serverStamp),
372
+ serverMsgId: deliver.serverMsgId,
373
+ convType: deliver.convType,
374
+ toId: deliver.toId,
375
+ ...(attachments.length > 0 ? { attachments } : {}),
376
+ };
377
+ return { ok: true, message };
378
+ }
379
+
380
+ return {
381
+ ok: false,
382
+ skip: {
383
+ reason: 'unsupported-content-type',
384
+ detail: 'no known oneof field present in envelope',
385
+ },
386
+ };
387
+ }
388
+
389
+ // ─────────────────────────────────────────────────────────────────────────────
390
+ // E2EE 分叉:decodeInboundPacketWithE2ee(task 6.3 / 6.4)
391
+ //
392
+ // 在原 decodeInboundPacket 基础上追加 e2eeFlag 的分叉处理:
393
+ // - e2eeFlag=false → 走原明文路径
394
+ // - e2eeFlag=true → 校验 toE2eeId == local.e2eeId,然后调 e2eeDecrypt 回调;
395
+ // 回调返回明文字节后替换 deliver.envelope 再走原解码流程;返回 null →
396
+ // skip: 'decrypt-failed';envelopeType=2 的操作消息由 service 内部分发 →
397
+ // skip: 'operation-consumed'
398
+ //
399
+ // 模块边界约束(requirement R12):
400
+ // 本文件顶层不得 import `../vodozemackit` 或 `src/e2ee/*`,只通过注入的
401
+ // e2eeDecrypt 回调访问 E2EE 能力。
402
+ // ─────────────────────────────────────────────────────────────────────────────
403
+
404
+ /** e2eeDecrypt 回调的入参,由 E2eeService 在 monitor 层注入 */
405
+ export interface E2eeDecryptParams {
406
+ fromId: string;
407
+ fromE2eeId: string;
408
+ toE2eeId: string;
409
+ e2eeSid: string;
410
+ encryptedBody: Uint8Array;
411
+ clientMsgId: string;
412
+ envelopeType: number;
413
+ }
414
+
415
+ /** e2eeDecrypt 回调:成功返回明文字节,失败返回 null(由 service 内部处理反向信令) */
416
+ export type E2eeDecryptFn = (params: E2eeDecryptParams) => Promise<Uint8Array | null>;
417
+
418
+ /** E2EE 分叉特有的 skip 原因 */
419
+ export type InboundE2eeSkipReason =
420
+ | 'decrypt-failed' // e2eeDecrypt 回调返回 null
421
+ | 'toE2eeId-mismatch' // 目标设备不是本端
422
+ | 'operation-consumed' // envelopeType=2 的操作消息,由 service 内部消费
423
+ | 'missing-e2ee-fields'; // e2eeFlag=true 但缺 fromE2eeId/toE2eeId/e2eeSid
424
+
425
+ /** 带 E2EE 解密回调的解包结果 */
426
+ export type InboundE2eeDecodeResult =
427
+ | { ok: true; message: InboundTextMessage }
428
+ | { ok: false; skip: InboundDecodeSkip | { reason: InboundE2eeSkipReason; detail?: string } };
429
+
430
+ const ENVELOPE_TYPE_OPERATION = 2;
431
+
432
+ /**
433
+ * 将平台推送的 PbPacket 二进制四层解包(E2EE 版)。
434
+ *
435
+ * 与 decodeInboundPacket 的差异:
436
+ * - 在 decoding deliver body 后按 `deliver.e2eeFlag` 分叉
437
+ * - e2eeFlag=true 时注入的 `e2eeDecrypt` 回调返回明文字节后继续走原解码流程
438
+ *
439
+ * @param packetBytes 平台推送的原始 protobuf 二进制
440
+ * @param opts.e2eeDecrypt 必填回调;由 E2eeService.decryptSingle 适配
441
+ */
442
+ export async function decodeInboundPacketWithE2ee(
443
+ packetBytes: Uint8Array,
444
+ opts: { e2eeDecrypt: E2eeDecryptFn },
445
+ ): Promise<InboundE2eeDecodeResult> {
446
+ const reg = await getRegistry();
447
+
448
+ // ── 第1层:PbPacket.decode() ──────────────────────────────────────────────
449
+ const packet = reg.PbPacket.decode(packetBytes) as unknown as DecodedPbPacket;
450
+
451
+ if (packet.type !== PB_TYPE_SERVER_REQ || !packet.request) {
452
+ return { ok: false, skip: { reason: 'not-server-req', detail: `type=${packet.type}` } };
453
+ }
454
+
455
+ // ── 第2层:PbChatMsgDeliverBody.decode(PbRequest.body) ───────────────────
456
+ const body = packet.request.body;
457
+ if (!body || body.length === 0) {
458
+ return { ok: false, skip: { reason: 'no-body', detail: `path=${packet.request.path}` } };
459
+ }
460
+
461
+ const deliver = reg.PbChatMsgDeliverBody.decode(body) as unknown as DecodedDeliverBody;
462
+
463
+ // ── E2EE 分叉:e2eeFlag=false 走原路径 ───────────────────────────────────
464
+ if (!deliver.e2eeFlag) {
465
+ // 明文路径:等同于 decodeInboundPacket 的后续流程
466
+ return decodeInboundPacketPlaintextTail(reg, deliver);
467
+ }
468
+
469
+ // ── E2EE 分叉:e2eeFlag=true ────────────────────────────────────────────
470
+ // 校验必需字段
471
+ if (!deliver.fromE2eeId || !deliver.toE2eeId || !deliver.e2eeSid) {
472
+ return {
473
+ ok: false,
474
+ skip: {
475
+ reason: 'missing-e2ee-fields',
476
+ detail: `fromE2eeId=${!!deliver.fromE2eeId}, toE2eeId=${!!deliver.toE2eeId}, e2eeSid=${!!deliver.e2eeSid}`,
477
+ },
478
+ };
479
+ }
480
+
481
+ if (!deliver.envelope || deliver.envelope.length === 0) {
482
+ return { ok: false, skip: { reason: 'empty-envelope' } };
483
+ }
484
+
485
+ // 调用注入的 e2eeDecrypt 回调;回调内部负责:
486
+ // - toE2eeId 与 local.e2eeId 的匹配校验
487
+ // - 解密失败时构造 decryptError 信令、打标 session.staleAt
488
+ // - 解密成功返回明文字节;失败返回 null
489
+ const plainBytes = await opts.e2eeDecrypt({
490
+ fromId: deliver.fromId,
491
+ fromE2eeId: deliver.fromE2eeId,
492
+ toE2eeId: deliver.toE2eeId,
493
+ e2eeSid: deliver.e2eeSid,
494
+ encryptedBody: deliver.envelope,
495
+ clientMsgId: deliver.clientMsgId,
496
+ envelopeType: Number(deliver.envelopeType),
497
+ });
498
+
499
+ if (plainBytes === null) {
500
+ return { ok: false, skip: { reason: 'decrypt-failed' } };
501
+ }
502
+
503
+ // envelopeType=2:操作信令(如 decryptError / botThinking),由 E2eeService
504
+ // 的 dispatchOperation 内部消费(service 会从回调内部调用 dispatchOperation
505
+ // 或上层再分派);codec 层返回 operation-consumed。
506
+ if (Number(deliver.envelopeType) === ENVELOPE_TYPE_OPERATION) {
507
+ return { ok: false, skip: { reason: 'operation-consumed' } };
508
+ }
509
+
510
+ // 将解密后的明文字节替换 deliver.envelope,走原明文解码尾部流程
511
+ const decryptedDeliver: DecodedDeliverBody = { ...deliver, envelope: plainBytes };
512
+ return decodeInboundPacketPlaintextTail(reg, decryptedDeliver);
513
+ }
514
+
515
+ /**
516
+ * 从 DecodedDeliverBody 继续 `decodeInboundPacket` 的第 3~4 层解码流程。
517
+ *
518
+ * 明文路径与 E2EE 解密后复用同一尾部:envelopeType=1 → PbChatMsgEnvelope
519
+ * → oneof 分支 → InboundTextMessage;其他 envelopeType / oneof 返回 skip。
520
+ */
521
+ async function decodeInboundPacketPlaintextTail(
522
+ reg: Awaited<ReturnType<typeof getRegistry>>,
523
+ deliver: DecodedDeliverBody,
524
+ ): Promise<InboundDecodeResult> {
525
+ if (Number(deliver.envelopeType) !== ENVELOPE_TYPE_CHAT) {
526
+ return {
527
+ ok: false,
528
+ skip: {
529
+ reason: 'unsupported-envelope-type',
530
+ detail: `envelopeType=${deliver.envelopeType}`,
531
+ },
532
+ };
533
+ }
534
+
535
+ if (!deliver.envelope || deliver.envelope.length === 0) {
536
+ return { ok: false, skip: { reason: 'empty-envelope' } };
537
+ }
538
+
539
+ const envelope = reg.PbChatMsgEnvelope.decode(
540
+ deliver.envelope,
541
+ ) as unknown as DecodedChatMsgEnvelope;
542
+
543
+ if (envelope.textContent) {
544
+ const textContent = envelope.textContent;
545
+ const bodyRanges = textContent.bodyRanges ?? [];
546
+ const message: InboundTextMessage = {
547
+ msgId: deliver.clientMsgId,
548
+ senderId: deliver.fromId,
549
+ content: textContent.text,
550
+ timestampMs: Number(deliver.serverStamp),
551
+ serverMsgId: deliver.serverMsgId,
552
+ convType: deliver.convType,
553
+ toId: deliver.toId,
554
+ ...(bodyRanges.length > 0
555
+ ? {
556
+ bodyRanges: bodyRanges.map((r) => ({
557
+ key: r.key as 0 | 1 | 2,
558
+ payload: r.payload,
559
+ ...(r.start !== undefined ? { start: r.start } : {}),
560
+ ...(r.length !== undefined ? { length: r.length } : {}),
561
+ })),
562
+ }
563
+ : {}),
564
+ ...(textContent.referenceClientMsgId
565
+ ? { referenceClientMsgId: textContent.referenceClientMsgId }
566
+ : {}),
567
+ };
568
+ return { ok: true, message };
569
+ }
570
+
571
+ if (envelope.richMediaContent) {
572
+ const attachments = decodeRichMediaContent(envelope.richMediaContent, 'image');
573
+ const message: InboundTextMessage = {
574
+ msgId: deliver.clientMsgId,
575
+ senderId: deliver.fromId,
576
+ content: envelope.richMediaContent.caption ?? '',
577
+ timestampMs: Number(deliver.serverStamp),
578
+ serverMsgId: deliver.serverMsgId,
579
+ convType: deliver.convType,
580
+ toId: deliver.toId,
581
+ ...(attachments.length > 0 ? { attachments } : {}),
582
+ };
583
+ return { ok: true, message };
584
+ }
585
+
586
+ if (envelope.fileContent) {
587
+ const attachments = decodeRichMediaContent(envelope.fileContent, 'file');
588
+ const message: InboundTextMessage = {
589
+ msgId: deliver.clientMsgId,
590
+ senderId: deliver.fromId,
591
+ content: envelope.fileContent.caption ?? '',
592
+ timestampMs: Number(deliver.serverStamp),
593
+ serverMsgId: deliver.serverMsgId,
594
+ convType: deliver.convType,
595
+ toId: deliver.toId,
596
+ ...(attachments.length > 0 ? { attachments } : {}),
597
+ };
598
+ return { ok: true, message };
599
+ }
600
+
601
+ if (envelope.audioContent) {
602
+ const attachment = decodeAudioContent(envelope.audioContent);
603
+ const attachments = attachment ? [attachment] : [];
604
+ const message: InboundTextMessage = {
605
+ msgId: deliver.clientMsgId,
606
+ senderId: deliver.fromId,
607
+ content: envelope.audioContent.speech ?? '',
608
+ timestampMs: Number(deliver.serverStamp),
609
+ serverMsgId: deliver.serverMsgId,
610
+ convType: deliver.convType,
611
+ toId: deliver.toId,
612
+ ...(attachments.length > 0 ? { attachments } : {}),
613
+ };
614
+ return { ok: true, message };
615
+ }
616
+
617
+ return {
618
+ ok: false,
619
+ skip: {
620
+ reason: 'unsupported-content-type',
621
+ detail: 'no known oneof field present in envelope',
622
+ },
623
+ };
624
+ }