mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-18 03:57:02 +08:00
fix: 修复公众号消息读取与 Windows 中文路径兼容
- 支持从 biz_message 分片读取公众号聊天记录 - 在左侧栏增加独立的公众号折叠分组 - 为 Windows 中文数据目录建立 ASCII 路径桥接 (#12) - 补充公众号分片、侧栏分类和路径桥接测试
This commit is contained in:
@@ -34,6 +34,7 @@ export interface FormattedContact {
|
|||||||
m_nsNickName: string
|
m_nsNickName: string
|
||||||
md5: string
|
md5: string
|
||||||
type: 'user' | 'group'
|
type: 'user' | 'group'
|
||||||
|
isOfficialAccount?: boolean
|
||||||
avatar?: string
|
avatar?: string
|
||||||
wechatNickname?: string
|
wechatNickname?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
@@ -157,6 +158,7 @@ export function listContacts(filter?: string): FormattedContact[] {
|
|||||||
m_nsNickName: user.nickname || '未知用户',
|
m_nsNickName: user.nickname || '未知用户',
|
||||||
md5,
|
md5,
|
||||||
type: isGroup ? 'group' : 'user',
|
type: isGroup ? 'group' : 'user',
|
||||||
|
isOfficialAccount: !isGroup && user.m_nsUsrName.startsWith('gh_'),
|
||||||
avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
|
avatar: typeof user.avatar === 'string' ? user.avatar : undefined,
|
||||||
wechatNickname: user.wechatNickname,
|
wechatNickname: user.wechatNickname,
|
||||||
remark: user.remark,
|
remark: user.remark,
|
||||||
|
|||||||
+152
-32
@@ -39,6 +39,11 @@ export interface Wcdb4SessionQueryOptions {
|
|||||||
hydrateStatuses?: boolean
|
hydrateStatuses?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WindowsNativePathBridgeOptions {
|
||||||
|
platform?: NodeJS.Platform
|
||||||
|
publicRoot?: string
|
||||||
|
}
|
||||||
|
|
||||||
type Wcdb4MessageStore = {
|
type Wcdb4MessageStore = {
|
||||||
tableName: string
|
tableName: string
|
||||||
dbPath: string
|
dbPath: string
|
||||||
@@ -236,11 +241,58 @@ type WcdbHandleOut = [number]
|
|||||||
|
|
||||||
const nodeRequire = createRequire(import.meta.url)
|
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 {
|
export class Wcdb4Client {
|
||||||
static readonly defaultRoot = Wcdb4Client.findExistingDefaultRoot()
|
static readonly defaultRoot = Wcdb4Client.findExistingDefaultRoot()
|
||||||
|
|
||||||
private readonly key: string
|
private readonly key: string
|
||||||
private readonly accountRoot: string
|
private readonly accountRoot: string
|
||||||
|
private readonly nativeAccountRoot: string
|
||||||
private readonly wxid: string
|
private readonly wxid: string
|
||||||
private readonly dbStoragePath: string
|
private readonly dbStoragePath: string
|
||||||
private readonly sessionDbPath: string
|
private readonly sessionDbPath: string
|
||||||
@@ -352,8 +404,9 @@ export class Wcdb4Client {
|
|||||||
this.accountRoot = accountRoot
|
this.accountRoot = accountRoot
|
||||||
? Wcdb4Client.resolveAccountRoot(accountRoot)
|
? Wcdb4Client.resolveAccountRoot(accountRoot)
|
||||||
: Wcdb4Client.findLatestAccountRoot()
|
: Wcdb4Client.findLatestAccountRoot()
|
||||||
|
this.nativeAccountRoot = resolveWindowsNativeAccountRoot(this.accountRoot)
|
||||||
this.wxid = Wcdb4Client.cleanAccountDirName(path.basename(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()
|
this.sessionDbPath = this.findSessionDb()
|
||||||
|
|
||||||
if (!this.sessionDbPath) {
|
if (!this.sessionDbPath) {
|
||||||
@@ -931,7 +984,7 @@ export class Wcdb4Client {
|
|||||||
)
|
)
|
||||||
try {
|
try {
|
||||||
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows)
|
const cursorMessages = this.getMessagesByCursor(username, startTime, endTime, maxRows)
|
||||||
if (cursorMessages) {
|
if (cursorMessages && cursorMessages.length > 0) {
|
||||||
const recoveredMessages = this.readRecallJournal(username, startTime, endTime)
|
const recoveredMessages = this.readRecallJournal(username, startTime, endTime)
|
||||||
const mergedMessages = this.mergeMessageRows(cursorMessages, recoveredMessages, maxRows)
|
const mergedMessages = this.mergeMessageRows(cursorMessages, recoveredMessages, maxRows)
|
||||||
console.log(
|
console.log(
|
||||||
@@ -1035,16 +1088,7 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
let tables: Wcdb4MessageStore[]
|
let tables: Wcdb4MessageStore[]
|
||||||
try {
|
try {
|
||||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
tables = await this.listMessageStoresAsync(username)
|
||||||
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)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[WCDB4] voice count table stats failed username=${username}:`, error)
|
console.warn(`[WCDB4] voice count table stats failed username=${username}:`, error)
|
||||||
return null
|
return null
|
||||||
@@ -1094,7 +1138,7 @@ export class Wcdb4Client {
|
|||||||
endTime?: number,
|
endTime?: number,
|
||||||
limit?: number
|
limit?: number
|
||||||
): Wcdb4Message[] {
|
): Wcdb4Message[] {
|
||||||
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
if (!this.wcdbExecQuery) return []
|
||||||
|
|
||||||
let tables: Wcdb4MessageStore[] = []
|
let tables: Wcdb4MessageStore[] = []
|
||||||
try {
|
try {
|
||||||
@@ -1139,20 +1183,11 @@ export class Wcdb4Client {
|
|||||||
endTime?: number,
|
endTime?: number,
|
||||||
limit?: number
|
limit?: number
|
||||||
): Promise<Wcdb4Message[]> {
|
): Promise<Wcdb4Message[]> {
|
||||||
if (!this.wcdbGetMessageTableStats || !this.wcdbExecQuery) return []
|
if (!this.wcdbExecQuery) return []
|
||||||
|
|
||||||
let tables: Wcdb4MessageStore[] = []
|
let tables: Wcdb4MessageStore[] = []
|
||||||
try {
|
try {
|
||||||
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
|
tables = await this.listMessageStoresAsync(username)
|
||||||
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)
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn(`[WCDB4] async message table stats failed username=${username}:`, error)
|
console.warn(`[WCDB4] async message table stats failed username=${username}:`, error)
|
||||||
throw new Error(
|
throw new Error(
|
||||||
@@ -1231,10 +1266,37 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private listMessageStores(username: string): Wcdb4MessageStore[] {
|
private listMessageStores(username: string): Wcdb4MessageStore[] {
|
||||||
if (!this.wcdbGetMessageTableStats) return []
|
let stores: Wcdb4MessageStore[] = []
|
||||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
if (this.wcdbGetMessageTableStats) {
|
||||||
this.wcdbGetMessageTableStats!(handle, username, outJson)
|
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 : [])
|
return (Array.isArray(rows) ? rows : [])
|
||||||
.map((row) => ({
|
.map((row) => ({
|
||||||
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
|
tableName: this.pickString(row, ['table_name', 'tableName', 'name']),
|
||||||
@@ -1243,6 +1305,64 @@ export class Wcdb4Client {
|
|||||||
.filter((row) => row.tableName && row.dbPath)
|
.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>[] {
|
private executeMessageSql(store: Wcdb4MessageStore, sql: string): Record<string, unknown>[] {
|
||||||
if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道')
|
if (!this.wcdbExecQuery) throw new Error('当前 WCDB 数据服务不支持 SQL 通道')
|
||||||
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
const rows = this.callJson<Record<string, unknown>[]>((handle, outJson) =>
|
||||||
@@ -1889,7 +2009,7 @@ export class Wcdb4Client {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
return this.callJson<Wcdb4ImageHardlink>((handle, outJson) =>
|
return this.callJson<Wcdb4ImageHardlink>((handle, outJson) =>
|
||||||
this.wcdbResolveImageHardlink!(handle, normalizedMd5, this.accountRoot, outJson)
|
this.wcdbResolveImageHardlink!(handle, normalizedMd5, this.nativeAccountRoot, outJson)
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[WCDB4] resolve image hardlink failed:', error)
|
console.warn('[WCDB4] resolve image hardlink failed:', error)
|
||||||
@@ -1908,7 +2028,7 @@ export class Wcdb4Client {
|
|||||||
return await this.callJsonAsync<Wcdb4ImageHardlink>(
|
return await this.callJsonAsync<Wcdb4ImageHardlink>(
|
||||||
this.wcdbResolveImageHardlink as unknown as KoffiAsyncFunction,
|
this.wcdbResolveImageHardlink as unknown as KoffiAsyncFunction,
|
||||||
normalizedMd5,
|
normalizedMd5,
|
||||||
this.accountRoot
|
this.nativeAccountRoot
|
||||||
)
|
)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.warn('[WCDB4] async image hardlink resolve failed:', error)
|
console.warn('[WCDB4] async image hardlink resolve failed:', error)
|
||||||
@@ -2287,8 +2407,8 @@ export class Wcdb4Client {
|
|||||||
const candidates = [
|
const candidates = [
|
||||||
path.join(this.dbStoragePath, 'emoticon', 'emoticon.db'),
|
path.join(this.dbStoragePath, 'emoticon', 'emoticon.db'),
|
||||||
path.join(this.dbStoragePath, 'emotion', 'emoticon.db'),
|
path.join(this.dbStoragePath, 'emotion', 'emoticon.db'),
|
||||||
path.join(this.accountRoot, this.wxid, 'db_storage', 'emoticon', 'emoticon.db'),
|
path.join(this.nativeAccountRoot, 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', 'emotion', 'emoticon.db')
|
||||||
]
|
]
|
||||||
for (const candidate of candidates) {
|
for (const candidate of candidates) {
|
||||||
if (fs.existsSync(candidate)) return candidate
|
if (fs.existsSync(candidate)) return candidate
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export interface ConversationSidebarProps {
|
|||||||
onRefresh: (filterKeyword: string) => Promise<void>
|
onRefresh: (filterKeyword: string) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
type SectionName = 'groups' | 'folded' | 'contacts'
|
type SectionName = 'groups' | 'folded' | 'officialAccounts' | 'contacts'
|
||||||
type ConversationRow =
|
type ConversationRow =
|
||||||
| { kind: 'header'; id: string; title: string; count: number; section: SectionName }
|
| { kind: 'header'; id: string; title: string; count: number; section: SectionName }
|
||||||
| { kind: 'contact'; id: string; contact: Contact }
|
| { kind: 'contact'; id: string; contact: Contact }
|
||||||
@@ -48,13 +48,24 @@ export function ConversationSidebar({
|
|||||||
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
|
const [expandedSections, setExpandedSections] = useState<Record<SectionName, boolean>>({
|
||||||
groups: true,
|
groups: true,
|
||||||
folded: false,
|
folded: false,
|
||||||
|
officialAccounts: false,
|
||||||
contacts: false
|
contacts: false
|
||||||
})
|
})
|
||||||
const listRef = useRef<HTMLDivElement>(null)
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
const groups = contacts.filter((contact) => contact.type === 'group' && !contact.isFolded)
|
const groups = contacts.filter((contact) => contact.type === 'group' && !contact.isFolded)
|
||||||
const foldedGroups = 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[]>(
|
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',
|
kind: 'header',
|
||||||
id: 'contacts-header',
|
id: 'contacts-header',
|
||||||
@@ -104,8 +133,10 @@ export function ConversationSidebar({
|
|||||||
expandedSections.contacts,
|
expandedSections.contacts,
|
||||||
expandedSections.folded,
|
expandedSections.folded,
|
||||||
expandedSections.groups,
|
expandedSections.groups,
|
||||||
|
expandedSections.officialAccounts,
|
||||||
foldedGroups,
|
foldedGroups,
|
||||||
groups,
|
groups,
|
||||||
|
officialAccounts,
|
||||||
users
|
users
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ export interface Contact {
|
|||||||
m_nsNickName: string
|
m_nsNickName: string
|
||||||
md5: string
|
md5: string
|
||||||
type: 'user' | 'group'
|
type: 'user' | 'group'
|
||||||
|
isOfficialAccount?: boolean
|
||||||
avatar?: string
|
avatar?: string
|
||||||
wechatNickname?: string
|
wechatNickname?: string
|
||||||
remark?: string
|
remark?: string
|
||||||
|
|||||||
@@ -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(
|
||||||
|
<ConversationSidebar
|
||||||
|
contacts={[
|
||||||
|
{
|
||||||
|
m_nsUsrName: 'gh_fixture',
|
||||||
|
m_nsNickName: '测试公众号',
|
||||||
|
md5: 'official-md5',
|
||||||
|
type: 'user',
|
||||||
|
isOfficialAccount: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
m_nsUsrName: 'wxid_fixture',
|
||||||
|
m_nsNickName: '测试联系人',
|
||||||
|
md5: 'contact-md5',
|
||||||
|
type: 'user'
|
||||||
|
}
|
||||||
|
]}
|
||||||
|
selectedContact={null}
|
||||||
|
onSelectContact={vi.fn()}
|
||||||
|
onSearch={vi.fn()}
|
||||||
|
onContentFilter={vi.fn()}
|
||||||
|
width={320}
|
||||||
|
selfInfo={null}
|
||||||
|
dbReady
|
||||||
|
onOpenSettings={vi.fn()}
|
||||||
|
onRefresh={vi.fn(async () => 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()
|
||||||
|
})
|
||||||
|
})
|
||||||
@@ -1,5 +1,12 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest'
|
import fs from 'fs-extra'
|
||||||
import { Wcdb4Client, type Wcdb4Message } from '../../src/main/wcdb4-client'
|
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 => ({
|
const message = (id: string, year: number, serverId = `server-${id}`): Wcdb4Message => ({
|
||||||
mesLocalID: id,
|
mesLocalID: id,
|
||||||
@@ -12,6 +19,12 @@ const message = (id: string, year: number, serverId = `server-${id}`): Wcdb4Mess
|
|||||||
})
|
})
|
||||||
|
|
||||||
describe('WCDB message shard pagination', () => {
|
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 () => {
|
it('keeps messages whose local ids repeat across database shards', async () => {
|
||||||
const cursor = vi.fn(async () => [
|
const cursor = vi.fn(async () => [
|
||||||
message('1', 2024, 'server-2024'),
|
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 })
|
client.getMessagesAsync('fixture@chatroom', undefined, 1_767_225_600, { limit: 20 })
|
||||||
).rejects.toThrow('无法检查历史消息分片')
|
).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))
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|||||||
Reference in New Issue
Block a user