diff --git a/README.md b/README.md index d0cfc42..32cbb48 100644 --- a/README.md +++ b/README.md @@ -8,7 +8,7 @@

本地优先的微信聊天记录工作台:查看、搜索、提问、总结和导出
- 查看聊天 · 找回信息 · AI 问答 · 群聊日报总结 · 语音转写 · 导出 · 微信机器人 · Agent 接入 + 查看聊天 · 找回信息 · AI 问答 · 微信群聊总结 · 语音转写 · 导出 · 微信机器人 · Agent 接入

@@ -439,6 +439,15 @@ TraceMemo 在支持微信 4.x 时,参考并使用了 **[WeFlow](https://github --- +## 最后说两句 + +这个项目起初只是一个一时兴起的项目,所以它大概也不会有一份特别严肃的产品路线图。 + +我可能会按照自己的兴趣继续折腾,也可能突然加入一些奇奇怪怪、但觉得有意思的功能—— 比如让AI给某个好友, 某个群发一个语音条(逗逗群友) 或者定时生成群聊日报并做成微信卡片。 + +也因此,这个项目随时可能继续折腾,也可能因为其他事情暂时搁置。如果你有想要的功能,可以提Issue;如果觉得现有实现不符合你的需求,也欢迎直接 Fork 后自己改。 + +

TraceMemo(迹忆)
diff --git a/docs/development/local-startup-troubleshooting.md b/docs/development/local-startup-troubleshooting.md index db1cd70..8bc02b6 100644 --- a/docs/development/local-startup-troubleshooting.md +++ b/docs/development/local-startup-troubleshooting.md @@ -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`,先执行: diff --git a/electron-builder.win.yml b/electron-builder.win.yml index ab80da6..d1d0156 100644 --- a/electron-builder.win.yml +++ b/electron-builder.win.yml @@ -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 diff --git a/electron-builder.yml b/electron-builder.yml index ca68915..feb124e 100644 --- a/electron-builder.yml +++ b/electron-builder.yml @@ -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. diff --git a/package.json b/package.json index b3ec68f..72dcf11 100644 --- a/package.json +++ b/package.json @@ -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" ] } -} +} \ No newline at end of file diff --git a/resources/macos/libWCDB.dylib b/resources/macos/libWCDB.dylib index 75eb279..71060b9 100755 Binary files a/resources/macos/libWCDB.dylib and b/resources/macos/libWCDB.dylib differ diff --git a/resources/macos/libwcdb_api.dylib b/resources/macos/libwcdb_api.dylib index db376bb..abdd2a7 100755 Binary files a/resources/macos/libwcdb_api.dylib and b/resources/macos/libwcdb_api.dylib differ diff --git a/resources/wcdb/win32/arm64/WCDB.dll b/resources/wcdb/win32/arm64/WCDB.dll deleted file mode 100644 index 60c3508..0000000 Binary files a/resources/wcdb/win32/arm64/WCDB.dll and /dev/null differ diff --git a/resources/wcdb/win32/arm64/wcdb_api.dll b/resources/wcdb/win32/arm64/wcdb_api.dll deleted file mode 100644 index 1a1139c..0000000 Binary files a/resources/wcdb/win32/arm64/wcdb_api.dll and /dev/null differ diff --git a/resources/wcdb/win32/x64/WCDB.dll b/resources/wcdb/win32/x64/WCDB.dll index 8127079..4aaf338 100644 Binary files a/resources/wcdb/win32/x64/WCDB.dll and b/resources/wcdb/win32/x64/WCDB.dll differ diff --git a/resources/wcdb/win32/x64/wcdb_api.dll b/resources/wcdb/win32/x64/wcdb_api.dll index 8842616..88ddfad 100644 Binary files a/resources/wcdb/win32/x64/wcdb_api.dll and b/resources/wcdb/win32/x64/wcdb_api.dll differ diff --git a/scripts/after-pack.cjs b/scripts/after-pack.cjs index 3b4dd14..be0dff6 100644 --- a/scripts/after-pack.cjs +++ b/scripts/after-pack.cjs @@ -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 diff --git a/src/main/app-data-bootstrap.ts b/src/main/app-data-bootstrap.ts index 58ad39d..dcb19a5 100644 --- a/src/main/app-data-bootstrap.ts +++ b/src/main/app-data-bootstrap.ts @@ -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'] diff --git a/src/main/index.ts b/src/main/index.ts index 2b22007..d1161a8 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -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 }) diff --git a/src/main/key-service-win.ts b/src/main/key-service-win.ts index 19161c4..97a9cbe 100644 --- a/src/main/key-service-win.ts +++ b/src/main/key-service-win.ts @@ -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 } diff --git a/src/main/preload-env.ts b/src/main/preload-env.ts index 346d0eb..6ec71e8 100644 --- a/src/main/preload-env.ts +++ b/src/main/preload-env.ts @@ -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) diff --git a/src/main/services/bootstrap-cache.ts b/src/main/services/bootstrap-cache.ts index 46ad6e6..6038242 100644 --- a/src/main/services/bootstrap-cache.ts +++ b/src/main/services/bootstrap-cache.ts @@ -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( diff --git a/src/main/services/chat-service.ts b/src/main/services/chat-service.ts index 5a94b19..114f654 100644 --- a/src/main/services/chat-service.ts +++ b/src/main/services/chat-service.ts @@ -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 { 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( diff --git a/src/main/services/settings-store.ts b/src/main/services/settings-store.ts index b48b252..316e7e4 100644 --- a/src/main/services/settings-store.ts +++ b/src/main/services/settings-store.ts @@ -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)) } diff --git a/src/main/wcdb-debug.ts b/src/main/wcdb-debug.ts new file mode 100644 index 0000000..60e150a --- /dev/null +++ b/src/main/wcdb-debug.ts @@ -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) +} diff --git a/src/main/wcdb4-client.ts b/src/main/wcdb4-client.ts index 371e47c..5a3c641 100644 --- a/src/main/wcdb4-client.ts +++ b/src/main/wcdb4-client.ts @@ -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 { 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 { 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[]>( 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 { 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[] = [] + 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[] 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 { @@ -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() diff --git a/src/main/wechat-db.ts b/src/main/wechat-db.ts index 74e3b44..23b6d4a 100644 --- a/src/main/wechat-db.ts +++ b/src/main/wechat-db.ts @@ -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 { 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( diff --git a/tests/unit/bootstrap-cache.test.ts b/tests/unit/bootstrap-cache.test.ts index 65db6ab..4913a8e 100644 --- a/tests/unit/bootstrap-cache.test.ts +++ b/tests/unit/bootstrap-cache.test.ts @@ -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: '' + }) + }) }) diff --git a/tests/unit/wcdb-debug.test.ts b/tests/unit/wcdb-debug.test.ts new file mode 100644 index 0000000..3506b14 --- /dev/null +++ b/tests/unit/wcdb-debug.test.ts @@ -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') + }) +}) diff --git a/tests/unit/wcdb-group-members.test.ts b/tests/unit/wcdb-group-members.test.ts index 57f55b9..6f721c3 100644 --- a/tests/unit/wcdb-group-members.test.ts +++ b/tests/unit/wcdb-group-members.test.ts @@ -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(), + myWxid: '', + wxid: '' + }) as Wcdb4Client + + const finalizeMessages = (client as unknown as { + finalizeMessages: ( + username: string, + rows: Record[] + ) => 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') + }) })