diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index ad36ddb..3daee7a 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -163,10 +163,26 @@ export type ParsedContent = | SystemContent | UnknownContent +/** + * `local_type` 可能是打包 u64:低 32 位为类型枚举,高 32 位为标志/子类型。 + */ +export function normalizeLocalType(messageType: number | string): number { + try { + const value = + typeof messageType === 'number' + ? BigInt(Math.trunc(messageType)) + : BigInt(String(messageType).trim()) + return Number(value & 0xffffffffn) + } catch { + const fallback = Number(messageType) + return Number.isFinite(fallback) ? Math.trunc(fallback) : 0 + } +} + export function parseMessageContent(content: string, messageType: number): ParsedContent { - // Voice rows may keep their binary payload outside msgContent, so an empty + const localType = normalizeLocalType(messageType) // content string is still a valid voice message. - if (messageType === 34) { + if (localType === 34) { const duration = parseVoiceDurationSeconds(content) return duration === undefined ? { type: 'voice' } : { type: 'voice', duration } } @@ -176,11 +192,15 @@ export function parseMessageContent(content: string, messageType: number): Parse const normalized = content.trim() - switch (messageType) { + switch (localType) { case 1: return { type: 'text', content: normalized } case 3: return parseImageMessage(normalized) + case 17: + return parseForwardBundle(normalized) + case 37: + return parseFriendVerifyMessage(normalized) case 42: return parseCardMessage(normalized) case 43: @@ -197,7 +217,7 @@ export function parseMessageContent(content: string, messageType: number): Parse case 10002: return parseSystemMessage(normalized) default: - return { type: 'unknown', raw: normalized, messageType } + return { type: 'unknown', raw: normalized, messageType: localType } } } @@ -521,6 +541,15 @@ function parseLocationMessage(content: string): ParsedContent { function parseShareMessage(content: string): ParsedContent { const appMsgType = extractAppMsgType(content) const isFileMessage = appMsgType === '6' || appMsgType === '74' + if (/]+type=["']pat["']/i.test(content)) { + return parsePatMessage(content) + } + if (/`)→ 系统文案。 */ +function parsePatMessage(content: string): ParsedContent { + const decoded = decodeXmlEntities(stripChatroomPrefix(content)) + const template = + extractXmlValue(decoded, 'template') || + extractXmlValue(decoded, 'pattemplate') || + extractXmlValue(decoded, 'plain') || + '' + const from = extractXmlValue(decoded, 'fromusername') || extractXmlValue(decoded, 'fromuser') + const to = extractXmlValue(decoded, 'tousername') || extractXmlValue(decoded, 'touser') + const pat = extractXmlValue(decoded, 'pat') || extractXmlValue(decoded, 'patted') + const text = + normalizeSystemText( + template.replace(/\$from\$/g, from || '').replace(/\$to\$/g, to || '').replace(/\$pat\$/g, pat || '') + ) || normalizeSystemText(decoded.replace(/<[^>]+>/g, ' ')) || '拍了拍' + return { type: 'system', content: text, raw: content } +} + +/** 视频号名片(``)→ 只读 share。 */ +function parseFinderNameCard(content: string): ParsedContent { + const decoded = decodeXmlEntities(stripChatroomPrefix(content)) + const username = extractXmlValue(decoded, 'username') || extractXmlValue(decoded, 'finderUsername') + const nickname = extractXmlValue(decoded, 'nickname') || extractXmlValue(decoded, 'finderNickname') + const title = nickname || username || extractXmlValue(decoded, 'title') || '视频号名片' + const url = decodeXmlUrl(extractXmlValue(decoded, 'url') || extractXmlValue(decoded, 'appPageUrl') || '') + return { + type: 'share', + title, + des: username && nickname ? username : undefined, + url: url || '', + appname: '视频号', + typeVal: extractAppMsgType(content) || 'findernamecard' + } +} + +/** 商品卡(``)→ 只读 share。 */ +function parseProductItem(content: string): ParsedContent { + const decoded = decodeXmlEntities(stripChatroomPrefix(content)) + const title = + extractXmlValue(decoded, 'productName') || + extractXmlValue(decoded, 'product_name') || + extractXmlValue(decoded, 'title') || + '商品' + const des = + extractXmlValue(decoded, 'productDesc') || + extractXmlValue(decoded, 'sellingPrice') || + extractXmlValue(decoded, 'referdes') + const url = decodeXmlUrl(extractXmlValue(decoded, 'url') || extractXmlValue(decoded, 'productUrl') || '') + return { + type: 'share', + title, + des: des || undefined, + url: url || '', + appname: extractXmlValue(decoded, 'sellername') || '商品', + typeVal: extractAppMsgType(content) || 'productitem' + } +} + +/** 好友验证(local_type=37)→ 系统文案,避免 unknown 黑块。 */ +function parseFriendVerifyMessage(content: string): ParsedContent { + const decoded = decodeXmlEntities(stripChatroomPrefix(content)) + const text = extractSysmsgTemplateText(decoded) + if (text) return { type: 'system', content: text, raw: content } + const nickname = extractXmlValue(decoded, 'nickname') || extractXmlValue(decoded, 'NickName') + const username = extractXmlValue(decoded, 'username') || extractXmlValue(decoded, 'UserName') + const contentText = + extractXmlValue(decoded, 'content') || + extractXmlValue(decoded, 'bighead') || + extractXmlValue(decoded, 'source') + const label = normalizeSystemText( + [nickname || username ? `${nickname || username}` : '', contentText || '好友验证消息'] + .filter(Boolean) + .join(' · ') + ) + return { type: 'system', content: label || '好友验证消息', raw: content } +} + function parseShareArticles(content: string): ShareArticle[] { if (!/ { }) }) + it('unmasks packed local_type low 32 bits', () => { + const packed = 1 + 5 * 0x100000000 + expect(parseMessageContent('plain text body', packed)).toEqual({ + type: 'text', + content: 'plain text body' + }) + }) + + it('parses patMsg into system text', () => { + const xml = [ + '', + '', + '', + '', + '', + '', + '', + '' + ].join('') + const parsed = parseMessageContent(xml, 49) + expect(parsed).toMatchObject({ + type: 'system', + content: 'alice拍了拍bob的头像' + }) + }) + + it('parses findernamecard as a read-only share card', () => { + const xml = [ + '', + '51', + '<![CDATA[视频号]]>', + '', + '', + '', + '', + '' + ].join('') + const parsed = parseMessageContent(xml, 49) + expect(parsed).toMatchObject({ + type: 'share', + title: '示例视频号', + appname: '视频号', + typeVal: '51' + }) + }) + + it('falls back to product item share without a title', () => { + const xml = [ + '', + '2000', + '', + '', + '', + '', + '' + ].join('') + const parsed = parseMessageContent(xml, 49) + expect(parsed).toMatchObject({ + type: 'share', + title: '测试商品', + des: '¥9.9', + typeVal: '2000' + }) + }) + + it('renders friend verify local_type 37 as system text', () => { + const xml = '' + const parsed = parseMessageContent(xml, 37) + expect(parsed).toMatchObject({ + type: 'system', + content: '新朋友 · 我是群里的' + }) + }) + it('parses transfer wcpayinfo fields (type 2000)', () => { // 2026-09 真机转账采样字段名(含微信原文 transcationid 拼写) const xml = [