fix: 修复公众号消息读取与 Windows 中文路径兼容

- 支持从 biz_message 分片读取公众号聊天记录
- 在左侧栏增加独立的公众号折叠分组
- 为 Windows 中文数据目录建立 ASCII 路径桥接 (#12)
- 补充公众号分片、侧栏分类和路径桥接测试
This commit is contained in:
Wxw-Gu
2026-08-07 16:21:39 +08:00
parent 0b845db2e0
commit 96c67f5bf8
6 changed files with 306 additions and 36 deletions
+2
View File
@@ -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,
+152 -32
View File
@@ -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<Record<string, unknown>[]>(
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<Wcdb4Message[]> {
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
if (!this.wcdbExecQuery) return []
let tables: Wcdb4MessageStore[] = []
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
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<Record<string, unknown>[]>((handle, outJson) =>
this.wcdbGetMessageTableStats!(handle, username, outJson)
)
let stores: Wcdb4MessageStore[] = []
if (this.wcdbGetMessageTableStats) {
try {
const rows = this.callJson<Record<string, unknown>[]>((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<Wcdb4MessageStore[]> {
let stores: Wcdb4MessageStore[] = []
if (this.wcdbGetMessageTableStats) {
try {
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
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<string, unknown>[]): 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<Record<string, unknown>[]>((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<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 = await this.callJsonAsync<Record<string, unknown>[]>(
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<string, unknown>[] {
if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道')
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
@@ -1889,7 +2009,7 @@ export class Wcdb4Client {
try {
return this.callJson<Wcdb4ImageHardlink>((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<Wcdb4ImageHardlink>(
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
@@ -26,7 +26,7 @@ export interface ConversationSidebarProps {
onRefresh: (filterKeyword: string) => Promise<void>
}
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<Record<SectionName, boolean>>({
groups: true,
folded: false,
officialAccounts: false,
contacts: false
})
const listRef = useRef<HTMLDivElement>(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<ConversationRow[]>(
() => [
{
@@ -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
]
)
+1
View File
@@ -3,6 +3,7 @@ export interface Contact {
m_nsNickName: string
md5: string
type: 'user' | 'group'
isOfficialAccount?: boolean
avatar?: string
wechatNickname?: string
remark?: string