diff --git a/package.json b/package.json index bea3d25..f31fe44 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,7 @@ "start": "electron-vite preview", "dev": "node scripts/ensure-env.cjs && node scripts/build-wechat-connector.cjs && electron-vite dev", "test:wechat-connector": "go -C services/wechat-connector test ./... && go -C services/wechat-connector vet ./...", + "test:stability": "node --experimental-strip-types --test tests/stability-compat.test.mjs", "build:wechat-connector": "node scripts/build-wechat-connector.cjs", "build:wechat-connector:win": "node scripts/build-wechat-connector.cjs --platform win32 --arch x64,arm64", "build:wechat-connector:mac": "node scripts/build-wechat-connector.cjs --platform darwin --arch x64,arm64", diff --git a/src/main/index.ts b/src/main/index.ts index 7a33d62..fa904c6 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -125,10 +125,7 @@ const COLD_IMAGE_LOAD_GAP_MS = 100 const MAX_CONCURRENT_COLD_IMAGE_LOADS = 2 function pumpColdImageLoads(): void { - if ( - activeColdImageLoads >= MAX_CONCURRENT_COLD_IMAGE_LOADS || - coldImageLoadQueue.length === 0 - ) { + if (activeColdImageLoads >= MAX_CONCURRENT_COLD_IMAGE_LOADS || coldImageLoadQueue.length === 0) { return } @@ -768,8 +765,8 @@ app.whenReady().then(async () => { return contacts }) - ipcMain.handle('db:getContactAvatars', (_, usernames: string[]) => { - const avatars = chat.getContactAvatars(usernames) + ipcMain.handle('db:getContactAvatars', async (_, usernames: string[]) => { + const avatars = await chat.getContactAvatars(usernames) if (chat.isReady()) mergeBootstrapAvatars(chat.getCurrentAccountRoot(), avatars) return avatars }) diff --git a/src/main/message-parser.ts b/src/main/message-parser.ts index 7f9f0e6..5421a2a 100644 --- a/src/main/message-parser.ts +++ b/src/main/message-parser.ts @@ -16,6 +16,19 @@ type ShareContent = { appname?: string typeVal?: string } +type ForwardedMessageItem = { + messageType: number + sender?: string + sentAt?: string + text: string + nested?: ForwardedMessageItem[] +} +type ForwardBundleContent = { + type: 'forwardBundle' + title: string + description?: string + items: ForwardedMessageItem[] +} type MiniProgramContent = { type: 'miniProgram' title: string @@ -82,7 +95,7 @@ type SystemContent = { recallTime?: number } } -type UnknownContent = { type: 'unknown'; raw: string } +type UnknownContent = { type: 'unknown'; raw: string; messageType?: string | number } export type ParsedContent = | TextContent @@ -90,6 +103,7 @@ export type ParsedContent = | LocationContent | CardContent | ShareContent + | ForwardBundleContent | MiniProgramContent | RedPacketContent | VoipContent @@ -108,6 +122,10 @@ export function parseMessageContent(content: string, messageType: number): Parse const normalized = content.trim() switch (messageType) { + case 1: + return { type: 'text', content: normalized } + case 34: + return { type: 'voice' } case 3: return parseImageMessage(normalized) case 42: @@ -126,7 +144,7 @@ export function parseMessageContent(content: string, messageType: number): Parse case 10002: return parseSystemMessage(normalized) default: - return { type: 'text', content: normalized } + return { type: 'unknown', raw: normalized, messageType } } } @@ -412,6 +430,9 @@ function parseLocationMessage(content: string): ParsedContent { function parseShareMessage(content: string): ParsedContent { const appMsgType = extractAppMsgType(content) + if (appMsgType === '19' || /]*>([\s\S]*?)<\/recorditem>/gi), + (match) => match[1] || '' + ) + const sources = containers.length ? containers : [normalized] + const items = dedupeForwardedItems(sources.flatMap((source) => parseForwardedItems(source))) + return { type: 'forwardBundle', title, description, items } +} + +function parseForwardedItems(container: string, depth = 0): ForwardedMessageItem[] { + if (!container || depth > 4) return [] + const variants = new Set([container, decodeXmlEntities(container)]) + for (const match of container.matchAll(//g)) { + if (match[1]) variants.add(decodeXmlEntities(match[1])) + } + + const items: ForwardedMessageItem[] = [] + for (const variant of variants) { + for (const match of variant.matchAll(/]*)>([\s\S]*?)<\/dataitem>/gi)) { + const attributes = match[1] || '' + const body = match[2] || '' + const attrType = /datatype\s*=\s*["']?(\d+)/i.exec(attributes)?.[1] + const messageType = Number.parseInt(attrType || extractXmlValue(body, 'datatype') || '0', 10) + const sender = decodeXmlEntities(extractXmlValue(body, 'sourcename')) || undefined + const sentAt = extractXmlValue(body, 'sourcetime') || undefined + const title = decodeXmlEntities(extractXmlValue(body, 'datatitle')) + const description = decodeXmlEntities( + extractXmlValue(body, 'datadesc') || extractXmlValue(body, 'content') + ) + const nestedXml = extractXmlBody(body, 'recordxml') + const nested = + messageType === 17 && nestedXml + ? parseForwardedItems(decodeXmlEntities(nestedXml), depth + 1) + : undefined + const text = description || title || forwardedTypeLabel(messageType) + if (!sender && !text && !nested?.length) continue + items.push({ + messageType: Number.isFinite(messageType) ? messageType : 0, + sender, + sentAt, + text: text || '[消息]', + nested: nested?.length ? nested : undefined + }) + } + } + return dedupeForwardedItems(items) +} + +function dedupeForwardedItems(items: ForwardedMessageItem[]): ForwardedMessageItem[] { + const seen = new Set() + return items.filter((item) => { + const key = `${item.messageType}|${item.sender || ''}|${item.sentAt || ''}|${item.text}` + if (seen.has(key)) return false + seen.add(key) + return true + }) +} + +function forwardedTypeLabel(messageType: number): string { + switch (messageType) { + case 3: + return '[图片]' + case 34: + return '[语音]' + case 43: + return '[视频]' + case 47: + return '[表情包]' + case 8: + case 49: + return '[文件或分享]' + case 17: + return '[聊天记录]' + default: + return '[消息]' + } +} + +function extractXmlBody(xml: string, tagName: string): string { + const match = new RegExp(`<${tagName}[^>]*>([\\s\\S]*?)<\\/${tagName}>`, 'i').exec(xml) + if (!match?.[1]) return '' + return match[1].replace(/^$/, '$1').trim() +} + function parseQuoteMessage(content: string): { content?: string sender?: string @@ -713,9 +822,7 @@ export function parseImageDatNameFromRow(row: Record): string | return hexMatch?.[1]?.toLowerCase() } -export function parseImageBufferDataUrlFromRow( - row: Record -): string | undefined { +export function parseImageBufferDataUrlFromRow(row: Record): string | undefined { const raw = pickRowString(row, [ 'ImgBuf', 'imgBuf', diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index ecd5e68..10c4686 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -37,6 +37,8 @@ export interface FormattedContact { avatar?: string wechatNickname?: string remark?: string + isFolded?: boolean + isMuted?: boolean } export interface FormattedMessage { @@ -140,7 +142,9 @@ export function listContacts(filter?: string): FormattedContact[] { type: isGroup ? 'group' : 'user', avatar: typeof user.avatar === 'string' ? user.avatar : undefined, wechatNickname: user.wechatNickname, - remark: user.remark + remark: user.remark, + isFolded: user.isFolded, + isMuted: user.isMuted }) } @@ -174,17 +178,20 @@ export function listContacts(filter?: string): FormattedContact[] { export async function listContactsAsync(filter?: string): Promise { if (!dbRef) return [] - await dbRef.getWcdb4Client().getSessionsAsync({ hydrateDisplayNames: false }) + await dbRef.getWcdb4Client().getSessionsAsync({ + hydrateDisplayNames: false, + hydrateStatuses: true + }) return listContacts(filter) } -export function getContactAvatars(usernames: string[]): Record { +export async function getContactAvatars(usernames: string[]): Promise> { if (!dbRef) return {} const normalized = Array.from( new Set((usernames || []).map((username) => String(username || '').trim()).filter(Boolean)) ) if (normalized.length === 0) return {} - return dbRef.getWcdb4Client().getAvatarUrls(normalized) + return dbRef.getWcdb4Client().getAvatarUrlsAsync(normalized) } function listSourceMessages( @@ -251,7 +258,13 @@ function listSourceMessages( const patContent = system.type === 'system' ? { ...system, pat: true } - : { type: 'system' as const, content: String(content || '').replace(/<[^>]+>/g, '').trim(), pat: true } + : { + type: 'system' as const, + content: String(content || '') + .replace(/<[^>]+>/g, '') + .trim(), + pat: true + } contentData = patContent content = patContent.content displayType = '系统消息' @@ -265,11 +278,9 @@ function listSourceMessages( try { const isQuotePayload = /\s*47\s*<\/type>/i.test(content) + /<(?:emoji|sticker|emoticon)\b/i.test(content) || /\s*47\s*<\/type>/i.test(content) const rowSticker = - inferredMsgType === 47 || - (inferredMsgType === 49 && !isQuotePayload && hasStickerPayload) + inferredMsgType === 47 || (inferredMsgType === 49 && !isQuotePayload && hasStickerPayload) ? parseStickerMessageFromRow(msg, content) : undefined const parsedContent = parseMessageContent(content, inferredMsgType) @@ -301,7 +312,7 @@ function listSourceMessages( if (parsed.type === 'system') { content = parsed.content contentData = parsed - } else if (parsed.type !== 'unknown') { + } else { content = '' } if (parsed.type === 'image') { @@ -311,8 +322,7 @@ function listSourceMessages( contentData = { ...parsed, thumbDatName: parsed.thumbDatName || parseImageDatNameFromRow(msg), - thumbDataUrl: - parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg) + thumbDataUrl: parsed.thumbDataUrl || parseImageBufferDataUrlFromRow(msg.raw || msg) } } else if (parsed.type !== 'system') { if (parsed.type === 'sticker' && !parsed.url && parsed.md5) { @@ -327,6 +337,11 @@ function listSourceMessages( if (parsed.type === 'sticker') displayType = '表情包' if (parsed.type === 'miniProgram') displayType = '小程序' if (parsed.type === 'redPacket') displayType = '微信红包' + if (parsed.type === 'forwardBundle') displayType = '合并转发' + if (parsed.type === 'unknown') { + displayType = '不支持的消息' + contentData = { ...parsed, messageType: msgType } + } if (parsed.type === 'share') { if (parsed.typeVal === '5') displayType = '公众号链接' if (parsed.typeVal === '6') displayType = '文件' @@ -351,6 +366,12 @@ function listSourceMessages( } } + if (!contentData && !MSG_TYPE_DICT[msgType] && msgType !== 0) { + contentData = { type: 'unknown', raw: rawContent, messageType: msgType } + content = '' + displayType = '不支持的消息' + } + if (msgType === 34) content = '[语音消息]' const recoveredFromRecallJournal = Boolean(msg['_wxe_recovered'] || msg.raw?.['_wxe_recovered']) @@ -403,13 +424,7 @@ export async function listMessagesAsync( ): Promise { if (!dbRef) return [] const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options) - const sourceMessages = listSourceMessages( - userMd5, - startTime, - endTime, - options, - rawMessages - ) + const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options, rawMessages) const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || '' recordRecallArchiveMessages(userMd5, username, sourceMessages) return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit) diff --git a/src/main/sticker-service.ts b/src/main/sticker-service.ts index 3a4d943..6bb79da 100644 --- a/src/main/sticker-service.ts +++ b/src/main/sticker-service.ts @@ -5,8 +5,15 @@ import https from 'https' import os from 'os' import path from 'path' import { Wcdb4Client } from './wcdb4-client' +import { classifyStickerHttpFailure, StickerFailureCode } from '../shared/sticker' -type StickerResult = { success: boolean; data?: string; error?: string } +type StickerResult = { + success: boolean + data?: string + error?: string + failureCode?: StickerFailureCode + httpStatus?: number +} const downloadCache = new Map>() @@ -129,15 +136,24 @@ export class StickerService { const redirectUrl = response.headers.location if (redirectUrl && [301, 302, 303, 307, 308].includes(Number(response.statusCode || 0))) { const nextUrl = new URL(redirectUrl, url).toString() + response.resume() this.downloadToDataUrl(nextUrl, cacheKey, redirectCount + 1).then(resolve) return } if (response.statusCode !== 200) { + const statusCode = Number(response.statusCode || 0) + const failure = classifyStickerHttpFailure(statusCode, url) + response.resume() console.warn( - `[StickerService] download failed: HTTP ${response.statusCode}; md5=${cacheKey}; url=${url}` + `[StickerService] download failed code=${failure.code} status=${statusCode} md5=${cacheKey} host=${this.getUrlHost(url)}` ) - resolve({ success: false, error: `表情包下载失败: HTTP ${response.statusCode}` }) + resolve({ + success: false, + error: failure.message, + failureCode: failure.code, + httpStatus: statusCode + }) return } @@ -198,6 +214,14 @@ export class StickerService { } } + private getUrlHost(url: string): string { + try { + return new URL(url).hostname || 'unknown' + } catch { + return 'unknown' + } + } + private toDataUrl(buffer: Buffer, ext: string): string { const mimeTypes: Record = { '.gif': 'image/gif', diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index a48c863..c8ce8a5 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -12,6 +12,8 @@ export interface Wcdb4Session { avatar?: string wechatNickname?: string remark?: string + isFolded?: boolean + isMuted?: boolean raw: Record } @@ -34,6 +36,7 @@ export interface Wcdb4MessageQueryOptions { export interface Wcdb4SessionQueryOptions { hydrateDisplayNames?: boolean + hydrateStatuses?: boolean } type Wcdb4MessageStore = { @@ -177,9 +180,12 @@ export function bootstrapWcdbNativeAsync( ) => number const resourceRoots = Array.from( new Set( - [libDir, path.dirname(libDir), process.env.WCDB_RESOURCES_PATH || '', ...getResourceRoots()].filter( - Boolean - ) + [ + libDir, + path.dirname(libDir), + process.env.WCDB_RESOURCES_PATH || '', + ...getResourceRoots() + ].filter(Boolean) ) ) let initOk = false @@ -242,12 +248,15 @@ export class Wcdb4Client { private handle: number | null = null private displayNameCache = new Map() private avatarCache = new Map() + private sessionStatusCache = new Map() private groupNicknameCache = new Map>() private cachedSessions: Wcdb4Session[] | null = null private cachedChatTables: { name: string; db_number: string }[] | null = null private sessionsInFlight: Promise | null = null private sessionDisplayNamesInFlight: Promise | null = null private sessionDisplayNamesHydrated = false + private sessionStatusesInFlight: Promise | null = null + private sessionStatusesUpdatedAt = 0 private sessionCacheGeneration = 0 private wcdbShutdown: (() => number) | null = null @@ -276,6 +285,12 @@ export class Wcdb4Client { private wcdbGetAvatarUrls: | ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number) | null = null + private wcdbGetContactStatus: + | ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number) + | null = null + private wcdbGetHeadImageBuffers: + | ((handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number) + | null = null private wcdbExecQuery: | ((handle: number, kind: string, dbPath: string, sql: string, outJson: WcdbVoidOut) => number) | null = null @@ -565,8 +580,11 @@ export class Wcdb4Client { this.handle = null this.cachedSessions = null this.sessionDisplayNamesHydrated = false + this.sessionStatusesInFlight = null + this.sessionStatusesUpdatedAt = 0 this.displayNameCache.clear() this.avatarCache.clear() + this.sessionStatusCache.clear() this.groupNicknameCache.clear() } @@ -720,16 +738,11 @@ export class Wcdb4Client { .map((row) => this.normalizeSession(row)) .filter((session) => session.username) - this.hydrateDisplayNames( - sessions - .filter((session) => this.shouldHydrateSessionDisplayName(session)) - .map((session) => session.username) - ) this.cachedSessions = sessions.map((session) => ({ ...session, nickname: this.displayNameCache.get(session.username) || session.nickname || session.username })) - this.sessionDisplayNamesHydrated = true + this.sessionDisplayNamesHydrated = false return this.cachedSessions } @@ -738,11 +751,13 @@ export class Wcdb4Client { const hydrateDisplayNames = options.hydrateDisplayNames !== false if (this.cachedSessions) { if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync() + if (options.hydrateStatuses) await this.refreshSessionStatusesAsync() return this.cachedSessions } if (this.sessionsInFlight) { await this.sessionsInFlight if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync() + if (options.hydrateStatuses) await this.refreshSessionStatusesAsync() return this.cachedSessions || [] } if (!this.wcdbGetSessions) return [] @@ -765,9 +780,59 @@ export class Wcdb4Client { if (this.sessionsInFlight === request) this.sessionsInFlight = null } if (hydrateDisplayNames) await this.ensureSessionDisplayNamesAsync() + if (options.hydrateStatuses) await this.refreshSessionStatusesAsync() return this.cachedSessions || [] } + private async refreshSessionStatusesAsync(): Promise { + if (Date.now() - this.sessionStatusesUpdatedAt < 5 * 60 * 1000) return + if (this.sessionStatusesInFlight) { + await this.sessionStatusesInFlight + return + } + const sessions = this.cachedSessions + if (!sessions?.length || !this.wcdbGetContactStatus) return + const groupUsernames = sessions + .map((session) => session.username) + .filter((username) => username.endsWith('@chatroom')) + if (!groupUsernames.length) { + this.sessionStatusesUpdatedAt = Date.now() + return + } + const request = (async (): Promise => { + try { + const map = await this.callJsonAsync< + Record + >( + this.wcdbGetContactStatus as unknown as KoffiAsyncFunction, + JSON.stringify(groupUsernames) + ) + for (const username of groupUsernames) { + const status = map?.[username] + this.sessionStatusCache.set(username, { + isFolded: Boolean(status?.isFolded), + isMuted: Boolean(status?.isMuted) + }) + } + if (this.cachedSessions) { + this.cachedSessions = this.cachedSessions.map((session) => { + const status = this.sessionStatusCache.get(session.username) + return status ? { ...session, ...status } : session + }) + } + this.sessionStatusesUpdatedAt = Date.now() + } catch (error) { + console.warn('[WCDB4] session status lookup failed:', error) + } + })() + this.sessionStatusesInFlight = request + try { + await request + } finally { + if (this.sessionStatusesInFlight === request) this.sessionStatusesInFlight = null + } + } + invalidateSessionCache(): void { this.sessionCacheGeneration += 1 this.cachedSessions = null @@ -1122,6 +1187,52 @@ export class Wcdb4Client { return result } + async getAvatarUrlsAsync(usernames: string[]): Promise> { + const normalized = this.uniq(usernames) + await this.hydrateAvatarUrlsAsync(normalized) + const localCandidates = normalized.filter((username) => { + const avatar = this.avatarCache.get(username) + return !avatar || !avatar.startsWith('data:') + }) + if (localCandidates.length && this.wcdbGetHeadImageBuffers) { + try { + const buffers = await this.callJsonAsync>( + this.wcdbGetHeadImageBuffers as unknown as KoffiAsyncFunction, + JSON.stringify(localCandidates) + ) + for (const [username, hex] of Object.entries(buffers || {})) { + const avatar = this.avatarHexToDataUrl(hex) + if (avatar) this.avatarCache.set(username, avatar) + } + } catch (error) { + console.warn('[WCDB4] local avatar fallback failed:', error) + } + } + + const result: Record = {} + for (const username of normalized) { + const avatar = this.avatarCache.get(username) + if (avatar) result[username] = avatar + } + return result + } + + private avatarHexToDataUrl(value: string): string | undefined { + const hex = String(value || '').trim() + if (!hex || hex.length % 2 !== 0 || !/^[a-f0-9]+$/i.test(hex)) return undefined + const buffer = Buffer.from(hex, 'hex') + let mime = 'image/jpeg' + if (buffer.length >= 8 && buffer.subarray(1, 4).toString('ascii') === 'PNG') mime = 'image/png' + if ( + buffer.length >= 12 && + buffer.subarray(0, 4).toString('ascii') === 'RIFF' && + buffer.subarray(8, 12).toString('ascii') === 'WEBP' + ) { + mime = 'image/webp' + } + return `data:${mime};base64,${buffer.toString('base64')}` + } + getMyGroupNickname(chatroomId: string): string | undefined { const groupNicknames = this.getGroupNicknames(chatroomId) for (const candidate of this.getMyUsernameCandidates()) { @@ -1488,9 +1599,10 @@ export class Wcdb4Client { const nicknames = new Map() if (!this.wcdbGetGroupNicknames || !chatroomId) return nicknames - const rows = await this.callJsonAsync< - Record | Record[] - >(this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction, chatroomId) + const rows = await this.callJsonAsync | Record[]>( + this.wcdbGetGroupNicknames as unknown as KoffiAsyncFunction, + chatroomId + ) this.readStringMap(rows, [ 'nickname', 'nickName', @@ -1756,6 +1868,22 @@ export class Wcdb4Client { this.wcdbGetAvatarUrls = null } + try { + this.wcdbGetContactStatus = lib.func( + 'int32 wcdb_get_contact_status(int64 handle, const char* usernamesJson, _Out_ void** outJson)' + ) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number + } catch { + this.wcdbGetContactStatus = null + } + + try { + this.wcdbGetHeadImageBuffers = lib.func( + 'int32 wcdb_get_head_image_buffers(int64 handle, const char* usernamesJson, _Out_ void** outJson)' + ) as (handle: number, usernamesJson: string, outJson: WcdbVoidOut) => number + } catch { + this.wcdbGetHeadImageBuffers = null + } + try { this.wcdbExecQuery = lib.func( 'int32 wcdb_exec_query(int64 handle, const char* kind, const char* path, const char* sql, _Out_ void** outJson)' @@ -2079,7 +2207,16 @@ export class Wcdb4Client { 'contactRemark', 'contact_remark' ]) - return { username, nickname, wechatNickname, remark, raw: row } + const status = this.sessionStatusCache.get(username) + return { + username, + nickname, + wechatNickname, + remark, + isFolded: status?.isFolded, + isMuted: status?.isMuted, + raw: row + } } private normalizeMessage(row: Record): Wcdb4Message { @@ -2208,9 +2345,10 @@ export class Wcdb4Client { const missing = this.uniq(usernames).filter((username) => !this.displayNameCache.has(username)) if (missing.length === 0) return try { - const rows = await this.callJsonAsync< - Record | Record[] - >(this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction, JSON.stringify(missing)) + const rows = await this.callJsonAsync | Record[]>( + this.wcdbGetDisplayNames as unknown as KoffiAsyncFunction, + JSON.stringify(missing) + ) this.readStringMap(rows, [ 'nickname', 'displayName', @@ -2290,9 +2428,10 @@ export class Wcdb4Client { const missing = this.uniq(usernames).filter((username) => !this.avatarCache.has(username)) if (missing.length === 0) return try { - const rows = await this.callJsonAsync< - Record | Record[] - >(this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction, JSON.stringify(missing)) + const rows = await this.callJsonAsync | Record[]>( + this.wcdbGetAvatarUrls as unknown as KoffiAsyncFunction, + JSON.stringify(missing) + ) this.readStringMap(rows, [ 'avatarUrl', 'avatar_url', diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts index 6f86049..8640d2d 100644 --- a/src/main/wechat-db.ts +++ b/src/main/wechat-db.ts @@ -6,6 +6,8 @@ export interface UserContact { avatar?: string wechatNickname?: string remark?: string + isFolded?: boolean + isMuted?: boolean } export interface WechatMessage { @@ -80,7 +82,9 @@ export class WechatDb { nickname: session.nickname || session.username, avatar: session.avatar, wechatNickname: session.wechatNickname, - remark: session.remark + remark: session.remark, + isFolded: session.isFolded, + isMuted: session.isMuted })) .filter((contact) => { if (!keyword) return true @@ -176,12 +180,7 @@ export class WechatDb { this.ensureChatTableMapping() const username = this.chatMd5ToUsername.get(userMd5) if (!username) return [] - const messages = await this.wcdb4Client.getMessagesAsync( - username, - startTime, - endTime, - options - ) + const messages = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime, options) return messages.map((message) => ({ ...message, ...message.raw })) } diff --git a/src/preload/index.d.ts b/src/preload/index.d.ts index f016935..e8988c6 100644 --- a/src/preload/index.d.ts +++ b/src/preload/index.d.ts @@ -221,7 +221,13 @@ declare global { getSticker: ( cdnUrl?: string, md5?: string - ) => Promise<{ success: boolean; data?: string; error?: string }> + ) => Promise<{ + success: boolean + data?: string + error?: string + failureCode?: import('../shared/sticker').StickerFailureCode + httpStatus?: number + }> startExport: (request: ExportRequest) => Promise cancelExport: (jobId: string) => Promise<{ success: boolean }> revealExport: (path: string) => Promise<{ success: boolean; error?: string }> diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index e57df39..6122b89 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -601,8 +601,11 @@ function App(): React.ReactElement { setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true) setIsDatabaseConnected(true) setDbKeyStatus('已连接数据库') - // Cached contacts/self info are enough for startup. Native refresh is - // intentionally user-triggered so it cannot freeze the first session. + // The cached list paints first. Refresh lightweight session flags and + // missing avatars after the database is connected. + void loadContacts({ waitForAvatars: false }).catch((error) => { + console.warn('[Startup] background contact refresh failed:', error) + }) }) .catch((error) => { console.warn('[Startup] background database init failed:', error) diff --git a/src/renderer/src/components/RichMessageBubble.tsx b/src/renderer/src/components/RichMessageBubble.tsx index b09ab57..733d56b 100644 --- a/src/renderer/src/components/RichMessageBubble.tsx +++ b/src/renderer/src/components/RichMessageBubble.tsx @@ -24,13 +24,11 @@ export function RichMessageBubble({ return case 'share': return + case 'forwardBundle': + return case 'miniProgram': return ( - + ) case 'redPacket': return @@ -44,8 +42,11 @@ export function RichMessageBubble({ return case 'unknown': return ( -
- {renderWechatEmojiText((contentData as { raw?: string }).raw || '[未知消息]')} +
+ 暂不支持此消息 + + 消息类型 {(contentData as { messageType?: string | number }).messageType || '未知'} +
) default: @@ -53,6 +54,56 @@ export function RichMessageBubble({ } } +function ForwardBundleBubble({ + data +}: { + data: Extract +}): JSX.Element { + const [expanded, setExpanded] = useState(false) + const visibleItems = expanded ? data.items : data.items.slice(0, 3) + const hiddenCount = Math.max(0, data.items.length - visibleItems.length) + + return ( +
+ +
+ {visibleItems.length ? ( + visibleItems.map((item, index) => ( +
+ {item.sender && {item.sender}} + {renderWechatEmojiText(item.text, 24)} + {item.nested?.length ? 包含 {item.nested.length} 条聊天记录 : null} +
+ )) + ) : ( +
暂未解析到可展示的记录
+ )} +
+ {(hiddenCount > 0 || expanded) && data.items.length > 3 ? ( + + ) : null} +
+ ) +} + function LocationBubble({ data }: { @@ -161,12 +212,7 @@ function MiniProgramBubble({ />
) : data.iconUrl ? ( - + ) : null}
@@ -242,6 +288,7 @@ function StickerBubble({ ) const [loading, setLoading] = useState(Boolean(sourceUrl || md5) && !displayUrl) const [error, setError] = useState(false) + const [errorText, setErrorText] = useState('') useEffect(() => { if (!cacheKey || displayUrl || error) return @@ -255,12 +302,17 @@ function StickerBubble({ stickerDataUrlCache.set(cacheKey, result.data) setDisplayUrl(result.data) setError(false) + setErrorText('') } else { setError(true) + setErrorText(result.error || '表情包未缓存') } }) .catch(() => { - if (!cancelled) setError(true) + if (!cancelled) { + setError(true) + setErrorText('表情包加载失败') + } }) .finally(() => { if (!cancelled) setLoading(false) @@ -289,7 +341,7 @@ function StickerBubble({ return (
-
{error ? '表情包未缓存' : '表情包'}
+
{error ? errorText || '表情包未缓存' : '表情包'}
{md5 &&
MD5: {md5}
}
) diff --git a/src/renderer/src/components/VoicePlayer.tsx b/src/renderer/src/components/VoicePlayer.tsx index d87778e..0f3abee 100644 --- a/src/renderer/src/components/VoicePlayer.tsx +++ b/src/renderer/src/components/VoicePlayer.tsx @@ -16,15 +16,16 @@ export function VoicePlayer({ sessionId, localId, createTime, - svrId + svrId, + duration }: VoicePlayerProps): JSX.Element { const [isPlaying, setIsPlaying] = useState(false) const [audioUrl, setAudioUrl] = useState(null) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) - const [audioDuration, setAudioDuration] = useState(undefined) - const [shouldAutoPlay, setShouldAutoPlay] = useState(false) + const [audioDuration, setAudioDuration] = useState(duration) const audioRef = useRef(null) + const objectUrlRef = useRef(null) const stopCurrentAndPlay = useCallback((audio: HTMLAudioElement) => { if (globalCurrentAudio && globalCurrentAudio !== audio) { @@ -35,126 +36,113 @@ export function VoicePlayer({ globalCurrentAudio = audio }, []) - const handlePlayPause = useCallback(async () => { - // 如果还没有音频数据,先获取 - if (!audioUrl && !loading) { - setLoading(true) - setShouldAutoPlay(true) - console.log('[VoicePlayer] fetching voice data:', { sessionId, localId, createTime }) - try { - const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId) - console.log('[VoicePlayer] got result:', result) - if (result.success && result.data) { - console.log('[VoicePlayer] setting audioUrl, data length:', result.data.length) - // 使用 Blob URL 替代 data URL,绕过 CSP 限制 - const byteCharacters = atob(result.data) - const byteNumbers = new Array(byteCharacters.length) - for (let i = 0; i < byteCharacters.length; i++) { - byteNumbers[i] = byteCharacters.charCodeAt(i) - } - const byteArray = new Uint8Array(byteNumbers) - const blob = new Blob([byteArray], { type: 'audio/wav' }) - const blobUrl = URL.createObjectURL(blob) - console.log('[VoicePlayer] created blob URL:', blobUrl) - setAudioUrl(blobUrl) - } else { - console.log('[VoicePlayer] getVoiceData failed:', result.error) - setError(result.error || '获取语音数据失败') - setShouldAutoPlay(false) - } - } catch (e) { - console.log('[VoicePlayer] exception:', e) - setError('加载语音失败') - setShouldAutoPlay(false) - } - setLoading(false) - return - } - - if (!audioRef.current) { - console.log('[VoicePlayer] no audioRef') - return - } - - const audio = audioRef.current - - if (isPlaying) { - audio.pause() - setIsPlaying(false) - globalStopCallback = null - } else { + const playAudio = useCallback( + async (audio: HTMLAudioElement): Promise => { stopCurrentAndPlay(audio) - audio - .play() - .then(() => { - console.log('[VoicePlayer] play() succeeded') - }) - .catch((e) => { - console.log('[VoicePlayer] play() failed:', e) - }) - setIsPlaying(true) - globalStopCallback = () => { - setIsPlaying(false) - audio.currentTime = 0 - } - } - }, [audioUrl, loading, isPlaying, sessionId, localId, createTime, svrId, stopCurrentAndPlay]) - - useEffect(() => { - if (!audioUrl) return - - let audio = audioRef.current - if (!audio) { - audio = new Audio(audioUrl) - audioRef.current = audio - } - - const audioEl = audio! - - audioEl.addEventListener('loadedmetadata', () => { - setAudioDuration(audioEl.duration) - console.log('[VoicePlayer] loadedmetadata, duration:', audioEl.duration) - }) - - audioEl.addEventListener('ended', () => { - setIsPlaying(false) - globalStopCallback = null - }) - - audioEl.addEventListener('timeupdate', () => { - if (audioEl.duration && isFinite(audioEl.duration)) { - setAudioDuration(audioEl.duration) - } - }) - - audioEl.addEventListener('canplay', () => { - console.log('[VoicePlayer] canplay event, shouldAutoPlay:', shouldAutoPlay) - if (shouldAutoPlay && audioRef.current) { - setShouldAutoPlay(false) - stopCurrentAndPlay(audioRef.current) - audioRef.current.play() + try { + await audio.play() + setError(null) setIsPlaying(true) globalStopCallback = () => { setIsPlaying(false) - if (audioRef.current) { - audioRef.current.currentTime = 0 - } + audio.currentTime = 0 } + } catch (playError) { + if (globalCurrentAudio === audio) { + globalCurrentAudio = null + globalStopCallback = null + } + setIsPlaying(false) + setError('语音播放失败,请重试') + console.warn('[VoicePlayer] play failed:', playError) } - }) + }, + [stopCurrentAndPlay] + ) - return () => { - if (audioRef.current) { - audioRef.current.pause() - audioRef.current.src = '' - audioRef.current = null - } - if (globalCurrentAudio === audioRef.current) { + const createAudio = useCallback((blobUrl: string): HTMLAudioElement => { + const audio = new Audio() + audio.preload = 'auto' + audio.src = blobUrl + audio.onloadedmetadata = () => { + if (Number.isFinite(audio.duration)) setAudioDuration(audio.duration) + } + audio.ontimeupdate = () => { + if (Number.isFinite(audio.duration)) setAudioDuration(audio.duration) + } + audio.onended = () => { + setIsPlaying(false) + if (globalCurrentAudio === audio) { globalCurrentAudio = null globalStopCallback = null } } - }, [audioUrl, shouldAutoPlay, stopCurrentAndPlay]) + audioRef.current = audio + objectUrlRef.current = blobUrl + return audio + }, []) + + const handlePlayPause = useCallback(async () => { + if (loading) return + + let audio = audioRef.current + if (!audio) { + setLoading(true) + setError(null) + try { + const result = await window.api.getVoiceData(sessionId, localId, createTime, svrId) + if (result.success && result.data) { + const byteCharacters = atob(result.data) + const byteArray = new Uint8Array(byteCharacters.length) + for (let i = 0; i < byteCharacters.length; i++) { + byteArray[i] = byteCharacters.charCodeAt(i) + } + const blob = new Blob([byteArray], { type: 'audio/wav' }) + const blobUrl = URL.createObjectURL(blob) + setAudioUrl(blobUrl) + audio = createAudio(blobUrl) + await playAudio(audio) + } else { + setError(result.error || '获取语音数据失败') + } + } catch (loadError) { + console.warn('[VoicePlayer] load failed:', loadError) + setError('加载语音失败') + } finally { + setLoading(false) + } + return + } + + if (isPlaying) { + audio.pause() + setIsPlaying(false) + if (globalCurrentAudio === audio) { + globalCurrentAudio = null + globalStopCallback = null + } + } else { + await playAudio(audio) + } + }, [createAudio, createTime, isPlaying, loading, localId, playAudio, sessionId, svrId]) + + useEffect(() => { + return () => { + const audio = audioRef.current + if (audio) { + audio.pause() + audio.removeAttribute('src') + audio.load() + } + if (globalCurrentAudio === audio) { + globalCurrentAudio = null + globalStopCallback = null + } + if (objectUrlRef.current) URL.revokeObjectURL(objectUrlRef.current) + objectUrlRef.current = null + audioRef.current = null + } + }, []) const formatDuration = (seconds: number | undefined): string => { if (!seconds || !isFinite(seconds)) return '0:00' diff --git a/src/renderer/src/components/chat/MessageBubble.tsx b/src/renderer/src/components/chat/MessageBubble.tsx index f2d61fb..76943db 100644 --- a/src/renderer/src/components/chat/MessageBubble.tsx +++ b/src/renderer/src/components/chat/MessageBubble.tsx @@ -31,7 +31,9 @@ const RICH_MESSAGE_TYPES = [ '引用消息', '通话', '表情包', - '系统消息' + '系统消息', + '合并转发', + '不支持的消息' ] export function MessageBubble({ @@ -46,7 +48,8 @@ export function MessageBubble({ const isVoice = message.type === '语音' const isImage = message.type === '图片' const isVideo = message.type === '视频' - const isRichMedia = RICH_MESSAGE_TYPES.includes(message.type) + const isRichMedia = + RICH_MESSAGE_TYPES.includes(message.type) || message.contentData?.type === 'unknown' const hoverTime = formatMessageTime(message) return ( @@ -63,6 +66,8 @@ export function MessageBubble({ sessionId={message.sessionId} localId={message.localId || 0} createTime={message.createTime || 0} + svrId={message.serverId} + duration={message.voiceDuration} /> ) : isImage && message.contentData && message.contentData.type === 'image' ? ( () + const [failedAvatar, setFailedAvatar] = useState<{ username: string; source: string }>() + const repairedSource = repairedAvatar?.username === wxid ? repairedAvatar.source : undefined + const avatar = repairedSource || contact.avatar + const avatarFailed = failedAvatar?.username === wxid && failedAvatar.source === avatar + + const handleAvatarError = (): void => { + if (!avatar || avatarFailed) return + setFailedAvatar({ username: wxid, source: avatar }) + if (contact.type !== 'group' || avatar.startsWith('data:')) return + void window.api + .getContactAvatars([wxid]) + .then((avatars) => { + const fallback = avatars[wxid] + if (!fallback || fallback === avatar) return + setRepairedAvatar({ username: wxid, source: fallback }) + setFailedAvatar(undefined) + }) + .catch(() => undefined) + } return ( ) } @@ -113,7 +167,10 @@ export function ConversationSidebar({
0) { + return numeric > 10_000_000_000 ? numeric : numeric * 1000 + } + const parsed = Date.parse(raw) + if (Number.isFinite(parsed)) return parsed + } + } catch { + // Invalid URLs have no trustworthy expiry metadata. + } + return undefined +} diff --git a/src/shared/types.ts b/src/shared/types.ts index 04339f4..61f0c43 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -6,6 +6,8 @@ export interface Contact { avatar?: string wechatNickname?: string remark?: string + isFolded?: boolean + isMuted?: boolean } export interface Message { @@ -53,6 +55,19 @@ type ShareContent = { appname?: string typeVal?: string } +export type ForwardedMessageItem = { + messageType: number + sender?: string + sentAt?: string + text: string + nested?: ForwardedMessageItem[] +} +type ForwardBundleContent = { + type: 'forwardBundle' + title: string + description?: string + items: ForwardedMessageItem[] +} type MiniProgramContent = { type: 'miniProgram' title: string @@ -119,7 +134,7 @@ type SystemContent = { recallTime?: number } } -type UnknownContent = { type: 'unknown'; raw: string } +type UnknownContent = { type: 'unknown'; raw: string; messageType?: string | number } export type ParsedContent = | TextContent @@ -127,6 +142,7 @@ export type ParsedContent = | LocationContent | CardContent | ShareContent + | ForwardBundleContent | MiniProgramContent | RedPacketContent | VoipContent diff --git a/tests/stability-compat.test.mjs b/tests/stability-compat.test.mjs new file mode 100644 index 0000000..dc1b5ed --- /dev/null +++ b/tests/stability-compat.test.mjs @@ -0,0 +1,64 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { parseMessageContent } from '../src/main/message-parser.ts' +import { classifyStickerHttpFailure } from '../src/shared/sticker.ts' + +test('merged forwarding messages expose expandable record items', () => { + const content = ` + 项目讨论记录19 + + + + 2026-08-01 10:00 + + + + + 2026-08-01 10:01 + + + ]]> + ` + + const parsed = parseMessageContent(content, 49) + assert.equal(parsed.type, 'forwardBundle') + assert.equal(parsed.title, '项目讨论记录') + assert.deepEqual( + parsed.items.map((item) => [item.sender, item.text]), + [ + ['张三', '第一条消息'], + ['李四', '[图片]'] + ] + ) +}) + +test('unknown message types are not misclassified as text', () => { + const parsed = parseMessageContent('1', 9999) + assert.equal(parsed.type, 'unknown') + assert.equal(parsed.messageType, 9999) +}) + +test('sticker 403 with expired timestamp is classified as an expired link', () => { + const result = classifyStickerHttpFailure( + 403, + 'https://example.invalid/sticker?expire=1700000000', + 1_800_000_000_000 + ) + assert.equal(result.code, 'link_expired') +}) + +test('sticker authorization and removal failures remain distinct', () => { + assert.equal( + classifyStickerHttpFailure(401, 'https://example.invalid/sticker').code, + 'authentication_required' + ) + assert.equal( + classifyStickerHttpFailure(403, 'https://example.invalid/sticker').code, + 'access_denied' + ) + assert.equal( + classifyStickerHttpFailure(404, 'https://example.invalid/sticker').code, + 'resource_removed' + ) +})