fix: 兼容微信新版系统消息模板格式,入群通知不再显示成「撤销」

This commit is contained in:
Wxw-Gu
2026-09-18 14:41:50 +08:00
parent 0f270366aa
commit 9b82d29037
4 changed files with 230 additions and 7 deletions
+1
View File
@@ -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`
+88 -7
View File
@@ -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()
+49
View File
@@ -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',