mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-10-03 18:33:14 +08:00
fix: 兼容微信新版系统消息模板格式,入群通知不再显示成「撤销」
This commit is contained in:
@@ -50,6 +50,7 @@ Agent Hub 让微信机器人调用本机 TraceMemo;Reader Skill / Local HTTP A
|
||||
|
||||
- [开发、测试与构建](./development/overview.md)
|
||||
- [界面开发规范:按钮与主题色](./development/ui-guidelines.md)
|
||||
- [微信系统消息解析与格式兼容](./development/wechat-system-message-parsing.md)
|
||||
- [Query Agent POC(开发测试入口)](./development/query-agent-poc.md)
|
||||
- [本地启动排障](./development/local-startup-troubleshooting.md)
|
||||
- [macOS 数据访问说明](./platform/macos.md)
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
# 微信系统消息(sysmsg)解析与格式兼容
|
||||
|
||||
微信的「系统消息」(入群、撤回、成员变动等)以 XML(`<sysmsg>`)存放在消息内容里,
|
||||
但**同一类提示的 XML 结构会随客户端版本变化**。本文说明 TraceMemo 的解析方式,
|
||||
以及在遇到新格式时应当怎么扩展。
|
||||
|
||||
## 两类格式
|
||||
|
||||
### 旧格式:正文直接放在 `<plain>`
|
||||
|
||||
```xml
|
||||
<sysmsg type="delchatroommember">
|
||||
<delchatroommember>
|
||||
<plain><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊]]></plain>
|
||||
<text><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊]]></text>
|
||||
<link>
|
||||
<scene>qrcode</scene>
|
||||
<text><![CDATA[撤销]]></text>
|
||||
</link>
|
||||
</delchatroommember>
|
||||
</sysmsg>
|
||||
```
|
||||
|
||||
解析:命中 `delchatroommember`,直接取 `<plain>`。
|
||||
|
||||
### 新格式:正文在 `<template>`,用 `$名称$` 引用 link
|
||||
|
||||
```xml
|
||||
<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_xxxxxxxx]]></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>
|
||||
```
|
||||
|
||||
三个要点:
|
||||
|
||||
- `<plain>` 变成**空 CDATA**,正文挪进 `<template>`;
|
||||
- 正文里的 `$名称$` 是占位符,按 `<link_list>` 中 `link[name]` 回填;
|
||||
- `hidden="1"` 的 link 在微信里是**可点击按钮**,纯文本展示时应省略其文案。
|
||||
|
||||
## 解析流程
|
||||
|
||||
`src/main/message-parser.ts` 的 `parseSystemMessage()` 按以下顺序尝试:
|
||||
|
||||
| 顺序 | 分支 | 处理对象 |
|
||||
| --- | --- | --- |
|
||||
| 1 | `extractRecallMessage` | `<revokemsg>` 撤回通知 |
|
||||
| 2 | `extractSysmsgTemplateText` | `<sysmsgtemplate>` 模板消息 |
|
||||
| 3 | `extractDelChatroomMemberText` | `<delchatroommember>` 成员变动 |
|
||||
| 4 | 通用提取(`plain` → `text` → `title`),再退回 `fallbackSystemText` | 其余未覆盖类型 |
|
||||
|
||||
第 4 步之前会先调用 `stripSysmsgLinkList()` 剥掉 `<link_list>`。
|
||||
|
||||
## 为什么必须显式处理新格式
|
||||
|
||||
通用提取链只在第 1~3 步全部落空时才执行,而新格式恰好让它落空:
|
||||
`<plain>` 是空 CDATA,又没有 `<text>`,于是取到 `<title>` ——
|
||||
那是 `hidden="1"` 按钮的标题。**结果是整条系统消息只剩一个按钮文案**,
|
||||
例如把「某某通过扫描你分享的二维码加入群聊」显示成「撤销」。
|
||||
|
||||
因此三处约束缺一不可:
|
||||
|
||||
1. 模板分支必须排在通用提取之前;
|
||||
2. 占位符回填必须尊重 `hidden="1"`;
|
||||
3. 通用提取前先剥 `<link_list>`,作为未知类型的防护。
|
||||
|
||||
## 新增一类系统消息时
|
||||
|
||||
1. 从真实消息中取出 `content`(`<sysmsg>` 原文),确认 `type` 与承载正文的标签;
|
||||
2. 在 `parseSystemMessage()` 里加一个**早于通用提取**的分支;
|
||||
3. 补 `tests/unit/message-parser.test.ts` 用例,**新旧两版各一条**,防止回归;
|
||||
4. 文档与代码注释只写结构,不粘贴真实会话内容、昵称、wxid 或二维码链接。
|
||||
|
||||
## 相关位置
|
||||
|
||||
- 解析实现:`src/main/message-parser.ts`
|
||||
- 单元测试:`tests/unit/message-parser.test.ts`
|
||||
@@ -181,6 +181,14 @@ function parseSystemMessage(content: string): ParsedContent {
|
||||
recall
|
||||
}
|
||||
}
|
||||
const templateText = extractSysmsgTemplateText(decoded)
|
||||
if (templateText) {
|
||||
return {
|
||||
type: 'system',
|
||||
content: templateText,
|
||||
raw: content
|
||||
}
|
||||
}
|
||||
const delChatroomMemberText = extractDelChatroomMemberText(decoded)
|
||||
if (delChatroomMemberText) {
|
||||
return {
|
||||
@@ -189,16 +197,19 @@ function parseSystemMessage(content: string): ParsedContent {
|
||||
raw: content
|
||||
}
|
||||
}
|
||||
// <link_list> 里放的是富文本片段(可能含 hidden="1" 的可点击按钮),
|
||||
// 不是消息正文;先剥掉再走通用提取,避免把按钮文案当成整条系统消息。
|
||||
const withoutLinkList = stripSysmsgLinkList(decoded)
|
||||
const plainText =
|
||||
extractXmlNodeText(decoded, 'plain') ||
|
||||
extractXmlNodeText(decoded, 'text') ||
|
||||
extractXmlNodeText(decoded, 'title') ||
|
||||
extractXmlValue(decoded, 'plain') ||
|
||||
extractXmlValue(decoded, 'text') ||
|
||||
extractXmlValue(decoded, 'title') ||
|
||||
extractXmlNodeText(withoutLinkList, 'plain') ||
|
||||
extractXmlNodeText(withoutLinkList, 'text') ||
|
||||
extractXmlNodeText(withoutLinkList, 'title') ||
|
||||
extractXmlValue(withoutLinkList, 'plain') ||
|
||||
extractXmlValue(withoutLinkList, 'text') ||
|
||||
extractXmlValue(withoutLinkList, 'title') ||
|
||||
''
|
||||
|
||||
const normalized = normalizeSystemText(plainText || fallbackSystemText(decoded))
|
||||
const normalized = normalizeSystemText(plainText || fallbackSystemText(withoutLinkList))
|
||||
return {
|
||||
type: 'system',
|
||||
content: normalized || '[系统消息]',
|
||||
@@ -838,6 +849,76 @@ function extractDelChatroomMemberText(xml: string): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
/** 剥掉 <link_list> 区块 —— 其中的文案属于富文本片段,不是消息正文。 */
|
||||
function stripSysmsgLinkList(xml: string): string {
|
||||
return String(xml || '').replace(/<link_list\b[\s\S]*?<\/link_list>/gi, ' ')
|
||||
}
|
||||
|
||||
/**
|
||||
* 微信 4.x 起,部分系统消息改成「模板」格式,正文不再写在 <plain> 里。
|
||||
*
|
||||
* 旧格式(正文就在 <plain>,取到即可):
|
||||
*
|
||||
* <sysmsg type="delchatroommember"><delchatroommember>
|
||||
* <plain><![CDATA["成员昵称"通过扫描你分享的二维码加入群聊]]></plain>
|
||||
* <link><scene>qrcode</scene><text><![CDATA[撤销]]></text>…</link>
|
||||
* </delchatroommember></sysmsg>
|
||||
*
|
||||
* 新格式(<plain> 变空,正文挪进 <template>,用 $名称$ 引用 <link_list> 里的 link):
|
||||
*
|
||||
* <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><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>
|
||||
*
|
||||
* 两个要点:
|
||||
* 1. 正文取自 <template>,其中的 $名称$ 占位符按 <link_list> 的 link name 回填;
|
||||
* 2. hidden="1" 的 link 在微信里是可点击按钮,纯文本展示时省略其文案。
|
||||
*
|
||||
* 漏掉这段会让新格式消息落进通用提取链:<plain> 为空、又没有 <text>,
|
||||
* 于是取到 <title> —— 也就是那个隐藏按钮的标题,整条系统消息只剩一个按钮名。
|
||||
*/
|
||||
function extractSysmsgTemplateText(xml: string): string {
|
||||
if (!/<sysmsgtemplate\b|<content_template\b/i.test(xml)) return ''
|
||||
|
||||
const template = extractXmlNodeText(xml, 'template')
|
||||
if (!template) return ''
|
||||
|
||||
const links = new Map<string, { text: string; hidden: boolean }>()
|
||||
const linkPattern = /<link\b([^>]*)>([\s\S]*?)<\/link>/gi
|
||||
let linkMatch: RegExpExecArray | null
|
||||
while ((linkMatch = linkPattern.exec(xml)) !== null) {
|
||||
const name = extractXmlValue(linkMatch[1], 'name')
|
||||
if (!name) continue
|
||||
links.set(name, {
|
||||
// title 用于按钮文案,nickname 用于成员展示名,text 作最后兜底。
|
||||
text:
|
||||
extractXmlNodeText(linkMatch[2], 'title') ||
|
||||
extractXmlNodeText(linkMatch[2], 'nickname') ||
|
||||
extractXmlNodeText(linkMatch[2], 'text') ||
|
||||
'',
|
||||
hidden: /hidden\s*=\s*["']1["']/i.test(linkMatch[1])
|
||||
})
|
||||
}
|
||||
|
||||
const rendered = template.replace(/\$([A-Za-z0-9_]+)\$/g, (_raw, name: string) => {
|
||||
const link = links.get(name)
|
||||
return link && !link.hidden ? link.text : ''
|
||||
})
|
||||
|
||||
return normalizeSystemText(rendered)
|
||||
}
|
||||
|
||||
function normalizeMd5(value: unknown): string | undefined {
|
||||
const md5 = String(value || '')
|
||||
.trim()
|
||||
|
||||
@@ -118,6 +118,55 @@ describe('message parser', () => {
|
||||
})
|
||||
})
|
||||
|
||||
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',
|
||||
|
||||
Reference in New Issue
Block a user