diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index e6a9204..48ece7b 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -34,6 +34,7 @@ export interface FormattedContact { m_nsNickName: string md5: string type: 'user' | 'group' + isOfficialAccount?: boolean avatar?: string wechatNickname?: string remark?: string @@ -157,6 +158,7 @@ export function listContacts(filter?: string): FormattedContact[] { m_nsNickName: user.nickname || '未知用户', md5, type: isGroup ? 'group' : 'user', + isOfficialAccount: !isGroup && user.m_nsUsrName.startsWith('gh_'), avatar: typeof user.avatar === 'string' ? user.avatar : undefined, wechatNickname: user.wechatNickname, remark: user.remark, diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index c4a5f24..b6ef39a 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -39,6 +39,11 @@ export interface Wcdb4SessionQueryOptions { hydrateStatuses?: boolean } +export interface WindowsNativePathBridgeOptions { + platform?: NodeJS.Platform + publicRoot?: string +} + type Wcdb4MessageStore = { tableName: string dbPath: string @@ -236,11 +241,58 @@ type WcdbHandleOut = [number] const nodeRequire = createRequire(import.meta.url) +function isAsciiPath(value: string): boolean { + return /^[\x20-\x7e]+$/.test(value) +} + +export function resolveWindowsNativeAccountRoot( + accountRoot: string, + options: WindowsNativePathBridgeOptions = {} +): string { + const platform = options.platform || process.platform + if (platform !== 'win32' || isAsciiPath(accountRoot)) return accountRoot + + const publicRoot = [ + options.publicRoot, + process.env.PUBLIC, + path.join(process.env.SystemDrive || 'C:', 'Users', 'Public') + ] + .map((candidate) => String(candidate || '').trim()) + .find((candidate) => candidate && isAsciiPath(candidate)) + if (!publicRoot || !isAsciiPath(publicRoot)) return accountRoot + + const bridgeRoot = path.join(publicRoot, 'WechatExplorer', 'path-bridges') + const bridgePath = path.join( + bridgeRoot, + crypto + .createHash('sha256') + .update(path.resolve(accountRoot).toLowerCase()) + .digest('hex') + .slice(0, 24) + ) + try { + fs.ensureDirSync(bridgeRoot) + if (fs.existsSync(bridgePath)) { + const existingTarget = fs.realpathSync.native(bridgePath) + if (path.resolve(existingTarget).toLowerCase() === path.resolve(accountRoot).toLowerCase()) { + return bridgePath + } + fs.removeSync(bridgePath) + } + fs.symlinkSync(path.resolve(accountRoot), bridgePath, 'junction') + return bridgePath + } catch (error) { + console.warn('[WCDB4] 无法为中文数据目录建立 native 路径别名:', error) + return accountRoot + } +} + export class Wcdb4Client { static readonly defaultRoot = Wcdb4Client.findExistingDefaultRoot() private readonly key: string private readonly accountRoot: string + private readonly nativeAccountRoot: string private readonly wxid: string private readonly dbStoragePath: string private readonly sessionDbPath: string @@ -352,8 +404,9 @@ export class Wcdb4Client { this.accountRoot = accountRoot ? Wcdb4Client.resolveAccountRoot(accountRoot) : Wcdb4Client.findLatestAccountRoot() + this.nativeAccountRoot = resolveWindowsNativeAccountRoot(this.accountRoot) this.wxid = Wcdb4Client.cleanAccountDirName(path.basename(this.accountRoot)) - this.dbStoragePath = path.join(this.accountRoot, 'db_storage') + this.dbStoragePath = path.join(this.nativeAccountRoot, 'db_storage') this.sessionDbPath = this.findSessionDb() if (!this.sessionDbPath) { @@ -931,7 +984,7 @@ export class Wcdb4Client { ) try { const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows) - if (cursorMessages) { + if (cursorMessages && cursorMessages.length > 0) { const recoveredMessages = this.readRecallJournal(username, startTime, endTime) const mergedMessages = this.mergeMessageRows(cursorMessages, recoveredMessages, maxRows) console.log( @@ -1035,16 +1088,7 @@ export class Wcdb4Client { let tables: Wcdb4MessageStore[] try { - const rows = await this.callJsonAsync[]>( - this.wcdbGetMessageTableStats as unknown as KoffiAsyncFunction, - username - ) - tables = (Array.isArray(rows) ? rows : []) - .map((row) => ({ - tableName: this.pickString(row, ['table_name', 'tableName', 'name']), - dbPath: this.pickString(row, ['db_path', 'dbPath', 'path']) - })) - .filter((row) => row.tableName && row.dbPath) + tables = await this.listMessageStoresAsync(username) } catch (error) { console.warn(`[WCDB4] voice count table stats failed username=${username}:`, error) return null @@ -1094,7 +1138,7 @@ export class Wcdb4Client { endTime?: number, limit?: number ): Wcdb4Message[] { - if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return [] + if (!this.wcdbExecQuery) return [] let tables: Wcdb4MessageStore[] = [] try { @@ -1139,20 +1183,11 @@ export class Wcdb4Client { endTime?: number, limit?: number ): Promise { - if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return [] + if (!this.wcdbExecQuery) return [] let tables: Wcdb4MessageStore[] = [] try { - const rows = await this.callJsonAsync[]>( - this.wcdbGetMessageTableStats as unknown as KoffiAsyncFunction, - username - ) - tables = (Array.isArray(rows) ? rows : []) - .map((row) => ({ - tableName: this.pickString(row, ['table_name', 'tableName', 'name']), - dbPath: this.pickString(row, ['db_path', 'dbPath', 'path']) - })) - .filter((row) => row.tableName && row.dbPath) + tables = await this.listMessageStoresAsync(username) } catch (error) { console.warn(`[WCDB4] async message table stats failed username=${username}:`, error) throw new Error( @@ -1231,10 +1266,37 @@ export class Wcdb4Client { } private listMessageStores(username: string): Wcdb4MessageStore[] { - if (!this.wcdbGetMessageTableStats) return [] - const rows = this.callJson[]>((handle, outJson) => - this.wcdbGetMessageTableStats!(handle, username, outJson) - ) + let stores: Wcdb4MessageStore[] = [] + if (this.wcdbGetMessageTableStats) { + try { + const rows = this.callJson[]>((handle, outJson) => + this.wcdbGetMessageTableStats!(handle, username, outJson) + ) + stores = this.parseMessageStores(rows) + } catch (error) { + if (!username.startsWith('gh_')) throw error + } + } + return stores.length > 0 ? stores : this.listBizMessageStores(username) + } + + private async listMessageStoresAsync(username: string): Promise { + let stores: Wcdb4MessageStore[] = [] + if (this.wcdbGetMessageTableStats) { + try { + const rows = await this.callJsonAsync[]>( + this.wcdbGetMessageTableStats as unknown as KoffiAsyncFunction, + username + ) + stores = this.parseMessageStores(rows) + } catch (error) { + if (!username.startsWith('gh_')) throw error + } + } + return stores.length > 0 ? stores : this.listBizMessageStoresAsync(username) + } + + private parseMessageStores(rows: Record[]): Wcdb4MessageStore[] { return (Array.isArray(rows) ? rows : []) .map((row) => ({ tableName: this.pickString(row, ['table_name', 'tableName', 'name']), @@ -1243,6 +1305,64 @@ export class Wcdb4Client { .filter((row) => row.tableName && row.dbPath) } + private getBizMessageDatabasePaths(): string[] { + const messageRoot = path.join(this.dbStoragePath, 'message') + try { + return fs + .readdirSync(messageRoot) + .filter((name) => /^biz_message(?:_\d+)?\.db$/i.test(name)) + .sort() + .map((name) => path.join(messageRoot, name)) + } catch { + return [] + } + } + + private listBizMessageStores(username: string): Wcdb4MessageStore[] { + if (!this.wcdbExecQuery || !username.startsWith('gh_')) return [] + const tableName = `Msg_${this.md5(username)}` + const escapedTableName = tableName.replace(/'/g, "''") + const stores: Wcdb4MessageStore[] = [] + for (const dbPath of this.getBizMessageDatabasePaths()) { + try { + const rows = this.callJson[]>((handle, outJson) => + this.wcdbExecQuery!( + handle, + 'message', + dbPath, + `SELECT name FROM sqlite_master WHERE type='table' AND name='${escapedTableName}'`, + outJson + ) + ) + if (Array.isArray(rows) && rows.length > 0) stores.push({ tableName, dbPath }) + } catch { + // Older biz shards may be absent or use a different key; continue scanning. + } + } + return stores + } + + private async listBizMessageStoresAsync(username: string): Promise { + if (!this.wcdbExecQuery || !username.startsWith('gh_')) return [] + const tableName = `Msg_${this.md5(username)}` + const escapedTableName = tableName.replace(/'/g, "''") + const stores: Wcdb4MessageStore[] = [] + for (const dbPath of this.getBizMessageDatabasePaths()) { + try { + const rows = await this.callJsonAsync[]>( + this.wcdbExecQuery as unknown as KoffiAsyncFunction, + 'message', + dbPath, + `SELECT name FROM sqlite_master WHERE type='table' AND name='${escapedTableName}'` + ) + if (Array.isArray(rows) && rows.length > 0) stores.push({ tableName, dbPath }) + } catch { + // Older biz shards may be absent or use a different key; continue scanning. + } + } + return stores + } + private executeMessageSql(store: Wcdb4MessageStore, sql: string): Record[] { if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道') const rows = this.callJson[]>((handle, outJson) => @@ -1889,7 +2009,7 @@ export class Wcdb4Client { try { return this.callJson((handle, outJson) => - this.wcdbResolveImageHardlink!(handle, normalizedMd5, this.accountRoot, outJson) + this.wcdbResolveImageHardlink!(handle, normalizedMd5, this.nativeAccountRoot, outJson) ) } catch (error) { console.warn('[WCDB4] resolve image hardlink failed:', error) @@ -1908,7 +2028,7 @@ export class Wcdb4Client { return await this.callJsonAsync( this.wcdbResolveImageHardlink as unknown as KoffiAsyncFunction, normalizedMd5, - this.accountRoot + this.nativeAccountRoot ) } catch (error) { console.warn('[WCDB4] async image hardlink resolve failed:', error) @@ -2287,8 +2407,8 @@ export class Wcdb4Client { const candidates = [ path.join(this.dbStoragePath, 'emoticon', 'emoticon.db'), path.join(this.dbStoragePath, 'emotion', 'emoticon.db'), - path.join(this.accountRoot, this.wxid, 'db_storage', 'emoticon', 'emoticon.db'), - path.join(this.accountRoot, this.wxid, 'db_storage', 'emotion', 'emoticon.db') + path.join(this.nativeAccountRoot, this.wxid, 'db_storage', 'emoticon', 'emoticon.db'), + path.join(this.nativeAccountRoot, this.wxid, 'db_storage', 'emotion', 'emoticon.db') ] for (const candidate of candidates) { if (fs.existsSync(candidate)) return candidate diff --git a/src/renderer/src/components/conversation/ConversationSidebar.tsx b/src/renderer/src/components/conversation/ConversationSidebar.tsx index 89c657e..64f7d13 100644 --- a/src/renderer/src/components/conversation/ConversationSidebar.tsx +++ b/src/renderer/src/components/conversation/ConversationSidebar.tsx @@ -26,7 +26,7 @@ export interface ConversationSidebarProps { onRefresh: (filterKeyword: string) => Promise } -type SectionName = 'groups' | 'folded' | 'contacts' +type SectionName = 'groups' | 'folded' | 'officialAccounts' | 'contacts' type ConversationRow = | { kind: 'header'; id: string; title: string; count: number; section: SectionName } | { kind: 'contact'; id: string; contact: Contact } @@ -48,13 +48,24 @@ export function ConversationSidebar({ const [expandedSections, setExpandedSections] = useState>({ groups: true, folded: false, + officialAccounts: false, contacts: false }) const listRef = useRef(null) const groups = contacts.filter((contact) => contact.type === 'group' && !contact.isFolded) const foldedGroups = contacts.filter((contact) => contact.type === 'group' && contact.isFolded) - const users = contacts.filter((contact) => contact.type === 'user') + const officialAccounts = contacts.filter( + (contact) => + contact.type === 'user' && + (contact.isOfficialAccount || contact.m_nsUsrName.startsWith('gh_')) + ) + const users = contacts.filter( + (contact) => + contact.type === 'user' && + !contact.isOfficialAccount && + !contact.m_nsUsrName.startsWith('gh_') + ) const rows = useMemo( () => [ { @@ -89,6 +100,24 @@ export function ConversationSidebar({ : []) ] : []), + ...(officialAccounts.length + ? [ + { + kind: 'header' as const, + id: 'official-accounts-header', + title: '公众号', + count: officialAccounts.length, + section: 'officialAccounts' as const + }, + ...(expandedSections.officialAccounts + ? officialAccounts.map((contact) => ({ + kind: 'contact' as const, + id: `official-${contact.md5}`, + contact + })) + : []) + ] + : []), { kind: 'header', id: 'contacts-header', @@ -104,8 +133,10 @@ export function ConversationSidebar({ expandedSections.contacts, expandedSections.folded, expandedSections.groups, + expandedSections.officialAccounts, foldedGroups, groups, + officialAccounts, users ] ) diff --git a/src/shared/types.ts b/src/shared/types.ts index b24c4eb..1d8c122 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -3,6 +3,7 @@ export interface Contact { m_nsNickName: string md5: string type: 'user' | 'group' + isOfficialAccount?: boolean avatar?: string wechatNickname?: string remark?: string diff --git a/tests/component/conversation-sidebar.test.tsx b/tests/component/conversation-sidebar.test.tsx new file mode 100644 index 0000000..2407f04 --- /dev/null +++ b/tests/component/conversation-sidebar.test.tsx @@ -0,0 +1,57 @@ +import { render, screen } from '@testing-library/react' +import userEvent from '@testing-library/user-event' +import { describe, expect, it, vi } from 'vitest' +import { ConversationSidebar } from '../../src/renderer/src/components/conversation/ConversationSidebar' + +vi.mock('@tanstack/react-virtual', () => ({ + useVirtualizer: ({ count }: { count: number }) => ({ + getTotalSize: () => count * 58, + getVirtualItems: () => + Array.from({ length: count }, (_, index) => ({ + index, + key: index, + start: index * 58, + size: 58 + })) + }) +})) + +describe('ConversationSidebar', () => { + it('keeps official accounts in their own collapsible section', async () => { + render( + undefined)} + /> + ) + + expect(screen.getByRole('button', { name: '公众号 (1)' })).toBeInTheDocument() + expect(screen.getByRole('button', { name: '联系人 (1)' })).toBeInTheDocument() + expect(screen.queryByText('测试公众号')).not.toBeInTheDocument() + + await userEvent.click(screen.getByRole('button', { name: '公众号 (1)' })) + expect(screen.getByText('测试公众号')).toBeInTheDocument() + }) +}) diff --git a/tests/unit/wcdb-message-shards.test.ts b/tests/unit/wcdb-message-shards.test.ts index a174e93..83e3036 100644 --- a/tests/unit/wcdb-message-shards.test.ts +++ b/tests/unit/wcdb-message-shards.test.ts @@ -1,5 +1,12 @@ -import { describe, expect, it, vi } from 'vitest' -import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client' +import fs from 'fs-extra' +import os from 'os' +import path from 'path' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { + resolveWindowsNativeAccountRoot, + Wcdb4Client, + type Wcdb4Message +} from '../../src/main/wcdb4-client' const message = (id: string, year: number, serverId = `server-${id}`): Wcdb4Message => ({ mesLocalID: id, @@ -12,6 +19,12 @@ const message = (id: string, year: number, serverId = `server-${id}`): Wcdb4Mess }) describe('WCDB message shard pagination', () => { + const temporaryDirectories: string[] = [] + + afterEach(() => { + for (const directory of temporaryDirectories.splice(0)) fs.removeSync(directory) + }) + it('keeps messages whose local ids repeat across database shards', async () => { const cursor = vi.fn(async () => [ message('1', 2024, 'server-2024'), @@ -57,4 +70,50 @@ describe('WCDB message shard pagination', () => { client.getMessagesAsync('fixture@chatroom', undefined, 1_767_225_600, { limit: 20 }) ).rejects.toThrow('无法检查历史消息分片') }) + + it('falls back to biz_message shards for official-account sessions', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-biz-shards-')) + temporaryDirectories.push(root) + const messageRoot = path.join(root, 'db_storage', 'message') + fs.ensureDirSync(messageRoot) + const bizDbPath = path.join(messageRoot, 'biz_message_0.db') + fs.writeFileSync(bizDbPath, '') + + const client = Object.assign(Object.create(Wcdb4Client.prototype), { + dbStoragePath: path.join(root, 'db_storage'), + wcdbGetMessageTableStats: null, + wcdbExecQuery: vi.fn(), + callJson: vi.fn(() => [{ name: 'Msg_19cde0e21f4f938ca1fcebd7146dbbd2' }]) + }) as Wcdb4Client + + const stores = Reflect.get(client, 'listMessageStores').call(client, 'gh_23069e016533') + + expect(stores).toEqual([ + { + tableName: 'Msg_19cde0e21f4f938ca1fcebd7146dbbd2', + dbPath: bizDbPath + } + ]) + }) + + it('creates a stable ASCII junction for a Windows account path containing Chinese', () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), 'wxe-path-bridge-')) + temporaryDirectories.push(root) + const publicRoot = path.join(root, 'Public') + const accountRoot = path.join(root, '微信聊天记录', 'wxid_fixture') + fs.ensureDirSync(path.join(accountRoot, 'db_storage')) + + const first = resolveWindowsNativeAccountRoot(accountRoot, { + platform: 'win32', + publicRoot + }) + const second = resolveWindowsNativeAccountRoot(accountRoot, { + platform: 'win32', + publicRoot + }) + + expect(first).toBe(second) + expect(first).not.toContain('微信聊天记录') + expect(fs.realpathSync(first)).toBe(fs.realpathSync(accountRoot)) + }) })