From f96ada906c34c2de6cab7f2f71d82ad654c7acd2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E7=94=B5=E6=91=87=E5=B0=8F=E5=AD=90?= Date: Tue, 30 Jun 2026 16:04:01 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20=E5=AE=9E=E7=8E=B0=E9=80=80=E7=BE=A4?= =?UTF-8?q?=E7=9B=91=E6=8E=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 41 ++++- src/main/message-parser.ts | 84 +++++++++- src/preload/index.d.ts | 5 + src/preload/index.ts | 1 + src/renderer/src/App.tsx | 153 +++++++++++++++++- src/renderer/src/assets/main.css | 25 +++ src/renderer/src/components/ChatWindow.tsx | 14 +- .../src/components/RichMessageBubble.tsx | 6 + src/shared/types.ts | 2 +- 9 files changed, 319 insertions(+), 12 deletions(-) diff --git a/src/main/index.ts b/src/main/index.ts index 04cd9b1..fa5c597 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -265,19 +265,22 @@ app.whenReady().then(() => { / { return { id: msg.mesLocalID || Math.random().toString(), - from: isMine ? 'assistant' : 'user', + from: contentData?.type === 'system' ? 'system' : isMine ? 'assistant' : 'user', type: displayType, datetime: date.toLocaleString('zh-CN', { hour12: false }), content: content, @@ -327,6 +330,36 @@ app.whenReady().then(() => { }) }) + ipcMain.handle('db:getGroupSnapshot', (_, userMd5: string) => { + if (!wechatDb) return null + const wcdb4Client = wechatDb.getWcdb4Client() + if (!wcdb4Client) return null + + const roomId = wcdb4Client.getUsernameByMd5(userMd5) + if (!roomId || !roomId.endsWith('@chatroom')) return null + + const members = wcdb4Client + .getGroupMembers(roomId) + .filter((member) => member?.m_nsUsrName) + .map((member) => ({ + wxid: member.m_nsUsrName, + nickname: member.nickname || '', + avatar: member.m_nsHeadImgUrl || '' + })) + + console.log( + `[GroupSnapshot] roomId=${roomId} memberCount=${members.length} members=${members + .map((member) => `${member.nickname || member.wxid}(${member.wxid})`) + .join(', ')}` + ) + + return { + roomId, + memberCount: members.length, + members + } + }) + ipcMain.handle('db:search', (_, keyword: string) => { if (!wechatDb) return null return wechatDb.searchAllMessages(keyword) diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index 47be29c..fe0ce9f 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -41,7 +41,7 @@ type QuoteContent = { quotedSender?: string quotedType?: string } -type SystemContent = { type: 'system'; content: string } +type SystemContent = { type: 'system'; content: string; raw?: string } type UnknownContent = { type: 'unknown'; raw: string } export type ParsedContent = @@ -79,12 +79,40 @@ export function parseMessageContent(content: string, messageType: number): Parse return parseVoipMessage(normalized) case 10000: case 10002: - return { type: 'system', content: normalized } + return parseSystemMessage(normalized) default: return { type: 'text', content: normalized } } } +function parseSystemMessage(content: string): ParsedContent { + const stripped = stripChatroomPrefix(content) + const decoded = decodeXmlEntities(stripped) + const delChatroomMemberText = extractDelChatroomMemberText(decoded) + if (delChatroomMemberText) { + return { + type: 'system', + content: normalizeSystemText(delChatroomMemberText), + raw: content + } + } + const plainText = + extractXmlNodeText(decoded, 'plain') || + extractXmlNodeText(decoded, 'text') || + extractXmlNodeText(decoded, 'title') || + extractXmlValue(decoded, 'plain') || + extractXmlValue(decoded, 'text') || + extractXmlValue(decoded, 'title') || + '' + + const normalized = normalizeSystemText(plainText || fallbackSystemText(decoded)) + return { + type: 'system', + content: normalized || '[系统消息]', + raw: content + } +} + function parseImageMessage(content: string): ParsedContent { // 尝试 XML 格式: let md5 = extractXmlAttribute(content, 'img', 'md5') || extractXmlValue(content, 'md5') || '' @@ -356,6 +384,20 @@ function sanitizeQuotedContent(content: string): string { return decoded } +function stripChatroomPrefix(content: string): string { + return String(content || '') + .replace(/^[0-9a-z_-]+@chatroom:\s*/i, '') + .replace(/^wxid_[^:\n]+:\s*/i, '') + .trim() +} + +function normalizeSystemText(content: string): string { + return String(content || '') + .replace(/\s+/g, ' ') + .replace(/\s+([,.;!?])/g, '$1') + .trim() +} + function parseVoipMessage(content: string): ParsedContent { const roomTypeStr = extractXmlValue(content, 'room_type') const msg = extractXmlValue(content, 'msg') || '' @@ -416,6 +458,44 @@ function decodeXmlUrl(value: string): string { } } +function decodeXmlEntities(value: string): string { + return String(value || '') + .replace(/</g, '<') + .replace(/>/g, '>') + .replace(/"/g, '"') + .replace(/'/g, "'") + .replace(/&/g, '&') +} + +function extractXmlNodeText(xml: string, tagName: string): string { + const match = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i').exec(xml) + if (!match?.[1]) return '' + return normalizeSystemText( + decodeXmlEntities( + match[1].replace(//g, '$1').replace(/<[^>]+>/g, ' ') + ) + ) +} + +function fallbackSystemText(xml: string): string { + return normalizeSystemText( + decodeXmlEntities( + String(xml || '') + .replace(//g, '$1') + .replace(/<[^>]+>/g, ' ') + ) + ) +} + +function extractDelChatroomMemberText(xml: string): string { + if (!/]+delchatroommember/i.test(xml)) return '' + const plainMatch = /]*><\/plain>/i.exec(xml) + if (plainMatch?.[1]) return plainMatch[1].trim() + const textMatch = /]*><\/text>/i.exec(xml) + if (textMatch?.[1]) return textMatch[1].trim() + return '' +} + function normalizeMd5(value: unknown): string | undefined { const md5 = String(value || '') .trim() diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 31c806a..5db59cb 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -39,6 +39,11 @@ declare global { ) => Promise getContacts: (filter?: string) => Promise getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise + getGroupSnapshot: (userMd5: string) => Promise<{ + roomId: string + memberCount: number + members: { wxid: string; nickname: string; avatar: string }[] + } | null> search: (keyword: string) => Promise aiChat: ( messages: { role: string; content: string }[], diff --git a/src/preload/index.ts b/src/preload/index.ts index eba7b65..b70d05e 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -8,6 +8,7 @@ const api = { getContacts: (filter?: string) => ipcRenderer.invoke('db:getContacts', filter), getMessages: (userMd5: string, startTime?: number, endTime?: number) => ipcRenderer.invoke('db:getMessages', userMd5, startTime, endTime), + getGroupSnapshot: (userMd5: string) => ipcRenderer.invoke('db:getGroupSnapshot', userMd5), search: (keyword: string) => ipcRenderer.invoke('db:search', keyword), aiChat: ( messages: { role: string; content: string }[], diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 51655b1..48ec4b6 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -21,6 +21,67 @@ const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => { return true } +type GroupSnapshot = { + roomId: string + memberCount: number + members: { wxid: string; nickname: string; avatar: string }[] +} + +const formatGroupMemberName = (member: GroupSnapshot['members'][number]): string => + member.nickname || member.wxid + +const buildSyntheticGroupMessages = ( + previous: GroupSnapshot | null, + next: GroupSnapshot | null, + referenceMessages: Message[] +): Message[] => { + if (!previous || !next || previous.roomId !== next.roomId) return [] + + const previousMap = new Map(previous.members.map((member) => [member.wxid, member])) + const nextMap = new Map(next.members.map((member) => [member.wxid, member])) + const latestMessageTime = referenceMessages.reduce( + (max, message) => Math.max(max, message.createTime || 0), + 0 + ) + const fallbackNow = Math.floor(Date.now() / 1000) + const events: Message[] = [] + + let offset = 1 + for (const [wxid, member] of previousMap.entries()) { + if (nextMap.has(wxid)) continue + const name = formatGroupMemberName(member) + const eventTime = Math.max(latestMessageTime + offset, fallbackNow) + const eventDate = new Date(eventTime * 1000) + offset += 1 + events.push({ + id: `synthetic-leave:${next.roomId}:${wxid}:${eventTime}`, + from: 'system', + type: '系统消息', + datetime: eventDate.toLocaleString('zh-CN', { hour12: false }), + content: `${name} 退出了群聊`, + isSender: false, + createTime: eventTime + }) + } + + if (events.length) { + console.log( + `[GroupMonitor] synthetic leave detected roomId=${next.roomId} events=${events + .map((event) => event.content) + .join(' | ')}` + ) + } + + return events +} + +const sortMessagesChronologically = (items: Message[]): Message[] => + [...items].sort((left, right) => { + const timeDelta = (left.createTime || 0) - (right.createTime || 0) + if (timeDelta !== 0) return timeDelta + return getMessageIdentity(left).localeCompare(getMessageIdentity(right)) + }) + function App(): React.ReactElement { const [isAuthenticated, setIsAuthenticated] = useState(false) const [dbKey, setDbKey] = useState(import.meta.env.VITE_DB_KEY || '') @@ -36,6 +97,8 @@ function App(): React.ReactElement { const [showDbKey, setShowDbKey] = useState(false) const [showMacKeyFaq, setShowMacKeyFaq] = useState(false) const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false) + const currentGroupSnapshotRef = React.useRef(null) + const syntheticGroupMessagesRef = React.useRef>({}) React.useEffect(() => { let active = true @@ -84,6 +147,41 @@ function App(): React.ReactElement { } } + const logGroupSnapshot = React.useCallback( + async (contact: Contact | null, reason: string): Promise => { + if (!contact || contact.type !== 'group') return null + try { + const snapshot = (await window.api.getGroupSnapshot(contact.md5)) as GroupSnapshot | null + if (!snapshot) { + console.log(`[GroupSnapshot] reason=${reason} name=${contact.m_nsNickName} snapshot=null`) + return null + } + console.log( + `[GroupSnapshot] reason=${reason} name=${contact.m_nsNickName} roomId=${snapshot.roomId} memberCount=${snapshot.memberCount}`, + snapshot.members + ) + return snapshot + } catch (error) { + console.warn(`[GroupSnapshot] reason=${reason} name=${contact.m_nsNickName} failed:`, error) + return null + } + }, + [] + ) + + const mergeSyntheticMessages = React.useCallback( + (contact: Contact | null, baseMessages: Message[], roomId?: string): Message[] => { + if (!contact || contact.type !== 'group') return baseMessages + const resolvedRoomId = roomId || currentGroupSnapshotRef.current?.roomId + if (!resolvedRoomId) return baseMessages + const synthetic = syntheticGroupMessagesRef.current[resolvedRoomId] || [] + return synthetic.length + ? sortMessagesChronologically([...baseMessages, ...synthetic]) + : baseMessages + }, + [] + ) + const handleAutoGetDbKey = async (): Promise => { if (isFetchingDbKey) return setIsFetchingDbKey(true) @@ -175,14 +273,18 @@ function App(): React.ReactElement { setSelectedContact(contact) const { startTime, endTime } = getDateRangeParams(dateRange) const msgs = await window.api.getMessages(contact.md5, startTime, endTime) - setMessages(msgs) + const snapshot = await logGroupSnapshot(contact, 'select-contact') + currentGroupSnapshotRef.current = snapshot + setMessages(mergeSyntheticMessages(contact, msgs, snapshot?.roomId)) } const handleDateRangeChange = (range: string): void => { setDateRange(range) if (selectedContact) { const { startTime, endTime } = getDateRangeParams(range) - window.api.getMessages(selectedContact.md5, startTime, endTime).then(setMessages) + window.api.getMessages(selectedContact.md5, startTime, endTime).then((nextMessages) => { + setMessages(mergeSyntheticMessages(selectedContact, nextMessages)) + }) } } @@ -201,9 +303,45 @@ function App(): React.ReactElement { range.startTime, range.endTime ) + const latestSnapshot = await logGroupSnapshot(selectedContact, 'wcdb-change') + const syntheticEvents = buildSyntheticGroupMessages( + currentGroupSnapshotRef.current, + latestSnapshot, + latestMessages + ) + if (latestSnapshot) { + currentGroupSnapshotRef.current = latestSnapshot + if (syntheticEvents.length) { + const existing = syntheticGroupMessagesRef.current[latestSnapshot.roomId] || [] + const existingIds = new Set(existing.map((message) => message.id)) + const appended = syntheticEvents.filter((message) => !existingIds.has(message.id)) + if (appended.length) { + syntheticGroupMessagesRef.current[latestSnapshot.roomId] = [...existing, ...appended] + console.log( + `[GroupMonitor] merged synthetic messages roomId=${latestSnapshot.roomId} total=${syntheticGroupMessagesRef.current[latestSnapshot.roomId].length}` + ) + if (!disposed) { + setMessages((current) => + sortMessagesChronologically([ + ...current, + ...appended.filter( + (message) => + !current.some((existingMessage) => existingMessage.id === message.id) + ) + ]) + ) + } + } + } + } + const nextMessages = mergeSyntheticMessages( + selectedContact, + latestMessages, + latestSnapshot?.roomId + ) if (!disposed) { setMessages((current) => - areMessagesEquivalent(current, latestMessages) ? current : latestMessages + areMessagesEquivalent(current, nextMessages) ? current : nextMessages ) } } catch (error) { @@ -224,7 +362,14 @@ function App(): React.ReactElement { if (refreshTimer) window.clearTimeout(refreshTimer) unsubscribe() } - }, [dateRange, isAuthenticated, isNativeMonitorActive, selectedContact]) + }, [ + dateRange, + isAuthenticated, + isNativeMonitorActive, + selectedContact, + logGroupSnapshot, + mergeSyntheticMessages + ]) const handleSearchContacts = (keyword: string): void => { if (!keyword) { diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 74def3c..1b939ad 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -252,6 +252,31 @@ body { justify-content: flex-end; } +.wechat-system-message-row { + display: flex; + flex-direction: column; + align-items: center; + gap: 4px; + margin: 2px 0 6px; +} + +.wechat-system-message { + max-width: min(78%, 760px); + padding: 6px 12px; + border-radius: 999px; + background: rgba(227, 86, 86, 0.1); + color: #c84a4a; + font-size: 12px; + line-height: 1.5; + text-align: center; + word-break: break-word; +} + +.wechat-system-message-meta { + color: #a0a7ab; + font-size: 11px; +} + .message-avatar { width: 38px; height: 38px; diff --git a/src/renderer/src/components/ChatWindow.tsx b/src/renderer/src/components/ChatWindow.tsx index 16330bd..21cf6c0 100644 --- a/src/renderer/src/components/ChatWindow.tsx +++ b/src/renderer/src/components/ChatWindow.tsx @@ -304,6 +304,7 @@ const ChatWindow: React.FC = ({
{filteredMessages.map((msg) => { const isMine = msg.from === 'assistant' + const isSystem = msg.from === 'system' || msg.type === '系统消息' const displayName = isMine ? '我' : isGroupChat @@ -312,7 +313,18 @@ const ChatWindow: React.FC = ({ const avatarSrc = isMine ? msg.img : msg.img || contact.avatar const isVoice = msg.type === '语音' const isImage = msg.type === '图片' - const isRichMedia = ['名片', '位置', '分享消息', '通话', '表情包'].includes(msg.type) + const isRichMedia = ['名片', '位置', '分享消息', '通话', '表情包', '系统消息'].includes( + msg.type + ) + + if (isSystem) { + return ( +
+
{msg.content}
+
{msg.datetime}
+
+ ) + } return (
diff --git a/src/renderer/src/components/RichMessageBubble.tsx b/src/renderer/src/components/RichMessageBubble.tsx index 6b36e68..1739ebc 100644 --- a/src/renderer/src/components/RichMessageBubble.tsx +++ b/src/renderer/src/components/RichMessageBubble.tsx @@ -22,6 +22,8 @@ export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX. return case 'quote': return + case 'system': + return case 'unknown': return (
{(contentData as { raw?: string }).raw || '[未知消息]'}
@@ -212,6 +214,10 @@ function QuoteBubble({ data }: { data: Extract ) } +function SystemBubble({ data }: { data: Extract }): JSX.Element { + return
{data.content || '[系统消息]'}
+} + function getUrlHost(url?: string): string { if (!url) return '' try { diff --git a/src/shared/types.ts b/src/shared/types.ts index 8a60c3a..18f8016 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -66,7 +66,7 @@ type QuoteContent = { quotedSender?: string quotedType?: string } -type SystemContent = { type: 'system'; content: string } +type SystemContent = { type: 'system'; content: string; raw?: string } type UnknownContent = { type: 'unknown'; raw: string } export type ParsedContent =