mirror of
https://github.com/hellodigua/ChatLab.git
synced 2026-10-25 17:54:34 +08:00
fix(parser): QCE private chats misdetected as group and phantom "0" member
This commit is contained in:
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
|
||||
|
||||
Reference in new issue
Block a user