diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index cdca514..7f9f0e6 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -16,6 +16,22 @@ type ShareContent = { appname?: string typeVal?: string } +type MiniProgramContent = { + type: 'miniProgram' + title: string + description?: string + appName?: string + iconUrl?: string + thumbMd5?: string + thumbDatName?: string + thumbDataUrl?: string +} +type RedPacketContent = { + type: 'redPacket' + title: string + description?: string + url?: string +} type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number } type ImageContent = { type: 'image' @@ -74,6 +90,8 @@ export type ParsedContent = | LocationContent | CardContent | ShareContent + | MiniProgramContent + | RedPacketContent | VoipContent | ImageContent | VideoContent @@ -413,6 +431,31 @@ function parseShareMessage(content: string): ParsedContent { } } + if (appMsgType === '33' || appMsgType === '36') { + return { + type: 'miniProgram', + title: extractXmlValue(content, 'title') || '小程序', + description: extractXmlValue(content, 'des') || undefined, + appName: + extractXmlValue(content, 'sourcedisplayname') || + extractXmlValue(content, 'appname') || + '小程序', + iconUrl: decodeXmlUrl(extractXmlValue(content, 'weappiconurl')) || undefined, + thumbMd5: normalizeMd5( + extractXmlValue(content, 'cdnthumbmd5') || extractXmlValue(content, 'md5') + ) + } + } + + if (appMsgType === '2001') { + return { + type: 'redPacket', + title: extractXmlValue(content, 'title') || '微信红包', + description: extractXmlValue(content, 'des') || '恭喜发财,大吉大利', + url: decodeXmlUrl(extractXmlValue(content, 'url')) || undefined + } + } + const title = extractXmlValue(content, 'title') || '' const des = extractXmlValue(content, 'des') || extractXmlValue(content, 'desc') || '' const url = extractXmlValue(content, 'url') || '' @@ -485,6 +528,10 @@ function extractAppMsgType(content: string): string { const inner = appmsgMatch[1] .replace(//gi, '') .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') + .replace(//gi, '') const typeMatch = /([\s\S]*?)<\/type>/i.exec(inner) return typeMatch?.[1]?.trim() || '' } @@ -666,6 +713,71 @@ export function parseImageDatNameFromRow(row: Record): string | return hexMatch?.[1]?.toLowerCase() } +export function parseImageBufferDataUrlFromRow( + row: Record +): string | undefined { + const raw = pickRowString(row, [ + 'ImgBuf', + 'imgBuf', + 'img_buf', + 'imageBuffer', + 'image_buffer', + 'thumbBuffer', + 'thumb_buffer', + 'WCDB_CT_img_buf', + 'WCDB_CT_ImgBuf' + ]) + const buffer = decodeInlineImageBuffer(raw) + if (!buffer || buffer.length === 0) return undefined + const mime = detectImageMime(buffer) + return mime ? `data:${mime};base64,${buffer.toString('base64')}` : undefined +} + +function decodeInlineImageBuffer(raw: unknown): Buffer | null { + if (!raw) return null + if (Buffer.isBuffer(raw)) return raw + if (raw instanceof Uint8Array) return Buffer.from(raw) + if (Array.isArray(raw)) return Buffer.from(raw) + if (typeof raw === 'object') { + const record = raw as { buffer?: unknown; data?: unknown } + return decodeInlineImageBuffer(record.buffer ?? record.data) + } + if (typeof raw !== 'string') return null + const value = raw.trim() + const dataUrl = /^data:image\/[a-z0-9.+-]+;base64,(.+)$/i.exec(value) + const encoded = dataUrl?.[1] || value + if (!/^[a-z0-9+/]+={0,2}$/i.test(encoded)) return null + try { + return Buffer.from(encoded, 'base64') + } catch { + return null + } +} + +function detectImageMime(buffer: Buffer): string | undefined { + if (buffer.length >= 3 && buffer[0] === 0xff && buffer[1] === 0xd8 && buffer[2] === 0xff) { + return 'image/jpeg' + } + if ( + buffer.length >= 8 && + buffer[0] === 0x89 && + buffer.subarray(1, 4).toString('ascii') === 'PNG' + ) { + return 'image/png' + } + if (buffer.length >= 6 && /^GIF8[79]a$/.test(buffer.subarray(0, 6).toString('ascii'))) { + return 'image/gif' + } + if ( + buffer.length >= 12 && + buffer.subarray(0, 4).toString('ascii') === 'RIFF' && + buffer.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + return 'image/webp' + } + return undefined +} + function pickRowString(row: Record, keys: string[]): unknown { for (const key of keys) { if (Object.prototype.hasOwnProperty.call(row, key)) return row[key] diff --git a/src/main/services/bootstrap-cache.ts b/src/main/services/bootstrap-cache.ts index 863cb4a..9ae74b1 100644 --- a/src/main/services/bootstrap-cache.ts +++ b/src/main/services/bootstrap-cache.ts @@ -155,6 +155,27 @@ function cachedMessageIdentity(message: Message): string { return `id:${message.id}` } +function containsLegacyMisparsedAppMessage(items: Message[]): boolean { + return items.some((message) => { + const content = message.contentData + if (content?.type === 'system' && content.raw) { + return ( + /\s*(?:33|36|2001)\s*<\/type>/i.test(content.raw) + ) + } + if ( + content?.type === 'share' && + ((content.typeVal === '3' && !content.url) || content.typeVal === '2001') + ) { + return true + } + if (content?.type !== 'sticker') return false + const url = String(content.url || content.thumbUrl || '') + return /wxapp\.tenpay\.com\/mmpayhb|mp\.weixin\.qq\.com\/mp\/waerrpage/i.test(url) + }) +} + function pruneMessageBuckets(messages: Record): void { const entries = Object.entries(messages) if (entries.length <= MAX_MESSAGE_BUCKETS) return @@ -315,7 +336,7 @@ export function getCachedMessagePage( } } return { - hit: Boolean(bucket), + hit: Boolean(bucket) && !containsLegacyMisparsedAppMessage(bucket?.items || []), messages: bucket?.items || [], groupSnapshot: cache?.groupSnapshots?.[userMd5]?.snapshot } diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index ae1d831..dba70dc 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -1,5 +1,6 @@ import { WechatDb, WechatMessage } from '../wechat-db' import { + parseImageBufferDataUrlFromRow, parseImageDatNameFromRow, parseMessageContent, parseStickerMessageFromRow @@ -235,7 +236,10 @@ function listSourceMessages( let contentData: ReturnType | undefined let displayType = MSG_TYPE_DICT[msgType] || msg.messageType - const isPatMessage = /\s*62\s*<\/type>/i.test(rawContent) || + ([10000, 10002].includes(msgType) && /拍了拍/i.test(rawContent)) if (isPatMessage) { const system = parseMessageContent(content, 10000) const patContent = @@ -254,27 +258,40 @@ function listSourceMessages( if (!isPatMessage && [3, 42, 43, 47, 48, 49, 50, 10000, 10002].includes(inferredMsgType)) { try { const isQuotePayload = / Boolean(msg[key])) + /\s*47\s*<\/type>/i.test(content) const rowSticker = - inferredMsgType === 47 || (inferredMsgType === 49 && !isQuotePayload && hasStickerHints) + inferredMsgType === 47 || + (inferredMsgType === 49 && !isQuotePayload && hasStickerPayload) ? parseStickerMessageFromRow(msg, content) : undefined - const parsed = - rowSticker?.type === 'sticker' - ? rowSticker - : parseMessageContent(content, inferredMsgType) + const parsedContent = parseMessageContent(content, inferredMsgType) + const rowStickerUrl = rowSticker?.type === 'sticker' ? String(rowSticker.url || '') : '' + const parsedShareUrl = parsedContent.type === 'share' ? parsedContent.url : '' + const redPacketUrl = rowStickerUrl || parsedShareUrl + const isRedPacketFallback = + (parsedContent.type === 'share' && parsedContent.typeVal === '2001') || + /wxapp\.tenpay\.com\/mmpayhb/i.test(redPacketUrl) + const parsed: ReturnType = + parsedContent.type === 'miniProgram' || parsedContent.type === 'redPacket' + ? parsedContent + : isRedPacketFallback + ? { + type: 'redPacket', + title: + parsedContent.type === 'share' && parsedContent.title + ? parsedContent.title + : '微信红包', + description: + parsedContent.type === 'share' && parsedContent.des + ? parsedContent.des + : '恭喜发财,大吉大利', + url: redPacketUrl || undefined + } + : rowSticker?.type === 'sticker' + ? rowSticker + : parsedContent if (parsed.type === 'system') { content = parsed.content contentData = parsed @@ -284,6 +301,13 @@ function listSourceMessages( if (parsed.type === 'image') { const imageDatName = parseImageDatNameFromRow(msg) contentData = { ...parsed, datName: parsed.datName || imageDatName } + } else if (parsed.type === 'miniProgram') { + contentData = { + ...parsed, + thumbDatName: parsed.thumbDatName || parseImageDatNameFromRow(msg), + thumbDataUrl: + parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg) + } } else if (parsed.type !== 'system') { if (parsed.type === 'sticker' && !parsed.url && parsed.md5) { parsed.url = wcdb4Client.resolveEmoticonCdnUrl(parsed.md5) @@ -295,6 +319,15 @@ function listSourceMessages( } if (parsed.type === 'quote') displayType = '引用消息' if (parsed.type === 'sticker') displayType = '表情包' + if (parsed.type === 'miniProgram') displayType = '小程序' + if (parsed.type === 'redPacket') displayType = '微信红包' + if (parsed.type === 'share') { + if (parsed.typeVal === '5') displayType = '公众号链接' + if (parsed.typeVal === '6') displayType = '文件' + if (parsed.typeVal === '74') displayType = '文件发送中' + if (parsed.typeVal === '51') displayType = '视频号' + if (parsed.typeVal === '2000') displayType = '转账' + } } catch { // ignore parse errors } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index 4df09df..8f32c6f 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -46,7 +46,25 @@ export type ParsedContent = | { type: 'voice'; duration?: number } | { type: 'location'; poiname?: string; label?: string; lat: number; lng: number } | { type: 'card'; username: string; nickname: string; avatarUrl?: string } - | { type: 'share'; title: string; des?: string; url: string; appname?: string; type?: string } + | { + type: 'share' + title: string + des?: string + url: string + appname?: string + typeVal?: string + } + | { + type: 'miniProgram' + title: string + description?: string + appName?: string + iconUrl?: string + thumbMd5?: string + thumbDatName?: string + thumbDataUrl?: string + } + | { type: 'redPacket'; title: string; description?: string; url?: string } | { type: 'voip'; duration?: number; status: string; roomType?: number } | { type: 'image'; md5?: string; datName?: string; aeskey?: string; encrypVer?: number } | { diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 4dc381d..a351874 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -263,6 +263,7 @@ function App(): React.ReactElement { const messageHistoryRef = React.useRef([]) const messagesRef = React.useRef([]) const messagePrefetchRef = React.useRef | null>(null) + const pendingLiveRefreshMd5Ref = React.useRef('') messagesRef.current = messages React.useEffect(() => { if (!reportNotice) return @@ -922,6 +923,7 @@ function App(): React.ReactElement { storeGroupMemberMeta(contact, cachedPage.groupSnapshot) } messageHistoryRef.current = cachedMsgs + pendingLiveRefreshMd5Ref.current = cachedPage.hit ? '' : contact.md5 setMessages( applyGroupMemberMeta( contact, @@ -929,7 +931,9 @@ function App(): React.ReactElement { ) ) const needsLivePage = - forceLive || (isDatabaseConnected && cachedMsgs.length < MESSAGE_PREFETCH_COUNT) + forceLive || + (isDatabaseConnected && + (!cachedPage.hit || cachedMsgs.length < MESSAGE_PREFETCH_COUNT)) if (!needsLivePage) { setIsMessagesLoading(false) if (contact.type === 'group' && cachedMsgs.length > 0 && !cachedPage.groupSnapshot) { @@ -955,6 +959,7 @@ function App(): React.ReactElement { }) if (selectedContactMd5Ref.current !== contact.md5) return messageHistoryRef.current = msgs + pendingLiveRefreshMd5Ref.current = '' const visibleMessages = applyGroupMemberMeta( contact, mergeSyntheticMessages(contact, msgs.slice(-INITIAL_MESSAGE_COUNT)) @@ -986,7 +991,9 @@ function App(): React.ReactElement { } React.useEffect(() => { - if (!isDatabaseConnected || !selectedContact || messages.length > 0) return + if (!isDatabaseConnected || !selectedContact) return + const needsPendingRefresh = pendingLiveRefreshMd5Ref.current === selectedContact.md5 + if (messages.length > 0 && !needsPendingRefresh) return // The first live page uses async native cursors, so a cache miss can be filled // without blocking the Electron main thread. void handleSelectContact(selectedContact) diff --git a/src/renderer/src/assets/main.css b/src/renderer/src/assets/main.css index 3f02aa3..9556804 100644 --- a/src/renderer/src/assets/main.css +++ b/src/renderer/src/assets/main.css @@ -2837,6 +2837,143 @@ body { text-overflow: ellipsis; } +.mini-program-message { + width: min(280px, 48vw); + overflow: hidden; + color: var(--wxex-text-primary); +} + +.mini-program-title { + margin-bottom: 8px; + font-size: 14px; + line-height: 20px; + word-break: break-word; +} + +.mini-program-description { + margin: -4px 0 8px; + color: var(--wxex-text-secondary); + font-size: 12px; + line-height: 18px; +} + +.mini-program-preview .image-bubble, +.mini-program-preview .image-content { + width: 100%; + max-width: 100%; + max-height: 220px; + border-radius: 4px; +} + +.mini-program-preview .image-content { + object-fit: cover; +} + +.mini-program-inline-image { + display: block; + width: 100%; + max-height: 220px; + border-radius: 4px; + object-fit: cover; + cursor: zoom-in; +} + +.mini-program-preview .image-content.image-fallback { + width: 64px; + height: 64px; + margin: 28px auto; + border-radius: 8px; + object-fit: cover; +} + +.mini-program-preview .image-actions { + display: none; +} + +.mini-program-icon { + display: block; + width: 52px; + height: 52px; + margin: 8px 0; + border-radius: 6px; + object-fit: cover; +} + +.mini-program-footer { + display: flex; + align-items: center; + gap: 5px; + margin-top: 8px; + padding-top: 7px; + border-top: 1px solid var(--wxex-border); + color: var(--wxex-text-muted); + font-size: 11px; + line-height: 16px; +} + +.red-packet-message { + width: min(280px, 48vw); + overflow: hidden; + border-radius: 6px; + background: #fa9d3b; + color: #fff; +} + +.red-packet-message.is-clickable { + cursor: pointer; +} + +.red-packet-main { + display: flex; + align-items: center; + gap: 12px; + min-height: 72px; + padding: 12px 14px; +} + +.red-packet-icon { + display: grid; + width: 36px; + height: 36px; + flex: 0 0 36px; + place-items: center; + border: 2px solid rgba(255, 239, 170, 0.92); + border-radius: 50%; + color: #fff1a8; + font-size: 18px; + font-weight: 700; +} + +.red-packet-copy { + display: flex; + min-width: 0; + flex-direction: column; + gap: 2px; +} + +.red-packet-copy strong { + overflow: hidden; + font-size: 15px; + font-weight: 500; + line-height: 21px; + text-overflow: ellipsis; + white-space: nowrap; +} + +.red-packet-copy small { + color: rgba(255, 255, 255, 0.82); + font-size: 12px; + line-height: 18px; +} + +.red-packet-footer { + padding: 5px 14px; + background: #fff; + color: var(--wxex-text-muted); + font-size: 11px; + line-height: 16px; +} + .voip-message { display: flex; align-items: center; diff --git a/src/renderer/src/components/ImageBubble.tsx b/src/renderer/src/components/ImageBubble.tsx index 381430c..075d474 100644 --- a/src/renderer/src/components/ImageBubble.tsx +++ b/src/renderer/src/components/ImageBubble.tsx @@ -45,6 +45,7 @@ interface ImageBubbleProps { imageDatName?: string sessionId?: string isThumb?: boolean + fallbackUrl?: string onImageClick?: (imageUrl: string) => void } @@ -53,6 +54,7 @@ export function ImageBubble({ imageDatName, sessionId, isThumb = false, + fallbackUrl, onImageClick }: ImageBubbleProps): JSX.Element { const initialCachedImage = getCachedImage(imageMd5, imageDatName) @@ -61,11 +63,18 @@ export function ImageBubble({ const [upgrading, setUpgrading] = useState(false) const [error, setError] = useState(null) const [isThumbnail, setIsThumbnail] = useState(Boolean(initialCachedImage?.isThumbnail)) + const [usingFallback, setUsingFallback] = useState(false) const containerRef = useRef(null) const loadImage = useCallback(async () => { if (imageUrl || loading) return if (!imageMd5 && !imageDatName) { + if (fallbackUrl) { + setImageUrl(fallbackUrl) + setUsingFallback(true) + setError(null) + return + } setError('缺少图片标识') return } @@ -79,17 +88,30 @@ export function ImageBubble({ isThumbnail: Boolean(result.isThumb) }) setImageUrl(result.data) + setUsingFallback(false) setIsThumbnail(Boolean(result.isThumb)) setError(null) } else { - setError(result.error || '加载图片失败') + if (fallbackUrl) { + setImageUrl(fallbackUrl) + setUsingFallback(true) + setError(null) + } else { + setError(result.error || '加载图片失败') + } } } catch { - setError('加载图片失败') + if (fallbackUrl) { + setImageUrl(fallbackUrl) + setUsingFallback(true) + setError(null) + } else { + setError('加载图片失败') + } } finally { setLoading(false) } - }, [imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading]) + }, [fallbackUrl, imageMd5, imageDatName, sessionId, isThumb, imageUrl, loading]) useEffect(() => { if (imageUrl || loading || error) return @@ -141,6 +163,7 @@ export function ImageBubble({ isThumbnail: Boolean(result.isThumb) }) setImageUrl(result.data) + setUsingFallback(false) setIsThumbnail(Boolean(result.isThumb)) setError(null) onImageClick?.(result.data) @@ -184,7 +207,11 @@ export function ImageBubble({ return (
- 图片 + 图片 {(upgrading || isThumbnail) && (
{upgrading ? '正在查找原图' : '缩略图'}
)} diff --git a/src/renderer/src/components/RichMessageBubble.tsx b/src/renderer/src/components/RichMessageBubble.tsx index 588903c..b09ab57 100644 --- a/src/renderer/src/components/RichMessageBubble.tsx +++ b/src/renderer/src/components/RichMessageBubble.tsx @@ -24,6 +24,16 @@ export function RichMessageBubble({ return case 'share': return + case 'miniProgram': + return ( + + ) + case 'redPacket': + return case 'voip': return case 'sticker': @@ -117,6 +127,83 @@ function ShareBubble({ data }: { data: Extract ) } +function MiniProgramBubble({ + data, + sessionId, + onImageClick +}: { + data: Extract + sessionId?: string + onImageClick?: (imageUrl: string) => void +}): JSX.Element { + return ( +
+
{data.title}
+ {data.description &&
{data.description}
} + {data.thumbDataUrl ? ( +
+ {data.title} onImageClick?.(data.thumbDataUrl || '')} + /> +
+ ) : data.thumbMd5 ? ( +
+ +
+ ) : data.iconUrl ? ( + + ) : null} +
+ + {data.appName || '小程序'} +
+
+ ) +} + +function RedPacketBubble({ + data +}: { + data: Extract +}): JSX.Element { + const handleClick = (): void => { + if (data.url) window.open(data.url, '_blank') + } + + return ( +
+
+ + ¥ + + + {data.title} + {data.description || '恭喜发财,大吉大利'} + +
+
微信红包
+
+ ) +} + function VoipBubble({ data }: { data: Extract }): JSX.Element { const { status, roomType, duration } = data const isVideo = roomType === 1 diff --git a/src/renderer/src/components/chat/MessageBubble.tsx b/src/renderer/src/components/chat/MessageBubble.tsx index 34d3e37..5581024 100644 --- a/src/renderer/src/components/chat/MessageBubble.tsx +++ b/src/renderer/src/components/chat/MessageBubble.tsx @@ -16,7 +16,22 @@ interface MessageBubbleProps { onImageClick: (imageUrl: string) => void } -const RICH_MESSAGE_TYPES = ['名片', '位置', '分享消息', '引用消息', '通话', '表情包', '系统消息'] +const RICH_MESSAGE_TYPES = [ + '名片', + '位置', + '分享消息', + '小程序', + '微信红包', + '公众号链接', + '文件', + '文件发送中', + '视频号', + '转账', + '引用消息', + '通话', + '表情包', + '系统消息' +] export function MessageBubble({ message, diff --git a/src/shared/types.ts b/src/shared/types.ts index ff38079..04339f4 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -53,6 +53,22 @@ type ShareContent = { appname?: string typeVal?: string } +type MiniProgramContent = { + type: 'miniProgram' + title: string + description?: string + appName?: string + iconUrl?: string + thumbMd5?: string + thumbDatName?: string + thumbDataUrl?: string +} +type RedPacketContent = { + type: 'redPacket' + title: string + description?: string + url?: string +} type VoipContent = { type: 'voip'; duration?: number; status: string; roomType?: number } type ImageContent = { type: 'image' @@ -111,6 +127,8 @@ export type ParsedContent = | LocationContent | CardContent | ShareContent + | MiniProgramContent + | RedPacketContent | VoipContent | ImageContent | VideoContent