Files
WechatExplorer/tests/unit/message-parser.test.ts
T

436 lines
16 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { describe, expect, it } from 'vitest'
import { parseMessageContent } from '../../src/main/message-parser'
describe('message parser', () => {
it('parses image, voice and sticker messages without confusing their types', () => {
expect(parseMessageContent('<img md5="0123456789abcdef0123456789abcdef" />', 3)).toMatchObject({
type: 'image',
md5: '0123456789abcdef0123456789abcdef'
})
expect(parseMessageContent('voice fixture', 34)).toEqual({ type: 'voice' })
expect(parseMessageContent('', 34)).toEqual({ type: 'voice' })
expect(
parseMessageContent(
'<emoji md5="abcdefabcdefabcdefabcdefabcdefab" cdnurl="https://fixture.invalid/a" />',
47
)
).toMatchObject({ type: 'sticker', md5: 'abcdefabcdefabcdefabcdefabcdefab' })
})
it('reads the WeChat voice length in milliseconds and keeps fractional seconds', () => {
// 真机实测(2026-09-20):voicelength 才是毫秒时长,length 是编码数据长度——别取错。
// 属性值取自一条真机采样的语音(1.6 秒,微信气泡显示 2")。
const parsed = parseMessageContent(
'<msg><voicemsg endflag="1" cancelflag="0" forwardflag="0" voiceformat="4" voicelength="1600" length="6672" bufid="0" /></msg>',
34
)
expect(parsed).toEqual({ type: 'voice', duration: 1.6 })
// 误取 length 会得到 6.672 秒(把 2" 的语音显示成 0:07)——这条断言就是防这个回归。
expect(parsed).not.toEqual({ type: 'voice', duration: 6.672 })
// 微信四舍五入到整秒,取整必须在显示层做,不能在解析层丢精度。
expect(parseMessageContent('<msg><voicemsg voicelength="4211" /></msg>', 34)).toEqual({
type: 'voice',
duration: 4.211
})
})
it('leaves the voice duration undefined when the payload is missing or unusable', () => {
expect(parseMessageContent('', 34)).toEqual({ type: 'voice' })
expect(parseMessageContent('voice fixture', 34)).toEqual({ type: 'voice' })
expect(parseMessageContent('<msg><voicemsg voiceformat="4" /></msg>', 34)).toEqual({
type: 'voice'
})
expect(parseMessageContent('<msg><voicemsg voicelength="0" /></msg>', 34)).toEqual({
type: 'voice'
})
expect(parseMessageContent('<msg><voicemsg voicelength="abc" /></msg>', 34)).toEqual({
type: 'voice'
})
})
it('keeps video metadata when WeChat omits every MD5 field', () => {
const parsed = parseMessageContent(
'<msg><videomsg length="6402169" playlength="30" cdnthumbwidth="224" cdnthumbheight="398" aeskey="25201cc658042689d1ad6747cea2b240" rawmd5="" /></msg>',
43
)
expect(parsed).toEqual({
type: 'video',
md5: undefined,
newMd5: undefined,
rawMd5: undefined,
byteLength: 6402169,
duration: 30,
width: 224,
height: 398
})
})
it('parses merged forwards and preserves nested visible text', () => {
const parsed = parseMessageContent(
'<appmsg><type>19</type><title>转发多条内容</title><recorditem><dataitem datatype="1"><sourcename>测试成员</sourcename><datadesc>脱敏内容</datadesc></dataitem></recorditem></appmsg>',
49
)
expect(parsed.type).toBe('forwardBundle')
if (parsed.type === 'forwardBundle') {
expect(parsed.title).toBe('转发多条内容')
expect(parsed.items.map((item) => item.text).join(' ')).toContain('脱敏内容')
}
})
it.each([
['6', '测试附件.pdf'],
['74', '发送中的附件.zip']
])(
'keeps file app message type %s when attachment metadata contains record tags',
(typeVal, title) => {
const parsed = parseMessageContent(
`<appmsg><type>${typeVal}</type><title>${title}</title><des>1 MB</des><appattach><recorditem>legacy metadata</recorditem><dataitem datatype="8"><datatitle>${title}</datatitle></dataitem></appattach></appmsg>`,
49
)
expect(parsed).toMatchObject({
type: 'share',
title,
typeVal
})
}
)
it('decodes XML entities in file titles used for attachment lookup', () => {
const parsed = parseMessageContent(
'<appmsg><type>6</type><title>Check-in Voucher (Samabe Bali Suites &amp; Villas).pdf</title></appmsg>',
49
)
expect(parsed).toMatchObject({
type: 'share',
title: 'Check-in Voucher (Samabe Bali Suites & Villas).pdf',
typeVal: '6'
})
})
it('does not classify empty incidental record metadata as a merged forward', () => {
const parsed = parseMessageContent(
'<appmsg><type>5</type><title>普通分享</title><recorditem>legacy metadata</recorditem></appmsg>',
49
)
expect(parsed).toMatchObject({ type: 'share', title: '普通分享', typeVal: '5' })
})
it('preserves every article in a public-account multi-article message', () => {
const parsed = parseMessageContent(
`<appmsg><type>5</type><appname>长江日报</appname><mmreader><category count="3"><item><title><![CDATA[女子吃酒席时意外发现]]></title><url><![CDATA[https://mp.weixin.qq.com/a]]></url><cover><![CDATA[https://img.test/a.jpg]]></cover></item><item><title>霍尔木兹海峡开放临时协议</title><digest>国际油价短期走势</digest><url>https://mp.weixin.qq.com/b</url></item><item><title>东野圭吾新作</title><url>https://mp.weixin.qq.com/c</url></item></category></mmreader></appmsg>`,
49
)
expect(parsed).toMatchObject({ type: 'share', appname: '长江日报' })
if (parsed.type === 'share') {
expect(parsed.articles).toHaveLength(3)
expect(parsed.articles?.map((article) => article.title)).toEqual([
'女子吃酒席时意外发现',
'霍尔木兹海峡开放临时协议',
'东野圭吾新作'
])
}
})
it('uses the quoted group member id instead of the chatroom id', () => {
const parsed = parseMessageContent(
'<appmsg><type>57</type><title>回复内容</title><refermsg><type>1</type><fromusr>123456789@chatroom</fromusr><chatusr>wxid_fixture_member</chatusr><content>被引用内容</content></refermsg></appmsg>',
49
)
expect(parsed).toMatchObject({
type: 'quote',
quotedSender: 'wxid_fixture_member',
quotedContent: '被引用内容'
})
})
it('renders the templated join-group notice instead of its hidden button label', () => {
// 微信 4.x 的 sysmsgtemplate:<plain> 为空、正文在 <template> 里用 $名称$ 引用 link,
// hidden="1" 的 link 是可点击按钮,不应作为正文。
const parsed = parseMessageContent(
[
'<sysmsg type="sysmsgtemplate">',
'<sysmsgtemplate><content_template type="tmpl_type_profilewithrevokeqrcode">',
'<plain><![CDATA[]]></plain>',
'<template><![CDATA["$adder$"通过扫描你分享的二维码加入群聊 $revoke$]]></template>',
'<link_list>',
'<link name="adder" type="link_profile"><memberlist><member>',
'<username><![CDATA[wxid_fixture_member]]></username>',
'<nickname><![CDATA[成员昵称]]></nickname>',
'</member></memberlist></link>',
'<link name="revoke" type="link_revoke_qrcode" hidden="1">',
'<title><![CDATA[撤销]]></title>',
'</link>',
'</link_list>',
'</content_template></sysmsgtemplate></sysmsg>'
].join(''),
10000
)
expect(parsed).toMatchObject({
type: 'system',
content: '"成员昵称"通过扫描你分享的二维码加入群聊'
})
})
it('keeps parsing the legacy delchatroommember join-group notice', () => {
const parsed = parseMessageContent(
[
'<sysmsg type="delchatroommember"><delchatroommember>',
'<plain><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊 ]]></plain>',
'<text><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊 ]]></text>',
'<link><scene>qrcode</scene><text><![CDATA[ 撤销]]></text>',
'<memberlist><username><![CDATA[wxid_fixture_member]]></username></memberlist>',
'</link>',
'</delchatroommember></sysmsg>'
].join(''),
10000
)
expect(parsed).toMatchObject({
type: 'system',
content: '"成员昵称"通过扫描你分享的二维码加入群聊'
})
})
it('uses an explicit unknown type for unsupported messages', () => {
expect(parseMessageContent('opaque fixture payload', 999)).toEqual({
type: 'unknown',
raw: 'opaque fixture payload',
messageType: 999
})
})
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 = [
'<msg><appmsg>',
'<patMsg>',
'<template><![CDATA[$from$拍了拍$to$$pat$]]></template>',
'<fromusername><![CDATA[alice]]></fromusername>',
'<tousername><![CDATA[bob]]></tousername>',
'<pat><![CDATA[的头像]]></pat>',
'</patMsg>',
'</appmsg></msg>'
].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 = [
'<msg><appmsg>',
'<type>51</type>',
'<title><![CDATA[视频号]]></title>',
'<findernamecard>',
'<username><![CDATA[finder_ab12]]></username>',
'<nickname><![CDATA[示例视频号]]></nickname>',
'</findernamecard>',
'</appmsg></msg>'
].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 = [
'<msg><appmsg>',
'<type>2000</type>',
'<productitem>',
'<productName><![CDATA[测试商品]]></productName>',
'<sellingPrice><![CDATA[¥9.9]]></sellingPrice>',
'</productitem>',
'</appmsg></msg>'
].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 = '<msg><nickname><![CDATA[新朋友]]></nickname><content><![CDATA[我是群里的]]></content></msg>'
const parsed = parseMessageContent(xml, 37)
expect(parsed).toMatchObject({
type: 'system',
content: '新朋友 · 我是群里的'
})
})
it('parses music share cards from song/album fields', () => {
const xml = [
'<msg><appmsg>',
'<type>3</type>',
'<title><![CDATA[晴天]]></title>',
'<des><![CDATA[周杰伦]]></des>',
'<url><![CDATA[https://music.example/song]]></url>',
'<songalbumurl><![CDATA[https://music.example/cover]]></songalbumurl>',
'<songlyric><![CDATA[故事的小黄花]]></songlyric>',
'</appmsg></msg>'
].join('')
const parsed = parseMessageContent(xml, 49)
expect(parsed).toMatchObject({
type: 'share',
title: '晴天',
des: '周杰伦',
url: 'https://music.example/song',
appname: '音乐',
typeVal: '3'
})
})
it('parses subscribe template cards', () => {
const clean = [
'<msg><appmsg>',
'<template_header><![CDATA[服务通知]]></template_header>',
'<template_detail><![CDATA[您的订单已发货]]></template_detail>',
'<url><![CDATA[https://mp.example/notice]]></url>',
'<updatablemsg></updatablemsg>',
'</appmsg></msg>'
].join('')
const parsed = parseMessageContent(clean, 49)
expect(parsed).toMatchObject({
type: 'share',
title: '服务通知',
des: '您的订单已发货',
appname: '订阅消息',
typeVal: 'subscribe'
})
})
it('parses kefu template cards and falls back to system text', () => {
const card = [
'<msg><appmsg>',
'<template_header><![CDATA[店铺客服]]></template_header>',
'<template_detail><![CDATA[点击查看订单]]></template_detail>',
'<opencustomerservicemsg></opencustomerservicemsg>',
'</appmsg></msg>'
].join('')
expect(parseMessageContent(card, 49)).toMatchObject({
type: 'share',
title: '店铺客服',
des: '点击查看订单',
typeVal: 'kefu'
})
const menu = '<msg><appmsg><kefumenu></kefumenu></appmsg></msg>'
expect(parseMessageContent(menu, 49)).toMatchObject({
type: 'system',
content: '客服消息'
})
})
it('parses transfer wcpayinfo fields (type 2000)', () => {
// 2026-09 真机转账采样字段名(含微信原文 transcationid 拼写)
const xml = [
'<msg><appmsg appid="" sdkver="">',
'<title><![CDATA[微信转账]]></title>',
'<des><![CDATA[收到转账2900.00元。]]></des>',
'<type>2000</type>',
'<url><![CDATA[https://support.weixin.qq.com/upgrade]]></url>',
'<wcpayinfo>',
'<paysubtype>3</paysubtype>',
'<feedesc><![CDATA[¥2900.00]]></feedesc>',
'<transcationid><![CDATA[53010003370089202609134087894875]]></transcationid>',
'<transferid><![CDATA[1000050001202609130232821248942]]></transferid>',
'<invalidtime><![CDATA[1789348128]]></invalidtime>',
'<begintransfertime><![CDATA[1789261728]]></begintransfertime>',
'<effectivedate><![CDATA[1]]></effectivedate>',
'<pay_memo><![CDATA[房租]]></pay_memo>',
'<receiver_username><![CDATA[wxid_receiver]]></receiver_username>',
'<payer_username><![CDATA[]]></payer_username>',
'<transfer_status>2</transfer_status>',
'<trans_id><![CDATA[trans-fixture-1]]></trans_id>',
'<fee_type><![CDATA[CNY]]></fee_type>',
'<refund_bank_type><![CDATA[1]]></refund_bank_type>',
'</wcpayinfo></appmsg></msg>'
].join('')
const parsed = parseMessageContent(xml, 49)
expect(parsed).toMatchObject({
type: 'share',
typeVal: '2000',
title: '微信转账',
transfer: {
paySubtype: '3',
amountText: '¥2900.00',
transcationId: '53010003370089202609134087894875',
transferId: '1000050001202609130232821248942',
invalidTime: '1789348128',
beginTransferTime: '1789261728',
effectiveDate: '1',
payMemo: '房租',
receiverUsername: 'wxid_receiver',
transferStatus: '2',
transferStatusText: '已收款',
transId: 'trans-fixture-1',
feeType: 'CNY',
refundBankType: '1'
}
})
})
it('parses red packet wcpayinfo fields (type 2001 / mmpayhb)', () => {
const xml = [
'<msg><appmsg appid="" sdkver="">',
'<title><![CDATA[中秋快乐]]></title>',
'<des><![CDATA[我给你发了一个红包,赶紧去拆!]]></des>',
'<type>2001</type>',
'<wcpayinfo>',
'<templateid><![CDATA[7a2a165d31da7fce6dd77e05c300028a]]></templateid>',
'<url><![CDATA[https://wxapp.tenpay.com/mmpayhb/wxhb_personalreceive?msgtype=1&sendid=1000039801202609247176036834007&sign=abc]]></url>',
'<iconurl><![CDATA[https://wx.gtimg.com/hongbao/1800/hb.png]]></iconurl>',
'<receivertitle><![CDATA[恭喜发财,大吉大利]]></receivertitle>',
'<sendertitle><![CDATA[中秋快乐,望重置]]></sendertitle>',
'<scenetext><![CDATA[微信红包]]></scenetext>',
'<senderdes><![CDATA[查看红包]]></senderdes>',
'<receiverdes><![CDATA[领取红包]]></receiverdes>',
'<nativeurl><![CDATA[wxpay://c2cbizmessagehandler/hongbao/receivehongbao?sendid=1]]></nativeurl>',
'<hb_type>1</hb_type>',
'<hb_status>1</hb_status>',
'<receive_status>2</receive_status>',
'</wcpayinfo></appmsg></msg>'
].join('')
const parsed = parseMessageContent(xml, 49)
expect(parsed).toMatchObject({
type: 'redPacket',
title: '中秋快乐,望重置',
pay: {
templateId: '7a2a165d31da7fce6dd77e05c300028a',
receiveTitle: '恭喜发财,大吉大利',
sendTitle: '中秋快乐,望重置',
sceneText: '微信红包',
senderDes: '查看红包',
receiverDes: '领取红包',
sendId: '1000039801202609247176036834007',
hbType: '1',
hbStatus: '1',
receiveStatus: '2',
redPacketStatusText: '已领取'
}
})
})
})