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.
- package/.env.example +2 -0
- package/.github/ISSUE_TEMPLATE/bug_report.md +38 -0
- package/.github/ISSUE_TEMPLATE/custom.md +10 -0
- package/.github/ISSUE_TEMPLATE/feature_request.md +20 -0
- package/.github/dependabot.yml +13 -0
- package/.github/workflows/publish.yml +23 -0
- package/LICENSE +21 -0
- package/README.md +218 -0
- package/bin/openbird.js +26 -0
- package/docs/plans/2026-02-12-openbird-v1-design.md +281 -0
- package/examples/basic-usage.mjs +117 -0
- package/package.json +38 -0
- package/src/core/api.js +1182 -0
- package/src/core/auth.js +39 -0
- package/src/core/builders/header.js +560 -0
- package/src/core/builders/params.js +96 -0
- package/src/core/builders/proto.js +4432 -0
- package/src/core/builders/richtext.js +592 -0
- package/src/core/generated/proto.js +19041 -0
- package/src/core/generated/proto_pb.js +16469 -0
- package/src/core/proto/proto.proto +949 -0
- package/src/core/proto/proto_pb.d.ts +4383 -0
- package/src/core/proto/proto_pb.js +785 -0
- package/src/core/utils/cookie.js +58 -0
- package/src/core/utils/encryption.js +216 -0
- package/src/core/utils/time.js +79 -0
- package/src/core/utils/varint.js +31 -0
- package/src/core/websocket.js +311 -0
- package/src/index.js +68 -0
- package/src/logger.js +28 -0
- package/src/mcp/server.js +234 -0
- package/src/webhook/dispatcher.js +42 -0
- package/src/webhook/normalizer.js +86 -0
|
@@ -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;
|