fix: 更新打包配置并修复群昵称刷新

更新跨平台 WCDB 和运行时命名。
优化消息获取速度
修复群昵称刷新回退与消息发送者显示,并增加可控的消息读取诊断日志和有界查询优化。
This commit is contained in:
Wxw-Gu
2026-08-25 14:26:29 +08:00
committed by 电摇小子
parent fc54c3b946
commit 47e0d7cd1e
25 changed files with 415 additions and 148 deletions
+10 -1
View File
@@ -8,7 +8,7 @@
<p align="center">
本地优先的微信聊天记录工作台:查看、搜索、提问、总结和导出<br />
查看聊天 · 找回信息 · AI 问答 · 群聊日报总结 · 语音转写 · 导出 · 微信机器人 · Agent 接入
查看聊天 · 找回信息 · AI 问答 · 微信群聊总结 · 语音转写 · 导出 · 微信机器人 · Agent 接入
</p>
<p align="center">
@@ -439,6 +439,15 @@ TraceMemo 在支持微信 4.x 时,参考并使用了 **[WeFlow](https://github
---
## 最后说两句
这个项目起初只是一个一时兴起的项目,所以它大概也不会有一份特别严肃的产品路线图。
我可能会按照自己的兴趣继续折腾,也可能突然加入一些奇奇怪怪、但觉得有意思的功能—— 比如让AI给某个好友, 某个群发一个语音条(逗逗群友) 或者定时生成群聊日报并做成微信卡片。
也因此,这个项目随时可能继续折腾,也可能因为其他事情暂时搁置。如果你有想要的功能,可以提Issue;如果觉得现有实现不符合你的需求,也欢迎直接 Fork 后自己改。
<p align="center">
<b>TraceMemo(迹忆)</b>
<br />
@@ -12,6 +12,16 @@
`6131` 是应用提供给本机集成使用的 API 端口,不是 Vite 的页面端口。
## WCDB 消息读取诊断
`WCDB_DEBUG_LOGS` 默认关闭。需要排查消息读取链路时,可以在启动命令前设置为 `1`:
```bash
WCDB_DEBUG_LOGS=1 pnpm dev
```
开启后会输出 `GETMSG-xxx` 请求耗时和 native `WCDB-EXPLAIN` 执行计划,不记录聊天正文。取消该环境变量或设为 `0` 即可关闭。
## Go 命令找不到
如果 `pnpm dev` 在构建微信连接器时出现 `spawnSync go ENOENT`,先执行:
+5 -2
View File
@@ -39,8 +39,11 @@ extraResources:
- '**/*'
win:
icon: icon.ico
# WCDB's Windows runtime checks the host executable name.
executableName: electron
executableName: TraceMemo
target:
- target: nsis
arch:
- x64
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
-9
View File
@@ -30,12 +30,6 @@ extraResources:
to: skill/tracememo-reader
filter:
- '**/*'
win:
icon: icon.ico
# WCDB's Windows runtime checks the host executable name. The dev runtime is
# electron.exe, so keep the packaged executable compatible while using
# TraceMemo as the product/shortcut name.
executableName: electron
nsis:
oneClick: false
allowToChangeInstallationDirectory: true
@@ -47,9 +41,6 @@ mac:
icon: icon.icns
entitlementsInherit: build/entitlements.mac.plist
extendInfo:
# The bundled WCDB bridge still uses Electron as its internal executable
# compatibility name; the public product and bundle identity are TraceMemo.
CFBundleName: Electron
CFBundleDisplayName: TraceMemo
NSCameraUsageDescription: Application requests access to the device's camera.
NSMicrophoneUsageDescription: Application requests access to the device's microphone.
+4 -2
View File
@@ -2,7 +2,7 @@
"name": "tracememo",
"version": "2.2.2",
"packageManager": "pnpm@7.33.7",
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、AI 总结和 Agent 助手。",
"description": "TraceMemo(迹忆)是一款本地优先、可追溯的 AI 微信知识与分析工作台。 原名 WechatExplorer,支持聊天记录搜索、知识库、微信群聊总结和 Agent 助手。",
"keywords": [
"wechat",
"wechat chat",
@@ -11,6 +11,7 @@
"windows微信",
"微信聊天记录",
"微信聊天记录搜索",
"微信群聊总结",
"微信AI",
"微信机器人",
"AI聊天搜索",
@@ -36,6 +37,7 @@
"prepare:ffmpeg:win": "node scripts/prepare-electron-runtime.cjs --platform win32 --arch x64",
"prepare:wechat-personal": "node scripts/prepare-wechat-chatter-runtime.cjs",
"start": "electron-vite preview",
"predev": "node -e \"require('electron')\"",
"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:unit": "vitest run --config vitest.unit.config.ts",
@@ -156,4 +158,4 @@
"ffmpeg-static"
]
}
}
}
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+1 -48
View File
@@ -1,11 +1,9 @@
/* eslint-disable @typescript-eslint/no-require-imports, @typescript-eslint/explicit-function-return-type */
const { chmodSync, existsSync, renameSync } = require('node:fs')
const { chmodSync, existsSync } = require('node:fs')
const { execFileSync } = require('node:child_process')
const path = require('node:path')
const asar = require('@electron/asar')
const COMPATIBILITY_NAME = 'Electron'
const HELPER_SUFFIXES = ['', ' (Plugin)', ' (Renderer)', ' (GPU)']
const REQUIRED_RUNTIME_PACKAGES = [
'@electron-toolkit/preload',
'@electron-toolkit/utils',
@@ -101,10 +99,6 @@ function validateAsarRuntimeDependencies(runtimeResources) {
)
}
}
function setPlistValue(plistPath, key, value) {
execFileSync('/usr/libexec/PlistBuddy', ['-c', `Set :${key} ${value}`, plistPath])
}
function validateReaderSkillRuntime(runtimeResources) {
const skillPath = path.join(runtimeResources, 'skill', 'tracememo-reader', 'SKILL.md')
if (!existsSync(skillPath)) {
@@ -148,47 +142,6 @@ exports.default = async function afterPack(context) {
return
}
if (context.electronPlatformName !== 'darwin') return
const productName = context.packager.appInfo.productFilename
const appPath = path.join(context.appOutDir, `${productName}.app`)
const contentsPath = path.join(appPath, 'Contents')
const sourceExecutable = path.join(contentsPath, 'MacOS', productName)
const targetExecutable = path.join(contentsPath, 'MacOS', COMPATIBILITY_NAME)
if (existsSync(sourceExecutable)) renameSync(sourceExecutable, targetExecutable)
if (!existsSync(targetExecutable)) {
throw new Error(`Missing Electron main executable: ${sourceExecutable}`)
}
const appPlistPath = path.join(contentsPath, 'Info.plist')
setPlistValue(appPlistPath, 'CFBundleExecutable', COMPATIBILITY_NAME)
setPlistValue(appPlistPath, 'CFBundleName', COMPATIBILITY_NAME)
const frameworksPath = path.join(contentsPath, 'Frameworks')
for (const suffix of HELPER_SUFFIXES) {
const sourceName = `${productName} Helper${suffix}`
const targetName = `${COMPATIBILITY_NAME} Helper${suffix}`
const sourceBundle = path.join(frameworksPath, `${sourceName}.app`)
const targetBundle = path.join(frameworksPath, `${targetName}.app`)
if (existsSync(sourceBundle)) renameSync(sourceBundle, targetBundle)
if (!existsSync(targetBundle)) {
throw new Error(`Missing Electron helper bundle: ${sourceBundle}`)
}
const sourceExecutable = path.join(targetBundle, 'Contents', 'MacOS', sourceName)
const targetExecutable = path.join(targetBundle, 'Contents', 'MacOS', targetName)
if (existsSync(sourceExecutable)) renameSync(sourceExecutable, targetExecutable)
if (!existsSync(targetExecutable)) {
throw new Error(`Missing Electron helper executable: ${sourceExecutable}`)
}
const plistPath = path.join(targetBundle, 'Contents', 'Info.plist')
setPlistValue(plistPath, 'CFBundleExecutable', targetName)
setPlistValue(plistPath, 'CFBundleName', targetName)
}
}
exports.getRuntimeResources = getRuntimeResources
+4 -9
View File
@@ -9,17 +9,12 @@ export const LEGACY_MIGRATION_RESULT_FD_ENV = 'TRACEMEMO_LEGACY_MIGRATION_RESULT
// This module must remain the first main-process import. Static imports in
// settings/cache services can otherwise resolve Electron paths before the
// TraceMemo userData is installed before any consumers. macOS uses the old
// safeStorage identity only inside the helper; Windows keeps in both
// processes because WCDB requires that technical runtime name.
// TraceMemo userData is installed before any consumers. The migration helper
// temporarily uses the legacy identity; normal Windows builds use TraceMemo.
const isLegacyMigrationHelper = process.env[LEGACY_MIGRATION_HELPER_ENV] === '1'
const legacyRuntimeName = process.platform === 'win32' ? 'WeFlow' : LEGACY_USER_DATA_NAME
const legacyRuntimeName = LEGACY_USER_DATA_NAME
const runtimeName =
process.platform === 'win32'
? 'WeFlow'
: isLegacyMigrationHelper
? legacyRuntimeName
: TRACE_MEMO_RUNTIME_NAME
isLegacyMigrationHelper ? legacyRuntimeName : TRACE_MEMO_RUNTIME_NAME
app.setName(runtimeName)
const isolatedUserData = process.env['WXE_USER_DATA']
+32 -5
View File
@@ -121,6 +121,14 @@ import type {
SynthesizeTextToSpeechRequest
} from '../shared/text-to-speech'
import { appLogger } from './app-logger'
import { wcdbDebugLog } from './wcdb-debug'
let getMessagesRequestSequence = 0
function nextGetMessagesRequestId(): string {
getMessagesRequestSequence += 1
return `GETMSG-${String(getMessagesRequestSequence).padStart(3, '0')}`
}
import type { AppLogEntry } from '../shared/app-log'
import { appUpdateService } from './services/app-update-service'
import { clearCache, getCacheSummary, openKnowledgeDirectory } from './services/cache-service'
@@ -1071,18 +1079,37 @@ app.whenReady().then(async () => {
endTime?: number,
options?: { limit?: number }
) => {
const messages = await chat.listMessagesAsync(userMd5, startTime, endTime, options)
if (chat.isReady()) {
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
const requestId = nextGetMessagesRequestId()
const startedAt = Date.now()
wcdbDebugLog(
`[${requestId}] IPC db:getMessages start userMd5=${userMd5} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
)
try {
const messages = await chat.listMessagesAsync(
userMd5,
startTime,
endTime,
options,
requestId
)
if (chat.isReady()) {
saveCachedMessages(chat.getCurrentAccountRoot(), userMd5, startTime, endTime, messages)
}
wcdbDebugLog(
`[${requestId}] IPC db:getMessages end rows=${messages.length} cost=${Date.now() - startedAt}ms`
)
return messages
} catch (error) {
wcdbDebugLog(`[${requestId}] IPC db:getMessages error cost=${Date.now() - startedAt}ms`)
throw error
}
return messages
}
)
ipcMain.handle('db:getGroupSnapshot', async (_, userMd5: string) => {
const snapshot = await chat.getGroupSnapshotAsync(userMd5)
if (snapshot && chat.isReady()) {
saveCachedGroupSnapshot(chat.getCurrentAccountRoot(), userMd5, snapshot)
return saveCachedGroupSnapshot(chat.getCurrentAccountRoot(), userMd5, snapshot)
}
return snapshot
})
+2 -2
View File
@@ -105,7 +105,7 @@ export class KeyService {
private localizeNetworkDll(originalPath: string): string {
try {
const tempDir = join(os.tmpdir(), 'weflow_dll_cache')
const tempDir = join(os.tmpdir(), 'tracememo_dll_cache')
if (!existsSync(tempDir)) {
mkdirSync(tempDir, { recursive: true })
}
@@ -361,7 +361,7 @@ export class KeyService {
error.includes('ACCESS_DENIED') ||
error.includes('打开目标进程失败')
) {
return '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 WeFlow 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
return '权限不足:无法访问微信进程。\n\n解决方法:\n1. 右键 TraceMemo 图标,选择"以管理员身份运行"\n2. 关闭可能拦截的安全软件(如360、火绒等)\n3. 确保微信没有以管理员权限运行'
}
return error
}
+1 -1
View File
@@ -30,7 +30,7 @@ try {
])
process.env.WCDB_RESOURCES_PATH = process.env.WCDB_RESOURCES_PATH || resourcesRoot
process.env.WEFLOW_PROJECT_NAME = process.env.WEFLOW_PROJECT_NAME || 'WeFlow'
process.env.TRACEMEMO_PROJECT_NAME = process.env.TRACEMEMO_PROJECT_NAME || 'TraceMemo'
prependPath(dllDirs.filter((dir) => fs.existsSync(dir)))
} catch (error) {
console.error('[TraceMemo] failed to enforce local DLL priority:', error)
+29 -3
View File
@@ -603,23 +603,49 @@ export function saveCachedGroupSnapshot(
accountRoot: string,
userMd5: string,
snapshot: CachedGroupSnapshot
): void {
): CachedGroupSnapshot {
const normalizedRoot = normalizeRoot(accountRoot)
if (!normalizedRoot || !userMd5) return
if (!normalizedRoot || !userMd5) return snapshot
const paths = getAccountCachePaths(normalizedRoot)
const file = getGroupCacheFile(normalizedRoot, userMd5)
const previous = readGroupSnapshotFile(normalizedRoot, userMd5)?.snapshot
const previousByWxid = new Map((previous?.members || []).map((member) => [member.wxid, member]))
const mergedSnapshot: CachedGroupSnapshot = previous && previous.roomId === snapshot.roomId
? {
...snapshot,
members: snapshot.members.map((member) => {
const oldMember = previousByWxid.get(member.wxid)
const freshGroupNickname = String(member.groupNickname || '').trim()
const freshWechatNickname = String(member.wechatNickname || '').trim()
const oldGroupNickname = String(oldMember?.groupNickname || '').trim()
const freshLooksLikeContactFallback =
!freshGroupNickname ||
freshGroupNickname === freshWechatNickname
const groupNickname =
freshLooksLikeContactFallback && oldGroupNickname
? oldGroupNickname
: freshGroupNickname
return {
...member,
groupNickname,
nickname: groupNickname || member.nickname
}
})
}
: snapshot
const value: CachedGroupSnapshotFile = {
version: CACHE_VERSION,
platform: process.platform,
accountRoot: normalizedRoot,
userMd5,
updatedAt: Date.now(),
snapshot
snapshot: mergedSnapshot
}
touchMemory(groupMemory, file, value, MAX_MEMORY_GROUP_SNAPSHOTS)
scheduleWrite(file, value, {
prune: { directory: paths.groups, maxFiles: MAX_GROUP_SNAPSHOTS }
})
return mergedSnapshot
}
export function saveCachedMessages(
+40 -9
View File
@@ -15,6 +15,7 @@ import {
} from '../../shared/windows-runtime'
import { mergeRecallArchiveMessages, recordRecallArchiveMessages } from './recall-archive-service'
import type { ExportImageQuality } from '../../shared/image-quality'
import { wcdbDebugLog } from '../wcdb-debug'
export function getCurrentKey(): string {
if (!dbRef) return ''
@@ -226,7 +227,8 @@ function listSourceMessages(
startTime?: number,
endTime?: number,
options?: { limit?: number },
rawMessagesOverride?: WechatMessage[]
rawMessagesOverride?: WechatMessage[],
requestId = 'NO-REQUEST'
): FormattedMessage[] {
if (!dbRef) return []
@@ -234,13 +236,13 @@ function listSourceMessages(
const wcdb4Client = dbRef.getWcdb4Client()
const username = wcdb4Client.getUsernameByMd5(userMd5)
const isGroupChat = Boolean(username?.endsWith('@chatroom'))
console.log(
`[ChatService] listMessages begin md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
wcdbDebugLog(
`[${requestId}] ChatService listSourceMessages start md5=${userMd5} username=${username || ''} start=${startTime || 0} end=${endTime || 0} limit=${options?.limit || 0}`
)
const rawMessages =
rawMessagesOverride ?? dbRef.getUserMessages(userMd5, startTime, endTime, options)
console.log(
`[ChatService] listMessages native done md5=${userMd5} raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
wcdbDebugLog(
`[${requestId}] ChatService raw snapshot ready raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
)
const formatted = rawMessages.map((msg: WechatMessage) => {
@@ -447,14 +449,43 @@ export async function listMessagesAsync(
userMd5: string,
startTime?: number,
endTime?: number,
options?: { limit?: number }
options?: { limit?: number },
requestId = 'NO-REQUEST'
): Promise<FormattedMessage[]> {
if (!dbRef) return []
const rawMessages = await dbRef.getUserMessagesAsync(userMd5, startTime, endTime, options)
const sourceMessages = listSourceMessages(userMd5, startTime, endTime, options, rawMessages)
const startedAt = Date.now()
wcdbDebugLog(`[${requestId}] ChatService listMessagesAsync start md5=${userMd5}`)
const rawMessages = await dbRef.getUserMessagesAsync(
userMd5,
startTime,
endTime,
options,
requestId
)
wcdbDebugLog(
`[${requestId}] ChatService getUserMessagesAsync end raw=${rawMessages.length} cost=${Date.now() - startedAt}ms`
)
const sourceMessages = listSourceMessages(
userMd5,
startTime,
endTime,
options,
rawMessages,
requestId
)
const username = dbRef.getWcdb4Client().getUsernameByMd5(userMd5) || ''
recordRecallArchiveMessages(userMd5, username, sourceMessages)
return mergeRecallArchiveMessages(userMd5, sourceMessages, startTime, endTime, options?.limit)
const result = mergeRecallArchiveMessages(
userMd5,
sourceMessages,
startTime,
endTime,
options?.limit
)
wcdbDebugLog(
`[${requestId}] ChatService listMessagesAsync end formatted=${result.length} cost=${Date.now() - startedAt}ms`
)
return result
}
export async function listMessagesForExport(
-20
View File
@@ -58,7 +58,6 @@ function getDefaultDbRootCandidates(home: string): string[] {
// 仅支持 WeChat 4.0:剔除 V3 时代的 "WeChat Files" 目录,
// 只认 xwechat_files(含 Documents\ 和 AppData\Roaming\Tencent\ 两种合法位置)。
const candidates = [
...getWeflowDbPathCandidates(home),
path.join(home, 'Documents', 'xwechat_files'),
path.join(os.homedir(), 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
]
@@ -66,25 +65,6 @@ function getDefaultDbRootCandidates(home: string): string[] {
return unique(candidates)
}
function getWeflowDbPathCandidates(home: string): string[] {
const configPaths = [
path.join(home, 'AppData', 'Roaming', 'weflow', 'WeFlow-config.json'),
path.join(home, 'AppData', 'Roaming', 'WeFlow', 'WeFlow-config.json')
]
const candidates: string[] = []
for (const configPath of configPaths) {
try {
const config = fs.readJsonSync(configPath) as { dbPath?: unknown }
if (typeof config.dbPath === 'string' && config.dbPath.trim()) {
candidates.push(config.dbPath.trim())
}
} catch {
// WeFlow is optional; ignore missing or unreadable config.
}
}
return candidates
}
function unique(values: string[]): string[] {
return Array.from(new Set(values))
}
+7
View File
@@ -0,0 +1,7 @@
export function isWcdbDebugEnabled(): boolean {
return process.env.WCDB_DEBUG_LOGS === '1'
}
export function wcdbDebugLog(message: string): void {
if (isWcdbDebugEnabled()) console.log(message)
}
+123 -34
View File
@@ -5,6 +5,7 @@ import crypto from 'crypto'
import { createRequire } from 'module'
import { createConnection, Socket } from 'net'
import { getResourceRoots } from './resource-paths'
import { wcdbDebugLog } from './wcdb-debug'
export interface Wcdb4Session {
username: string
@@ -513,7 +514,6 @@ export class Wcdb4Client {
if (process.platform === 'win32') {
// 仅支持 WeChat 4.0:只认 xwechat_files,V3 时代的 "WeChat Files" 不再加入候选
const candidates = [
...Wcdb4Client.getWeflowDbPathCandidates(home),
path.join(home, 'Documents', 'xwechat_files'),
path.join(home, 'AppData', 'Roaming', 'Tencent', 'xwechat_files')
]
@@ -524,25 +524,6 @@ export class Wcdb4Client {
]
}
private static getWeflowDbPathCandidates(home: string): string[] {
const configPaths = [
path.join(home, 'AppData', 'Roaming', 'weflow', 'WeFlow-config.json'),
path.join(home, 'AppData', 'Roaming', 'WeFlow', 'WeFlow-config.json')
]
const candidates: string[] = []
for (const configPath of configPaths) {
try {
const config = fs.readJsonSync(configPath) as { dbPath?: unknown }
if (typeof config.dbPath === 'string' && config.dbPath.trim()) {
candidates.push(config.dbPath.trim())
}
} catch {
// WeFlow is optional; ignore missing or unreadable config.
}
}
return candidates
}
private static hasDbStorage(candidate: string): boolean {
try {
return (
@@ -1094,13 +1075,23 @@ export class Wcdb4Client {
username: string,
startTime?: number,
endTime?: number,
options: Wcdb4MessageQueryOptions = {}
options: Wcdb4MessageQueryOptions = {},
requestId = 'NO-REQUEST'
): Promise<Wcdb4Message[]> {
const startedAt = Date.now()
const maxRows = this.normalizeMessageLimit(options.limit)
wcdbDebugLog(
`[${requestId}] WCDB getMessagesAsync start username=${username} maxRows=${maxRows || 0} start=${startTime || 0} end=${endTime || 0}`
)
let cursorMessages: Wcdb4Message[] = []
try {
cursorMessages = await this.getMessagesByCursorAsync(username, startTime, endTime, maxRows)
cursorMessages = await this.getMessagesByCursorAsync(
username,
startTime,
endTime,
maxRows,
requestId
)
} catch (error) {
console.warn(`[WCDB4] async cursor messages failed username=${username}:`, error)
}
@@ -1113,12 +1104,28 @@ export class Wcdb4Client {
// enumerate. A bounded query must inspect all matching stores so history
// cannot silently stop at a shard boundary.
let tableMessages: Wcdb4Message[] = []
if (endTime || cursorMessages.length === 0) {
tableMessages = await this.getMessagesByTableScanAsync(username, startTime, endTime, maxRows)
const cursorSatisfiesLimit = maxRows !== undefined && cursorMessages.length >= maxRows
let tableScanExecuted = false
if (!cursorSatisfiesLimit && (endTime || cursorMessages.length === 0)) {
tableScanExecuted = true
tableMessages = await this.getMessagesByTableScanAsync(
username,
startTime,
endTime,
maxRows,
requestId
)
}
wcdbDebugLog(
`[${requestId}] WCDB message source decision cursorSatisfiesLimit=${cursorSatisfiesLimit} tableScanExecuted=${tableScanExecuted} cursorRows=${cursorMessages.length} tableRows=${tableMessages.length}`
)
const mergeStartedAt = Date.now()
const messages = this.mergeMessageRows(cursorMessages, tableMessages, maxRows)
console.log(
`[WCDB4] getMessages async username=${username} rows=${messages.length} cursor=${cursorMessages.length} tables=${tableMessages.length} cost=${Date.now() - startedAt}ms`
wcdbDebugLog(
`[${requestId}] WCDB mergeMessageRows cost=${Date.now() - mergeStartedAt}ms finalRows=${messages.length}`
)
wcdbDebugLog(
`[${requestId}] WCDB getMessagesAsync end username=${username} rows=${messages.length} cursor=${cursorMessages.length} tables=${tableMessages.length} totalCost=${Date.now() - startedAt}ms`
)
return messages
}
@@ -1225,13 +1232,20 @@ export class Wcdb4Client {
username: string,
startTime?: number,
endTime?: number,
limit?: number
limit?: number,
requestId = 'NO-REQUEST'
): Promise<Wcdb4Message[]> {
if (!this.wcdbExecQuery) return []
const startedAt = Date.now()
wcdbDebugLog(`[${requestId}] WCDB table scan start username=${username}`)
let tables: Wcdb4MessageStore[] = []
try {
const statsStartedAt = Date.now()
tables = await this.listMessageStoresAsync(username)
wcdbDebugLog(
`[${requestId}] WCDB get_message_table_stats end stores=${tables.length} cost=${Date.now() - statsStartedAt}ms`
)
} catch (error) {
console.warn(`[WCDB4] async message table stats failed username=${username}:`, error)
throw new Error(
@@ -1254,12 +1268,16 @@ export class Wcdb4Client {
for (const table of tables) {
try {
const sql = `SELECT * FROM ${this.quoteSqlIdentifier(table.tableName)}${whereSql} ORDER BY "create_time" ${order} LIMIT ${rowLimit}`
const queryStartedAt = Date.now()
const rows = await this.callJsonAsync<Record<string, unknown>[]>(
this.wcdbExecQuery as unknown as KoffiAsyncFunction,
'message',
table.dbPath,
sql
)
wcdbDebugLog(
`[${requestId}] WCDB exec_query table=${table.tableName} db=${table.dbPath} rows=${Array.isArray(rows) ? rows.length : 0} cost=${Date.now() - queryStartedAt}ms`
)
successfulTables += 1
if (Array.isArray(rows)) allRows.push(...rows)
} catch (error) {
@@ -1274,7 +1292,11 @@ export class Wcdb4Client {
throw new Error('历史消息分片均读取失败,请检查数据目录或微信数据版本')
}
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
const result = this.finalizeMessages(username, allRows, startTime, endTime, limit)
wcdbDebugLog(
`[${requestId}] WCDB table scan end rows=${result.length} cost=${Date.now() - startedAt}ms`
)
return result
}
installRecallJournal(usernames: string[]): { installed: number; failed: number } {
@@ -1682,6 +1704,13 @@ export class Wcdb4Client {
): Wcdb4Message[] {
const messages = rows.map((row) => this.normalizeMessage(row))
// Message rows carry a sender id, while the group-local display name lives
// in the separate group nickname map. Resolve that map once per group
// snapshot so chat bubbles do not fall back to the contact nickname.
const groupNicknames = username.endsWith('@chatroom')
? this.getGroupNicknames(username)
: undefined
const sorted = messages
.filter((message) => {
const createTime = Number(message.msgCreateTime)
@@ -1695,7 +1724,10 @@ export class Wcdb4Client {
return visibleMessages.map((message) => {
if (!message.sender) return message
const senderNickname = this.displayNameCache.get(message.sender) || message.senderNickname
const senderNickname =
groupNicknames?.get(message.sender) ||
this.displayNameCache.get(message.sender) ||
message.senderNickname
const senderAvatar = this.avatarCache.get(message.sender) || message.senderAvatar
const shouldPrefixSender =
username.endsWith('@chatroom') &&
@@ -1747,7 +1779,8 @@ export class Wcdb4Client {
username: string,
startTime?: number,
endTime?: number,
limit?: number
limit?: number,
requestId = 'NO-REQUEST'
): Promise<Wcdb4Message[]> {
if (!this.wcdbOpenMessageCursor || !this.wcdbFetchMessageBatch) return []
@@ -1757,6 +1790,10 @@ export class Wcdb4Client {
const begin = this.normalizeTimestamp(startTime || 0)
const end = this.normalizeTimestamp(endTime || 0)
const ascending = limit ? 0 : 1
wcdbDebugLog(
`[${requestId}] WCDB open_message_cursor start username=${username} batchSize=${batchSize} ascending=${ascending} begin=${begin} end=${end}`
)
const openStartedAt = Date.now()
const openResult = await this.callAsyncCode(
this.wcdbOpenMessageCursor as unknown as KoffiAsyncFunction,
handle,
@@ -1767,14 +1804,20 @@ export class Wcdb4Client {
end,
cursorOut
)
wcdbDebugLog(
`[${requestId}] WCDB open_message_cursor end code=${openResult} cursor=${cursorOut[0]} cost=${Date.now() - openStartedAt}ms`
)
if (openResult !== 0 || cursorOut[0] <= 0) return []
const cursor = cursorOut[0]
const allRows: Record<string, unknown>[] = []
let fetchIndex = 0
try {
while (true) {
fetchIndex += 1
const outJson: WcdbVoidOut = [null]
const outHasMore: [number] = [0]
const fetchStartedAt = Date.now()
const fetchResult = await this.callAsyncCode(
this.wcdbFetchMessageBatch as unknown as KoffiAsyncFunction,
handle,
@@ -1782,24 +1825,43 @@ export class Wcdb4Client {
outJson,
outHasMore
)
if (fetchResult !== 0 || !outJson[0]) break
const fetchNativeCost = Date.now() - fetchStartedAt
if (fetchResult !== 0 || !outJson[0]) {
wcdbDebugLog(
`[${requestId}] WCDB fetch_message_batch #${fetchIndex} code=${fetchResult} nativeCost=${fetchNativeCost}ms output=${Boolean(outJson[0])}`
)
break
}
try {
const decodeStartedAt = Date.now()
const json = this.koffi!.decode(outJson[0], 'char', -1)
const batch = JSON.parse(json) as Record<string, unknown>[]
if (Array.isArray(batch)) allRows.push(...batch)
wcdbDebugLog(
`[${requestId}] WCDB fetch_message_batch #${fetchIndex} code=${fetchResult} nativeCost=${fetchNativeCost}ms jsonCost=${Date.now() - decodeStartedAt}ms batchRows=${Array.isArray(batch) ? batch.length : 0} hasMore=${outHasMore[0]}`
)
} finally {
this.wcdbFreeString?.(outJson[0])
}
if (!outHasMore[0] || (limit && allRows.length >= limit)) break
}
} finally {
const closeStartedAt = Date.now()
try {
this.wcdbCloseMessageCursor?.(handle, cursor)
} catch {
// Best-effort cursor cleanup.
}
wcdbDebugLog(
`[${requestId}] WCDB close_message_cursor cursor=${cursor} cost=${Date.now() - closeStartedAt}ms fetches=${fetchIndex}`
)
}
return this.finalizeMessages(username, allRows, startTime, endTime, limit)
const finalizeStartedAt = Date.now()
const result = this.finalizeMessages(username, allRows, startTime, endTime, limit)
wcdbDebugLog(
`[${requestId}] WCDB finalizeMessages cost=${Date.now() - finalizeStartedAt}ms rows=${result.length}`
)
return result
}
async getGroupMembersAsync(chatroomId: string): Promise<Wcdb4GroupMember[]> {
@@ -2672,6 +2734,7 @@ export class Wcdb4Client {
'WCDB_CT_compressContent'
])
const content = this.decodeMessageContent(contentRaw, compressRaw)
const contentSender = this.extractSenderFromMessageContent(content)
const sender = this.pickString(row, [
'sender_username',
'senderUsername',
@@ -2679,7 +2742,7 @@ export class Wcdb4Client {
'fromUsername',
'from_username',
'WCDB_CT_sender_username'
])
]) || contentSender
const createTime = this.pickNumber(row, [
'create_time',
'createTime',
@@ -2719,7 +2782,7 @@ export class Wcdb4Client {
'type',
'WCDB_CT_local_type'
])
const isSend = this.pickBoolean(row, [
const rawIsSend = this.pickValue(row, [
'computed_is_send',
'computedIsSend',
'is_send',
@@ -2727,6 +2790,22 @@ export class Wcdb4Client {
'mesDes',
'WCDB_CT_is_send'
])
const parsedIsSend = rawIsSend === undefined || rawIsSend === null
? false
: this.pickBoolean(row, [
'computed_is_send',
'computedIsSend',
'is_send',
'isSend',
'mesDes',
'WCDB_CT_is_send'
])
const isSelfSender = sender ? this.getMyUsernameCandidates().some((candidate) => {
const left = String(candidate || '').trim().toLowerCase()
const right = String(sender || '').trim().toLowerCase()
return Boolean(left && right && (left === right || left.startsWith(`${right}_`) || right.startsWith(`${left}_`)))
}) : false
const isSend = isSelfSender || parsedIsSend
return {
mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`,
@@ -2742,6 +2821,16 @@ export class Wcdb4Client {
}
}
/** 解决群里自己的消息被识别成 user */
private extractSenderFromMessageContent(content: string): string {
const firstLine = String(content || '').split(/\r?\n/, 1)[0].trim()
if (!firstLine || firstLine.length > 128 || /[<>]/.test(firstLine)) return ''
const candidate = firstLine.replace(/:$/, '').trim()
if (!candidate || /\s/.test(candidate)) return ''
if (candidate.includes('@') || candidate.startsWith('wxid_') || candidate.startsWith('gh_')) return candidate
return this.getMyUsernameCandidates().includes(candidate) ? candidate : ''
}
private shouldHydrateSessionDisplayName(session: Wcdb4Session): boolean {
const username = String(session.username || '').trim()
const nickname = String(session.nickname || '').trim()
+17 -3
View File
@@ -1,4 +1,5 @@
import { Wcdb4Client, Wcdb4MessageQueryOptions } from './wcdb4-client'
import { wcdbDebugLog } from './wcdb-debug'
export interface UserContact {
m_nsUsrName: string
@@ -197,13 +198,26 @@ export class WechatDb {
userMd5: string,
startTime?: number,
endTime?: number,
options?: Wcdb4MessageQueryOptions
options?: Wcdb4MessageQueryOptions,
requestId = 'NO-REQUEST'
): Promise<WechatMessage[]> {
this.ensureChatTableMapping()
const username = this.chatMd5ToUsername.get(userMd5)
if (!username) return []
const messages = await this.wcdb4Client.getMessagesAsync(username, startTime, endTime, options)
return messages.map((message) => ({ ...message.raw, ...message }))
const startedAt = Date.now()
wcdbDebugLog(`[${requestId}] WechatDb getUserMessagesAsync start username=${username}`)
const messages = await this.wcdb4Client.getMessagesAsync(
username,
startTime,
endTime,
options,
requestId
)
const mapped = messages.map((message) => ({ ...message.raw, ...message }))
wcdbDebugLog(
`[${requestId}] WechatDb getUserMessagesAsync end rows=${mapped.length} cost=${Date.now() - startedAt}ms`
)
return mapped
}
public async getUserMessagesForExport(
+67
View File
@@ -19,6 +19,7 @@ import {
mergeCachedSelfInfo,
saveBootstrapContacts,
saveBootstrapSelf,
saveCachedGroupSnapshot,
saveCachedMessages
} from '../../src/main/services/bootstrap-cache'
@@ -141,4 +142,70 @@ describe('bootstrap cache', () => {
})
flushBootstrapCacheWritesSync()
})
it('preserves known group nicknames when a refresh only returns contact nicknames', () => {
const groupRoot = '/fixture/group-account'
const first = {
roomId: 'fixture@chatroom',
memberCount: 1,
members: [
{
wxid: 'wxid-member',
nickname: '群内昵称',
groupNickname: '群内昵称',
wechatNickname: '通讯录昵称',
remark: '',
avatar: ''
}
]
}
const refreshed = {
...first,
members: [{ ...first.members[0], nickname: '通讯录昵称', groupNickname: '通讯录昵称' }]
}
saveCachedGroupSnapshot(groupRoot, 'fixture-md5', first)
flushBootstrapCacheWritesSync()
clearBootstrapCache()
const merged = saveCachedGroupSnapshot(groupRoot, 'fixture-md5', refreshed)
expect(merged.members[0]).toMatchObject({
nickname: '群内昵称',
groupNickname: '群内昵称',
wechatNickname: '通讯录昵称'
})
})
it('accepts a refreshed group nickname when it is not a contact fallback', () => {
const groupRoot = '/fixture/group-nickname-update'
const first = {
roomId: 'fixture@chatroom',
memberCount: 1,
members: [
{
wxid: 'wxid-member',
nickname: '旧群昵称',
groupNickname: '旧群昵称',
wechatNickname: '',
remark: '',
avatar: ''
}
]
}
const refreshed = {
...first,
members: [{ ...first.members[0], nickname: '新群昵称', groupNickname: '新群昵称' }]
}
saveCachedGroupSnapshot(groupRoot, 'fixture-md5', first)
flushBootstrapCacheWritesSync()
clearBootstrapCache()
const merged = saveCachedGroupSnapshot(groupRoot, 'fixture-md5', refreshed)
expect(merged.members[0]).toMatchObject({
nickname: '新群昵称',
groupNickname: '新群昵称',
wechatNickname: ''
})
})
})
+32
View File
@@ -0,0 +1,32 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { isWcdbDebugEnabled, wcdbDebugLog } from '../../src/main/wcdb-debug'
const originalDebugValue = process.env.WCDB_DEBUG_LOGS
afterEach(() => {
if (originalDebugValue === undefined) delete process.env.WCDB_DEBUG_LOGS
else process.env.WCDB_DEBUG_LOGS = originalDebugValue
vi.restoreAllMocks()
})
describe('WCDB debug logging', () => {
it('is disabled by default', () => {
delete process.env.WCDB_DEBUG_LOGS
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
wcdbDebugLog('[GETMSG-001] hidden')
expect(isWcdbDebugEnabled()).toBe(false)
expect(log).not.toHaveBeenCalled()
})
it('logs when WCDB_DEBUG_LOGS is enabled', () => {
process.env.WCDB_DEBUG_LOGS = '1'
const log = vi.spyOn(console, 'log').mockImplementation(() => {})
wcdbDebugLog('[GETMSG-001] visible')
expect(isWcdbDebugEnabled()).toBe(true)
expect(log).toHaveBeenCalledWith('[GETMSG-001] visible')
})
})
+31
View File
@@ -110,4 +110,35 @@ describe('WCDB group member names', () => {
expectedMember({ nickname: '真实群昵称', wechatNickname: '', remark: '' })
])
})
it('uses the group nickname for message sender display before contact display names', () => {
const client = Object.assign(Object.create(Wcdb4Client.prototype), {
accountRoot: '/fixture/account',
displayNameCache: new Map([['wxid-member', '通讯录昵称']]),
getGroupNicknames: vi.fn(() => new Map([['wxid-member', '真实群昵称']])),
avatarCache: new Map<string, string>(),
myWxid: '',
wxid: ''
}) as Wcdb4Client
const finalizeMessages = (client as unknown as {
finalizeMessages: (
username: string,
rows: Record<string, unknown>[]
) => Array<{ senderNickname?: string }>
}).finalizeMessages
expect(
finalizeMessages.call(client, 'fixture@chatroom', [
{
sender_username: 'wxid-member',
create_time: 1731327263,
local_id: 1,
local_type: 1,
msg_content: 'fixture'
}
])
).toMatchObject([{ senderNickname: '真实群昵称' }])
expect(client.getGroupNicknames).toHaveBeenCalledWith('fixture@chatroom')
})
})