openbird 1.0.0

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.

Potentially problematic release.


This version of openbird might be problematic. Click here for more details.

@@ -0,0 +1,4432 @@
1
+ /**
2
+ * Protocol Buffers builder for Feishu API
3
+ * Uses pre-generated code from protoc-gen-es
4
+ */
5
+
6
+ import logger from '../../logger.js';
7
+
8
+ import {
9
+ FrameSchema,
10
+ PacketSchema,
11
+ PushMessagesRequestSchema,
12
+ PutMessageRequestSchema,
13
+ PutChatRequestSchema,
14
+ PutChatResponseSchema,
15
+ UniversalSearchRequestSchema,
16
+ UniversalSearchResponseSchema,
17
+ ContentSchema,
18
+ TextPropertySchema,
19
+ entities_MessageSchema,
20
+ ImageContentSchema,
21
+ ImageSetSchema,
22
+ ImageSetV2Schema,
23
+ ImageSchema,
24
+ CryptoSchema,
25
+ CipherSchema,
26
+ RichTextSchema,
27
+ RichTextElementsSchema,
28
+ RichTextElementSchema,
29
+ GetChatHistoryRequestSchema,
30
+ GetChatHistoryResponseSchema,
31
+ ChatSchema,
32
+ PushGroupMessageReadStateNoticeSchema,
33
+ PushDeviceOnlineStatusSchema,
34
+ DeviceOnlineStatus_TerminalType,
35
+ DeviceOnlineStatus_OnlineStatus,
36
+ } from '../proto/proto_pb.js';
37
+ import { create, protoInt64, toBinary, fromBinary } from '@bufbuild/protobuf';
38
+ import { generateRequestCid } from '../utils/encryption.js';
39
+ import { buildRichText, hasMarkdownFormatting } from './richtext.js';
40
+ import { pushVarintLength } from '../utils/varint.js';
41
+
42
+ // 消息类型常量 (aligned with entities.Message.Type enum in proto)
43
+ export const MESSAGE_TYPES = {
44
+ 2: 'POST', // 富文本/图文混合
45
+ 3: 'FILE', // 文件
46
+ 4: 'TEXT', // 纯文本
47
+ 5: 'IMAGE', // 图片
48
+ 6: 'SYSTEM', // 系统消息
49
+ 7: 'AUDIO', // 音频
50
+ 8: 'EMAIL', // 邮件
51
+ 9: 'SHARE_GROUP_CHAT', // 分享群聊
52
+ 10: 'STICKER', // 表情
53
+ 11: 'MERGE_FORWARD', // 合并转发
54
+ 12: 'CALENDAR', // 日历
55
+ 13: 'CLOUD_FILE', // 云文件
56
+ 14: 'CARD', // 卡片消息
57
+ 15: 'MEDIA', // 媒体
58
+ 16: 'SHARE_CALENDAR_EVENT',
59
+ 17: 'HONGBAO', // 红包
60
+ 18: 'GENERAL_CALENDAR',
61
+ 19: 'VIDEO_CHAT', // 视频通话
62
+ 20: 'LOCATION', // 位置
63
+ 22: 'COMMERCIALIZED_HONGBAO',
64
+ 23: 'SHARE_USER_CARD', // 名片分享
65
+ 24: 'TODO', // 待办
66
+ 25: 'FOLDER', // 文件夹
67
+ };
68
+
69
+ // 发送者类型常量 (entities.FromType)
70
+ export const FROM_TYPES = {
71
+ 0: 'UNKNOWN',
72
+ 1: 'USER', // 真人用户
73
+ 2: 'BOT', // 机器人
74
+ };
75
+
76
+ // 聊天类型常量 (entities.ChatType)
77
+ export const CHAT_TYPES = {
78
+ 0: 'UNKNOWN',
79
+ 1: 'P2P', // 私聊/单聊
80
+ 2: 'GROUP', // 普通群聊
81
+ 3: 'TOPIC_GROUP', // 话题群聊
82
+ };
83
+
84
+ // WS 推送事件 cmd 类型
85
+ export const PUSH_CMD_TYPES = {
86
+ 6: 'PUSH_MESSAGE',
87
+ 48: 'PUSH_CHAT_CHATTERS',
88
+ 49: 'PUSH_CHAT_MUTABLE_INFO',
89
+ 74: 'PUSH_GROUP_READ_STATE',
90
+ 77: 'MULTI_PACKETS',
91
+ 8106: 'PUSH_THREAD_READ_STATE',
92
+ 7001: 'PUSH_DEVICE_ONLINE_STATUS',
93
+ 7017: 'PUSH_DEVICE_NETWORK_EVENT',
94
+ 7032: 'PUSH_THREE_COLUMNS_SETTING',
95
+ };
96
+
97
+ /**
98
+ * 获取发送者类型名称
99
+ * @param {number} type - 发送者类型数字
100
+ * @returns {string} 类型名称
101
+ */
102
+ export function getFromTypeName(type) {
103
+ return FROM_TYPES[type] || `UNKNOWN(${type})`;
104
+ }
105
+
106
+ /**
107
+ * 获取聊天类型名称
108
+ * @param {number} type - 聊天类型数字
109
+ * @returns {string} 类型名称
110
+ */
111
+ export function getChatTypeName(type) {
112
+ return CHAT_TYPES[type] || `UNKNOWN(${type})`;
113
+ }
114
+
115
+ /**
116
+ * 获取消息类型名称
117
+ * @param {number} type - 消息类型数字
118
+ * @returns {string} 类型名称
119
+ */
120
+ export function getMessageTypeName(type) {
121
+ return MESSAGE_TYPES[type] || `UNKNOWN(${type})`;
122
+ }
123
+
124
+ // 图片加密信息缓存 (imageKey -> cryptoInfo)
125
+ const imageCryptoCache = new Map();
126
+
127
+ // 缓存过期时间 (24小时)
128
+ const CACHE_EXPIRY_MS = 24 * 60 * 60 * 1000;
129
+
130
+ /**
131
+ * 获取缓存的图片加密信息
132
+ * @param {string} imageKey - 图片 key
133
+ * @returns {Object|null} 加密信息 { secret, nonce, additionalData, type } 或 null
134
+ */
135
+ export function getImageCryptoInfo(imageKey) {
136
+ const cached = imageCryptoCache.get(imageKey);
137
+ if (cached && Date.now() - cached.timestamp < CACHE_EXPIRY_MS) {
138
+ return cached.cryptoInfo;
139
+ }
140
+ // 清除过期缓存
141
+ if (cached) {
142
+ imageCryptoCache.delete(imageKey);
143
+ }
144
+ return null;
145
+ }
146
+
147
+ /**
148
+ * 缓存图片加密信息
149
+ * @param {string} imageKey - 图片 key
150
+ * @param {Object} cryptoInfo - 加密信息 { secret, nonce, additionalData, type }
151
+ */
152
+ function cacheImageCryptoInfo(imageKey, cryptoInfo) {
153
+ imageCryptoCache.set(imageKey, {
154
+ cryptoInfo,
155
+ timestamp: Date.now()
156
+ });
157
+ }
158
+
159
+ class ProtoBuilder {
160
+ /**
161
+ * Build protobuf message for sending text
162
+ * @param {string} text - Text content to send
163
+ * @param {string} requestId - Request ID
164
+ * @param {string} chatId - Chat ID
165
+ * @returns {Buffer} Serialized protobuf message
166
+ */
167
+ static buildSendMessageRequest(text, requestId, chatId) {
168
+ const cid1 = generateRequestCid();
169
+ const cid2 = generateRequestCid();
170
+
171
+ // Build TextProperty
172
+ const textProperty = create(TextPropertySchema, {
173
+ content: text
174
+ });
175
+
176
+ // Build Content with RichText (嵌套消息对象,不要序列化)
177
+ const content = create(ContentSchema, {
178
+ text: text,
179
+ richText: {
180
+ elementIds: [cid2],
181
+ innerText: text,
182
+ elements: {
183
+ dictionary: {
184
+ [cid2]: {
185
+ tag: 1, // TEXT
186
+ property: toBinary(TextPropertySchema, textProperty)
187
+ }
188
+ }
189
+ }
190
+ }
191
+ });
192
+
193
+ // Build PutMessageRequest
194
+ // 注意:content 是 Content 类型的嵌套消息,直接传对象,不要序列化为 bytes
195
+ const payload = create(PutMessageRequestSchema, {
196
+ type: 4, // TEXT
197
+ chatId: chatId,
198
+ cid: cid1,
199
+ isNotified: true,
200
+ version: 1,
201
+ content: content // 直接传 Content 对象,不是 toBinary(...)
202
+ });
203
+
204
+ // Build outer Packet
205
+ const packet = create(PacketSchema, {
206
+ payloadType: 1, // PB2
207
+ cmd: 5,
208
+ cid: requestId,
209
+ payload: toBinary(PutMessageRequestSchema, payload)
210
+ });
211
+
212
+ return Buffer.from(toBinary(PacketSchema, packet));
213
+ }
214
+
215
+ /**
216
+ * Build protobuf message for sending rich text (Markdown supported)
217
+ * @param {string} text - Text content with optional Markdown formatting
218
+ * @param {string} requestId - Request ID
219
+ * @param {string} chatId - Chat ID
220
+ * @param {Object} options - Options
221
+ * @param {boolean} options.parseMarkdown - Whether to parse Markdown (default: true)
222
+ * @returns {Buffer} Serialized protobuf message
223
+ */
224
+ static buildRichMessageRequest(text, requestId, chatId, options = {}) {
225
+ const { parseMarkdown = true } = options;
226
+ const cid1 = generateRequestCid();
227
+
228
+ let richTextResult;
229
+ let innerText;
230
+ let isBlockLevel = false;
231
+
232
+ if (parseMarkdown && hasMarkdownFormatting(text)) {
233
+ richTextResult = buildRichText(text);
234
+ innerText = richTextResult.innerText;
235
+ isBlockLevel = richTextResult.isBlockLevel || false;
236
+ } else {
237
+ // Plain text - use manual building too for consistency
238
+ richTextResult = buildRichText(text);
239
+ innerText = richTextResult.innerText;
240
+ }
241
+
242
+ // Build Content bytes manually for block-level elements
243
+ // to ensure proper dictionary serialization
244
+ const contentBytes = this.buildContentBytes(
245
+ isBlockLevel ? '' : innerText,
246
+ richTextResult.elementIds,
247
+ innerText,
248
+ richTextResult.dictionaryBytes
249
+ );
250
+
251
+ // Build PutMessageRequest manually
252
+ const payloadBytes = this.buildPutMessageRequestBytes(
253
+ isBlockLevel ? 2 : 4,
254
+ chatId,
255
+ cid1,
256
+ contentBytes
257
+ );
258
+
259
+ // Build outer Packet
260
+ const packet = create(PacketSchema, {
261
+ payloadType: 1, // PB2
262
+ cmd: 5,
263
+ cid: requestId,
264
+ payload: payloadBytes
265
+ });
266
+
267
+ return Buffer.from(toBinary(PacketSchema, packet));
268
+ }
269
+
270
+ /**
271
+ * Build Content bytes manually
272
+ */
273
+ static buildContentBytes(textField, elementIds, innerText, dictionaryBytes) {
274
+ const parts = [];
275
+
276
+ // Field 1: text (string)
277
+ if (textField) {
278
+ const textBytes = new TextEncoder().encode(textField);
279
+ parts.push(0x0a); // field 1, wire type 2
280
+ pushVarintLength(parts, textBytes.length);
281
+ parts.push(...textBytes);
282
+ } else {
283
+ // Empty text field - must include for block elements
284
+ parts.push(0x0a, 0x00);
285
+ }
286
+
287
+ // Field 14: richText
288
+ const richTextBytes = this.buildRichTextBytes(elementIds, innerText, dictionaryBytes);
289
+ parts.push(0x72); // field 14, wire type 2 (14 << 3 | 2 = 114 = 0x72)
290
+ // Length encoding for richText
291
+ const rtLen = richTextBytes.length;
292
+ pushVarintLength(parts, rtLen);
293
+ parts.push(...richTextBytes);
294
+
295
+ return new Uint8Array(parts);
296
+ }
297
+
298
+ /**
299
+ * Build RichText bytes manually
300
+ */
301
+ static buildRichTextBytes(elementIds, innerText, dictionaryBytes) {
302
+ const parts = [];
303
+
304
+ // Field 1: elementIds (repeated string)
305
+ for (const id of elementIds) {
306
+ const idBytes = new TextEncoder().encode(id);
307
+ parts.push(0x0a); // field 1, wire type 2
308
+ pushVarintLength(parts, idBytes.length);
309
+ parts.push(...idBytes);
310
+ }
311
+
312
+ // Field 2: innerText (string)
313
+ const innerTextBytes = new TextEncoder().encode(innerText);
314
+ parts.push(0x12); // field 2, wire type 2
315
+ pushVarintLength(parts, innerTextBytes.length);
316
+ parts.push(...innerTextBytes);
317
+
318
+ // Field 3: elements (RichTextElements containing dictionary)
319
+ // Wrap dictionary in elements message
320
+ const elementsBytes = this.buildRichTextElementsBytes(dictionaryBytes);
321
+ parts.push(0x1a); // field 3, wire type 2
322
+ const elemLen = elementsBytes.length;
323
+ pushVarintLength(parts, elemLen);
324
+ parts.push(...elementsBytes);
325
+
326
+ return new Uint8Array(parts);
327
+ }
328
+
329
+ /**
330
+ * Build RichTextElements bytes (wrapper for dictionary)
331
+ */
332
+ static buildRichTextElementsBytes(dictionaryBytes) {
333
+ // dictionary is field 1 in RichTextElements
334
+ // The dictionaryBytes already contains the map entries
335
+ return dictionaryBytes;
336
+ }
337
+
338
+ /**
339
+ * Build PutMessageRequest bytes manually
340
+ */
341
+ static buildPutMessageRequestBytes(type, chatId, cid, contentBytes) {
342
+ const parts = [];
343
+
344
+ // Field 1: type (int32)
345
+ parts.push(0x08); // field 1, wire type 0
346
+ parts.push(type);
347
+
348
+ // Field 2: content (bytes)
349
+ parts.push(0x12); // field 2, wire type 2
350
+ const contentLen = contentBytes.length;
351
+ pushVarintLength(parts, contentLen);
352
+ parts.push(...contentBytes);
353
+
354
+ // Field 3: chatId (string)
355
+ const chatIdBytes = new TextEncoder().encode(chatId);
356
+ parts.push(0x1a); // field 3, wire type 2 (3 << 3 | 2 = 26 = 0x1a)
357
+ pushVarintLength(parts, chatIdBytes.length);
358
+ parts.push(...chatIdBytes);
359
+
360
+ // Field 4: rootId (string) - empty
361
+ parts.push(0x22, 0x00); // field 4, empty string
362
+
363
+ // Field 5: parentId (string) - empty
364
+ parts.push(0x2a, 0x00); // field 5, empty string
365
+
366
+ // Field 6: cid (string)
367
+ const cidBytes = new TextEncoder().encode(cid);
368
+ parts.push(0x32); // field 6, wire type 2 (6 << 3 | 2 = 50 = 0x32)
369
+ pushVarintLength(parts, cidBytes.length);
370
+ parts.push(...cidBytes);
371
+
372
+ // Field 7: isNotified (bool) = true
373
+ parts.push(0x38); // field 7, wire type 0 (7 << 3 | 0 = 56 = 0x38)
374
+ parts.push(0x01);
375
+
376
+ // Field 9: version (int32) = 1 (optional)
377
+
378
+ return new Uint8Array(parts);
379
+ }
380
+
381
+ /**
382
+ * Build protobuf message for search
383
+ * @param {string} requestId - Request ID
384
+ * @param {string} query - Search query
385
+ * @returns {Buffer} Serialized protobuf message
386
+ */
387
+ static buildSearchRequest(requestId, query) {
388
+ const searchSession = generateRequestCid();
389
+
390
+ // Build UniversalSearchRequest
391
+ const payload = create(UniversalSearchRequestSchema, {
392
+ header: {
393
+ searchSession: searchSession,
394
+ sessionSeqId: 1,
395
+ query: query,
396
+ searchContext: {
397
+ tagName: 'SMART_SEARCH',
398
+ entityItems: [
399
+ { type: 1 }, // USER
400
+ { type: 3 }, // GROUP_CHAT
401
+ { type: 2 } // BOT
402
+ ]
403
+ },
404
+ commonFilter: {
405
+ includeOuterTenant: true
406
+ },
407
+ sourceKey: 'messenger',
408
+ locale: 'zh_CN'
409
+ }
410
+ });
411
+
412
+ // Build outer Packet
413
+ const packet = create(PacketSchema, {
414
+ payloadType: 1, // PB2
415
+ cmd: 11021,
416
+ cid: requestId,
417
+ payload: toBinary(UniversalSearchRequestSchema, payload)
418
+ });
419
+
420
+ return Buffer.from(toBinary(PacketSchema, packet));
421
+ }
422
+
423
+ /**
424
+ * Build protobuf message for creating chat
425
+ * @param {string} requestId - Request ID
426
+ * @param {string} userId - User ID to create chat with
427
+ * @returns {Buffer} Serialized protobuf message
428
+ */
429
+ static buildCreateChatRequest(requestId, userId) {
430
+ // Build PutChatRequest
431
+ const payload = create(PutChatRequestSchema, {
432
+ type: 1, // P2P
433
+ chatterIds: [userId]
434
+ });
435
+
436
+ // Build outer Packet
437
+ const packet = create(PacketSchema, {
438
+ payloadType: 1, // PB2
439
+ cmd: 13,
440
+ cid: requestId,
441
+ payload: toBinary(PutChatRequestSchema, payload)
442
+ });
443
+
444
+ return Buffer.from(toBinary(PacketSchema, packet));
445
+ }
446
+
447
+ /**
448
+ * Decode search response protobuf
449
+ * @param {Buffer} buffer - Response buffer
450
+ * @returns {Object} Decoded search results
451
+ */
452
+ static decodeSearchResponse(buffer) {
453
+ const packet = fromBinary(PacketSchema, buffer);
454
+
455
+ if (!packet.payload) {
456
+ return [];
457
+ }
458
+
459
+ const response = fromBinary(UniversalSearchResponseSchema, packet.payload);
460
+
461
+ return (response.results || []).map(result => ({
462
+ type: result.type === 1 ? 'user' : result.type === 3 ? 'group' : 'unknown',
463
+ id: result.id,
464
+ rawType: result.type,
465
+ titleHighlighted: result.titleHighlighted,
466
+ avatarKey: result.avatarKey
467
+ }));
468
+ }
469
+
470
+ /**
471
+ * Decode create chat response protobuf
472
+ * @param {Buffer} buffer - Response buffer
473
+ * @returns {string|null} Chat ID
474
+ */
475
+ static decodeCreateChatResponse(buffer) {
476
+ const packet = fromBinary(PacketSchema, buffer);
477
+
478
+ if (!packet.payload) {
479
+ return null;
480
+ }
481
+
482
+ const response = fromBinary(PutChatResponseSchema, packet.payload);
483
+
484
+ return response.chat?.id || null;
485
+ }
486
+
487
+ /**
488
+ * Decode received WebSocket message
489
+ * @param {Buffer} buffer - Message buffer
490
+ * @returns {Object} Decoded message data
491
+ */
492
+ static decodeReceiveMessage(buffer) {
493
+ const frame = fromBinary(FrameSchema, buffer);
494
+
495
+ if (!frame.payload) {
496
+ return { fromId: null, chatId: null, content: '', sid: null, type: null };
497
+ }
498
+
499
+ const packet = fromBinary(PacketSchema, frame.payload);
500
+
501
+ if (!packet.payload) {
502
+ return { fromId: null, chatId: null, content: '', sid: packet.sid, type: null, command: packet.cmd };
503
+ }
504
+
505
+ // cmd=77 (PROCESS_MULTI_PACKETS): 会下发图片资源及 crypto 信息
506
+ if (packet.cmd === 77) {
507
+ const cachedCount = this.cacheImageCryptoFromCmd77Payload(packet.payload);
508
+ if (cachedCount > 0) {
509
+ logger.log('[Proto] ✅ 从 cmd=77 缓存了 ' + cachedCount + ' 条图片加密信息');
510
+ }
511
+
512
+ return { fromId: null, chatId: null, content: '', sid: packet.sid, type: null, command: packet.cmd };
513
+ }
514
+
515
+ // cmd=74: 群消息已读状态推送
516
+ if (packet.cmd === 74) {
517
+ return this.decodeGroupReadState(packet);
518
+ }
519
+
520
+ // cmd=8106: 话题已读状态推送
521
+ if (packet.cmd === 8106) {
522
+ return this.decodeThreadReadState(packet);
523
+ }
524
+
525
+ // cmd=7001: 设备在线状态推送
526
+ if (packet.cmd === 7001) {
527
+ return this.decodeDeviceOnlineStatus(packet);
528
+ }
529
+
530
+ // cmd=48, 49, 7032, 7017: 其他推送事件,返回基本信息 + raw payload
531
+ if (packet.cmd === 48 || packet.cmd === 49 || packet.cmd === 7032 || packet.cmd === 7017) {
532
+ return {
533
+ fromId: null, chatId: null, content: '', sid: packet.sid,
534
+ command: packet.cmd, event: PUSH_CMD_TYPES[packet.cmd] || 'unknown',
535
+ payloadLength: packet.payload?.length || 0, type: null
536
+ };
537
+ }
538
+
539
+ // Try to decode PushMessagesRequest
540
+ let pushRequest;
541
+ try {
542
+ pushRequest = fromBinary(PushMessagesRequestSchema, packet.payload);
543
+ } catch (error) {
544
+ // If parsing fails, try to manually parse the messages map
545
+ logger.error('[Proto] Failed to decode PushMessagesRequest with schema, trying manual parse:', error.message);
546
+
547
+ // Manual parse: decode as raw message and extract messages
548
+ try {
549
+ const payload = packet.payload;
550
+ const messages = {};
551
+
552
+ // Parse the messages map manually
553
+ let offset = 0;
554
+ while (offset < payload.length) {
555
+ const tag = payload[offset];
556
+ if (tag === undefined) break;
557
+
558
+ const wireType = tag & 0x07;
559
+ const fieldNo = tag >> 3;
560
+
561
+ offset++;
562
+
563
+ if (fieldNo === 1) { // messages field
564
+ if (wireType === 2) { // length-delimited (map entry)
565
+ const length = this.readVarint(payload, offset);
566
+ offset += this.varintLength(payload, offset);
567
+
568
+ const entryData = payload.subarray(offset, offset + length);
569
+ offset += length;
570
+
571
+ // Parse map entry: string key, bytes value
572
+ let entryOffset = 0;
573
+ let key = null;
574
+ let value = null;
575
+
576
+ while (entryOffset < entryData.length) {
577
+ const entryTag = entryData[entryOffset];
578
+ if (entryTag === undefined) break;
579
+ const entryWireType = entryTag & 0x07;
580
+ const entryFieldNo = entryTag >> 3;
581
+ entryOffset++;
582
+
583
+ if (entryFieldNo === 1) { // key (string)
584
+ const strLen = this.readVarint(entryData, entryOffset);
585
+ entryOffset += this.varintLength(entryData, entryOffset);
586
+ key = entryData.subarray(entryOffset, entryOffset + strLen).toString('utf8');
587
+ entryOffset += strLen;
588
+ } else if (entryFieldNo === 2) { // value (bytes/message)
589
+ const msgLen = this.readVarint(entryData, entryOffset);
590
+ entryOffset += this.varintLength(entryData, entryOffset);
591
+ value = entryData.subarray(entryOffset, entryOffset + msgLen);
592
+ entryOffset += msgLen;
593
+ } else {
594
+ // Skip unknown field
595
+ if (entryWireType === 2) {
596
+ const len = this.readVarint(entryData, entryOffset);
597
+ entryOffset += this.varintLength(entryData, entryOffset) + len;
598
+ } else if (entryWireType === 0) {
599
+ entryOffset += this.varintLength(entryData, entryOffset);
600
+ } else {
601
+ entryOffset++; // Skip invalid
602
+ }
603
+ }
604
+ }
605
+
606
+ if (key && value) {
607
+ messages[key] = value;
608
+ }
609
+ }
610
+ } else {
611
+ // Skip non-map fields
612
+ if (wireType === 2) {
613
+ const len = this.readVarint(payload, offset);
614
+ offset += this.varintLength(payload, offset) + len;
615
+ } else if (wireType === 0) {
616
+ offset += this.varintLength(payload, offset);
617
+ } else {
618
+ offset++;
619
+ }
620
+ }
621
+ }
622
+
623
+ // Parse the first message
624
+ const messageKey = Object.keys(messages)[0];
625
+ if (!messageKey) {
626
+ return { fromId: null, chatId: null, content: '', sid: packet.sid, type: null };
627
+ }
628
+
629
+ // Try to parse the message bytes using entities_MessageSchema
630
+ try {
631
+ const message = fromBinary(entities_MessageSchema, messages[messageKey]);
632
+ return this.parseMessageContent(message, packet.sid);
633
+ } catch (msgError) {
634
+ logger.error('[Proto] Failed to decode individual message:', msgError.message);
635
+ return { fromId: null, chatId: null, content: '', sid: packet.sid, type: null };
636
+ }
637
+ } catch (manualError) {
638
+ logger.error('[Proto] Manual parse also failed:', manualError.message);
639
+ return { fromId: null, chatId: null, content: '', sid: packet.sid, parseError: error.message, type: null };
640
+ }
641
+ }
642
+
643
+ // Get first message from the map
644
+ const messages = pushRequest.messages || {};
645
+ const messageKey = Object.keys(messages)[0];
646
+
647
+ if (!messageKey) {
648
+ return { fromId: null, chatId: null, content: '', sid: packet.sid, type: null };
649
+ }
650
+
651
+ const message = messages[messageKey];
652
+ return this.parseMessageContent(message, packet.sid);
653
+ }
654
+
655
+ /**
656
+ * Parse message content based on type
657
+ * @param {Object} message - Decoded entities.Message
658
+ * @param {string} sid - Packet sid
659
+ * @returns {Object} Parsed message data
660
+ */
661
+ static parseMessageContent(message, sid) {
662
+ const baseResult = {
663
+ fromId: String(message.fromId || ''),
664
+ chatId: String(message.chatId || ''),
665
+ messageId: String(message.id || ''),
666
+ type: message.type,
667
+ fromType: message.fromType, // 发送者类型: USER=1, BOT=2
668
+ chatType: message.chatType, // 聊天类型: P2P=1, GROUP=2, TOPIC_GROUP=3
669
+ threadId: String(message.threadId || ''), // 话题ID
670
+ sid: sid,
671
+ content: '',
672
+ imageKey: null,
673
+ imageKeys: [], // 支持多图片
674
+ cryptoInfo: null,
675
+ cryptoInfos: [] // 支持多图片的加密信息
676
+ };
677
+
678
+ // POST = 2 (富文本,图文混合消息)
679
+ if (message.type === 2 && message.content) {
680
+ try {
681
+ const content = fromBinary(ContentSchema, message.content);
682
+ const postResult = this.parsePostContent(content);
683
+
684
+ // 缓存所有图片的加密信息
685
+ for (const imgInfo of postResult.images) {
686
+ if (imgInfo.imageKey && imgInfo.cryptoInfo) {
687
+ cacheImageCryptoInfo(imgInfo.imageKey, imgInfo.cryptoInfo);
688
+ }
689
+ }
690
+
691
+ return {
692
+ ...baseResult,
693
+ content: postResult.text,
694
+ imageKey: postResult.images[0]?.imageKey || null,
695
+ imageKeys: postResult.images.map(img => img.imageKey).filter(Boolean),
696
+ cryptoInfo: postResult.images[0]?.cryptoInfo || null,
697
+ cryptoInfos: postResult.images.map(img => img.cryptoInfo).filter(Boolean)
698
+ };
699
+ } catch (e) {
700
+ logger.error('[Proto] Failed to decode POST content:', e.message);
701
+ return baseResult;
702
+ }
703
+ }
704
+
705
+ // TEXT = 4
706
+ if (message.type === 4 && message.content) {
707
+ try {
708
+ // 直接从原始字节解析,避免字符串编码问题
709
+ const text = this.parseTextMessageContent(message.content);
710
+ return { ...baseResult, content: text };
711
+ } catch (e) {
712
+ logger.error('[Proto] Failed to decode TEXT content:', e.message);
713
+ return baseResult;
714
+ }
715
+ }
716
+
717
+ // CARD = 14 (卡片消息)
718
+ // Some web protocol variants may deliver CARD as 13; only treat it as card
719
+ // when card JSON can be extracted.
720
+ if ((message.type === 14 || message.type === 13) && message.content) {
721
+ try {
722
+ const cardResult = this.parseCardContent(message.content);
723
+ const hasCardJson = Boolean(cardResult?.json || cardResult?.rawJson);
724
+ if (message.type !== 14 && !hasCardJson) {
725
+ return baseResult;
726
+ }
727
+ return {
728
+ ...baseResult,
729
+ content: cardResult.summary || '[卡片消息]',
730
+ card: cardResult
731
+ };
732
+ } catch (e) {
733
+ logger.error('[Proto] Failed to decode CARD content:', e.message);
734
+ return { ...baseResult, content: '[卡片消息]' };
735
+ }
736
+ }
737
+
738
+ // IMAGE = 5
739
+ if (message.type === 5 && message.content) {
740
+ try {
741
+ const imageContent = fromBinary(ImageContentSchema, message.content);
742
+ const imageInfo = this.extractImageInfo(imageContent);
743
+
744
+ if (imageInfo.imageKey && imageInfo.cryptoInfo) {
745
+ // 缓存加密信息
746
+ cacheImageCryptoInfo(imageInfo.imageKey, imageInfo.cryptoInfo);
747
+ }
748
+
749
+ return {
750
+ ...baseResult,
751
+ content: '[图片]',
752
+ imageKey: imageInfo.imageKey,
753
+ cryptoInfo: imageInfo.cryptoInfo,
754
+ fsUnit: imageInfo.fsUnit
755
+ };
756
+ } catch (e) {
757
+ logger.error('[Proto] Failed to decode IMAGE content:', e.message);
758
+ return { ...baseResult, content: '[图片解析失败]' };
759
+ }
760
+ }
761
+
762
+ // Other message types
763
+ return baseResult;
764
+ }
765
+
766
+ /**
767
+ * Decode cmd=74 群消息已读状态推送
768
+ * @param {Object} packet - Decoded Packet
769
+ * @returns {Object} Parsed read state event
770
+ */
771
+ static decodeGroupReadState(packet) {
772
+ try {
773
+ const notice = fromBinary(PushGroupMessageReadStateNoticeSchema, packet.payload);
774
+
775
+ // Convert readTimes map (string->bigint) to plain object (string->number)
776
+ const readTimes = {};
777
+ if (notice.readTimes) {
778
+ for (const [key, value] of Object.entries(notice.readTimes)) {
779
+ readTimes[key] = Number(value);
780
+ }
781
+ }
782
+
783
+ return {
784
+ command: 74,
785
+ event: 'readState',
786
+ sid: packet.sid,
787
+ groupId: notice.groupId,
788
+ readPosition: notice.readPosition,
789
+ lastMessagePosition: notice.lastMessagePosition,
790
+ readTimes,
791
+ readPositionBadgeCount: notice.readPositionBadgeCount,
792
+ lastMessageId: notice.lastMessageId,
793
+ messageIds: notice.messageIds || [],
794
+ fromId: null,
795
+ chatId: notice.groupId,
796
+ content: '',
797
+ type: null,
798
+ };
799
+ } catch (error) {
800
+ logger.error('[Proto] Failed to decode cmd=74 PushGroupMessageReadStateNotice:', error.message);
801
+ return { fromId: null, chatId: null, content: '', sid: packet.sid, command: 74, type: null };
802
+ }
803
+ }
804
+
805
+ /**
806
+ * Decode cmd=7001 设备在线状态推送
807
+ * @param {Object} packet - Decoded Packet
808
+ * @returns {Object} Parsed device status event
809
+ */
810
+ static decodeDeviceOnlineStatus(packet) {
811
+ try {
812
+ const push = fromBinary(PushDeviceOnlineStatusSchema, packet.payload);
813
+ const status = push.deviceOnlineStatus;
814
+
815
+ return {
816
+ command: 7001,
817
+ event: 'deviceStatus',
818
+ sid: packet.sid,
819
+ deviceId: status?.deviceId || '',
820
+ terminalType: status?.terminalType ?? 0,
821
+ onlineStatus: status?.onlineStatus ?? 0,
822
+ fromId: null,
823
+ chatId: null,
824
+ content: '',
825
+ type: null,
826
+ };
827
+ } catch (error) {
828
+ logger.error('[Proto] Failed to decode cmd=7001 PushDeviceOnlineStatus:', error.message);
829
+ return { fromId: null, chatId: null, content: '', sid: packet.sid, command: 7001, type: null };
830
+ }
831
+ }
832
+
833
+ /**
834
+ * Decode cmd=8106 话题已读状态推送 (proto 定义缺失,手动解析)
835
+ * @param {Object} packet - Decoded Packet
836
+ * @returns {Object} Parsed thread read state event
837
+ */
838
+ static decodeThreadReadState(packet) {
839
+ try {
840
+ const fields = this.roughProtobufParse(packet.payload);
841
+
842
+ return {
843
+ command: 8106,
844
+ event: 'readState',
845
+ sid: packet.sid,
846
+ chatId: fields[1] || null,
847
+ readPosition: fields[8] != null ? Number(fields[8]) : undefined,
848
+ lastMessagePosition: fields[4] != null ? Number(fields[4]) : undefined,
849
+ updateTime: fields[12] != null ? Number(fields[12]) : undefined,
850
+ fromId: null,
851
+ content: '',
852
+ type: null,
853
+ };
854
+ } catch (error) {
855
+ logger.error('[Proto] Failed to decode cmd=8106 thread read state:', error.message);
856
+ return { fromId: null, chatId: null, content: '', sid: packet.sid, command: 8106, type: null };
857
+ }
858
+ }
859
+
860
+ /**
861
+ * Rough protobuf parser — extracts top-level fields as field_number -> value
862
+ * Strings/bytes are decoded as UTF-8 strings, varints as numbers
863
+ * @param {Uint8Array|Buffer} data - protobuf bytes
864
+ * @returns {Object} field_number -> value
865
+ */
866
+ static roughProtobufParse(data) {
867
+ const buffer = data instanceof Buffer ? data : Buffer.from(data);
868
+ const fields = {};
869
+ let offset = 0;
870
+
871
+ while (offset < buffer.length) {
872
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
873
+ if (!tagBytes) break;
874
+ offset += tagBytes;
875
+
876
+ const wireType = tag & 0x07;
877
+ const fieldNo = tag >> 3;
878
+
879
+ if (wireType === 0) { // varint
880
+ const { value, bytesRead } = this.readVarintWithLength(buffer, offset);
881
+ offset += bytesRead;
882
+ fields[fieldNo] = value;
883
+ } else if (wireType === 2) { // length-delimited
884
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
885
+ offset += bytesRead;
886
+ if (offset + len > buffer.length) break;
887
+ const chunk = buffer.subarray(offset, offset + len);
888
+ offset += len;
889
+ // Attempt to decode as UTF-8 string; keep as-is if it looks like text
890
+ try {
891
+ const str = chunk.toString('utf8');
892
+ if (!/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(str)) {
893
+ fields[fieldNo] = str;
894
+ } else {
895
+ fields[fieldNo] = chunk;
896
+ }
897
+ } catch {
898
+ fields[fieldNo] = chunk;
899
+ }
900
+ } else if (wireType === 1) { // 64-bit
901
+ offset += 8;
902
+ } else if (wireType === 5) { // 32-bit
903
+ offset += 4;
904
+ } else {
905
+ break;
906
+ }
907
+ }
908
+
909
+ return fields;
910
+ }
911
+
912
+ /**
913
+ * Extract image key and crypto info from ImageContent
914
+ * @param {Object} imageContent - Decoded ImageContent
915
+ * @returns {Object} { imageKey, cryptoInfo, fsUnit }
916
+ */
917
+ static extractImageInfo(imageContent) {
918
+ let imageKey = null;
919
+ let cryptoInfo = null;
920
+ let fsUnit = null;
921
+
922
+ // Try ImageSetV2 first (newer format)
923
+ if (imageContent.imageV2) {
924
+ const imageV2 = imageContent.imageV2;
925
+ imageKey = imageV2.imageKey;
926
+ fsUnit = imageV2.fsUnit;
927
+
928
+ if (imageV2.crypto) {
929
+ cryptoInfo = this.extractCryptoInfo(imageV2.crypto);
930
+ }
931
+ }
932
+
933
+ // Fallback to ImageSet (older format)
934
+ if (!imageKey && imageContent.image) {
935
+ const imageSet = imageContent.image;
936
+
937
+ // Try origin, then middle, then thumbnail
938
+ const sources = [imageSet.origin, imageSet.middle, imageSet.thumbnail];
939
+ for (const img of sources) {
940
+ if (img?.key) {
941
+ imageKey = img.key;
942
+ fsUnit = img.fsUnit;
943
+
944
+ if (img.crypto) {
945
+ cryptoInfo = this.extractCryptoInfo(img.crypto);
946
+ }
947
+ break;
948
+ }
949
+ }
950
+ }
951
+
952
+ return { imageKey, cryptoInfo, fsUnit };
953
+ }
954
+
955
+ /**
956
+ * Extract crypto info from Crypto message
957
+ * @param {Object} crypto - Decoded Crypto message
958
+ * @returns {Object|null} { secret, nonce, additionalData, type }
959
+ */
960
+ static extractCryptoInfo(crypto) {
961
+ if (!crypto.cipher) {
962
+ return null;
963
+ }
964
+
965
+ const cipher = crypto.cipher;
966
+
967
+ // Convert Uint8Array to Buffer
968
+ return {
969
+ secret: cipher.secret ? Buffer.from(cipher.secret) : null,
970
+ nonce: cipher.nonce ? Buffer.from(cipher.nonce) : null,
971
+ additionalData: cipher.additionalData ? Buffer.from(cipher.additionalData) : Buffer.alloc(0),
972
+ type: crypto.type // 1=AES_256_GCM, 6=SM4_128
973
+ };
974
+ }
975
+
976
+ /**
977
+ * Parse POST (richtext) content to extract text and images
978
+ * @param {Object} content - Decoded Content message
979
+ * @returns {Object} { text: string, images: Array<{imageKey, cryptoInfo}> }
980
+ */
981
+ static parsePostContent(content) {
982
+ const result = {
983
+ text: '',
984
+ images: []
985
+ };
986
+
987
+ if (!content.richText) {
988
+ // Fallback to simple text
989
+ result.text = content.text || '';
990
+ return result;
991
+ }
992
+
993
+ const richText = content.richText;
994
+ const texts = [];
995
+
996
+ // Parse elements dictionary to extract text and images
997
+ if (richText.elements?.dictionary) {
998
+ for (const [key, element] of Object.entries(richText.elements.dictionary)) {
999
+ // tag = 1 means TEXT element
1000
+ if (element.tag === 1 && element.property) {
1001
+ const textContent = this.parseTextProperty(element.property);
1002
+ if (textContent) {
1003
+ texts.push(textContent);
1004
+ }
1005
+ }
1006
+ // tag = 2 means IMG element
1007
+ else if (element.tag === 2 && element.property) {
1008
+ const imageInfo = this.parseImageProperty(element.property);
1009
+ if (imageInfo.imageKey) {
1010
+ result.images.push(imageInfo);
1011
+ }
1012
+ }
1013
+ }
1014
+ }
1015
+
1016
+ // Combine extracted texts, or use innerText as fallback
1017
+ result.text = texts.join('') || richText.innerText || '';
1018
+
1019
+ return result;
1020
+ }
1021
+
1022
+ /**
1023
+ * Parse CARD message content bytes
1024
+ * Card content is typically JSON wrapped in a protobuf structure.
1025
+ * The content bytes contain a protobuf with field 1 = JSON string of the card.
1026
+ * @param {Uint8Array} bytes - Raw content bytes
1027
+ * @returns {Object} { summary, title, json, rawJson }
1028
+ */
1029
+ static parseCardContent(bytes) {
1030
+ if (!bytes || bytes.length === 0) {
1031
+ return { summary: '[空卡片]' };
1032
+ }
1033
+
1034
+ // Strategy 1: Try to extract JSON string from protobuf wrapper
1035
+ // The content is likely a protobuf message with a string field containing JSON
1036
+ const jsonStr = this.extractCardJson(bytes);
1037
+ if (jsonStr) {
1038
+ try {
1039
+ const cardJson = JSON.parse(jsonStr);
1040
+ return this.parseCardJson(cardJson, jsonStr);
1041
+ } catch (e) {
1042
+ // JSON parse failed, try other approaches
1043
+ }
1044
+ }
1045
+
1046
+ // Strategy 2: Try raw bytes as UTF-8 JSON directly
1047
+ try {
1048
+ const rawStr = Buffer.from(bytes).toString('utf8');
1049
+ if (rawStr.startsWith('{')) {
1050
+ const cardJson = JSON.parse(rawStr);
1051
+ return this.parseCardJson(cardJson, rawStr);
1052
+ }
1053
+ } catch (e) {
1054
+ // Not raw JSON
1055
+ }
1056
+
1057
+ // Strategy 3: Scan for JSON-like content in the bytes
1058
+ const buffer = Buffer.from(bytes);
1059
+ const jsonStart = buffer.indexOf('{');
1060
+ if (jsonStart >= 0) {
1061
+ // Find matching closing brace
1062
+ let depth = 0;
1063
+ let jsonEnd = -1;
1064
+ for (let i = jsonStart; i < buffer.length; i++) {
1065
+ if (buffer[i] === 0x7b) depth++; // '{'
1066
+ if (buffer[i] === 0x7d) depth--; // '}'
1067
+ if (depth === 0) {
1068
+ jsonEnd = i;
1069
+ break;
1070
+ }
1071
+ }
1072
+ if (jsonEnd > jsonStart) {
1073
+ try {
1074
+ const extracted = buffer.slice(jsonStart, jsonEnd + 1).toString('utf8');
1075
+ const cardJson = JSON.parse(extracted);
1076
+ return this.parseCardJson(cardJson, extracted);
1077
+ } catch (e) {
1078
+ // Not valid JSON
1079
+ }
1080
+ }
1081
+ }
1082
+
1083
+ return {
1084
+ summary: '[卡片消息]',
1085
+ rawContentBase64: Buffer.from(bytes).toString('base64')
1086
+ };
1087
+ }
1088
+
1089
+ /**
1090
+ * Extract JSON string from card content protobuf bytes
1091
+ * Iterates protobuf fields looking for length-delimited string fields
1092
+ * that start with '{' (JSON object)
1093
+ * @param {Uint8Array} bytes - Raw protobuf bytes
1094
+ * @returns {string|null} JSON string or null
1095
+ */
1096
+ static extractCardJson(bytes) {
1097
+ const buffer = Buffer.from(bytes);
1098
+ let offset = 0;
1099
+
1100
+ while (offset < buffer.length) {
1101
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
1102
+ if (tagBytes === 0) break;
1103
+ offset += tagBytes;
1104
+
1105
+ const wireType = tag & 0x07;
1106
+ const fieldNo = tag >> 3;
1107
+
1108
+ if (wireType === 2) { // Length-delimited
1109
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
1110
+ offset += bytesRead;
1111
+
1112
+ if (offset + len > buffer.length) break;
1113
+
1114
+ const data = buffer.subarray(offset, offset + len);
1115
+ offset += len;
1116
+
1117
+ // Check if this field contains JSON
1118
+ if (len > 2 && data[0] === 0x7b) { // starts with '{'
1119
+ try {
1120
+ const str = data.toString('utf8');
1121
+ JSON.parse(str); // validate it's JSON
1122
+ return str;
1123
+ } catch (e) {
1124
+ // Not JSON, continue
1125
+ }
1126
+ }
1127
+
1128
+ // Recurse into nested messages to find JSON
1129
+ if (len > 10) {
1130
+ const nested = this.extractCardJson(data);
1131
+ if (nested) return nested;
1132
+ }
1133
+ } else if (wireType === 0) { // Varint
1134
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
1135
+ offset += bytesRead;
1136
+ } else if (wireType === 5) { // 32-bit
1137
+ offset += 4;
1138
+ } else if (wireType === 1) { // 64-bit
1139
+ offset += 8;
1140
+ } else {
1141
+ break;
1142
+ }
1143
+ }
1144
+
1145
+ return null;
1146
+ }
1147
+
1148
+ /**
1149
+ * Parse card JSON to extract summary info
1150
+ * @param {Object} cardJson - Parsed card JSON object
1151
+ * @param {string} rawJson - Original JSON string
1152
+ * @returns {Object} { summary, title, json, rawJson }
1153
+ */
1154
+ static parseCardJson(cardJson, rawJson) {
1155
+ const result = {
1156
+ json: cardJson,
1157
+ rawJson: rawJson,
1158
+ title: null,
1159
+ summary: null,
1160
+ };
1161
+
1162
+ // Extract title from multiple schema variants
1163
+ result.title = this.pickFirstText([
1164
+ cardJson.header?.title?.content,
1165
+ cardJson.header?.property?.title?.content,
1166
+ cardJson.header?.property?.title?.property?.content,
1167
+ cardJson.header?.property?.mainTitle?.content,
1168
+ cardJson.header?.property?.mainTitle?.property?.content,
1169
+ cardJson.header?.property?.subtitle?.content,
1170
+ cardJson.header?.property?.subtitle?.property?.content,
1171
+ ]);
1172
+
1173
+ // Extract summary from known summary fields
1174
+ result.summary = this.pickFirstText([
1175
+ cardJson.config?.summary?.content,
1176
+ cardJson.config?.summary,
1177
+ cardJson.summary?.content,
1178
+ cardJson.summary,
1179
+ ]);
1180
+
1181
+ // Build summary: prefer explicit summary, fallback to title
1182
+ if (!result.summary) {
1183
+ if (result.title) {
1184
+ result.summary = `[卡片] ${result.title}`;
1185
+ } else {
1186
+ // Try to extract text from body elements
1187
+ const bodyText = this.extractCardBodyText(cardJson);
1188
+ result.summary = bodyText
1189
+ ? `[卡片] ${bodyText.substring(0, 50)}`
1190
+ : '[卡片消息]';
1191
+ }
1192
+ }
1193
+
1194
+ return result;
1195
+ }
1196
+
1197
+ /**
1198
+ * Extract text content from card body elements
1199
+ * @param {Object} cardJson - Card JSON
1200
+ * @returns {string} Extracted text
1201
+ */
1202
+ static extractCardBodyText(cardJson) {
1203
+ const texts = [];
1204
+ const seen = new Set();
1205
+
1206
+ const addText = (value) => {
1207
+ if (typeof value !== 'string') return;
1208
+ const normalized = value.replace(/\s+/g, ' ').trim();
1209
+ if (!normalized) return;
1210
+
1211
+ // Skip low-signal tokens and metadata values
1212
+ if (/^https?:\/\//.test(normalized)) return;
1213
+ if (/^#[0-9a-fA-F]{6,8}$/.test(normalized)) return;
1214
+ if (/^(default|normal|small|medium|large)$/i.test(normalized)) return;
1215
+
1216
+ if (!seen.has(normalized)) {
1217
+ seen.add(normalized);
1218
+ texts.push(normalized);
1219
+ }
1220
+ };
1221
+
1222
+ const walk = (node, depth = 0) => {
1223
+ if (!node || depth > 10 || texts.length >= 8) return;
1224
+
1225
+ if (typeof node === 'string') {
1226
+ addText(node);
1227
+ return;
1228
+ }
1229
+
1230
+ if (Array.isArray(node)) {
1231
+ for (const item of node) {
1232
+ walk(item, depth + 1);
1233
+ if (texts.length >= 8) break;
1234
+ }
1235
+ return;
1236
+ }
1237
+
1238
+ if (typeof node !== 'object') return;
1239
+
1240
+ // Common card text slots
1241
+ addText(node.content);
1242
+ addText(node.text);
1243
+ addText(node.title);
1244
+ addText(node.subtitle);
1245
+
1246
+ if (node.text && typeof node.text === 'object') {
1247
+ addText(node.text.content);
1248
+ addText(node.text.text);
1249
+ }
1250
+
1251
+ if (node.property && typeof node.property === 'object') {
1252
+ addText(node.property.content);
1253
+ addText(node.property.text);
1254
+ addText(node.property.title);
1255
+ addText(node.property.subtitle);
1256
+ }
1257
+
1258
+ // Traverse likely nested containers first
1259
+ walk(node.newBody, depth + 1);
1260
+ walk(node.body, depth + 1);
1261
+ walk(node.elements, depth + 1);
1262
+ walk(node.fields, depth + 1);
1263
+ walk(node.columns, depth + 1);
1264
+ walk(node.actions, depth + 1);
1265
+ walk(node.header, depth + 1);
1266
+ walk(node.property, depth + 1);
1267
+ walk(node.text, depth + 1);
1268
+ walk(node.title, depth + 1);
1269
+ walk(node.subtitle, depth + 1);
1270
+ };
1271
+
1272
+ walk(cardJson);
1273
+
1274
+ return texts.slice(0, 3).join(' ');
1275
+ }
1276
+
1277
+ /**
1278
+ * Pick first non-empty text value
1279
+ * @param {Array<any>} values - candidate values
1280
+ * @returns {string|null}
1281
+ */
1282
+ static pickFirstText(values) {
1283
+ for (const value of values) {
1284
+ if (typeof value !== 'string') continue;
1285
+ const text = value.trim();
1286
+ if (text) return text;
1287
+ }
1288
+ return null;
1289
+ }
1290
+
1291
+ /**
1292
+ * Parse TEXT element property to extract text content
1293
+ * @param {Uint8Array} propertyBytes - The property bytes from RichTextElement
1294
+ * @returns {string} Extracted text content
1295
+ */
1296
+ static parseTextProperty(propertyBytes) {
1297
+ if (!propertyBytes || propertyBytes.length === 0) {
1298
+ return '';
1299
+ }
1300
+
1301
+ try {
1302
+ const textProp = fromBinary(TextPropertySchema, propertyBytes);
1303
+ return textProp.content || '';
1304
+ } catch (e) {
1305
+ // Fallback: try to extract string manually
1306
+ const buffer = Buffer.from(propertyBytes);
1307
+ let offset = 0;
1308
+
1309
+ while (offset < buffer.length) {
1310
+ const tag = buffer[offset];
1311
+ if (tag === undefined) break;
1312
+
1313
+ const wireType = tag & 0x07;
1314
+ const fieldNo = tag >> 3;
1315
+ offset++;
1316
+
1317
+ if (wireType === 2) {
1318
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
1319
+ offset += bytesRead;
1320
+
1321
+ // field 1 = content (string)
1322
+ if (fieldNo === 1) {
1323
+ return buffer.subarray(offset, offset + len).toString('utf8');
1324
+ }
1325
+
1326
+ offset += len;
1327
+ } else if (wireType === 0) {
1328
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
1329
+ offset += bytesRead;
1330
+ } else {
1331
+ break;
1332
+ }
1333
+ }
1334
+
1335
+ return '';
1336
+ }
1337
+ }
1338
+
1339
+ /**
1340
+ * Parse IMG element property to extract imageKey and crypto info
1341
+ * @param {Uint8Array} propertyBytes - The property bytes from RichTextElement
1342
+ * @returns {Object} { imageKey, cryptoInfo }
1343
+ */
1344
+ static parseImageProperty(propertyBytes) {
1345
+ const result = {
1346
+ imageKey: null,
1347
+ cryptoInfo: null
1348
+ };
1349
+
1350
+ if (!propertyBytes || propertyBytes.length === 0) {
1351
+ return result;
1352
+ }
1353
+
1354
+ const buffer = Buffer.from(propertyBytes);
1355
+ let offset = 0;
1356
+
1357
+ while (offset < buffer.length) {
1358
+ if (offset >= buffer.length) break;
1359
+
1360
+ // 读取 tag (可能是 multi-byte varint)
1361
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
1362
+ if (tagBytes === 0) break;
1363
+ offset += tagBytes;
1364
+
1365
+ const wireType = tag & 0x07;
1366
+ const fieldNo = tag >> 3;
1367
+
1368
+ if (wireType === 2) { // Length-delimited
1369
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
1370
+ offset += bytesRead;
1371
+
1372
+ const data = buffer.subarray(offset, offset + len);
1373
+ offset += len;
1374
+
1375
+ // field 2 = imageKey (string)
1376
+ if (fieldNo === 2) {
1377
+ result.imageKey = data.toString('utf8');
1378
+ }
1379
+ // field 17 = 嵌套消息(可能包含 ImageSetV2)
1380
+ else if (fieldNo === 17) {
1381
+ try {
1382
+ const imageV2 = fromBinary(ImageSetV2Schema, data);
1383
+ if (imageV2.crypto) {
1384
+ result.cryptoInfo = this.extractCryptoInfo(imageV2.crypto);
1385
+ }
1386
+ } catch (e) {
1387
+ // 尝试从嵌套结构中提取 crypto
1388
+ const cryptoInfo = this.extractCryptoFromBytes(data);
1389
+ if (cryptoInfo) {
1390
+ result.cryptoInfo = cryptoInfo;
1391
+ }
1392
+ }
1393
+ }
1394
+
1395
+ // 尝试在各种可能的字段中查找 crypto 信息
1396
+ if (len > 10 && fieldNo !== 17) {
1397
+ // 尝试解析为 ImageSetV2
1398
+ try {
1399
+ const imageV2 = fromBinary(ImageSetV2Schema, data);
1400
+ if (imageV2.crypto) {
1401
+ result.cryptoInfo = this.extractCryptoInfo(imageV2.crypto);
1402
+ }
1403
+ } catch (e) {
1404
+ // 尝试解析嵌套结构中的 crypto
1405
+ const cryptoInfo = this.extractCryptoFromBytes(data);
1406
+ if (cryptoInfo) {
1407
+ result.cryptoInfo = cryptoInfo;
1408
+ }
1409
+ }
1410
+ }
1411
+ } else if (wireType === 0) { // Varint
1412
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
1413
+ offset += bytesRead;
1414
+ } else {
1415
+ break;
1416
+ }
1417
+ }
1418
+
1419
+ if (!result.imageKey) {
1420
+ const text = buffer.toString('utf8');
1421
+ const fallbackKeys = this.extractImageKeysFromString(text);
1422
+ if (fallbackKeys.length > 0) {
1423
+ result.imageKey = fallbackKeys[0];
1424
+ }
1425
+ }
1426
+
1427
+ if (!result.cryptoInfo) {
1428
+ result.cryptoInfo = this.extractCryptoFromBytes(buffer);
1429
+ }
1430
+
1431
+ return result;
1432
+ }
1433
+
1434
+ /**
1435
+ * 直接从原始字节解析 TEXT 消息内容
1436
+ * Content 结构:
1437
+ * field 1 (text): string
1438
+ * field 3 (title): 嵌套的 RichText 结构
1439
+ * @param {Uint8Array} bytes - 原始消息内容字节
1440
+ * @returns {string} 解析出的文本
1441
+ */
1442
+ static parseTextMessageContent(bytes) {
1443
+ const buffer = Buffer.from(bytes);
1444
+ let offset = 0;
1445
+ let textField = '';
1446
+ let titleBytes = null;
1447
+
1448
+ // 解析 Content 结构
1449
+ while (offset < buffer.length) {
1450
+ if (offset >= buffer.length) break;
1451
+
1452
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
1453
+ if (tagBytes === 0) break;
1454
+ offset += tagBytes;
1455
+
1456
+ const wireType = tag & 0x07;
1457
+ const fieldNo = tag >> 3;
1458
+
1459
+ if (wireType === 2) { // Length-delimited
1460
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
1461
+ offset += bytesRead;
1462
+
1463
+ if (offset + len > buffer.length) break;
1464
+
1465
+ const data = buffer.subarray(offset, offset + len);
1466
+ offset += len;
1467
+
1468
+ // field 1 = text
1469
+ if (fieldNo === 1 && len > 0) {
1470
+ textField = data.toString('utf8');
1471
+ }
1472
+ // field 3 = title (嵌套的 RichText)
1473
+ else if (fieldNo === 3) {
1474
+ titleBytes = data;
1475
+ }
1476
+ } else if (wireType === 0) { // Varint
1477
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
1478
+ offset += bytesRead;
1479
+ } else {
1480
+ break;
1481
+ }
1482
+ }
1483
+
1484
+ // 优先使用 text 字段
1485
+ if (textField) {
1486
+ return textField;
1487
+ }
1488
+
1489
+ // 否则从 title (嵌套 RichText) 中提取
1490
+ if (titleBytes) {
1491
+ return this.parseRichTextFromBytes(titleBytes);
1492
+ }
1493
+
1494
+ return '';
1495
+ }
1496
+
1497
+ /**
1498
+ * 从 RichText 字节中解析文本
1499
+ * RichText 结构:
1500
+ * field 1 (elementIds): repeated string
1501
+ * field 2 (innerText): string
1502
+ * field 3 (elements): RichTextElements
1503
+ * @param {Buffer} buffer - RichText 字节
1504
+ * @returns {string} 解析出的文本
1505
+ */
1506
+ static parseRichTextFromBytes(buffer) {
1507
+ let offset = 0;
1508
+ let innerText = '';
1509
+ let elementsBytes = null;
1510
+
1511
+ while (offset < buffer.length) {
1512
+ if (offset >= buffer.length) break;
1513
+
1514
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
1515
+ if (tagBytes === 0) break;
1516
+ offset += tagBytes;
1517
+
1518
+ const wireType = tag & 0x07;
1519
+ const fieldNo = tag >> 3;
1520
+
1521
+ if (wireType === 2) { // Length-delimited
1522
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
1523
+ offset += bytesRead;
1524
+
1525
+ if (offset + len > buffer.length) break;
1526
+
1527
+ const data = buffer.subarray(offset, offset + len);
1528
+ offset += len;
1529
+
1530
+ // field 2 = innerText
1531
+ if (fieldNo === 2 && len > 0) {
1532
+ innerText = data.toString('utf8');
1533
+ }
1534
+ // field 3 = elements
1535
+ else if (fieldNo === 3) {
1536
+ elementsBytes = data;
1537
+ }
1538
+ } else if (wireType === 0) { // Varint
1539
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
1540
+ offset += bytesRead;
1541
+ } else {
1542
+ break;
1543
+ }
1544
+ }
1545
+
1546
+ // 优先使用 innerText
1547
+ if (innerText) {
1548
+ return innerText;
1549
+ }
1550
+
1551
+ // 否则从 elements 中提取
1552
+ if (elementsBytes) {
1553
+ return this.extractTextFromElementsBytes(elementsBytes);
1554
+ }
1555
+
1556
+ return '';
1557
+ }
1558
+
1559
+ /**
1560
+ * 从 RichTextElements 字节中提取文本
1561
+ * RichTextElements 结构:
1562
+ * field 1 (dictionary): map<string, RichTextElement>
1563
+ * @param {Buffer} buffer - RichTextElements 字节
1564
+ * @returns {string} 解析出的文本
1565
+ */
1566
+ static extractTextFromElementsBytes(buffer) {
1567
+ const texts = [];
1568
+ let offset = 0;
1569
+
1570
+ while (offset < buffer.length) {
1571
+ if (offset >= buffer.length) break;
1572
+
1573
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
1574
+ if (tagBytes === 0) break;
1575
+ offset += tagBytes;
1576
+
1577
+ const wireType = tag & 0x07;
1578
+ const fieldNo = tag >> 3;
1579
+
1580
+ if (wireType === 2) { // Length-delimited
1581
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
1582
+ offset += bytesRead;
1583
+
1584
+ if (offset + len > buffer.length) break;
1585
+
1586
+ const data = buffer.subarray(offset, offset + len);
1587
+ offset += len;
1588
+
1589
+ // field 1 = dictionary entry (map entry)
1590
+ if (fieldNo === 1) {
1591
+ const text = this.extractTextFromMapEntry(data);
1592
+ if (text) {
1593
+ texts.push(text);
1594
+ }
1595
+ }
1596
+ } else if (wireType === 0) {
1597
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
1598
+ offset += bytesRead;
1599
+ } else {
1600
+ break;
1601
+ }
1602
+ }
1603
+
1604
+ return texts.join('');
1605
+ }
1606
+
1607
+ /**
1608
+ * 从 map entry 中提取文本
1609
+ * Map entry 结构:
1610
+ * field 1 (key): string (element ID)
1611
+ * field 2 (value): RichTextElement
1612
+ * RichTextElement 结构:
1613
+ * field 1 (tag): int32 (1=TEXT, 2=IMG, ...)
1614
+ * field 3 (property): bytes (TextProperty for TEXT)
1615
+ * @param {Buffer} buffer - map entry 字节
1616
+ * @returns {string} 解析出的文本
1617
+ */
1618
+ static extractTextFromMapEntry(buffer) {
1619
+ let offset = 0;
1620
+ let valueBytes = null;
1621
+
1622
+ while (offset < buffer.length) {
1623
+ if (offset >= buffer.length) break;
1624
+
1625
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
1626
+ if (tagBytes === 0) break;
1627
+ offset += tagBytes;
1628
+
1629
+ const wireType = tag & 0x07;
1630
+ const fieldNo = tag >> 3;
1631
+
1632
+ if (wireType === 2) { // Length-delimited
1633
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
1634
+ offset += bytesRead;
1635
+
1636
+ if (offset + len > buffer.length) break;
1637
+
1638
+ const data = buffer.subarray(offset, offset + len);
1639
+ offset += len;
1640
+
1641
+ // field 2 = value (RichTextElement)
1642
+ if (fieldNo === 2) {
1643
+ valueBytes = data;
1644
+ }
1645
+ } else if (wireType === 0) {
1646
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
1647
+ offset += bytesRead;
1648
+ } else {
1649
+ break;
1650
+ }
1651
+ }
1652
+
1653
+ if (valueBytes) {
1654
+ return this.extractTextFromRichTextElement(valueBytes);
1655
+ }
1656
+
1657
+ return '';
1658
+ }
1659
+
1660
+ /**
1661
+ * 从 RichTextElement 中提取文本
1662
+ * RichTextElement 结构:
1663
+ * field 1 (tag): int32 (1=TEXT, 2=IMG)
1664
+ * field 3 (property): bytes
1665
+ * @param {Buffer} buffer - RichTextElement 字节
1666
+ * @returns {string} 解析出的文本
1667
+ */
1668
+ static extractTextFromRichTextElement(buffer) {
1669
+ let offset = 0;
1670
+ let elementTag = 0;
1671
+ let propertyBytes = null;
1672
+
1673
+ while (offset < buffer.length) {
1674
+ if (offset >= buffer.length) break;
1675
+
1676
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
1677
+ if (tagBytes === 0) break;
1678
+ offset += tagBytes;
1679
+
1680
+ const wireType = tag & 0x07;
1681
+ const fieldNo = tag >> 3;
1682
+
1683
+ if (wireType === 0) { // Varint
1684
+ const { value, bytesRead } = this.readVarintWithLength(buffer, offset);
1685
+ offset += bytesRead;
1686
+ // field 1 = tag
1687
+ if (fieldNo === 1) {
1688
+ elementTag = value;
1689
+ }
1690
+ } else if (wireType === 2) { // Length-delimited
1691
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
1692
+ offset += bytesRead;
1693
+
1694
+ if (offset + len > buffer.length) break;
1695
+
1696
+ const data = buffer.subarray(offset, offset + len);
1697
+ offset += len;
1698
+
1699
+ // field 3 = property
1700
+ if (fieldNo === 3) {
1701
+ propertyBytes = data;
1702
+ }
1703
+ } else {
1704
+ break;
1705
+ }
1706
+ }
1707
+
1708
+ // tag = 1 表示 TEXT 元素
1709
+ if (elementTag === 1 && propertyBytes) {
1710
+ return this.parseTextPropertyBytes(propertyBytes);
1711
+ }
1712
+
1713
+ return '';
1714
+ }
1715
+
1716
+ /**
1717
+ * 从 TextProperty 字节中解析文本
1718
+ * TextProperty 结构:
1719
+ * field 1 (content): string
1720
+ * @param {Buffer} buffer - TextProperty 字节
1721
+ * @returns {string} 解析出的文本
1722
+ */
1723
+ static parseTextPropertyBytes(buffer) {
1724
+ let offset = 0;
1725
+
1726
+ while (offset < buffer.length) {
1727
+ if (offset >= buffer.length) break;
1728
+
1729
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
1730
+ if (tagBytes === 0) break;
1731
+ offset += tagBytes;
1732
+
1733
+ const wireType = tag & 0x07;
1734
+ const fieldNo = tag >> 3;
1735
+
1736
+ if (wireType === 2) { // Length-delimited
1737
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
1738
+ offset += bytesRead;
1739
+
1740
+ if (offset + len > buffer.length) break;
1741
+
1742
+ const data = buffer.subarray(offset, offset + len);
1743
+ offset += len;
1744
+
1745
+ // field 1 = content
1746
+ if (fieldNo === 1) {
1747
+ return data.toString('utf8');
1748
+ }
1749
+ } else if (wireType === 0) {
1750
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
1751
+ offset += bytesRead;
1752
+ } else {
1753
+ break;
1754
+ }
1755
+ }
1756
+
1757
+ return '';
1758
+ }
1759
+
1760
+ /**
1761
+ * 尝试从原始字节中解码文本内容
1762
+ * @param {Uint8Array} bytes - 原始字节
1763
+ * @returns {string} 解码的文本,如果失败则返回空字符串
1764
+ */
1765
+ static tryDecodeTextFromBytes(bytes) {
1766
+ if (!bytes || bytes.length === 0) {
1767
+ return '';
1768
+ }
1769
+
1770
+ const buffer = Buffer.from(bytes);
1771
+ let offset = 0;
1772
+
1773
+ // 遍历 protobuf 字段,查找字符串类型的字段
1774
+ while (offset < buffer.length) {
1775
+ if (offset >= buffer.length) break;
1776
+
1777
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
1778
+ if (tagBytes === 0) break;
1779
+ offset += tagBytes;
1780
+
1781
+ const wireType = tag & 0x07;
1782
+ const fieldNo = tag >> 3;
1783
+
1784
+ if (wireType === 2) { // Length-delimited (string/bytes/message)
1785
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
1786
+ offset += bytesRead;
1787
+
1788
+ if (offset + len > buffer.length) break;
1789
+
1790
+ const data = buffer.subarray(offset, offset + len);
1791
+ offset += len;
1792
+
1793
+ // field 1 通常是 text 字段
1794
+ if (fieldNo === 1 && len > 0) {
1795
+ try {
1796
+ const text = data.toString('utf8');
1797
+ // 验证是否为有效的 UTF-8 文本(不包含控制字符)
1798
+ if (text && !/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(text)) {
1799
+ return text;
1800
+ }
1801
+ } catch {
1802
+ // 继续尝试其他字段
1803
+ }
1804
+ }
1805
+
1806
+ // field 14 可能是 richText,尝试递归解析
1807
+ if (fieldNo === 14 && len > 0) {
1808
+ const innerText = this.tryExtractInnerText(data);
1809
+ if (innerText) {
1810
+ return innerText;
1811
+ }
1812
+ }
1813
+ } else if (wireType === 0) { // Varint
1814
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
1815
+ offset += bytesRead;
1816
+ } else {
1817
+ break;
1818
+ }
1819
+ }
1820
+
1821
+ return '';
1822
+ }
1823
+
1824
+ /**
1825
+ * 尝试从 richText 结构中提取 innerText
1826
+ * @param {Buffer} buffer - richText 字节
1827
+ * @returns {string} innerText 或空字符串
1828
+ */
1829
+ static tryExtractInnerText(buffer) {
1830
+ let offset = 0;
1831
+
1832
+ while (offset < buffer.length) {
1833
+ if (offset >= buffer.length) break;
1834
+
1835
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
1836
+ if (tagBytes === 0) break;
1837
+ offset += tagBytes;
1838
+
1839
+ const wireType = tag & 0x07;
1840
+ const fieldNo = tag >> 3;
1841
+
1842
+ if (wireType === 2) {
1843
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
1844
+ offset += bytesRead;
1845
+
1846
+ if (offset + len > buffer.length) break;
1847
+
1848
+ const data = buffer.subarray(offset, offset + len);
1849
+ offset += len;
1850
+
1851
+ // field 2 是 innerText
1852
+ if (fieldNo === 2 && len > 0) {
1853
+ try {
1854
+ const text = data.toString('utf8');
1855
+ if (text && !/[\x00-\x08\x0B\x0C\x0E-\x1F]/.test(text)) {
1856
+ return text;
1857
+ }
1858
+ } catch {
1859
+ // 继续
1860
+ }
1861
+ }
1862
+ } else if (wireType === 0) {
1863
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
1864
+ offset += bytesRead;
1865
+ } else {
1866
+ break;
1867
+ }
1868
+ }
1869
+
1870
+ return '';
1871
+ }
1872
+
1873
+ /**
1874
+ * Read varint and return both value and bytes read
1875
+ */
1876
+ static readVarintWithLength(buffer, offset) {
1877
+ let value = 0;
1878
+ let shift = 0;
1879
+ let bytesRead = 0;
1880
+ let byte;
1881
+
1882
+ do {
1883
+ byte = buffer[offset + bytesRead];
1884
+ value |= (byte & 0x7F) << shift;
1885
+ bytesRead++;
1886
+ shift += 7;
1887
+ } while (byte & 0x80);
1888
+
1889
+ return { value, bytesRead };
1890
+ }
1891
+
1892
+ /**
1893
+ * Extract crypto info from raw bytes (fallback method)
1894
+ */
1895
+ static extractCryptoFromBytes(buffer, depth = 0) {
1896
+ if (!buffer || buffer.length === 0 || depth > 6) {
1897
+ return null;
1898
+ }
1899
+
1900
+ const bytes = Buffer.from(buffer);
1901
+
1902
+ // 1) 直接尝试按 Crypto 解析
1903
+ try {
1904
+ const crypto = fromBinary(CryptoSchema, bytes);
1905
+ const cryptoInfo = this.extractCryptoInfo(crypto);
1906
+ if (cryptoInfo?.secret && cryptoInfo?.nonce) {
1907
+ return cryptoInfo;
1908
+ }
1909
+ } catch {
1910
+ // ignore
1911
+ }
1912
+
1913
+ // 2) 尝试按 ImageSetV2 解析
1914
+ try {
1915
+ const imageV2 = fromBinary(ImageSetV2Schema, bytes);
1916
+ if (imageV2?.crypto) {
1917
+ const cryptoInfo = this.extractCryptoInfo(imageV2.crypto);
1918
+ if (cryptoInfo?.secret && cryptoInfo?.nonce) {
1919
+ return cryptoInfo;
1920
+ }
1921
+ }
1922
+ } catch {
1923
+ // ignore
1924
+ }
1925
+
1926
+ // 3) 尝试按 Image 解析
1927
+ try {
1928
+ const image = fromBinary(ImageSchema, bytes);
1929
+ if (image?.crypto) {
1930
+ const cryptoInfo = this.extractCryptoInfo(image.crypto);
1931
+ if (cryptoInfo?.secret && cryptoInfo?.nonce) {
1932
+ return cryptoInfo;
1933
+ }
1934
+ }
1935
+ } catch {
1936
+ // ignore
1937
+ }
1938
+
1939
+ // 4) 递归扫描 length-delimited 子字段
1940
+ let offset = 0;
1941
+ while (offset < bytes.length) {
1942
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(bytes, offset);
1943
+ if (!tagBytes) break;
1944
+ offset += tagBytes;
1945
+
1946
+ const wireType = tag & 0x07;
1947
+
1948
+ if (wireType === 2) {
1949
+ const { value: len, bytesRead } = this.readVarintWithLength(bytes, offset);
1950
+ offset += bytesRead;
1951
+
1952
+ if (len < 0 || offset + len > bytes.length) {
1953
+ break;
1954
+ }
1955
+
1956
+ const data = bytes.subarray(offset, offset + len);
1957
+ offset += len;
1958
+
1959
+ const nestedCrypto = this.extractCryptoFromBytes(data, depth + 1);
1960
+ if (nestedCrypto?.secret && nestedCrypto?.nonce) {
1961
+ return nestedCrypto;
1962
+ }
1963
+ } else if (wireType === 0) {
1964
+ const { bytesRead } = this.readVarintWithLength(bytes, offset);
1965
+ offset += bytesRead;
1966
+ } else if (wireType === 1) {
1967
+ offset += 8;
1968
+ } else if (wireType === 5) {
1969
+ offset += 4;
1970
+ } else {
1971
+ break;
1972
+ }
1973
+ }
1974
+
1975
+ return null;
1976
+ }
1977
+
1978
+ /**
1979
+ * 检查 cryptoInfo 是否满足图片解密要求
1980
+ * @param {Object|null} cryptoInfo
1981
+ * @returns {boolean}
1982
+ */
1983
+ static isValidImageCryptoInfo(cryptoInfo) {
1984
+ if (!cryptoInfo?.secret || !cryptoInfo?.nonce) {
1985
+ return false;
1986
+ }
1987
+
1988
+ const secretLength = Buffer.from(cryptoInfo.secret).length;
1989
+ const nonceLength = Buffer.from(cryptoInfo.nonce).length;
1990
+
1991
+ return secretLength === 32 && nonceLength === 12;
1992
+ }
1993
+
1994
+ /**
1995
+ * 生成 crypto 信息指纹(用于聚合)
1996
+ * @param {Object|null} cryptoInfo
1997
+ * @returns {string}
1998
+ */
1999
+ static getCryptoFingerprint(cryptoInfo) {
2000
+ if (!this.isValidImageCryptoInfo(cryptoInfo)) {
2001
+ return '';
2002
+ }
2003
+
2004
+ const secretHex = Buffer.from(cryptoInfo.secret).toString('hex');
2005
+ const nonceHex = Buffer.from(cryptoInfo.nonce).toString('hex');
2006
+ return secretHex.slice(0, 16) + ':' + nonceHex.slice(0, 8);
2007
+ }
2008
+
2009
+ /**
2010
+ * 仅尝试把当前 bytes 直接解码为 Crypto(不递归)
2011
+ * @param {Uint8Array|Buffer} bytes
2012
+ * @returns {Object|null}
2013
+ */
2014
+ static extractDirectCryptoInfo(bytes) {
2015
+ if (!bytes || bytes.length === 0) {
2016
+ return null;
2017
+ }
2018
+
2019
+ try {
2020
+ const crypto = fromBinary(CryptoSchema, bytes);
2021
+ const cryptoInfo = this.extractCryptoInfo(crypto);
2022
+ if (this.isValidImageCryptoInfo(cryptoInfo)) {
2023
+ return cryptoInfo;
2024
+ }
2025
+ } catch {
2026
+ // ignore
2027
+ }
2028
+
2029
+ return null;
2030
+ }
2031
+
2032
+ /**
2033
+ * 深度遍历 payload,收集可能的 Crypto block
2034
+ * @param {Uint8Array|Buffer} payload
2035
+ * @param {number} baseOffset
2036
+ * @param {number} depth
2037
+ * @param {number} maxDepth
2038
+ * @param {Array} blocks
2039
+ * @returns {Array<{offset:number,end:number,cryptoInfo:Object,fingerprint:string}>}
2040
+ */
2041
+ static collectCryptoBlocksFromPayload(payload, baseOffset = 0, depth = 0, maxDepth = 10, blocks = []) {
2042
+ if (!payload || depth > maxDepth) {
2043
+ return blocks;
2044
+ }
2045
+
2046
+ const buffer = payload instanceof Buffer ? payload : Buffer.from(payload);
2047
+ if (buffer.length === 0) {
2048
+ return blocks;
2049
+ }
2050
+
2051
+ let offset = 0;
2052
+ while (offset < buffer.length) {
2053
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
2054
+ if (!tagBytes) {
2055
+ break;
2056
+ }
2057
+ offset += tagBytes;
2058
+
2059
+ const wireType = tag & 0x07;
2060
+
2061
+ if (wireType === 2) {
2062
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
2063
+ offset += bytesRead;
2064
+
2065
+ if (len < 0 || offset + len > buffer.length) {
2066
+ break;
2067
+ }
2068
+
2069
+ const valueStart = offset;
2070
+ const valueEnd = offset + len;
2071
+ const data = buffer.subarray(valueStart, valueEnd);
2072
+
2073
+ const directCryptoInfo = this.extractDirectCryptoInfo(data);
2074
+ if (directCryptoInfo) {
2075
+ blocks.push({
2076
+ offset: baseOffset + valueStart,
2077
+ end: baseOffset + valueEnd,
2078
+ cryptoInfo: directCryptoInfo,
2079
+ fingerprint: this.getCryptoFingerprint(directCryptoInfo),
2080
+ });
2081
+ }
2082
+
2083
+ this.collectCryptoBlocksFromPayload(data, baseOffset + valueStart, depth + 1, maxDepth, blocks);
2084
+
2085
+ offset = valueEnd;
2086
+ } else if (wireType === 0) {
2087
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
2088
+ if (!bytesRead) {
2089
+ break;
2090
+ }
2091
+ offset += bytesRead;
2092
+ } else if (wireType === 1) {
2093
+ offset += 8;
2094
+ } else if (wireType === 5) {
2095
+ offset += 4;
2096
+ } else {
2097
+ break;
2098
+ }
2099
+ }
2100
+
2101
+ return blocks;
2102
+ }
2103
+
2104
+ /**
2105
+ * 在二进制 buffer 中查找 imageKey 出现位置
2106
+ * @param {Buffer} buffer
2107
+ * @param {string[]} imageKeys
2108
+ * @returns {Array<{imageKey:string,offset:number,end:number}>}
2109
+ */
2110
+ static findImageKeyOccurrencesInBuffer(buffer, imageKeys) {
2111
+ const occurrences = [];
2112
+
2113
+ for (const imageKey of imageKeys) {
2114
+ let startOffset = 0;
2115
+ while (startOffset < buffer.length) {
2116
+ const index = buffer.indexOf(imageKey, startOffset, 'utf8');
2117
+ if (index === -1) {
2118
+ break;
2119
+ }
2120
+
2121
+ occurrences.push({
2122
+ imageKey,
2123
+ offset: index,
2124
+ end: index + Buffer.byteLength(imageKey, 'utf8'),
2125
+ });
2126
+
2127
+ startOffset = index + 1;
2128
+ }
2129
+ }
2130
+
2131
+ return occurrences.sort((left, right) => left.offset - right.offset);
2132
+ }
2133
+
2134
+ /**
2135
+ * 从 cmd=77 响应 payload 中解析并缓存图片加密信息
2136
+ * @param {Uint8Array|Buffer} payload
2137
+ * @returns {number} 缓存条目数
2138
+ */
2139
+ static cacheImageCryptoFromCmd77Payload(payload) {
2140
+ if (!payload || payload.length === 0) {
2141
+ return 0;
2142
+ }
2143
+
2144
+ const buffer = payload instanceof Buffer ? payload : Buffer.from(payload);
2145
+ const imageKeys = this.extractImageKeysFromString(buffer.toString('utf8'));
2146
+ if (imageKeys.length === 0) {
2147
+ return 0;
2148
+ }
2149
+
2150
+ const imageKeyOccurrences = this.findImageKeyOccurrencesInBuffer(buffer, imageKeys);
2151
+ if (imageKeyOccurrences.length === 0) {
2152
+ return 0;
2153
+ }
2154
+
2155
+ const rawCryptoBlocks = this.collectCryptoBlocksFromPayload(buffer);
2156
+ if (rawCryptoBlocks.length === 0) {
2157
+ return 0;
2158
+ }
2159
+
2160
+ // 去重(避免递归扫描时重复记录)
2161
+ const dedupedBlockMap = new Map();
2162
+ for (const block of rawCryptoBlocks) {
2163
+ const dedupeKey = block.offset + ':' + block.fingerprint;
2164
+ if (!dedupedBlockMap.has(dedupeKey)) {
2165
+ dedupedBlockMap.set(dedupeKey, block);
2166
+ }
2167
+ }
2168
+ const cryptoBlocks = Array.from(dedupedBlockMap.values());
2169
+
2170
+ const votesByImageKey = new Map();
2171
+
2172
+ for (const occurrence of imageKeyOccurrences) {
2173
+ const forwardCandidates = cryptoBlocks.filter((block) => {
2174
+ const distance = block.offset - occurrence.end;
2175
+ return distance >= 0 && distance <= 2048;
2176
+ });
2177
+
2178
+ const candidateBlocks = forwardCandidates.length > 0
2179
+ ? forwardCandidates
2180
+ : cryptoBlocks.filter((block) => Math.abs(block.offset - occurrence.end) <= 512);
2181
+
2182
+ if (candidateBlocks.length === 0) {
2183
+ continue;
2184
+ }
2185
+
2186
+ let bestCandidate = null;
2187
+ for (const block of candidateBlocks) {
2188
+ const distance = Math.abs(block.offset - occurrence.end);
2189
+ const aheadPenalty = block.offset >= occurrence.end ? 0 : 256;
2190
+ const score = distance + aheadPenalty;
2191
+
2192
+ if (!bestCandidate || score < bestCandidate.score) {
2193
+ bestCandidate = { block, score };
2194
+ }
2195
+ }
2196
+
2197
+ if (!bestCandidate) {
2198
+ continue;
2199
+ }
2200
+
2201
+ const fingerprint = bestCandidate.block.fingerprint;
2202
+ if (!fingerprint) {
2203
+ continue;
2204
+ }
2205
+
2206
+ let voteMap = votesByImageKey.get(occurrence.imageKey);
2207
+ if (!voteMap) {
2208
+ voteMap = new Map();
2209
+ votesByImageKey.set(occurrence.imageKey, voteMap);
2210
+ }
2211
+
2212
+ const existingVote = voteMap.get(fingerprint) || {
2213
+ count: 0,
2214
+ bestScore: Number.MAX_SAFE_INTEGER,
2215
+ cryptoInfo: bestCandidate.block.cryptoInfo,
2216
+ };
2217
+
2218
+ existingVote.count += 1;
2219
+ existingVote.bestScore = Math.min(existingVote.bestScore, bestCandidate.score);
2220
+ voteMap.set(fingerprint, existingVote);
2221
+ }
2222
+
2223
+ let cachedCount = 0;
2224
+
2225
+ for (const [imageKey, voteMap] of votesByImageKey.entries()) {
2226
+ const voteEntries = Array.from(voteMap.entries());
2227
+ if (voteEntries.length === 0) {
2228
+ continue;
2229
+ }
2230
+
2231
+ voteEntries.sort((left, right) => {
2232
+ const [, leftVote] = left;
2233
+ const [, rightVote] = right;
2234
+ if (rightVote.count !== leftVote.count) {
2235
+ return rightVote.count - leftVote.count;
2236
+ }
2237
+ return leftVote.bestScore - rightVote.bestScore;
2238
+ });
2239
+
2240
+ const [, bestVote] = voteEntries[0];
2241
+ if (!this.isValidImageCryptoInfo(bestVote.cryptoInfo)) {
2242
+ continue;
2243
+ }
2244
+
2245
+ cacheImageCryptoInfo(imageKey, bestVote.cryptoInfo);
2246
+ cachedCount += 1;
2247
+ }
2248
+
2249
+ return cachedCount;
2250
+ }
2251
+
2252
+ /**
2253
+ * 从网关响应包中解析并缓存图片加密信息
2254
+ * @param {Buffer|Uint8Array} buffer - 完整响应包(二进制)
2255
+ * @returns {number} 缓存条目数
2256
+ */
2257
+ static cacheImageCryptoFromGatewayResponseBuffer(buffer) {
2258
+ if (!buffer || buffer.length === 0) {
2259
+ return 0;
2260
+ }
2261
+
2262
+ try {
2263
+ const bytes = buffer instanceof Uint8Array ? buffer : new Uint8Array(buffer);
2264
+ const packet = fromBinary(PacketSchema, bytes);
2265
+
2266
+ if (!packet.payload || packet.payload.length === 0) {
2267
+ return 0;
2268
+ }
2269
+
2270
+ return this.cacheImageCryptoFromCmd77Payload(packet.payload);
2271
+ } catch {
2272
+ return 0;
2273
+ }
2274
+ }
2275
+
2276
+ /**
2277
+ * 从 cmd=77 的完整响应包中解析并缓存图片加密信息
2278
+ * @param {Buffer|Uint8Array} buffer - 完整响应包(二进制)
2279
+ * @returns {number} 缓存条目数
2280
+ */
2281
+ static cacheImageCryptoFromCmd77ResponseBuffer(buffer) {
2282
+ return this.cacheImageCryptoFromGatewayResponseBuffer(buffer);
2283
+ }
2284
+
2285
+ /**
2286
+ * Read varint from buffer at offset
2287
+ */
2288
+ static readVarint(buffer, offset) {
2289
+ let value = 0;
2290
+ let shift = 0;
2291
+ let byte;
2292
+
2293
+ do {
2294
+ byte = buffer[offset];
2295
+ value |= (byte & 0x7F) << shift;
2296
+ offset++;
2297
+ shift += 7;
2298
+ } while (byte & 0x80);
2299
+
2300
+ return value;
2301
+ }
2302
+
2303
+ /**
2304
+ * Get length of varint at offset
2305
+ */
2306
+ static varintLength(buffer, offset) {
2307
+ let length = 0;
2308
+ while (buffer[offset + length] & 0x80) {
2309
+ length++;
2310
+ }
2311
+ return length + 1;
2312
+ }
2313
+
2314
+ /**
2315
+ * Build protobuf message for reply/回复消息
2316
+ * @param {string} text - Text content to send
2317
+ * @param {string} requestId - Request ID
2318
+ * @param {string} chatId - Chat ID
2319
+ * @param {Object} options - Options
2320
+ * @param {string} options.rootId - 回复的根消息ID
2321
+ * @param {string} options.parentId - 被回复的消息ID
2322
+ * @returns {Buffer} Serialized protobuf message
2323
+ */
2324
+ static buildReplyRequest(text, requestId, chatId, options = {}) {
2325
+ const { rootId, parentId } = options;
2326
+ const cid1 = generateRequestCid();
2327
+ const cid2 = generateRequestCid();
2328
+
2329
+ // Build TextProperty
2330
+ const textProperty = create(TextPropertySchema, {
2331
+ content: text
2332
+ });
2333
+
2334
+ // Build Content with RichText
2335
+ const content = create(ContentSchema, {
2336
+ text: text,
2337
+ richText: {
2338
+ elementIds: [cid2],
2339
+ innerText: text,
2340
+ elements: {
2341
+ dictionary: {
2342
+ [cid2]: {
2343
+ tag: 1, // TEXT
2344
+ property: toBinary(TextPropertySchema, textProperty)
2345
+ }
2346
+ }
2347
+ }
2348
+ }
2349
+ });
2350
+
2351
+ // Build PutMessageRequest with thread support
2352
+ const payload = create(PutMessageRequestSchema, {
2353
+ type: 4, // TEXT
2354
+ chatId: chatId,
2355
+ rootId: rootId || '',
2356
+ parentId: parentId || '',
2357
+ cid: cid1,
2358
+ isNotified: true,
2359
+ sendToChat: false, // 话题评论不发送到主聊天
2360
+ version: 1,
2361
+ isThreadGroupTopic: false, // 话题群话题标志
2362
+ content: content
2363
+ });
2364
+
2365
+ // Build outer Packet
2366
+ const packet = create(PacketSchema, {
2367
+ payloadType: 1, // PB2
2368
+ cmd: 5,
2369
+ cid: requestId,
2370
+ payload: toBinary(PutMessageRequestSchema, payload)
2371
+ });
2372
+
2373
+ return Buffer.from(toBinary(PacketSchema, packet));
2374
+ }
2375
+
2376
+ /**
2377
+ * Build ACK packet for WebSocket
2378
+ * @param {string} sid - Packet sid to acknowledge
2379
+ * @returns {Buffer} ACK protobuf message
2380
+ */
2381
+ static buildAckPacket(sid) {
2382
+ const current = Date.now();
2383
+
2384
+ // Build inner Packet
2385
+ const packet = create(PacketSchema, {
2386
+ payloadType: 1, // PB2
2387
+ cmd: 1, // ACK
2388
+ sid: sid
2389
+ });
2390
+
2391
+ // Build Frame
2392
+ const frame = create(FrameSchema, {
2393
+ seqid: protoInt64.parse(current),
2394
+ logid: protoInt64.parse(current),
2395
+ service: 1,
2396
+ method: 1,
2397
+ headers: [
2398
+ { key: 'x-request-time', value: `${current}000` }
2399
+ ],
2400
+ payloadType: 'pb',
2401
+ payload: toBinary(PacketSchema, packet)
2402
+ });
2403
+
2404
+ return Buffer.from(toBinary(FrameSchema, frame));
2405
+ }
2406
+
2407
+ /**
2408
+ * Build protobuf message for getting user profile (cmd=5031)
2409
+ * @param {string} requestId - Request ID
2410
+ * @param {string} userId - User ID to get profile for
2411
+ * @returns {Buffer} Serialized protobuf message
2412
+ */
2413
+ static buildGetUserProfileRequest(requestId, userId) {
2414
+ // Build payload manually based on captured request:
2415
+ // field 1 = 1 (fixed)
2416
+ // field 2 = userId (string)
2417
+ // field 5 = 2
2418
+ // field 11 = 1
2419
+ // field 12 = 1
2420
+ // field 14 = 1
2421
+ const parts = [];
2422
+
2423
+ // field 1: int32 = 1
2424
+ parts.push(0x08, 0x01); // field 1, wire type 0, value 1
2425
+
2426
+ // field 2: string = userId
2427
+ const userIdBytes = new TextEncoder().encode(userId);
2428
+ parts.push(0x12); // field 2, wire type 2
2429
+ pushVarintLength(parts, userIdBytes.length);
2430
+ parts.push(...userIdBytes);
2431
+
2432
+ // field 5: int32 = 2
2433
+ parts.push(0x28, 0x02); // field 5, wire type 0, value 2
2434
+
2435
+ // field 11: int32 = 1
2436
+ parts.push(0x58, 0x01); // field 11 (11 << 3 | 0 = 88 = 0x58), value 1
2437
+
2438
+ // field 12: int32 = 1
2439
+ parts.push(0x60, 0x01); // field 12 (12 << 3 | 0 = 96 = 0x60), value 1
2440
+
2441
+ // field 14: int32 = 1
2442
+ parts.push(0x70, 0x01); // field 14 (14 << 3 | 0 = 112 = 0x70), value 1
2443
+
2444
+ const payload = new Uint8Array(parts);
2445
+
2446
+ // Build outer Packet
2447
+ const packet = create(PacketSchema, {
2448
+ payloadType: 1, // PB2
2449
+ cmd: 5031,
2450
+ cid: requestId,
2451
+ payload: payload
2452
+ });
2453
+
2454
+ return Buffer.from(toBinary(PacketSchema, packet));
2455
+ }
2456
+
2457
+ /**
2458
+ * Decode user profile response (cmd=5031)
2459
+ * @param {Buffer} buffer - Response buffer
2460
+ * @returns {Object} User profile info
2461
+ */
2462
+ static decodeGetUserProfileResponse(buffer) {
2463
+ const packet = fromBinary(PacketSchema, buffer);
2464
+
2465
+ if (!packet.payload) {
2466
+ return null;
2467
+ }
2468
+
2469
+ // Parse the response payload to extract user info
2470
+ const payload = Buffer.from(packet.payload);
2471
+ return this.parseUserProfilePayload(payload);
2472
+ }
2473
+
2474
+ /**
2475
+ * Parse user profile payload
2476
+ * @param {Buffer} buffer - Payload buffer
2477
+ * @returns {Object} User profile info
2478
+ */
2479
+ static parseUserProfilePayload(buffer) {
2480
+ const result = {
2481
+ userId: null,
2482
+ name: null,
2483
+ enName: null,
2484
+ avatarUrl: null,
2485
+ avatarKey: null,
2486
+ tenantId: null
2487
+ };
2488
+
2489
+ // Structure:
2490
+ // 1. Early in response: FROM_ID:userId - just reference
2491
+ // 2. Later: 0a 13 + userId (19 bytes) + 12 09 + name (9 bytes) - actual profile
2492
+ // 3. After that: 0x3a + len + enName
2493
+
2494
+ const str = buffer.toString('utf8');
2495
+
2496
+ // Find all 19-digit userIds
2497
+ const userIdMatch = str.match(/\d{19}/g);
2498
+ if (userIdMatch) {
2499
+ result.userId = userIdMatch[0];
2500
+ }
2501
+
2502
+ // Find the user profile section - look for userId followed by name
2503
+ // Pattern: 0a 13 (field 1, len 19) + userId + 12 xx (field 2) + name
2504
+ if (result.userId) {
2505
+ const userIdBuf = Buffer.from(result.userId);
2506
+ let idx = 0;
2507
+ let searchStart = 0;
2508
+
2509
+ // Find the second occurrence (the real profile, not FROM_ID reference)
2510
+ while ((idx = buffer.indexOf(userIdBuf, searchStart)) !== -1) {
2511
+ // Check if this is preceded by 0a 13 (field 1, length 19)
2512
+ if (idx >= 2 && buffer[idx - 2] === 0x0a && buffer[idx - 1] === 0x13) {
2513
+ // This is the profile section!
2514
+ const afterUserId = idx + userIdBuf.length;
2515
+
2516
+ // Look for name field: 12 xx (field 2, length)
2517
+ if (afterUserId < buffer.length && buffer[afterUserId] === 0x12) {
2518
+ const nameLen = buffer[afterUserId + 1];
2519
+ if (nameLen > 0 && nameLen <= 30 && afterUserId + 2 + nameLen <= buffer.length) {
2520
+ const nameBytes = buffer.subarray(afterUserId + 2, afterUserId + 2 + nameLen);
2521
+ try {
2522
+ const name = nameBytes.toString('utf8');
2523
+ if (/^[\u4e00-\u9fa5]{2,10}$/.test(name) || /^[a-zA-Z\s]+$/.test(name)) {
2524
+ result.name = name;
2525
+ }
2526
+ } catch (e) {
2527
+ // ignore
2528
+ }
2529
+ }
2530
+ }
2531
+ break;
2532
+ }
2533
+ searchStart = idx + 1;
2534
+ }
2535
+ }
2536
+
2537
+ // Find English name - near the profile section
2538
+ // It appears at field 7 (tag 0x3a) within the user profile section
2539
+ if (result.userId) {
2540
+ const userIdBuf = Buffer.from(result.userId);
2541
+ const profileStart = buffer.lastIndexOf(userIdBuf);
2542
+ if (profileStart > 0) {
2543
+ // Search within the profile section (profileStart to +2000 bytes)
2544
+ const profileEnd = Math.min(buffer.length, profileStart + 2000);
2545
+ const profileSlice = buffer.subarray(profileStart, profileEnd);
2546
+ const profileStr = profileSlice.toString('utf8');
2547
+
2548
+ // Look for field 7 (0x3a) in the profile section - contains pinyin name
2549
+ const enNameMatch = profileStr.match(/([a-z]+(?: [a-z]+)+)/);
2550
+ if (enNameMatch && enNameMatch[0].length < 30) {
2551
+ result.enName = enNameMatch[0];
2552
+ }
2553
+ }
2554
+ }
2555
+
2556
+ // Extract avatar URL - look for feishucdn.com URLs
2557
+ const avatarMatch = str.match(/https:\/\/s\d-imfile\.feishucdn\.com\/static-resource\/v\d\/[^\s~?]+/);
2558
+ if (avatarMatch) {
2559
+ result.avatarUrl = avatarMatch[0];
2560
+ }
2561
+
2562
+ // Extract avatar key (v3_xxxx pattern)
2563
+ const avatarKeyMatch = str.match(/v3_[a-z0-9]{4}_[a-f0-9-]+/i);
2564
+ if (avatarKeyMatch) {
2565
+ result.avatarKey = avatarKeyMatch[0];
2566
+ }
2567
+
2568
+ return result;
2569
+ }
2570
+
2571
+ // ==================== Get User Info By ID API (cmd=46) ====================
2572
+
2573
+ /**
2574
+ * Build protobuf message for getting user info by ID (cmd=46)
2575
+ * This API can query both regular users and bots
2576
+ * @param {string} requestId - Request ID
2577
+ * @param {string} userId - User ID to get info for
2578
+ * @returns {Buffer} Serialized protobuf message
2579
+ */
2580
+ static buildGetUserInfoByIdRequest(requestId, userId) {
2581
+ // Payload structure (inner message):
2582
+ // field 1 = userId (string)
2583
+ // field 2 = 1 (varint)
2584
+ const parts = [];
2585
+
2586
+ // field 1 = userId (string)
2587
+ const userIdBytes = Buffer.from(userId, 'utf-8');
2588
+ parts.push(0x0a, userIdBytes.length, ...userIdBytes);
2589
+
2590
+ // field 2 = 1
2591
+ parts.push(0x10, 0x01);
2592
+
2593
+ const payload = new Uint8Array(parts);
2594
+
2595
+ const packet = create(PacketSchema, {
2596
+ payloadType: 1,
2597
+ cmd: 46,
2598
+ cid: requestId,
2599
+ payload: payload
2600
+ });
2601
+
2602
+ return Buffer.from(toBinary(PacketSchema, packet));
2603
+ }
2604
+
2605
+ /**
2606
+ * Decode user info response (cmd=46)
2607
+ * @param {Buffer} buffer - Response buffer
2608
+ * @returns {Object} User info including isBot flag
2609
+ * - userId: string
2610
+ * - name: string (Chinese name)
2611
+ * - isBot: boolean (true if this is a bot/app)
2612
+ * - userType: number (1=user, 2=bot)
2613
+ * - avatarKey: string
2614
+ */
2615
+ static decodeGetUserInfoByIdResponse(buffer) {
2616
+ const packet = fromBinary(PacketSchema, buffer);
2617
+
2618
+ if (!packet.payload) {
2619
+ return { success: false, error: 'No payload in response' };
2620
+ }
2621
+
2622
+ const payload = Buffer.from(packet.payload);
2623
+ return this.parseUserInfoPayload(payload);
2624
+ }
2625
+
2626
+ // ==================== Get User Profile Card API (cmd=5017) ====================
2627
+
2628
+ /**
2629
+ * Build protobuf message for getting user profile card (cmd=5017)
2630
+ *
2631
+ * Payload structure:
2632
+ * field 1 = userId (varint)
2633
+ *
2634
+ * @param {string} requestId - Request ID
2635
+ * @param {string} userId - User ID to get profile card for
2636
+ * @returns {Buffer} Serialized protobuf message
2637
+ */
2638
+ static buildGetUserProfileCardRequest(requestId, userId) {
2639
+ const numericUserId = BigInt(userId);
2640
+ const innerParts = [0x08]; // field 1, varint
2641
+ this.pushVarintBigInt(innerParts, numericUserId);
2642
+
2643
+ const payload = new Uint8Array(innerParts);
2644
+
2645
+ const packet = create(PacketSchema, {
2646
+ payloadType: 1,
2647
+ cmd: 5017,
2648
+ cid: requestId,
2649
+ payload
2650
+ });
2651
+
2652
+ return Buffer.from(toBinary(PacketSchema, packet));
2653
+ }
2654
+
2655
+ /**
2656
+ * Decode user profile card response (cmd=5017)
2657
+ * @param {Buffer} buffer - Response buffer
2658
+ * @returns {Object} Decoded profile card
2659
+ */
2660
+ static decodeGetUserProfileCardResponse(buffer) {
2661
+ const packet = fromBinary(PacketSchema, buffer);
2662
+
2663
+ if (!packet.payload) {
2664
+ return {
2665
+ success: false,
2666
+ error: 'No payload in response'
2667
+ };
2668
+ }
2669
+
2670
+ if (packet.status && packet.status !== 0) {
2671
+ return {
2672
+ success: false,
2673
+ error: `Packet status: ${packet.status}`
2674
+ };
2675
+ }
2676
+
2677
+ const payload = Buffer.from(packet.payload);
2678
+ return this.parseUserProfileCardPayload(payload);
2679
+ }
2680
+
2681
+ /**
2682
+ * Parse user profile card payload from cmd=5017 response
2683
+ *
2684
+ * Known top-level fields (inferred from recordings):
2685
+ * - field 2: org/tenant display name
2686
+ * - field 3: description (profile signature)
2687
+ * - field 5: email
2688
+ * - field 7: display name
2689
+ * - field 11: localized display name
2690
+ * - field 19: avatar key
2691
+ * - field 22: profile user ID (string)
2692
+ * - field 24: alias / extra name
2693
+ * - field 50(repeated): profile field policy entries
2694
+ *
2695
+ * @param {Buffer} buffer - Payload buffer
2696
+ * @returns {Object} Parsed result
2697
+ */
2698
+ static parseUserProfileCardPayload(buffer) {
2699
+ const result = {
2700
+ success: true,
2701
+ orgName: null,
2702
+ description: null,
2703
+ email: null,
2704
+ displayName: null,
2705
+ displayNameLocalized: null,
2706
+ avatarKey: null,
2707
+ profileUserId: null,
2708
+ alias: null,
2709
+ profileFieldPolicies: [],
2710
+ raw: buffer
2711
+ };
2712
+
2713
+ let offset = 0;
2714
+
2715
+ while (offset < buffer.length) {
2716
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
2717
+ if (!tagBytes) {
2718
+ break;
2719
+ }
2720
+ offset += tagBytes;
2721
+
2722
+ const wireType = tag & 0x07;
2723
+ const fieldNo = tag >> 3;
2724
+
2725
+ if (wireType === 2) {
2726
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
2727
+ offset += bytesRead;
2728
+
2729
+ if (offset + len > buffer.length) {
2730
+ break;
2731
+ }
2732
+
2733
+ const data = buffer.subarray(offset, offset + len);
2734
+ offset += len;
2735
+
2736
+ if (fieldNo === 2) {
2737
+ result.orgName = data.toString('utf8');
2738
+ } else if (fieldNo === 3) {
2739
+ result.description = data.toString('utf8');
2740
+ } else if (fieldNo === 5) {
2741
+ result.email = data.toString('utf8');
2742
+ } else if (fieldNo === 7) {
2743
+ result.displayName = data.toString('utf8');
2744
+ } else if (fieldNo === 11) {
2745
+ result.displayNameLocalized = data.toString('utf8');
2746
+ } else if (fieldNo === 19) {
2747
+ result.avatarKey = data.toString('utf8');
2748
+ } else if (fieldNo === 22) {
2749
+ result.profileUserId = data.toString('utf8');
2750
+ } else if (fieldNo === 24) {
2751
+ result.alias = data.toString('utf8');
2752
+ } else if (fieldNo === 50) {
2753
+ const policy = this.parseUserProfileFieldPolicy(data);
2754
+ if (policy) {
2755
+ result.profileFieldPolicies.push(policy);
2756
+ }
2757
+ }
2758
+ } else if (wireType === 0) {
2759
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
2760
+ offset += bytesRead;
2761
+ } else if (wireType === 1) {
2762
+ offset += 8;
2763
+ } else if (wireType === 5) {
2764
+ offset += 4;
2765
+ } else {
2766
+ break;
2767
+ }
2768
+ }
2769
+
2770
+ return result;
2771
+ }
2772
+
2773
+ /**
2774
+ * Parse user profile field policy entry (field 50 in cmd=5017 response)
2775
+ * @param {Buffer} buffer - Policy entry buffer
2776
+ * @returns {Object|null} Parsed policy
2777
+ */
2778
+ static parseUserProfileFieldPolicy(buffer) {
2779
+ const policy = {
2780
+ key: null,
2781
+ locale: null,
2782
+ visible: null,
2783
+ editable: null,
2784
+ mode: null
2785
+ };
2786
+
2787
+ let offset = 0;
2788
+
2789
+ while (offset < buffer.length) {
2790
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
2791
+ if (!tagBytes) {
2792
+ break;
2793
+ }
2794
+ offset += tagBytes;
2795
+
2796
+ const wireType = tag & 0x07;
2797
+ const fieldNo = tag >> 3;
2798
+
2799
+ if (wireType === 2) {
2800
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
2801
+ offset += bytesRead;
2802
+
2803
+ if (offset + len > buffer.length) {
2804
+ break;
2805
+ }
2806
+
2807
+ const data = buffer.subarray(offset, offset + len);
2808
+ offset += len;
2809
+
2810
+ if (fieldNo === 1) {
2811
+ policy.key = data.toString('utf8');
2812
+ } else if (fieldNo === 5) {
2813
+ policy.locale = data.toString('utf8');
2814
+ }
2815
+ } else if (wireType === 0) {
2816
+ const { value, bytesRead } = this.readVarintWithLength(buffer, offset);
2817
+ offset += bytesRead;
2818
+
2819
+ if (fieldNo === 3) {
2820
+ policy.visible = value;
2821
+ } else if (fieldNo === 4) {
2822
+ policy.editable = value;
2823
+ } else if (fieldNo === 6) {
2824
+ policy.mode = value;
2825
+ }
2826
+ } else if (wireType === 1) {
2827
+ offset += 8;
2828
+ } else if (wireType === 5) {
2829
+ offset += 4;
2830
+ } else {
2831
+ break;
2832
+ }
2833
+ }
2834
+
2835
+ if (!policy.key) {
2836
+ return null;
2837
+ }
2838
+
2839
+ return policy;
2840
+ }
2841
+
2842
+ /**
2843
+ * Parse user info payload from cmd=46 response
2844
+ * @param {Buffer} buffer - Payload buffer
2845
+ * @returns {Object} User info
2846
+ */
2847
+ static parseUserInfoPayload(buffer) {
2848
+ const result = {
2849
+ userId: null,
2850
+ name: null,
2851
+ isBot: false,
2852
+ userType: 1, // 1=user, 2=bot
2853
+ avatarKey: null
2854
+ };
2855
+
2856
+ // Parse the protobuf structure to find key fields:
2857
+ // - field 1 > field 2 > field 1: userId (string)
2858
+ // - field 1 > field 2 > field 2: name (bytes, UTF-8)
2859
+ // - field 1 > field 2 > field 9: userType (varint, 1=user, 2=bot)
2860
+ // - field 1 > field 2 > field 17: bot info (message with field 1 = "bot")
2861
+
2862
+ try {
2863
+ // Simple approach: search for key patterns in the buffer
2864
+ const str = buffer.toString('utf8');
2865
+
2866
+ // Extract userId (19-digit number)
2867
+ const userIdMatch = str.match(/\d{19}/);
2868
+ if (userIdMatch) {
2869
+ result.userId = userIdMatch[0];
2870
+ }
2871
+
2872
+ // Check for "bot" string which indicates this is a bot
2873
+ // The pattern is: field 17 contains a message with field 1 = "bot"
2874
+ // In hex: 8a 01 09 0a 03 62 6f 74 (field 17, len 9, field 1, len 3, "bot")
2875
+ const botMarker = Buffer.from([0x0a, 0x03, 0x62, 0x6f, 0x74]); // field 1, len 3, "bot"
2876
+ if (buffer.includes(botMarker)) {
2877
+ result.isBot = true;
2878
+ result.userType = 2;
2879
+ }
2880
+
2881
+ // Extract name - look for UTF-8 Chinese characters after userId
2882
+ // Pattern: userId bytes followed by 0x12 (field 2) + length + name bytes
2883
+ if (result.userId) {
2884
+ const userIdBuf = Buffer.from(result.userId);
2885
+ let idx = buffer.indexOf(userIdBuf);
2886
+ while (idx !== -1) {
2887
+ const afterUserId = idx + userIdBuf.length;
2888
+ if (afterUserId < buffer.length && buffer[afterUserId] === 0x12) {
2889
+ const nameLen = buffer[afterUserId + 1];
2890
+ if (nameLen > 0 && nameLen <= 50 && afterUserId + 2 + nameLen <= buffer.length) {
2891
+ const nameBytes = buffer.subarray(afterUserId + 2, afterUserId + 2 + nameLen);
2892
+ try {
2893
+ const name = nameBytes.toString('utf8');
2894
+ // Accept Chinese characters or ASCII text
2895
+ if (name.length > 0 && name.length <= 30) {
2896
+ result.name = name;
2897
+ break;
2898
+ }
2899
+ } catch (e) {
2900
+ // ignore decoding errors
2901
+ }
2902
+ }
2903
+ }
2904
+ idx = buffer.indexOf(userIdBuf, idx + 1);
2905
+ }
2906
+ }
2907
+
2908
+ // Extract avatar key (pattern: v3_xxxx_xxx... or just alphanumeric key)
2909
+ const avatarKeyMatch = str.match(/v3_[a-z0-9]{4}_[a-f0-9-]+/i);
2910
+ if (avatarKeyMatch) {
2911
+ result.avatarKey = avatarKeyMatch[0];
2912
+ } else {
2913
+ // Try simpler pattern for bot avatars
2914
+ const simpleKeyMatch = str.match(/[a-f0-9]{20,}/);
2915
+ if (simpleKeyMatch) {
2916
+ result.avatarKey = simpleKeyMatch[0];
2917
+ }
2918
+ }
2919
+
2920
+ } catch (e) {
2921
+ return { success: false, error: e.message };
2922
+ }
2923
+
2924
+ return { success: true, ...result };
2925
+ }
2926
+
2927
+ // ==================== Calendar Event API ====================
2928
+
2929
+ /**
2930
+ * RSVP status constants for calendar events
2931
+ */
2932
+ static RSVP_STATUS = {
2933
+ ACCEPT: 1, // 接受
2934
+ DECLINE: 2, // 拒绝
2935
+ TENTATIVE: 3 // 待定
2936
+ };
2937
+
2938
+ /**
2939
+ * Build protobuf message for calendar event RSVP (cmd=1020002)
2940
+ * @param {string} requestId - Request ID
2941
+ * @param {string} eventId - Calendar event ID (UUID format)
2942
+ * @param {string} userId - Current user ID
2943
+ * @param {number} rsvpStatus - RSVP status: 1=accept, 2=decline, 3=tentative
2944
+ * @returns {Buffer} Serialized protobuf message
2945
+ */
2946
+ static buildCalendarRsvpRequest(requestId, eventId, userId, rsvpStatus) {
2947
+ const parts = [];
2948
+
2949
+ // Inner payload structure:
2950
+ // field 1: nested message with eventId, userId
2951
+ // field 1: eventId (string)
2952
+ // field 2: userId (string)
2953
+ // field 3: 0
2954
+ // field 2: rsvpStatus (varint)
2955
+ // field 5: 1
2956
+ // field 6: 1
2957
+
2958
+ // Build inner nested message (field 1)
2959
+ const innerParts = [];
2960
+
2961
+ // field 1: eventId
2962
+ const eventIdBytes = new TextEncoder().encode(eventId);
2963
+ innerParts.push(0x0a); // field 1, wire type 2
2964
+ pushVarintLength(innerParts, eventIdBytes.length);
2965
+ innerParts.push(...eventIdBytes);
2966
+
2967
+ // field 2: userId
2968
+ const userIdBytes = new TextEncoder().encode(userId);
2969
+ innerParts.push(0x12); // field 2, wire type 2
2970
+ pushVarintLength(innerParts, userIdBytes.length);
2971
+ innerParts.push(...userIdBytes);
2972
+
2973
+ // field 3: 0
2974
+ innerParts.push(0x18, 0x00); // field 3, wire type 0, value 0
2975
+
2976
+ const innerMessage = new Uint8Array(innerParts);
2977
+
2978
+ // Build payload
2979
+ // field 1: inner message
2980
+ parts.push(0x0a); // field 1, wire type 2
2981
+ pushVarintLength(parts, innerMessage.length);
2982
+ parts.push(...innerMessage);
2983
+
2984
+ // field 2: rsvpStatus
2985
+ parts.push(0x10); // field 2, wire type 0
2986
+ parts.push(rsvpStatus);
2987
+
2988
+ // field 5: 1
2989
+ parts.push(0x28, 0x01); // field 5, wire type 0, value 1
2990
+
2991
+ // field 6: 1
2992
+ parts.push(0x30, 0x01); // field 6, wire type 0, value 1
2993
+
2994
+ const payload = new Uint8Array(parts);
2995
+
2996
+ // Build outer Packet
2997
+ const packet = create(PacketSchema, {
2998
+ payloadType: 1, // PB2
2999
+ cmd: 1020002,
3000
+ cid: requestId,
3001
+ payload: payload
3002
+ });
3003
+
3004
+ return Buffer.from(toBinary(PacketSchema, packet));
3005
+ }
3006
+
3007
+ /**
3008
+ * Build protobuf message for getting calendar event detail (cmd=1010048)
3009
+ * @param {string} requestId - Request ID
3010
+ * @param {string} userId - Current user ID
3011
+ * @param {number} timestamp - Timestamp for version/cursor (optional, use 0 or current timestamp)
3012
+ * @returns {Buffer} Serialized protobuf message
3013
+ */
3014
+ static buildCalendarEventDetailRequest(requestId, userId, timestamp = 0) {
3015
+ const parts = [];
3016
+
3017
+ // Inner payload structure (from recording):
3018
+ // field 1: nested message
3019
+ // field 1: userId (string)
3020
+ // field 2: timestamp (varint, signed)
3021
+ // field 4: 0
3022
+ // field 5: 1
3023
+ // field 6: 3
3024
+
3025
+ // Build inner nested message
3026
+ const innerParts = [];
3027
+
3028
+ // field 1: userId
3029
+ const userIdBytes = new TextEncoder().encode(userId);
3030
+ innerParts.push(0x0a); // field 1, wire type 2
3031
+ pushVarintLength(innerParts, userIdBytes.length);
3032
+ innerParts.push(...userIdBytes);
3033
+
3034
+ // field 2: timestamp (signed varint) - using zigzag encoding for negative values
3035
+ // From hex: 10b9b8da86ddb59203 = field 2, value = negative number
3036
+ // We'll use 0 or a positive timestamp for simplicity
3037
+ if (timestamp !== 0) {
3038
+ innerParts.push(0x10); // field 2, wire type 0
3039
+ // Encode as zigzag for negative support
3040
+ const zigzag = (timestamp << 1) ^ (timestamp >> 31);
3041
+ this.pushVarint(innerParts, zigzag);
3042
+ }
3043
+
3044
+ // field 4: 0
3045
+ innerParts.push(0x20, 0x00); // field 4, wire type 0, value 0
3046
+
3047
+ // field 5: 1
3048
+ innerParts.push(0x28, 0x01); // field 5, wire type 0, value 1
3049
+
3050
+ // field 6: 3
3051
+ innerParts.push(0x30, 0x03); // field 6, wire type 0, value 3
3052
+
3053
+ const innerMessage = new Uint8Array(innerParts);
3054
+
3055
+ // Build payload
3056
+ // field 1: inner message
3057
+ parts.push(0x0a); // field 1, wire type 2
3058
+ pushVarintLength(parts, innerMessage.length);
3059
+ parts.push(...innerMessage);
3060
+
3061
+ const payload = new Uint8Array(parts);
3062
+
3063
+ // Build outer Packet
3064
+ const packet = create(PacketSchema, {
3065
+ payloadType: 1, // PB2
3066
+ cmd: 1010048,
3067
+ cid: requestId,
3068
+ payload: payload
3069
+ });
3070
+
3071
+ return Buffer.from(toBinary(PacketSchema, packet));
3072
+ }
3073
+
3074
+ /**
3075
+ * Push varint bytes to array
3076
+ * @param {Array} arr - Target array
3077
+ * @param {number} value - Value to encode
3078
+ */
3079
+ static pushVarint(arr, value) {
3080
+ while (value > 127) {
3081
+ arr.push((value & 0x7f) | 0x80);
3082
+ value >>>= 7;
3083
+ }
3084
+ arr.push(value);
3085
+ }
3086
+
3087
+ /**
3088
+ * Push bigint varint bytes to array
3089
+ * @param {Array} arr - Target array
3090
+ * @param {bigint} value - BigInt value
3091
+ */
3092
+ static pushVarintBigInt(arr, value) {
3093
+ let current = BigInt(value);
3094
+
3095
+ while (current > 127n) {
3096
+ arr.push(Number((current & 0x7fn) | 0x80n));
3097
+ current >>= 7n;
3098
+ }
3099
+
3100
+ arr.push(Number(current));
3101
+ }
3102
+
3103
+ /**
3104
+ * Decode calendar event RSVP response (cmd=1020002)
3105
+ * @param {Buffer} buffer - Response buffer
3106
+ * @returns {Object} { success: boolean, error?: string }
3107
+ */
3108
+ static decodeCalendarRsvpResponse(buffer) {
3109
+ try {
3110
+ const packet = fromBinary(PacketSchema, buffer);
3111
+
3112
+ // Check status
3113
+ if (packet.status && packet.status !== 0) {
3114
+ // 尝试从 payload 中提取错误消息
3115
+ if (packet.payload && packet.payload.length > 0) {
3116
+ const errorMsg = this.extractErrorMessage(packet.payload);
3117
+ if (errorMsg) {
3118
+ return { success: false, error: errorMsg };
3119
+ }
3120
+ }
3121
+ return { success: false, error: `Status: ${packet.status}` };
3122
+ }
3123
+
3124
+ return { success: true };
3125
+ } catch (e) {
3126
+ // 如果 protobuf 解析失败,尝试直接从 buffer 提取错误消息
3127
+ const errorMsg = this.extractErrorMessage(buffer);
3128
+ if (errorMsg) {
3129
+ return { success: false, error: errorMsg };
3130
+ }
3131
+ return { success: false, error: e.message };
3132
+ }
3133
+ }
3134
+
3135
+ /**
3136
+ * 从 protobuf payload 中提取错误消息字符串
3137
+ * @param {Uint8Array|Buffer} payload
3138
+ * @returns {string|null}
3139
+ */
3140
+ static extractErrorMessage(payload) {
3141
+ try {
3142
+ // 错误响应格式: 0a <len> <error_string> ...
3143
+ // field 1, wire type 2 (length-delimited string)
3144
+ if (payload[0] === 0x0a && payload.length > 2) {
3145
+ const len = payload[1];
3146
+ if (len > 0 && len < payload.length - 2) {
3147
+ const msg = Buffer.from(payload.slice(2, 2 + len)).toString('utf8');
3148
+ // 只返回看起来像错误消息的字符串
3149
+ if (/^[A-Za-z][A-Za-z0-9_]+$/.test(msg)) {
3150
+ return msg;
3151
+ }
3152
+ }
3153
+ }
3154
+ } catch (e) {
3155
+ // ignore
3156
+ }
3157
+ return null;
3158
+ }
3159
+
3160
+ /**
3161
+ * Decode calendar event detail response (cmd=1010048)
3162
+ * @param {Buffer} buffer - Response buffer
3163
+ * @returns {Object} Calendar event details
3164
+ */
3165
+ static decodeCalendarEventDetailResponse(buffer) {
3166
+ try {
3167
+ const packet = fromBinary(PacketSchema, buffer);
3168
+
3169
+ if (!packet.payload || packet.payload.length < 10) {
3170
+ return { success: false, error: 'Empty payload', events: [] };
3171
+ }
3172
+
3173
+ const events = this.parseCalendarEventPayload(Buffer.from(packet.payload));
3174
+
3175
+ return {
3176
+ success: true,
3177
+ events: events
3178
+ };
3179
+ } catch (e) {
3180
+ return { success: false, error: e.message, events: [] };
3181
+ }
3182
+ }
3183
+
3184
+ /**
3185
+ * Parse calendar event payload to extract event details
3186
+ * @param {Buffer} buffer - Payload buffer
3187
+ * @returns {Array} Array of calendar events
3188
+ */
3189
+ static parseCalendarEventPayload(buffer) {
3190
+ const events = [];
3191
+ let offset = 0;
3192
+
3193
+ // Skip outer wrapper fields to find event data
3194
+ // Structure: field 1 (user info), field 2 (events repeated)
3195
+
3196
+ while (offset < buffer.length) {
3197
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
3198
+ if (tagBytes === 0) break;
3199
+ offset += tagBytes;
3200
+
3201
+ const wireType = tag & 0x07;
3202
+ const fieldNo = tag >> 3;
3203
+
3204
+ if (wireType === 2) { // Length-delimited
3205
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
3206
+ offset += bytesRead;
3207
+
3208
+ if (offset + len > buffer.length) break;
3209
+
3210
+ const data = buffer.subarray(offset, offset + len);
3211
+ offset += len;
3212
+
3213
+ // field 2 contains the event data
3214
+ if (fieldNo === 2) {
3215
+ const event = this.parseCalendarEvent(data);
3216
+ if (event) {
3217
+ events.push(event);
3218
+ }
3219
+ }
3220
+ } else if (wireType === 0) { // Varint
3221
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
3222
+ offset += bytesRead;
3223
+ } else {
3224
+ break;
3225
+ }
3226
+ }
3227
+
3228
+ return events;
3229
+ }
3230
+
3231
+ /**
3232
+ * Parse a single calendar event from protobuf bytes
3233
+ * @param {Buffer} buffer - Event data buffer
3234
+ * @returns {Object} Calendar event object
3235
+ */
3236
+ static parseCalendarEvent(buffer) {
3237
+ const event = {
3238
+ userId: null,
3239
+ eventId: null,
3240
+ title: null,
3241
+ startTime: null,
3242
+ endTime: null,
3243
+ timezone: null,
3244
+ organizer: null,
3245
+ attendees: [],
3246
+ meetingUrl: null,
3247
+ rsvpStatus: null // 当前用户的回复状态
3248
+ };
3249
+
3250
+ let offset = 0;
3251
+
3252
+ while (offset < buffer.length) {
3253
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
3254
+ if (tagBytes === 0) break;
3255
+ offset += tagBytes;
3256
+
3257
+ const wireType = tag & 0x07;
3258
+ const fieldNo = tag >> 3;
3259
+
3260
+ if (wireType === 2) { // Length-delimited
3261
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
3262
+ offset += bytesRead;
3263
+
3264
+ if (offset + len > buffer.length) break;
3265
+
3266
+ const data = buffer.subarray(offset, offset + len);
3267
+ offset += len;
3268
+
3269
+ // field 1: userId
3270
+ if (fieldNo === 1) {
3271
+ event.userId = data.toString('utf8');
3272
+ }
3273
+ // field 2: eventId (UUID)
3274
+ else if (fieldNo === 2) {
3275
+ event.eventId = data.toString('utf8');
3276
+ }
3277
+ // field 4: nested event details
3278
+ else if (fieldNo === 4) {
3279
+ this.parseEventDetails(data, event);
3280
+ }
3281
+ // field 5: user RSVP info
3282
+ else if (fieldNo === 5) {
3283
+ const rsvp = this.parseRsvpInfo(data);
3284
+ if (rsvp) {
3285
+ event.rsvpStatus = rsvp.status;
3286
+ }
3287
+ }
3288
+ // field 20: attendee info (repeated)
3289
+ else if (fieldNo === 20) {
3290
+ const attendee = this.parseAttendeeInfo(data);
3291
+ if (attendee) {
3292
+ event.attendees.push(attendee);
3293
+ }
3294
+ }
3295
+ } else if (wireType === 0) { // Varint
3296
+ const { value, bytesRead } = this.readVarintWithLength(buffer, offset);
3297
+ offset += bytesRead;
3298
+ } else {
3299
+ break;
3300
+ }
3301
+ }
3302
+
3303
+ // Only return if we have meaningful data
3304
+ if (event.eventId || event.title) {
3305
+ return event;
3306
+ }
3307
+
3308
+ return null;
3309
+ }
3310
+
3311
+ /**
3312
+ * Parse nested event details (field 4 in event)
3313
+ * @param {Buffer} buffer - Event details buffer
3314
+ * @param {Object} event - Event object to populate
3315
+ */
3316
+ static parseEventDetails(buffer, event) {
3317
+ let offset = 0;
3318
+
3319
+ while (offset < buffer.length) {
3320
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
3321
+ if (tagBytes === 0) break;
3322
+ offset += tagBytes;
3323
+
3324
+ const wireType = tag & 0x07;
3325
+ const fieldNo = tag >> 3;
3326
+
3327
+ if (wireType === 2) { // Length-delimited (string)
3328
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
3329
+ offset += bytesRead;
3330
+
3331
+ if (offset + len > buffer.length) break;
3332
+
3333
+ const data = buffer.subarray(offset, offset + len);
3334
+ offset += len;
3335
+
3336
+ // field 8: title
3337
+ if (fieldNo === 8) {
3338
+ event.title = data.toString('utf8');
3339
+ }
3340
+ // field 12: startTimezone
3341
+ else if (fieldNo === 12) {
3342
+ event.timezone = data.toString('utf8');
3343
+ }
3344
+ // field 14: endTimezone (usually same as start)
3345
+ // Skip for now
3346
+ } else if (wireType === 0) { // Varint
3347
+ const { value, bytesRead } = this.readVarintWithLength(buffer, offset);
3348
+ offset += bytesRead;
3349
+
3350
+ // field 11: startTime (Unix timestamp)
3351
+ if (fieldNo === 11) {
3352
+ event.startTime = value;
3353
+ }
3354
+ // field 13: endTime (Unix timestamp)
3355
+ else if (fieldNo === 13) {
3356
+ event.endTime = value;
3357
+ }
3358
+ } else {
3359
+ break;
3360
+ }
3361
+ }
3362
+ }
3363
+
3364
+ /**
3365
+ * Parse RSVP info (field 5 in event)
3366
+ * @param {Buffer} buffer - RSVP buffer
3367
+ * @returns {Object} { userId, status }
3368
+ */
3369
+ static parseRsvpInfo(buffer) {
3370
+ const result = { userId: null, status: null };
3371
+ let offset = 0;
3372
+
3373
+ while (offset < buffer.length) {
3374
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
3375
+ if (tagBytes === 0) break;
3376
+ offset += tagBytes;
3377
+
3378
+ const wireType = tag & 0x07;
3379
+ const fieldNo = tag >> 3;
3380
+
3381
+ if (wireType === 2) { // Length-delimited
3382
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
3383
+ offset += bytesRead;
3384
+
3385
+ if (offset + len > buffer.length) break;
3386
+
3387
+ const data = buffer.subarray(offset, offset + len);
3388
+ offset += len;
3389
+
3390
+ // field 2: userId
3391
+ if (fieldNo === 2) {
3392
+ result.userId = data.toString('utf8');
3393
+ }
3394
+ } else if (wireType === 0) { // Varint
3395
+ const { value, bytesRead } = this.readVarintWithLength(buffer, offset);
3396
+ offset += bytesRead;
3397
+
3398
+ // field 3: status (2 = pending response, etc.)
3399
+ if (fieldNo === 3) {
3400
+ result.status = value;
3401
+ }
3402
+ } else {
3403
+ break;
3404
+ }
3405
+ }
3406
+
3407
+ return result;
3408
+ }
3409
+
3410
+ /**
3411
+ * Parse attendee info (field 20 in event, repeated)
3412
+ * @param {Buffer} buffer - Attendee buffer
3413
+ * @returns {Object} { name, userId, rsvpStatus, type }
3414
+ */
3415
+ static parseAttendeeInfo(buffer) {
3416
+ const attendee = { name: null, userId: null, rsvpStatus: null, type: null };
3417
+ let offset = 0;
3418
+
3419
+ while (offset < buffer.length) {
3420
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
3421
+ if (tagBytes === 0) break;
3422
+ offset += tagBytes;
3423
+
3424
+ const wireType = tag & 0x07;
3425
+ const fieldNo = tag >> 3;
3426
+
3427
+ if (wireType === 2) { // Length-delimited
3428
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
3429
+ offset += bytesRead;
3430
+
3431
+ if (offset + len > buffer.length) break;
3432
+
3433
+ const data = buffer.subarray(offset, offset + len);
3434
+ offset += len;
3435
+
3436
+ // field 1: name
3437
+ if (fieldNo === 1) {
3438
+ attendee.name = data.toString('utf8');
3439
+ }
3440
+ // field 2: userId
3441
+ else if (fieldNo === 2) {
3442
+ attendee.userId = data.toString('utf8');
3443
+ }
3444
+ // field 11: nested type info
3445
+ else if (fieldNo === 11) {
3446
+ // Parse nested to get type string (e.g., "INDIVIDUAL")
3447
+ const typeStr = this.extractTypeFromNested(data);
3448
+ if (typeStr) {
3449
+ attendee.type = typeStr;
3450
+ }
3451
+ }
3452
+ } else if (wireType === 0) { // Varint
3453
+ const { value, bytesRead } = this.readVarintWithLength(buffer, offset);
3454
+ offset += bytesRead;
3455
+
3456
+ // field 3: rsvpStatus (0=not responded, 1=accepted, etc.)
3457
+ if (fieldNo === 3) {
3458
+ attendee.rsvpStatus = value;
3459
+ }
3460
+ } else {
3461
+ break;
3462
+ }
3463
+ }
3464
+
3465
+ return attendee.name || attendee.userId ? attendee : null;
3466
+ }
3467
+
3468
+ /**
3469
+ * Extract type string from nested message
3470
+ * @param {Buffer} buffer - Nested buffer
3471
+ * @returns {string|null} Type string
3472
+ */
3473
+ static extractTypeFromNested(buffer) {
3474
+ let offset = 0;
3475
+
3476
+ while (offset < buffer.length) {
3477
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
3478
+ if (tagBytes === 0) break;
3479
+ offset += tagBytes;
3480
+
3481
+ const wireType = tag & 0x07;
3482
+ const fieldNo = tag >> 3;
3483
+
3484
+ if (wireType === 2) { // Length-delimited
3485
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
3486
+ offset += bytesRead;
3487
+
3488
+ if (offset + len > buffer.length) break;
3489
+
3490
+ const data = buffer.subarray(offset, offset + len);
3491
+ offset += len;
3492
+
3493
+ // field 2 is usually the type string
3494
+ if (fieldNo === 2) {
3495
+ return data.toString('utf8');
3496
+ }
3497
+ } else if (wireType === 0) {
3498
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
3499
+ offset += bytesRead;
3500
+ } else {
3501
+ break;
3502
+ }
3503
+ }
3504
+
3505
+ return null;
3506
+ }
3507
+
3508
+ // ==================== Smart Reply Resource (cmd=77 wrapper) ====================
3509
+
3510
+ /**
3511
+ * Build office_ai.PullSmartReplyResourceRequest payload (inner cmd=1105047)
3512
+ *
3513
+ * message PullSmartReplyResourceRequest {
3514
+ * repeated string image_keys = 1;
3515
+ * }
3516
+ *
3517
+ * @param {string[]} imageKeys - Image keys (img_v2/img_v3)
3518
+ * @returns {Uint8Array} Encoded protobuf payload
3519
+ */
3520
+ static buildPullSmartReplyResourceRequestPayload(imageKeys) {
3521
+ const keys = [...new Set((imageKeys || [])
3522
+ .map(key => String(key || '').trim())
3523
+ .filter(key => /^img_v[23]_/.test(key)))];
3524
+
3525
+ const parts = [];
3526
+
3527
+ for (const imageKey of keys) {
3528
+ const keyBytes = Buffer.from(imageKey, 'utf8');
3529
+ parts.push(0x0a); // field 1, wire type 2
3530
+ pushVarintLength(parts, keyBytes.length);
3531
+ parts.push(...keyBytes);
3532
+ }
3533
+
3534
+ return new Uint8Array(parts);
3535
+ }
3536
+
3537
+ /**
3538
+ * Build cmd=77 wrapper request for pulling smart-reply image resources
3539
+ *
3540
+ * Wrapper entry shape (inferred):
3541
+ * field 2 = payloadType (1)
3542
+ * field 3 = inner command (1105047)
3543
+ * field 5 = inner payload bytes
3544
+ * field 6 = inner request id (string)
3545
+ *
3546
+ * @param {string} requestId - Outer request ID (x-request-id)
3547
+ * @param {string[]} imageKeys - Image keys to fetch resource crypto for
3548
+ * @param {Object} [options]
3549
+ * @param {string} [options.subRequestId] - Inner request ID
3550
+ * @returns {Buffer} Encoded cmd=77 packet
3551
+ */
3552
+ static buildCmd77PullSmartReplyResourceRequest(requestId, imageKeys, options = {}) {
3553
+ const payload = this.buildPullSmartReplyResourceRequestPayload(imageKeys);
3554
+ const subRequestId = typeof options.subRequestId === 'string' && options.subRequestId.trim()
3555
+ ? options.subRequestId.trim()
3556
+ : ((globalThis.crypto?.randomUUID?.() || generateRequestCid()).toUpperCase());
3557
+
3558
+ const entryParts = [];
3559
+
3560
+ // field 2: payloadType = 1
3561
+ entryParts.push(0x10, 0x01);
3562
+
3563
+ // field 3: inner command = 1105047
3564
+ entryParts.push(0x18);
3565
+ pushVarintLength(entryParts, 1105047);
3566
+
3567
+ // field 5: inner payload bytes
3568
+ entryParts.push(0x2a);
3569
+ pushVarintLength(entryParts, payload.length);
3570
+ entryParts.push(...payload);
3571
+
3572
+ // field 6: inner request id
3573
+ const subRequestIdBytes = Buffer.from(subRequestId, 'utf8');
3574
+ entryParts.push(0x32);
3575
+ pushVarintLength(entryParts, subRequestIdBytes.length);
3576
+ entryParts.push(...subRequestIdBytes);
3577
+
3578
+ // outer field 1: repeated wrapped packets
3579
+ const outerParts = [0x0a];
3580
+ pushVarintLength(outerParts, entryParts.length);
3581
+ outerParts.push(...entryParts);
3582
+
3583
+ const packet = create(PacketSchema, {
3584
+ payloadType: 1, // PB2
3585
+ cmd: 77,
3586
+ cid: requestId,
3587
+ payload: new Uint8Array(outerParts)
3588
+ });
3589
+
3590
+ return Buffer.from(toBinary(PacketSchema, packet));
3591
+ }
3592
+
3593
+ /**
3594
+ * Build direct cmd=1105047 request packet
3595
+ *
3596
+ * @param {string} requestId - Request ID
3597
+ * @param {string[]} imageKeys - Image keys
3598
+ * @returns {Buffer} Encoded cmd=1105047 packet
3599
+ */
3600
+ static buildPullSmartReplyResourceRequest(requestId, imageKeys) {
3601
+ const payload = this.buildPullSmartReplyResourceRequestPayload(imageKeys);
3602
+
3603
+ const packet = create(PacketSchema, {
3604
+ payloadType: 1, // PB2
3605
+ cmd: 1105047,
3606
+ cid: requestId,
3607
+ payload
3608
+ });
3609
+
3610
+ return Buffer.from(toBinary(PacketSchema, packet));
3611
+ }
3612
+
3613
+ // ==================== Chat History (cmd=43) ====================
3614
+
3615
+ /**
3616
+ * Build request to get chat history by message positions
3617
+ * @param {string} requestId - Request ID
3618
+ * @param {string} chatId - Chat ID (19-digit number string)
3619
+ * @param {number[]} positions - Array of message position numbers to fetch
3620
+ * @returns {Buffer} Encoded packet
3621
+ */
3622
+ static buildGetChatHistoryRequest(requestId, chatId, positions) {
3623
+ const payload = create(GetChatHistoryRequestSchema, {
3624
+ chatId: chatId,
3625
+ positions: positions,
3626
+ flag1: 1,
3627
+ flag2: 1,
3628
+ flag3: 2,
3629
+ flag4: 1
3630
+ });
3631
+
3632
+ const packet = create(PacketSchema, {
3633
+ payloadType: 1, // PB2
3634
+ cmd: 43,
3635
+ cid: requestId,
3636
+ payload: toBinary(GetChatHistoryRequestSchema, payload)
3637
+ });
3638
+
3639
+ return Buffer.from(toBinary(PacketSchema, packet));
3640
+ }
3641
+
3642
+ /**
3643
+ * Build emoji reaction request (cmd=25)
3644
+ *
3645
+ * Inner payload structure:
3646
+ * field 1 (string): messageId
3647
+ * field 2 (string): emoji type (e.g., "THUMBSUP", "SMILE", "HEART")
3648
+ * field 3 (varint): action type (1=add)
3649
+ *
3650
+ * @param {string} requestId - Request ID
3651
+ * @param {string} messageId - Message ID to react to
3652
+ * @param {string} emojiType - Emoji type string (e.g., "THUMBSUP")
3653
+ * @returns {Buffer} Serialized packet
3654
+ */
3655
+ static buildEmojiReactionRequest(requestId, messageId, emojiType) {
3656
+ const messageIdBytes = Buffer.from(messageId, 'utf-8');
3657
+ const emojiTypeBytes = Buffer.from(emojiType, 'utf-8');
3658
+
3659
+ const innerParts = [];
3660
+ // field 1 (string): messageId
3661
+ innerParts.push(0x0a);
3662
+ pushVarintLength(innerParts, messageIdBytes.length);
3663
+ innerParts.push(...messageIdBytes);
3664
+ // field 2 (string): emoji type
3665
+ innerParts.push(0x12);
3666
+ pushVarintLength(innerParts, emojiTypeBytes.length);
3667
+ innerParts.push(...emojiTypeBytes);
3668
+ // field 3 (varint): action = 1 (add)
3669
+ innerParts.push(0x18, 0x01);
3670
+
3671
+ const packet = create(PacketSchema, {
3672
+ payloadType: 1,
3673
+ cmd: 25,
3674
+ cid: requestId,
3675
+ payload: new Uint8Array(innerParts)
3676
+ });
3677
+
3678
+ return Buffer.from(toBinary(PacketSchema, packet));
3679
+ }
3680
+
3681
+ /**
3682
+ * Decode emoji reaction response (cmd=25)
3683
+ *
3684
+ * Response payload structure:
3685
+ * field 1 (varint): result code (1=success)
3686
+ *
3687
+ * @param {Buffer} buffer - Response buffer
3688
+ * @returns {Object} { success: boolean, error?: string }
3689
+ */
3690
+ static decodeEmojiReactionResponse(buffer) {
3691
+ try {
3692
+ const packet = fromBinary(PacketSchema, buffer);
3693
+
3694
+ if (packet.status && packet.status !== 0) {
3695
+ return {
3696
+ success: false,
3697
+ error: `Packet status: ${packet.status}`
3698
+ };
3699
+ }
3700
+
3701
+ return { success: true };
3702
+ } catch (error) {
3703
+ return {
3704
+ success: false,
3705
+ error: error.message
3706
+ };
3707
+ }
3708
+ }
3709
+
3710
+ /**
3711
+ * Build remove emoji reaction request (cmd=26)
3712
+ *
3713
+ * Inner payload structure (identical to cmd=25):
3714
+ * field 1 (string): messageId
3715
+ * field 2 (string): emojiType
3716
+ * field 3 (varint): action = 1
3717
+ *
3718
+ * @param {string} requestId - Request ID
3719
+ * @param {string} messageId - Message ID to remove reaction from
3720
+ * @param {string} emojiType - Emoji type string (e.g., "THUMBSUP")
3721
+ * @returns {Buffer} Serialized packet
3722
+ */
3723
+ static buildRemoveEmojiReactionRequest(requestId, messageId, emojiType) {
3724
+ const messageIdBytes = Buffer.from(messageId, 'utf-8');
3725
+ const emojiTypeBytes = Buffer.from(emojiType, 'utf-8');
3726
+
3727
+ const innerParts = [];
3728
+ // field 1 (string): messageId
3729
+ innerParts.push(0x0a);
3730
+ pushVarintLength(innerParts, messageIdBytes.length);
3731
+ innerParts.push(...messageIdBytes);
3732
+ // field 2 (string): emoji type
3733
+ innerParts.push(0x12);
3734
+ pushVarintLength(innerParts, emojiTypeBytes.length);
3735
+ innerParts.push(...emojiTypeBytes);
3736
+ // field 3 (varint): action = 1
3737
+ innerParts.push(0x18, 0x01);
3738
+
3739
+ const packet = create(PacketSchema, {
3740
+ payloadType: 1,
3741
+ cmd: 26,
3742
+ cid: requestId,
3743
+ payload: new Uint8Array(innerParts)
3744
+ });
3745
+
3746
+ return Buffer.from(toBinary(PacketSchema, packet));
3747
+ }
3748
+
3749
+ /**
3750
+ * Decode remove emoji reaction response (cmd=26)
3751
+ *
3752
+ * Response payload is empty for delete operations.
3753
+ * Only checks packet.status === 0 for success.
3754
+ *
3755
+ * @param {Buffer} buffer - Response buffer
3756
+ * @returns {Object} { success: boolean, error?: string }
3757
+ */
3758
+ static decodeRemoveEmojiReactionResponse(buffer) {
3759
+ try {
3760
+ const packet = fromBinary(PacketSchema, buffer);
3761
+
3762
+ if (packet.status && packet.status !== 0) {
3763
+ return {
3764
+ success: false,
3765
+ error: `Packet status: ${packet.status}`
3766
+ };
3767
+ }
3768
+
3769
+ return { success: true };
3770
+ } catch (error) {
3771
+ return {
3772
+ success: false,
3773
+ error: error.message
3774
+ };
3775
+ }
3776
+ }
3777
+
3778
+ /**
3779
+ * Build request to get chat meta by chatId (cmd=64)
3780
+ *
3781
+ * Payload structure:
3782
+ * field 1 = chatId (string)
3783
+ *
3784
+ * @param {string} requestId - Request ID
3785
+ * @param {string} chatId - Chat ID (19-digit number string)
3786
+ * @returns {Buffer} Encoded packet
3787
+ */
3788
+ static buildGetChatMetaRequest(requestId, chatId) {
3789
+ const chatIdBytes = Buffer.from(chatId, 'utf-8');
3790
+ const innerParts = [0x0a]; // field 1 (length-delimited)
3791
+ pushVarintLength(innerParts, chatIdBytes.length);
3792
+ innerParts.push(...chatIdBytes);
3793
+
3794
+ const packet = create(PacketSchema, {
3795
+ payloadType: 1, // PB2
3796
+ cmd: 64,
3797
+ cid: requestId,
3798
+ payload: new Uint8Array(innerParts)
3799
+ });
3800
+
3801
+ return Buffer.from(toBinary(PacketSchema, packet));
3802
+ }
3803
+
3804
+ /**
3805
+ * Decode chat meta response (cmd=64)
3806
+ *
3807
+ * Response payload structure (inferred):
3808
+ * field 1 (message)
3809
+ * - field 1: chatId (string)
3810
+ * - field 2: Chat message bytes
3811
+ *
3812
+ * @param {Buffer} buffer - Response buffer
3813
+ * @returns {Object} Decoded chat meta result
3814
+ */
3815
+ static decodeGetChatMetaResponse(buffer) {
3816
+ try {
3817
+ const packet = fromBinary(PacketSchema, buffer);
3818
+
3819
+ if (!packet.payload || packet.payload.length === 0) {
3820
+ return {
3821
+ success: false,
3822
+ error: 'Empty payload'
3823
+ };
3824
+ }
3825
+
3826
+ const payloadBuffer = Buffer.from(packet.payload);
3827
+ const outerEntry = this.extractLengthDelimitedField(payloadBuffer, 1);
3828
+
3829
+ if (!outerEntry) {
3830
+ return {
3831
+ success: false,
3832
+ error: 'No chat meta entry in payload'
3833
+ };
3834
+ }
3835
+
3836
+ const entryBuffer = Buffer.from(outerEntry);
3837
+ const chatIdBytes = this.extractLengthDelimitedField(entryBuffer, 1);
3838
+ const chatBytes = this.extractLengthDelimitedField(entryBuffer, 2);
3839
+
3840
+ if (!chatBytes) {
3841
+ return {
3842
+ success: false,
3843
+ error: 'No chat bytes in chat meta entry'
3844
+ };
3845
+ }
3846
+
3847
+ const chat = fromBinary(ChatSchema, chatBytes);
3848
+
3849
+ return {
3850
+ success: true,
3851
+ chat: {
3852
+ id: chat.id || (chatIdBytes ? Buffer.from(chatIdBytes).toString('utf8') : ''),
3853
+ type: chat.type,
3854
+ name: chat.name || '',
3855
+ ownerId: chat.ownerId || '',
3856
+ lastMessageId: chat.lastMessageId || '',
3857
+ lastVisibleMessageId: chat.lastVisibleMessageId || '',
3858
+ lastMessagePosition: chat.lastMessagePosition || 0,
3859
+ lastVisibleMessagePosition: chat.lastVisibleMessagePosition || 0,
3860
+ readPosition: chat.readPosition || 0,
3861
+ readPositionBadgeCount: chat.readPositionBadgeCount || 0,
3862
+ lastMessagePositionBadgeCount: chat.lastMessagePositionBadgeCount || 0,
3863
+ newMessageCount: chat.newMessageCount || 0,
3864
+ noBadgedNewMessageCount: chat.noBadgedNewMessageCount || 0,
3865
+ firstChatMessagePosition: chat.firstChatMessagePosition || 0,
3866
+ updateTime: Number(chat.updateTime || 0n),
3867
+ updateTimeMs: Number(chat.updateTimeMs || 0n),
3868
+ createTime: Number(chat.createTime || 0n),
3869
+ memberCount: chat.memberCount || 0,
3870
+ userCount: chat.userCount || 0,
3871
+ role: chat.role,
3872
+ status: chat.status,
3873
+ chatMode: chat.chatMode,
3874
+ allowPost: chat.allowPost,
3875
+ }
3876
+ };
3877
+ } catch (error) {
3878
+ return {
3879
+ success: false,
3880
+ error: error.message
3881
+ };
3882
+ }
3883
+ }
3884
+
3885
+ /**
3886
+ * Extract first length-delimited field bytes by field number
3887
+ * @param {Buffer} buffer - Protobuf message buffer
3888
+ * @param {number} targetFieldNo - Target field number
3889
+ * @returns {Uint8Array|null} Field bytes or null
3890
+ */
3891
+ static extractLengthDelimitedField(buffer, targetFieldNo) {
3892
+ let offset = 0;
3893
+
3894
+ while (offset < buffer.length) {
3895
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
3896
+ if (!tagBytes) break;
3897
+ offset += tagBytes;
3898
+
3899
+ const wireType = tag & 0x07;
3900
+ const fieldNo = tag >> 3;
3901
+
3902
+ if (wireType === 2) {
3903
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
3904
+ offset += bytesRead;
3905
+
3906
+ if (offset + len > buffer.length) {
3907
+ break;
3908
+ }
3909
+
3910
+ const data = buffer.subarray(offset, offset + len);
3911
+ offset += len;
3912
+
3913
+ if (fieldNo === targetFieldNo) {
3914
+ return data;
3915
+ }
3916
+ } else if (wireType === 0) {
3917
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
3918
+ offset += bytesRead;
3919
+ } else if (wireType === 1) {
3920
+ offset += 8;
3921
+ } else if (wireType === 5) {
3922
+ offset += 4;
3923
+ } else {
3924
+ break;
3925
+ }
3926
+ }
3927
+
3928
+ return null;
3929
+ }
3930
+
3931
+ /**
3932
+ * Try to decode bytes as UTF-8 string.
3933
+ * Returns the string if it looks like valid text (no replacement chars), or empty string.
3934
+ * @param {Uint8Array|Buffer|null|undefined} bytes
3935
+ * @returns {string}
3936
+ */
3937
+ static tryDecodeAsUtf8(bytes) {
3938
+ if (!bytes || bytes.length === 0) return '';
3939
+ try {
3940
+ const str = Buffer.from(bytes).toString('utf8');
3941
+ // If the result contains the UTF-8 replacement character, it's likely binary data
3942
+ if (str.includes('\ufffd')) return '';
3943
+ return str;
3944
+ } catch {
3945
+ return '';
3946
+ }
3947
+ }
3948
+
3949
+ /**
3950
+ * 从文本中提取飞书图片 key(支持 img_v2/img_v3)
3951
+ * @param {string} value
3952
+ * @returns {string[]}
3953
+ */
3954
+ static extractImageKeysFromString(value) {
3955
+ const source = String(value || '');
3956
+
3957
+ const strictV3 = source.match(/img_v3_[a-z0-9]{4}_[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}/gi) || [];
3958
+ const strictV2 = source.match(/img_v2_[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}/gi) || [];
3959
+ const strictMatches = [...strictV3, ...strictV2];
3960
+
3961
+ // 优先使用严格格式,避免把相邻 key 粘连成一个
3962
+ if (strictMatches.length > 0) {
3963
+ return [...new Set(strictMatches)];
3964
+ }
3965
+
3966
+ // 回退:按 img_v2_/img_v3_ 起始切分,逐段提取
3967
+ const parts = source.split(/(?=img_v[23]_)/g).filter(part => /^img_v[23]_/.test(part));
3968
+ const keys = [];
3969
+
3970
+ for (const part of parts) {
3971
+ const strictPart = part.match(/^(img_v3_[a-z0-9]{4}_[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12}|img_v2_[a-z0-9]{8}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{12})/i);
3972
+ if (strictPart) {
3973
+ keys.push(strictPart[1]);
3974
+ continue;
3975
+ }
3976
+
3977
+ const match = part.match(/^img_v[23]_[a-zA-Z0-9_-]+/);
3978
+ if (!match) continue;
3979
+
3980
+ const candidate = match[0];
3981
+ if (candidate.length < 20 || candidate.length > 80) continue;
3982
+ keys.push(candidate);
3983
+ }
3984
+
3985
+ return [...new Set(keys)];
3986
+ }
3987
+
3988
+ /**
3989
+ * 尝试从 chat history content 的 bytes 中解析图片信息
3990
+ * @param {Uint8Array|Buffer|null|undefined} payload
3991
+ * @param {(imageInfo: {imageKey: string, cryptoInfo?: object|null}) => void} addImageInfo
3992
+ */
3993
+ static tryDecodeChatHistoryImagePayload(payload, addImageInfo) {
3994
+ if (!payload || payload.length === 0) {
3995
+ return;
3996
+ }
3997
+
3998
+ const bytes = payload instanceof Uint8Array ? payload : new Uint8Array(payload);
3999
+
4000
+ // 1) 直接按 ImageSetV2 解析(chat history 中 IMAGE 消息的 field 2)
4001
+ try {
4002
+ const imageV2 = fromBinary(ImageSetV2Schema, bytes);
4003
+ if (imageV2.imageKey) {
4004
+ const imageInfo = {
4005
+ imageKey: imageV2.imageKey,
4006
+ cryptoInfo: imageV2.crypto ? this.extractCryptoInfo(imageV2.crypto) : null,
4007
+ fsUnit: imageV2.fsUnit || null,
4008
+ };
4009
+ addImageInfo(imageInfo);
4010
+ }
4011
+ } catch {
4012
+ // ignore
4013
+ }
4014
+
4015
+ // 2) 按 ImageContent 解析(图片消息)
4016
+ try {
4017
+ const imageContent = fromBinary(ImageContentSchema, bytes);
4018
+ const imageInfo = this.extractImageInfo(imageContent);
4019
+ if (imageInfo?.imageKey) {
4020
+ addImageInfo(imageInfo);
4021
+ }
4022
+ } catch {
4023
+ // ignore
4024
+ }
4025
+
4026
+ // 3) 按 Content 解析(富文本/混合消息)
4027
+ try {
4028
+ const content = fromBinary(ContentSchema, bytes);
4029
+
4030
+ if (content.imageKey) {
4031
+ addImageInfo({ imageKey: content.imageKey, cryptoInfo: null });
4032
+ }
4033
+
4034
+ if (content.richText) {
4035
+ const postResult = this.parsePostContent(content);
4036
+ for (const imageInfo of postResult.images || []) {
4037
+ if (imageInfo?.imageKey) {
4038
+ addImageInfo(imageInfo);
4039
+ }
4040
+ }
4041
+ }
4042
+ } catch {
4043
+ // ignore
4044
+ }
4045
+
4046
+ // 4) 按 RichText 直接解析(某些历史消息会把 richText 放在 bytes 字段)
4047
+ try {
4048
+ const richText = fromBinary(RichTextSchema, bytes);
4049
+ const postResult = this.parsePostContent({ richText, text: richText.innerText || '' });
4050
+ for (const imageInfo of postResult.images || []) {
4051
+ if (imageInfo?.imageKey) {
4052
+ addImageInfo(imageInfo);
4053
+ }
4054
+ }
4055
+ } catch {
4056
+ // ignore
4057
+ }
4058
+
4059
+ // 5) 按 RichTextElements 直接解析(再下一层)
4060
+ try {
4061
+ const elements = fromBinary(RichTextElementsSchema, bytes);
4062
+ if (elements?.dictionary) {
4063
+ for (const element of Object.values(elements.dictionary)) {
4064
+ if (element?.tag === 2 && element.property) {
4065
+ const imageInfo = this.parseImageProperty(element.property);
4066
+ if (imageInfo?.imageKey) {
4067
+ addImageInfo(imageInfo);
4068
+ }
4069
+ }
4070
+ }
4071
+ }
4072
+ } catch {
4073
+ // ignore
4074
+ }
4075
+ }
4076
+
4077
+ /**
4078
+ * 从 chat history content 中提取图片 key/crypto
4079
+ * @param {object} content
4080
+ * @returns {Array<{imageKey: string, cryptoInfo?: object|null}>}
4081
+ */
4082
+ static collectImageInfosFromChatHistoryContent(content = {}) {
4083
+ const imageInfos = [];
4084
+ const seenKeys = new Set();
4085
+
4086
+ const addImageInfo = (imageInfo) => {
4087
+ const imageKey = imageInfo?.imageKey ? String(imageInfo.imageKey).trim() : '';
4088
+ if (!imageKey || seenKeys.has(imageKey)) {
4089
+ return;
4090
+ }
4091
+
4092
+ seenKeys.add(imageKey);
4093
+ imageInfos.push({
4094
+ imageKey,
4095
+ cryptoInfo: imageInfo?.cryptoInfo || null,
4096
+ });
4097
+ };
4098
+
4099
+ // 从 bytes 字段中解析(可能包含 crypto)
4100
+ this.tryDecodeChatHistoryImagePayload(content?.richTextOrImageBytes, addImageInfo);
4101
+ this.tryDecodeChatHistoryImagePayload(content?.richTextBytes, addImageInfo);
4102
+ this.tryDecodeChatHistoryImagePayload(content?.richTextData, addImageInfo);
4103
+
4104
+ // 从 text/html 回退提取 key(无 crypto)
4105
+ const htmlText = content?.htmlText || this.tryDecodeAsUtf8(content?.richTextOrImageBytes) || '';
4106
+ const fallbackKeys = this.extractImageKeysFromString(`${content?.text || ''}
4107
+ ${htmlText}`);
4108
+ for (const imageKey of fallbackKeys) {
4109
+ addImageInfo({ imageKey, cryptoInfo: null });
4110
+ }
4111
+
4112
+ return imageInfos;
4113
+ }
4114
+
4115
+ /**
4116
+ * Decode chat history response
4117
+ * @param {Buffer} buffer - Response buffer
4118
+ * @returns {Object} Decoded response with messages array
4119
+ */
4120
+ static decodeGetChatHistoryResponse(buffer) {
4121
+ try {
4122
+ const packet = fromBinary(PacketSchema, buffer);
4123
+
4124
+ if (!packet.payload || packet.payload.length === 0) {
4125
+ return {
4126
+ success: false,
4127
+ error: 'Empty payload',
4128
+ messages: []
4129
+ };
4130
+ }
4131
+
4132
+ // Try to decode using the schema
4133
+ try {
4134
+ const response = fromBinary(GetChatHistoryResponseSchema, packet.payload);
4135
+ const messages = (response.entries || []).map(entry => {
4136
+ const msg = entry.message || {};
4137
+ const content = msg.content || {};
4138
+
4139
+ const imageInfos = this.collectImageInfosFromChatHistoryContent(content);
4140
+ const imageKeys = imageInfos.map(info => info.imageKey).filter(Boolean);
4141
+
4142
+ for (const imageInfo of imageInfos) {
4143
+ if (imageInfo.imageKey && imageInfo.cryptoInfo) {
4144
+ cacheImageCryptoInfo(imageInfo.imageKey, imageInfo.cryptoInfo);
4145
+ }
4146
+ }
4147
+
4148
+ // Derive htmlText: field 2 is now bytes, try UTF-8 decode for POST messages
4149
+ const htmlText = this.tryDecodeAsUtf8(content.richTextOrImageBytes) || '';
4150
+
4151
+ return {
4152
+ position: entry.position,
4153
+ messageId: msg.messageId || '',
4154
+ type: msg.type || 0,
4155
+ typeName: getMessageTypeName(msg.type),
4156
+ fromId: msg.fromId || '',
4157
+ fromType: msg.fromType || 0,
4158
+ fromTypeName: getFromTypeName(msg.fromType),
4159
+ chatId: msg.chatId || '',
4160
+ cid: msg.cid || '',
4161
+ createTime: Number(msg.createTime) || 0,
4162
+ text: content.text || '',
4163
+ htmlText,
4164
+ imageKey: imageKeys[0] || '',
4165
+ imageKeys,
4166
+ language: msg.language || '',
4167
+ replyCount: msg.replyCount || '0',
4168
+ parentMsgId: msg.parentMsgId || ''
4169
+ };
4170
+ });
4171
+
4172
+ return {
4173
+ success: true,
4174
+ messages
4175
+ };
4176
+ } catch (schemaError) {
4177
+ // Schema decode failed, try manual parsing
4178
+ return this.manualDecodeGetChatHistoryResponse(packet.payload);
4179
+ }
4180
+ } catch (error) {
4181
+ return {
4182
+ success: false,
4183
+ error: error.message,
4184
+ messages: []
4185
+ };
4186
+ }
4187
+ }
4188
+
4189
+ /**
4190
+ * Manual decode for chat history response when schema fails
4191
+ * @param {Uint8Array} payload - Payload bytes
4192
+ * @returns {Object} Decoded response
4193
+ */
4194
+ static manualDecodeGetChatHistoryResponse(payload) {
4195
+ const messages = [];
4196
+ const buffer = Buffer.from(payload);
4197
+ let offset = 0;
4198
+
4199
+ while (offset < buffer.length) {
4200
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
4201
+ if (tagBytes === 0) break;
4202
+ offset += tagBytes;
4203
+
4204
+ const wireType = tag & 0x07;
4205
+ const fieldNo = tag >> 3;
4206
+
4207
+ if (wireType === 2) { // Length-delimited
4208
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
4209
+ offset += bytesRead;
4210
+
4211
+ if (offset + len > buffer.length) break;
4212
+
4213
+ const data = buffer.subarray(offset, offset + len);
4214
+ offset += len;
4215
+
4216
+ // field 1 = entries (ChatMessageEntry)
4217
+ if (fieldNo === 1) {
4218
+ const entry = this.parseMessageEntry(data);
4219
+ if (entry) {
4220
+ messages.push(entry);
4221
+ }
4222
+ }
4223
+ } else if (wireType === 0) {
4224
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
4225
+ offset += bytesRead;
4226
+ } else {
4227
+ break;
4228
+ }
4229
+ }
4230
+
4231
+ return {
4232
+ success: true,
4233
+ messages
4234
+ };
4235
+ }
4236
+
4237
+ /**
4238
+ * Parse a single message entry from raw bytes
4239
+ * @param {Buffer} buffer - Entry bytes
4240
+ * @returns {Object|null} Parsed message entry
4241
+ */
4242
+ static parseMessageEntry(buffer) {
4243
+ let offset = 0;
4244
+ let position = 0;
4245
+ let messageBytes = null;
4246
+
4247
+ while (offset < buffer.length) {
4248
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
4249
+ if (tagBytes === 0) break;
4250
+ offset += tagBytes;
4251
+
4252
+ const wireType = tag & 0x07;
4253
+ const fieldNo = tag >> 3;
4254
+
4255
+ if (wireType === 0) { // Varint
4256
+ const { value, bytesRead } = this.readVarintWithLength(buffer, offset);
4257
+ offset += bytesRead;
4258
+ if (fieldNo === 1) {
4259
+ position = value;
4260
+ }
4261
+ } else if (wireType === 2) { // Length-delimited
4262
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
4263
+ offset += bytesRead;
4264
+
4265
+ if (offset + len > buffer.length) break;
4266
+
4267
+ const data = buffer.subarray(offset, offset + len);
4268
+ offset += len;
4269
+
4270
+ if (fieldNo === 2) {
4271
+ messageBytes = data;
4272
+ }
4273
+ } else {
4274
+ break;
4275
+ }
4276
+ }
4277
+
4278
+ if (!messageBytes) {
4279
+ return null;
4280
+ }
4281
+
4282
+ // Parse the message
4283
+ const msg = this.parseChatMessage(messageBytes);
4284
+ return {
4285
+ position,
4286
+ ...msg
4287
+ };
4288
+ }
4289
+
4290
+ /**
4291
+ * Parse a chat message from raw bytes
4292
+ * @param {Buffer} buffer - Message bytes
4293
+ * @returns {Object} Parsed message
4294
+ */
4295
+ static parseChatMessage(buffer) {
4296
+ let offset = 0;
4297
+ const result = {
4298
+ messageId: '',
4299
+ type: 0,
4300
+ typeName: 'UNKNOWN',
4301
+ fromId: '',
4302
+ fromType: 0,
4303
+ fromTypeName: 'UNKNOWN',
4304
+ chatId: '',
4305
+ cid: '',
4306
+ createTime: 0,
4307
+ text: '',
4308
+ htmlText: '',
4309
+ imageKey: '',
4310
+ imageKeys: [],
4311
+ language: '',
4312
+ replyCount: '0',
4313
+ parentMsgId: ''
4314
+ };
4315
+
4316
+ while (offset < buffer.length) {
4317
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
4318
+ if (tagBytes === 0) break;
4319
+ offset += tagBytes;
4320
+
4321
+ const wireType = tag & 0x07;
4322
+ const fieldNo = tag >> 3;
4323
+
4324
+ if (wireType === 0) { // Varint
4325
+ const { value, bytesRead } = this.readVarintWithLength(buffer, offset);
4326
+ offset += bytesRead;
4327
+
4328
+ switch (fieldNo) {
4329
+ case 2: result.type = value; result.typeName = getMessageTypeName(value); break;
4330
+ case 4: result.createTime = value; break;
4331
+ case 6: /* status */ break;
4332
+ case 7: result.fromType = value; result.fromTypeName = getFromTypeName(value); break;
4333
+ }
4334
+ } else if (wireType === 2) { // Length-delimited
4335
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
4336
+ offset += bytesRead;
4337
+
4338
+ if (offset + len > buffer.length) break;
4339
+
4340
+ const data = buffer.subarray(offset, offset + len);
4341
+ offset += len;
4342
+
4343
+ switch (fieldNo) {
4344
+ case 1: result.messageId = data.toString('utf8'); break;
4345
+ case 3: result.fromId = data.toString('utf8'); break;
4346
+ case 5: // content
4347
+ const content = this.parseChatMessageContent(data);
4348
+ result.text = content.text;
4349
+ result.htmlText = content.htmlText;
4350
+
4351
+ const imageInfos = content.imageInfos || [];
4352
+ const imageKeys = imageInfos.map(info => info.imageKey).filter(Boolean);
4353
+ result.imageKey = imageKeys[0] || '';
4354
+ result.imageKeys = imageKeys;
4355
+
4356
+ for (const imageInfo of imageInfos) {
4357
+ if (imageInfo.imageKey && imageInfo.cryptoInfo) {
4358
+ cacheImageCryptoInfo(imageInfo.imageKey, imageInfo.cryptoInfo);
4359
+ }
4360
+ }
4361
+ break;
4362
+ case 10: result.chatId = data.toString('utf8'); break;
4363
+ case 12: result.cid = data.toString('utf8'); break;
4364
+ case 16: result.replyCount = data.toString('utf8'); break;
4365
+ case 18: result.parentMsgId = data.toString('utf8'); break;
4366
+ case 39: result.language = data.toString('utf8'); break;
4367
+ }
4368
+ } else {
4369
+ // Skip other wire types
4370
+ break;
4371
+ }
4372
+ }
4373
+
4374
+ return result;
4375
+ }
4376
+
4377
+ /**
4378
+ * Parse message content from raw bytes (for chat history)
4379
+ * @param {Buffer} buffer - Content bytes
4380
+ * @returns {Object} Parsed content with text and htmlText
4381
+ */
4382
+ static parseChatMessageContent(buffer) {
4383
+ let offset = 0;
4384
+ const result = {
4385
+ text: '',
4386
+ htmlText: '',
4387
+ richTextOrImageBytes: new Uint8Array(),
4388
+ richTextBytes: new Uint8Array(),
4389
+ richTextData: new Uint8Array(),
4390
+ imageInfos: []
4391
+ };
4392
+
4393
+ while (offset < buffer.length) {
4394
+ const { value: tag, bytesRead: tagBytes } = this.readVarintWithLength(buffer, offset);
4395
+ if (tagBytes === 0) break;
4396
+ offset += tagBytes;
4397
+
4398
+ const wireType = tag & 0x07;
4399
+ const fieldNo = tag >> 3;
4400
+
4401
+ if (wireType === 2) { // Length-delimited
4402
+ const { value: len, bytesRead } = this.readVarintWithLength(buffer, offset);
4403
+ offset += bytesRead;
4404
+
4405
+ if (offset + len > buffer.length) break;
4406
+
4407
+ const data = buffer.subarray(offset, offset + len);
4408
+ offset += len;
4409
+
4410
+ switch (fieldNo) {
4411
+ case 1: result.text = data.toString('utf8'); break;
4412
+ case 2:
4413
+ result.richTextOrImageBytes = data;
4414
+ result.htmlText = this.tryDecodeAsUtf8(data) || '';
4415
+ break;
4416
+ case 3: result.richTextBytes = data; break;
4417
+ case 6: result.richTextData = data; break;
4418
+ }
4419
+ } else if (wireType === 0) {
4420
+ const { bytesRead } = this.readVarintWithLength(buffer, offset);
4421
+ offset += bytesRead;
4422
+ } else {
4423
+ break;
4424
+ }
4425
+ }
4426
+
4427
+ result.imageInfos = this.collectImageInfosFromChatHistoryContent(result);
4428
+ return result;
4429
+ }
4430
+ }
4431
+
4432
+ export default ProtoBuilder;