fix(parser): QCE private chats misdetected as group and phantom "0" member

This commit is contained in:
Shuakami authored and digua committed 2026-07-15 09:52:34 +08:00
1 parent 710b21f2ae
commit 748613adb7
2 files changed
+172 -12

No files matched your search

@@ -0,0 +1,132 @@
import assert from 'node:assert/strict'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, it } from 'node:test'
import { ChatType, MessageType } from '@openchatlab/shared-types'
import { detectFormat, parseFileSync } from '../index'
import type { ParseResult } from '../types'
function makeExport(options: {
chatInfoType?: string
senders: Array<{ uid: string; name: string }>
messages: unknown[]
}): string {
return JSON.stringify(
{
metadata: {
name: 'QQChatExporter V6',
version: '6.0.3',
exportTime: '2026-07-12T01:20:23.000Z',
},
chatInfo: {
name: 'Test Chat',
...(options.chatInfoType ? { type: options.chatInfoType } : {}),
},
statistics: {
totalMessages: options.messages.length,
senders: options.senders.map((s) => ({ ...s, messageCount: 1, percentage: 50 })),
},
messages: options.messages,
},
null,
2
)
}
function textMessage(uin: string, name: string, text: string, extra: Record<string, unknown> = {}): unknown {
return {
messageId: `msg-${uin}-${text}`,
timestamp: '2026-07-10T12:00:00.000Z',
sender: { uid: `u_${uin}`, uin, name },
messageType: 2,
content: { text },
...extra,
}
}
function systemMessage(text: string): unknown {
return {
messageId: `sys-${text}`,
timestamp: '2026-07-10T12:30:00.000Z',
sender: { uid: '未知', uin: '0', name: '0' },
messageType: 5,
content: { text },
system: true,
}
}
async function parseContent(content: string): Promise<ParseResult> {
const dir = mkdtempSync(join(tmpdir(), 'chatlab-qce-parser-'))
try {
const filePath = join(dir, 'qce-export.json')
writeFileSync(filePath, content, 'utf-8')
assert.equal(detectFormat(filePath)?.id, 'shuakami-qq-exporter')
return await parseFileSync(filePath)
} finally {
rmSync(dir, { recursive: true, force: true })
}
}
describe('shuakami-qq-exporter parser', () => {
it('prefers chatInfo.type and skips placeholder system senders', async () => {
const content = makeExport({
chatInfoType: 'private',
senders: [
{ uid: 'u_100', name: 'Alice' },
{ uid: 'u_200', name: 'Bob' },
{ uid: '未知', name: '0' },
],
messages: [
textMessage('100', 'Alice', 'hello'),
textMessage('200', 'Bob', 'hi'),
systemMessage('对方撤回了一条消息'),
],
})
const result = await parseContent(content)
assert.equal(result.meta.type, ChatType.PRIVATE)
assert.deepEqual(result.members.map((m) => m.platformId).sort(), ['100', '200'])
assert.equal(result.messages.length, 2)
assert.ok(result.messages.every((m) => m.senderPlatformId !== '0' && m.senderPlatformId !== '未知'))
})
it('falls back to real sender count when chatInfo.type is missing', async () => {
const content = makeExport({
senders: [
{ uid: 'u_100', name: 'Alice' },
{ uid: 'u_200', name: 'Bob' },
{ uid: '未知', name: '0' },
],
messages: [textMessage('100', 'Alice', 'hello'), textMessage('200', 'Bob', 'hi')],
})
const result = await parseContent(content)
assert.equal(result.meta.type, ChatType.PRIVATE)
})
it('supports current system/recalled field names alongside legacy ones', async () => {
const content = makeExport({
chatInfoType: 'group',
senders: [
{ uid: 'u_100', name: 'Alice' },
{ uid: 'u_200', name: 'Bob' },
{ uid: 'u_300', name: 'Carol' },
],
messages: [
textMessage('100', 'Alice', 'recalled new', { recalled: true }),
textMessage('200', 'Bob', 'recalled legacy', { isRecalled: true }),
textMessage('300', 'Carol', 'notice', { system: true }),
],
})
const result = await parseContent(content)
assert.equal(result.meta.type, ChatType.GROUP)
assert.equal(result.messages.length, 3)
assert.equal(result.messages[0].type, MessageType.RECALL)
assert.equal(result.messages[0].content, '[已撤回] recalled new')
assert.equal(result.messages[1].type, MessageType.RECALL)
assert.equal(result.messages[2].type, MessageType.SYSTEM)
})
})
@@ -73,8 +73,14 @@ interface V4Message {
name: string
}
messageType?: number
/** Legacy field name (pre-V6 exports). */
isSystemMessage?: boolean
/** Legacy field name (pre-V6 exports). */
isRecalled?: boolean
/** Current field name (V6+ exports). */
system?: boolean
/** Current field name (V6+ exports). */
recalled?: boolean
content: {
text: string
resources?: Array<{ type: string }>
@@ -163,7 +169,14 @@ function extractNameFromFilePath(filePath: string): string {
}
/**
* 计算 senders 数量以判断聊天类型
* 判断发送者 ID 是否是系统消息占位符(无真实发送者)
*/
function isPlaceholderSenderId(id: string): boolean {
return id === '0' || id === '未知'
}
/**
* 计算真实 senders 数量以判断聊天类型(排除系统消息占位发送者)
*/
function countSenders(content: string): number {
const sendersStart = content.indexOf('"senders"')
@@ -184,8 +197,24 @@ function countSenders(content: string): number {
if (depth !== 0) return 0
const sendersContent = content.slice(arrayStart + 1, i - 1)
const uidMatches = sendersContent.match(/"uid"\s*:/g)
return uidMatches ? uidMatches.length : 0
const uidMatches = sendersContent.matchAll(/"uid"\s*:\s*"([^"]*)"/g)
let count = 0
for (const match of uidMatches) {
const uid = match[1]
if (uid && !isPlaceholderSenderId(uid)) count++
}
return count
}
/**
* 判断聊天类型:优先使用 chatInfo.type,senders 计数仅作兜底
*/
function resolveChatType(chatInfoType: string | undefined, headContent: string): ChatType {
if (chatInfoType === 'private') return ChatType.PRIVATE
if (chatInfoType === 'group') return ChatType.GROUP
const sendersCount = countSenders(headContent)
return sendersCount > 2 ? ChatType.GROUP : sendersCount > 0 ? ChatType.PRIVATE : ChatType.GROUP
}
// ==================== 消息类型转换 ====================
@@ -268,9 +297,8 @@ async function* parseV4(options: ParseOptions): AsyncGenerator<ParseEvent, void,
// 使用默认值
}
// 根据 senders 数量判断聊天类型
const sendersCount = countSenders(headContent)
const chatType = sendersCount > 2 ? ChatType.GROUP : sendersCount > 0 ? ChatType.PRIVATE : ChatType.GROUP
// 判断聊天类型(优先 chatInfo.type,senders 计数兜底)
const chatType = resolveChatType(chatInfo.type, headContent)
// 发送 meta(包含群头像)
const meta: ParsedMeta = {
@@ -299,7 +327,7 @@ async function* parseV4(options: ParseOptions): AsyncGenerator<ParseEvent, void,
// 获取 platformId
const platformId =
value.sender.uin || value.sender.uid || value.rawMessage?.senderUin || value.rawMessage?.senderUid
if (!platformId) {
if (!platformId || isPlaceholderSenderId(platformId)) {
skippedMessages++
return
}
@@ -325,14 +353,14 @@ async function* parseV4(options: ParseOptions): AsyncGenerator<ParseEvent, void,
return
}
// 消息类型
const type = value.isSystemMessage
? MessageType.SYSTEM
: convertMessageType(value.messageType, value.content, value.isRecalled)
// 消息类型(system/recalled 为当前字段名,isSystemMessage/isRecalled 为旧字段名)
const isSystem = value.system ?? value.isSystemMessage
const isRecalled = value.recalled ?? value.isRecalled
const type = isSystem ? MessageType.SYSTEM : convertMessageType(value.messageType, value.content, isRecalled)
// 文本内容
let textContent = value.content?.text || ''
if (value.isRecalled) textContent = '[已撤回] ' + textContent
if (isRecalled) textContent = '[已撤回] ' + textContent
messageBatch.push({
platformMessageId: value.messageId, // 消息的平台原始 ID