mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 11:37:06 +08:00
feat: 实现退群监控
This commit is contained in:
+37
-4
@@ -265,19 +265,22 @@ app.whenReady().then(() => {
|
||||
/<appmsg\b|<refermsg\b|<appmsg\b|<refermsg\b/i.test(content)
|
||||
? 49
|
||||
: msgType
|
||||
if ([3, 42, 47, 48, 49, 50].includes(inferredMsgType)) {
|
||||
if ([3, 42, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) {
|
||||
try {
|
||||
const parsed =
|
||||
inferredMsgType === 47
|
||||
? parseStickerMessageFromRow(msg, content)
|
||||
: parseMessageContent(content, inferredMsgType)
|
||||
if (parsed.type !== 'unknown') {
|
||||
if (parsed.type === 'system') {
|
||||
content = parsed.content
|
||||
contentData = parsed
|
||||
} else if (parsed.type !== 'unknown') {
|
||||
content = ''
|
||||
}
|
||||
if (parsed.type === 'image') {
|
||||
const imageDatName = parseImageDatNameFromRow(msg)
|
||||
contentData = { ...parsed, datName: parsed.datName || imageDatName }
|
||||
} else {
|
||||
} else if (parsed.type !== 'system') {
|
||||
if (parsed.type === 'sticker' && !parsed.url && parsed.md5 && wcdb4Client) {
|
||||
parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5)
|
||||
}
|
||||
@@ -313,7 +316,7 @@ 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)
|
||||
|
||||
@@ -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 格式: <img md5="..." aeskey="..."/>
|
||||
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(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1').replace(/<[^>]+>/g, ' ')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function fallbackSystemText(xml: string): string {
|
||||
return normalizeSystemText(
|
||||
decodeXmlEntities(
|
||||
String(xml || '')
|
||||
.replace(/<!\[CDATA\[([\s\S]*?)\]\]>/g, '$1')
|
||||
.replace(/<[^>]+>/g, ' ')
|
||||
)
|
||||
)
|
||||
}
|
||||
|
||||
function extractDelChatroomMemberText(xml: string): string {
|
||||
if (!/<sysmsg[^>]+delchatroommember/i.test(xml)) return ''
|
||||
const plainMatch = /<plain[^>]*><!\[CDATA\[([\s\S]*?)\]\]><\/plain>/i.exec(xml)
|
||||
if (plainMatch?.[1]) return plainMatch[1].trim()
|
||||
const textMatch = /<text[^>]*><!\[CDATA\[([\s\S]*?)\]\]><\/text>/i.exec(xml)
|
||||
if (textMatch?.[1]) return textMatch[1].trim()
|
||||
return ''
|
||||
}
|
||||
|
||||
function normalizeMd5(value: unknown): string | undefined {
|
||||
const md5 = String(value || '')
|
||||
.trim()
|
||||
|
||||
Vendored
+5
@@ -39,6 +39,11 @@ declare global {
|
||||
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||
getContacts: (filter?: string) => Promise<Contact[]>
|
||||
getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise<Message[]>
|
||||
getGroupSnapshot: (userMd5: string) => Promise<{
|
||||
roomId: string
|
||||
memberCount: number
|
||||
members: { wxid: string; nickname: string; avatar: string }[]
|
||||
} | null>
|
||||
search: (keyword: string) => Promise<string | null>
|
||||
aiChat: (
|
||||
messages: { role: string; content: string }[],
|
||||
|
||||
@@ -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 }[],
|
||||
|
||||
+149
-4
@@ -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<GroupSnapshot | null>(null)
|
||||
const syntheticGroupMessagesRef = React.useRef<Record<string, Message[]>>({})
|
||||
|
||||
React.useEffect(() => {
|
||||
let active = true
|
||||
@@ -84,6 +147,41 @@ function App(): React.ReactElement {
|
||||
}
|
||||
}
|
||||
|
||||
const logGroupSnapshot = React.useCallback(
|
||||
async (contact: Contact | null, reason: string): Promise<GroupSnapshot | null> => {
|
||||
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<void> => {
|
||||
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) {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -304,6 +304,7 @@ const ChatWindow: React.FC<ChatWindowProps> = ({
|
||||
<div className="message-list wechat-message-list">
|
||||
{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<ChatWindowProps> = ({
|
||||
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 (
|
||||
<div key={msg.id} className="wechat-system-message-row">
|
||||
<div className="wechat-system-message">{msg.content}</div>
|
||||
<div className="wechat-system-message-meta">{msg.datetime}</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={msg.id} className={`wechat-message-row ${isMine ? 'mine' : 'other'}`}>
|
||||
|
||||
@@ -22,6 +22,8 @@ export function RichMessageBubble({ contentData }: RichMessageBubbleProps): JSX.
|
||||
return <StickerBubble data={contentData} />
|
||||
case 'quote':
|
||||
return <QuoteBubble data={contentData} />
|
||||
case 'system':
|
||||
return <SystemBubble data={contentData} />
|
||||
case 'unknown':
|
||||
return (
|
||||
<div className="message-text">{(contentData as { raw?: string }).raw || '[未知消息]'}</div>
|
||||
@@ -212,6 +214,10 @@ function QuoteBubble({ data }: { data: Extract<ParsedContent, { type: 'quote' }>
|
||||
)
|
||||
}
|
||||
|
||||
function SystemBubble({ data }: { data: Extract<ParsedContent, { type: 'system' }> }): JSX.Element {
|
||||
return <div className="message-text">{data.content || '[系统消息]'}</div>
|
||||
}
|
||||
|
||||
function getUrlHost(url?: string): string {
|
||||
if (!url) return ''
|
||||
try {
|
||||
|
||||
+1
-1
@@ -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 =
|
||||
|
||||
Reference in New Issue
Block a user