From 8129c1227b5b81fc40a18554d5d0f7961aba2d39 Mon Sep 17 00:00:00 2001
From: xuncha <1658671838@qq.com>
Date: Thu, 14 May 2026 22:16:12 +0800
Subject: [PATCH] =?UTF-8?q?[Question]:=20=E4=BB=8Ev4.3.1=E5=8D=87=E7=BA=A7?=
=?UTF-8?q?=E5=88=B0v4.4.5=E5=90=8E=EF=BC=8C=E9=80=9A=E8=BF=87/api/v1/mess?=
=?UTF-8?q?ages=E8=8E=B7=E5=8F=96=E5=88=B0=E7=9A=84content=E6=98=AFxml?=
=?UTF-8?q?=E6=A0=BC=E5=BC=8F=20Fixes=20#958?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
---
docs/HTTP-API.md | 24 ++-
electron/services/httpService.ts | 302 +++++++++++++++++++++++++++++--
2 files changed, 313 insertions(+), 13 deletions(-)
diff --git a/docs/HTTP-API.md b/docs/HTTP-API.md
index 736e2a4..da739e2 100644
--- a/docs/HTTP-API.md
+++ b/docs/HTTP-API.md
@@ -173,6 +173,8 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
- `content`
- `rawContent`
- `parsedContent`
+- `replyToMessageId`(引用回复目标消息的 `serverId`,仅引用消息返回)
+- `quote`(引用消息快照,包含被引用消息的 ID、发送者、内容和类型)
- `mediaType`
- `mediaFileName`
- `mediaUrl`
@@ -184,7 +186,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
{
"success": true,
"talker": "xxx@chatroom",
- "count": 2,
+ "count": 3,
"hasMore": true,
"media": {
"enabled": true,
@@ -203,6 +205,25 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
"rawContent": "你好",
"parsedContent": "你好"
},
+ {
+ "localId": 125,
+ "serverId": "6116895530414915133",
+ "localType": 244813135921,
+ "createTime": 1738713700,
+ "isSend": 0,
+ "senderUsername": "wxid_member",
+ "content": "收到",
+ "rawContent": "...",
+ "parsedContent": "收到",
+ "replyToMessageId": "6116895530414915131",
+ "quote": {
+ "platformMessageId": "6116895530414915131",
+ "sender": "wxid_other",
+ "accountName": "张三",
+ "content": "你好",
+ "type": 0
+ }
+ },
{
"localId": 124,
"localType": 3,
@@ -243,6 +264,7 @@ curl "http://127.0.0.1:5031/api/v1/messages?talker=xxx@chatroom&media=1&image=1&
- `messages[].type`
- `messages[].content`
- `messages[].platformMessageId`
+- `messages[].replyToMessageId`
- `messages[].mediaPath`
群聊里 `groupNickname` 会优先来自群成员群昵称;若源数据缺失,则回退为空或展示名。
diff --git a/electron/services/httpService.ts b/electron/services/httpService.ts
index dd447b4..1e51ff0 100644
--- a/electron/services/httpService.ts
+++ b/electron/services/httpService.ts
@@ -52,6 +52,20 @@ interface ChatLabMessage {
mediaPath?: string
}
+interface ApiQuoteSnapshot {
+ platformMessageId?: string
+ sender?: string
+ accountName?: string
+ content?: string
+ type?: number
+}
+
+interface ApiQuoteInfo {
+ replyText?: string
+ replyToMessageId?: string
+ quote?: ApiQuoteSnapshot
+}
+
interface ChatLabData {
chatlab: ChatLabHeader
meta: ChatLabMeta
@@ -1600,8 +1614,8 @@ class HttpService {
private toApiMessage(msg: Message, media?: ApiExportedMedia): Record {
const serverId = this.getMessageServerId(msg)
-
- return {
+ const quoteInfo = this.extractApiQuoteInfo(msg)
+ const apiMessage: Record = {
localId: msg.localId,
serverId: serverId || '0',
localType: msg.localType,
@@ -1609,7 +1623,7 @@ class HttpService {
sortSeq: msg.sortSeq,
isSend: msg.isSend,
senderUsername: msg.senderUsername,
- content: this.getMessageContent(msg),
+ content: this.getMessageContent(msg, quoteInfo),
rawContent: msg.rawContent,
parsedContent: msg.parsedContent,
mediaType: media?.kind,
@@ -1617,6 +1631,15 @@ class HttpService {
mediaUrl: media ? `http://${this.host}:${this.port}/api/v1/media/${media.relativePath}` : undefined,
mediaLocalPath: media?.fullPath
}
+
+ if (quoteInfo?.replyToMessageId) {
+ apiMessage.replyToMessageId = quoteInfo.replyToMessageId
+ }
+ if (quoteInfo?.quote && Object.keys(quoteInfo.quote).length > 0) {
+ apiMessage.quote = quoteInfo.quote
+ }
+
+ return apiMessage
}
private getMessageServerId(msg: Message): string {
@@ -1896,17 +1919,22 @@ class HttpService {
// 转换消息
const chatLabMessages: ChatLabMessage[] = messages.map(msg => {
const senderInfo = this.resolveChatLabSenderInfo(msg, talkerId, talkerName, myWxid, isGroup, senderNames, groupNicknamesMap)
+ const quoteInfo = this.extractApiQuoteInfo(msg)
- return {
+ const chatLabMessage: ChatLabMessage = {
sender: senderInfo.sender,
accountName: senderInfo.accountName,
groupNickname: senderInfo.groupNickname,
timestamp: msg.createTime,
type: this.mapMessageType(msg.localType, msg),
- content: this.getMessageContent(msg),
+ content: this.getMessageContent(msg, quoteInfo),
platformMessageId: this.getMessageServerId(msg) || undefined,
mediaPath: mediaMap.get(msg.localId) ? `http://${this.host}:${this.port}/api/v1/media/${mediaMap.get(msg.localId)!.relativePath}` : undefined
}
+ if (quoteInfo?.replyToMessageId) {
+ chatLabMessage.replyToMessageId = quoteInfo.replyToMessageId
+ }
+ return chatLabMessage
})
return {
@@ -1995,7 +2023,7 @@ class HttpService {
}
private extractType49Subtype(rawContent: string): string {
- const content = String(rawContent || '')
+ const content = this.normalizeAppMessageContent(String(rawContent || ''))
if (!content) return ''
const appmsgMatch = /([\s\S]*?)<\/appmsg>/i.exec(content)
@@ -2049,9 +2077,9 @@ class HttpService {
}
}
- private getType49Content(msg: Message): string {
+ private getType49Content(msg: Message, quoteInfo?: ApiQuoteInfo): string {
const subtype = this.resolveType49Subtype(msg)
- const title = msg.linkTitle || msg.fileName || ''
+ const title = msg.linkTitle || msg.fileName || this.extractAppMessageTitle(msg.rawContent) || ''
switch (subtype) {
case '5':
@@ -2065,7 +2093,7 @@ class HttpService {
case '36':
return title ? `[小程序] ${title}` : '[小程序]'
case '57':
- return msg.parsedContent || title || '[引用消息]'
+ return msg.parsedContent || quoteInfo?.replyText || title || '[引用消息]'
case '2000':
return title ? `[转账] ${title}` : '[转账]'
case '2001':
@@ -2080,7 +2108,7 @@ class HttpService {
/**
* 获取消息内容
*/
- private getMessageContent(msg: Message): string | null {
+ private getMessageContent(msg: Message, quoteInfo?: ApiQuoteInfo): string | null {
const normalizeTextContent = (value: string | null | undefined): string | null => {
const text = String(value || '')
if (!text) return null
@@ -2088,7 +2116,11 @@ class HttpService {
}
if (msg.localType === 49) {
- return this.getType49Content(msg)
+ return this.getType49Content(msg, quoteInfo)
+ }
+
+ if (this.isReplyMessage(msg, quoteInfo)) {
+ return msg.parsedContent || quoteInfo?.replyText || this.extractAppMessageTitle(msg.rawContent) || '[引用消息]'
}
// 优先使用已解析的内容
@@ -2113,12 +2145,258 @@ class HttpService {
case 48:
return '[位置]'
case 49:
- return this.getType49Content(msg)
+ return this.getType49Content(msg, quoteInfo)
default:
return normalizeTextContent(msg.parsedContent || msg.rawContent) || null
}
}
+ private isReplyMessage(msg: Message, quoteInfo?: ApiQuoteInfo): boolean {
+ if (!quoteInfo?.replyToMessageId && !quoteInfo?.quote) return false
+ if (msg.localType === 244813135921) return true
+ if (msg.localType === 49 && this.resolveType49Subtype(msg) === '57') return true
+ return false
+ }
+
+ private extractApiQuoteInfo(msg: Message): ApiQuoteInfo | undefined {
+ const rawContent = String(msg.rawContent || msg.content || '')
+ if (!rawContent || !this.messageMayContainQuote(rawContent)) {
+ return undefined
+ }
+
+ const normalized = this.normalizeAppMessageContent(rawContent)
+ const referMsgXml = this.extractXmlBlock(normalized, 'refermsg')
+ if (!referMsgXml) return undefined
+
+ const replyToMessageId = this.extractReplyToMessageId(referMsgXml)
+ const referTypeRaw = this.extractXmlValue(referMsgXml, 'type')
+ const referContentRaw = this.extractXmlValue(referMsgXml, 'content')
+ const quoteContent = this.resolveQuotedContent(referMsgXml, referTypeRaw, referContentRaw)
+ const sender = this.resolveQuotedSender(referMsgXml)
+ const accountName = this.resolveQuotedAccountName(referMsgXml)
+ const quoteType = this.mapQuotedMessageType(referTypeRaw, referContentRaw)
+
+ const quote: ApiQuoteSnapshot = {}
+ if (replyToMessageId) quote.platformMessageId = replyToMessageId
+ if (sender) quote.sender = sender
+ if (accountName) quote.accountName = accountName
+ if (quoteContent) quote.content = quoteContent
+ if (quoteType !== undefined) quote.type = quoteType
+
+ const replyText = this.extractAppMessageTitle(normalized)
+
+ if (!replyToMessageId && Object.keys(quote).length === 0 && !replyText) {
+ return undefined
+ }
+
+ return {
+ replyText: replyText || undefined,
+ replyToMessageId,
+ quote: Object.keys(quote).length > 0 ? quote : undefined
+ }
+ }
+
+ private messageMayContainQuote(content: string): boolean {
+ return content.includes('') ||
+ content.includes('<refermsg>') ||
+ content.includes('57') ||
+ content.includes('<type>57</type>')
+ }
+
+ private normalizeAppMessageContent(content: string): string {
+ return this.decodeHtmlEntities(String(content || ''))
+ }
+
+ private decodeHtmlEntities(text: string): string {
+ if (!text) return ''
+ return text
+ .replace(/</g, '<')
+ .replace(/>/g, '>')
+ .replace(/&/g, '&')
+ .replace(/"/g, '"')
+ .replace(/'/g, "'")
+ .replace(/'/g, "'")
+ }
+
+ private extractXmlBlock(xml: string, tag: string): string {
+ if (!xml || !tag) return ''
+ const match = new RegExp(`<${tag}\\b[^>]*>[\\s\\S]*?<\\/${tag}>`, 'i').exec(xml)
+ return match ? match[0] : ''
+ }
+
+ private extractXmlValue(xml: string, tag: string): string {
+ if (!xml || !tag) return ''
+ const match = new RegExp(`<${tag}\\b[^>]*>([\\s\\S]*?)<\\/${tag}>`, 'i').exec(xml)
+ if (!match) return ''
+ return this.decodeHtmlEntities(match[1])
+ .replace(//g, '')
+ .trim()
+ }
+
+ private extractAppMessageTitle(content: string): string {
+ const normalized = this.normalizeAppMessageContent(content || '')
+ if (!normalized) return ''
+ const appMsgXml = this.extractXmlBlock(normalized, 'appmsg')
+ return this.sanitizeQuotedContent(this.extractXmlValue(appMsgXml || normalized, 'title'))
+ }
+
+ private extractReplyToMessageId(referMsgXml: string): string | undefined {
+ const candidates = [
+ this.extractXmlValue(referMsgXml, 'svrid'),
+ this.extractXmlValue(referMsgXml, 'msgsvrid'),
+ this.extractXmlValue(referMsgXml, 'newmsgid'),
+ this.extractXmlValue(referMsgXml, 'msgid')
+ ]
+
+ for (const candidate of candidates) {
+ const normalized = this.normalizeUnsignedIntToken(candidate)
+ if (normalized && normalized !== '0') return normalized
+ }
+
+ return undefined
+ }
+
+ private resolveQuotedSender(referMsgXml: string): string | undefined {
+ const chatusr = this.extractXmlValue(referMsgXml, 'chatusr')
+ if (chatusr) return chatusr
+
+ const fromusr = this.extractXmlValue(referMsgXml, 'fromusr')
+ if (fromusr && !fromusr.endsWith('@chatroom')) return fromusr
+
+ return undefined
+ }
+
+ private resolveQuotedAccountName(referMsgXml: string): string | undefined {
+ const displayName = this.extractXmlValue(referMsgXml, 'displayname')
+ if (!displayName || this.looksLikeWxid(displayName)) return undefined
+ return displayName
+ }
+
+ private looksLikeWxid(value: string): boolean {
+ const text = String(value || '').trim().toLowerCase()
+ return Boolean(text) && (text.startsWith('wxid_') || /^wx[a-z0-9_-]{4,}$/.test(text))
+ }
+
+ private resolveQuotedContent(referMsgXml: string, referTypeRaw: string, referContentRaw: string): string {
+ const referType = String(referTypeRaw || '').trim()
+ switch (referType) {
+ case '1':
+ return this.extractPreferredQuotedText(referMsgXml)
+ case '3':
+ return '[图片]'
+ case '34':
+ return '[语音]'
+ case '43':
+ return '[视频]'
+ case '47':
+ return '[动画表情]'
+ case '42':
+ return '[名片]'
+ case '48':
+ return '[位置]'
+ case '49': {
+ const innerType = this.extractType49Subtype(referContentRaw)
+ if (innerType === '57') {
+ return this.extractAppMessageTitle(referContentRaw) || '[引用消息]'
+ }
+ if (innerType === '6') return '[文件]'
+ if (innerType === '19') return '[聊天记录]'
+ if (innerType === '33' || innerType === '36') return '[小程序]'
+ return '[链接]'
+ }
+ default:
+ if (!referContentRaw || referContentRaw.includes('wxid_')) return '[消息]'
+ return this.sanitizeQuotedContent(referContentRaw)
+ }
+ }
+
+ private extractPreferredQuotedText(referMsgXml: string): string {
+ const candidateTags = [
+ 'selectedcontent',
+ 'selectedtext',
+ 'selectcontent',
+ 'selecttext',
+ 'quotecontent',
+ 'quotetext',
+ 'partcontent',
+ 'parttext',
+ 'excerpt',
+ 'summary',
+ 'preview',
+ 'content'
+ ]
+
+ for (const tag of candidateTags) {
+ const value = this.sanitizeQuotedContent(this.extractXmlValue(referMsgXml, tag))
+ if (value) return value
+ }
+
+ return ''
+ }
+
+ private sanitizeQuotedContent(content: string): string {
+ if (!content) return ''
+ return String(content || '')
+ .replace(/wxid_[A-Za-z0-9_-]{3,}/g, '')
+ .replace(/^[\s::\-]+/, '')
+ .replace(/[::]{2,}/g, ':')
+ .replace(/^[\s::\-]+/, '')
+ .replace(/\s+/g, ' ')
+ .trim()
+ }
+
+ private mapQuotedMessageType(referTypeRaw: string, referContentRaw: string): number | undefined {
+ const referType = String(referTypeRaw || '').trim()
+ switch (referType) {
+ case '1':
+ return ChatLabType.TEXT
+ case '3':
+ return ChatLabType.IMAGE
+ case '34':
+ return ChatLabType.VOICE
+ case '43':
+ return ChatLabType.VIDEO
+ case '47':
+ return ChatLabType.EMOJI
+ case '48':
+ return ChatLabType.LOCATION
+ case '42':
+ return ChatLabType.CONTACT
+ case '50':
+ return ChatLabType.CALL
+ case '10000':
+ return ChatLabType.SYSTEM
+ case '49':
+ return this.mapQuotedType49MessageType(referContentRaw)
+ default:
+ return undefined
+ }
+ }
+
+ private mapQuotedType49MessageType(content: string): number {
+ const subtype = this.extractType49Subtype(content)
+ switch (subtype) {
+ case '57':
+ return ChatLabType.REPLY
+ case '6':
+ return ChatLabType.FILE
+ case '19':
+ return ChatLabType.FORWARD
+ case '33':
+ case '36':
+ return ChatLabType.SHARE
+ case '2000':
+ return ChatLabType.TRANSFER
+ case '2001':
+ return ChatLabType.RED_PACKET
+ case '5':
+ case '49':
+ default:
+ return ChatLabType.LINK
+ }
+ }
+
/**
* 发送 JSON 响应
*/