mirror of
https://wget.la/https://github.com/Wxw-Gu/WechatExplorer
synced 2026-08-17 19:47:08 +08:00
feat: 添加微信数据库实时监控,聊天记录自动刷新
This commit is contained in:
@@ -3,11 +3,38 @@
|
|||||||
MAC系统 获取微信聊天记录 AI一键生成群聊总结
|
MAC系统 获取微信聊天记录 AI一键生成群聊总结
|
||||||
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
|
是一个基于 Electron + React + TypeScript 开发的微信聊天记录查看与分析工具。它支持查看解密后的微信数据库内容,提供聊天记录搜索、导出以及 AI 智能总结功能。
|
||||||
|
|
||||||
|
## 项目说明
|
||||||
|
|
||||||
|
本项目的目标,是在自己的电脑上实现“本地查看微信聊天记录 + 一键生成群聊总结”的实用能力。
|
||||||
|
|
||||||
|
在微信 4.0 数据库解析、解密思路上,项目参考了 [WeFlow](https://github.com/hicccc77/WeFlow) 等开源项目的实现方式;此项目围绕我自己的使用场景做的定制化工具,重点放在本地聊天记录查看、群聊总结和个人工作流集成上。
|
||||||
|
|
||||||
|
## ✨ 功能特性
|
||||||
|
|
||||||
|
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录,支持头像显示。
|
||||||
|
- **全局搜索**: 快速搜索聊天内容。
|
||||||
|
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
||||||
|
- **群聊日报生成**: 支持围绕群聊内容生成日报,通常会覆盖以下模块中的部分或全部内容:
|
||||||
|
- **今日讨论热点**: 梳理群内主要话题,支持热度标签。
|
||||||
|
- **实用信息与资源**: 提取分享的链接、资源等信息。
|
||||||
|
- **重要消息汇总**: 标记并展示重要消息,带发送者头像。
|
||||||
|
- **有趣对话或金句**: 收录群内的精彩对话。
|
||||||
|
- **问题与解答**: 整理群内的问答内容。
|
||||||
|
- **群内数据可视化**: 消息热度条形图、话唠榜 TOP5、活跃时间线。
|
||||||
|
- **词云/关键词**: 可视化展示群聊关键词。
|
||||||
|
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
||||||
|
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
|
||||||
|
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
|
||||||
|
|
||||||
## 📸 预览
|
## 📸 预览
|
||||||
|
|
||||||
<img src="./public/example1.png" alt="预览图1" />
|
### 群聊总结长图
|
||||||
<img src="./public/example2.png" alt="预览图2" />
|
|
||||||
<img src="./public/example3.png" alt="预览图3" />
|
<img src="./public/example1.png" alt="总结图片" />
|
||||||
|
|
||||||
|
### 软件主页面
|
||||||
|
|
||||||
|
<img src="./public/example2.png" alt="软件主页面" />
|
||||||
|
|
||||||
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
## [点击这里下载](https://github.com/Wxw-Gu/WechatExplorer/releases)
|
||||||
|
|
||||||
@@ -18,22 +45,13 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结
|
|||||||
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
|
3. 如果遇到“无法打开,因为开发者无法验证”的提示,请前往:
|
||||||
`系统设置 -> 隐私与安全性 -> 仍要打开`。
|
`系统设置 -> 隐私与安全性 -> 仍要打开`。
|
||||||
|
|
||||||
## ✨ 功能特性
|
|
||||||
|
|
||||||
- **聊天记录查看**: 浏览微信好友和群聊的聊天记录。
|
|
||||||
- **全局搜索**: 快速搜索聊天内容。
|
|
||||||
- **AI 智能总结**: 支持多模型服务配置(DeepSeek/GPT-4o/Claude/Moonshot),一键总结群聊精华内容,生成话题报告。
|
|
||||||
- **图片生成**: 将 AI 总结的内容生成精美图片,方便分享。
|
|
||||||
- **数据导出**: 支持导出聊天记录为 CSV 文件(今日、昨日、近7天或全部)。
|
|
||||||
- **安全隐私**: 所有数据仅在本地处理,AI 功能需自行配置 API Key。
|
|
||||||
|
|
||||||
## 🚀 快速开始
|
## 🚀 快速开始
|
||||||
|
|
||||||
### 使用前置要求
|
### 使用前置要求
|
||||||
|
|
||||||
- **微信版本**: 推荐使用微信 **4.0 以下**版本(4.0以上版本不支持数据库解密)
|
- **微信版本**:
|
||||||
- 微信 <= 4.0: 可正常使用,获取数据库密码方式参考:[Mac 导出微信聊天记录](https://blog.vcvit.me/2024/08/02/mac-export-wechat-chat-records/)
|
- 微信 <= 4.0: 可正常使用,获取数据库密码方式参考:[Mac 导出微信聊天记录](https://blog.vcvit.me/2024/08/02/mac-export-wechat-chat-records/)
|
||||||
- 微信 >= 4.0: 可正常使用 (正在迭代) ,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog)
|
- 微信 >= 4.0: 已支持部分能力,仍在持续迭代与兼容性验证中;如需更成熟的完整方案,推荐使用 [WeFlow](https://github.com/hicccc77/WeFlow) [Chatlog](https://github.com/sjzar/chatlog)
|
||||||
- 如无法获取本地数据库密码,则无法使用当前项目
|
- 如无法获取本地数据库密码,则无法使用当前项目
|
||||||
- Node.js (推荐 v16+)
|
- Node.js (推荐 v16+)
|
||||||
- pnpm@7
|
- pnpm@7
|
||||||
@@ -44,15 +62,15 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结
|
|||||||
|
|
||||||
可选配置项,可在 `.env` 文件中设置:
|
可选配置项,可在 `.env` 文件中设置:
|
||||||
|
|
||||||
| 变量名 | 说明 | 示例 |
|
| 变量名 | 说明 | 示例 |
|
||||||
|--------|------|------|
|
| ----------------------- | --------------------------- | --------------------------- |
|
||||||
| `VITE_DB_KEY` | 微信数据库密钥 (32字节hex) | `YOUR_DB_KEY_HERE` |
|
| `VITE_DB_KEY` | 微信数据库密钥 (32字节hex) | `YOUR_DB_KEY_HERE` |
|
||||||
| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥 (hex格式) | `0x40` |
|
| `VITE_IMAGE_XOR_KEY` | 图片解密 XOR 密钥 (hex格式) | `0x40` |
|
||||||
| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥 (16字符) | `YOUR_AES_KEY_HERE` |
|
| `VITE_IMAGE_AES_KEY` | 图片解密 AES 密钥 (16字符) | `YOUR_AES_KEY_HERE` |
|
||||||
| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` |
|
| `VITE_DEEPSEEK_API_KEY` | DeepSeek API Key | `sk-xxx` |
|
||||||
| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` |
|
| `VITE_AI_BASE_URL` | AI API 地址 | `https://api.deepseek.com` |
|
||||||
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
| `VITE_AI_MODEL` | AI 模型 | `deepseek-chat` |
|
||||||
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
| `VITE_FILTER_MSG_TYPES` | 过滤的消息类型 | `分享消息,图片,表情包,视频` |
|
||||||
|
|
||||||
#### 图片解密密钥说明
|
#### 图片解密密钥说明
|
||||||
|
|
||||||
@@ -62,6 +80,7 @@ MAC系统 获取微信聊天记录 AI一键生成群聊总结
|
|||||||
- **AES Key**: 16字符字符串,用于 AES-128-ECB 解密
|
- **AES Key**: 16字符字符串,用于 AES-128-ECB 解密
|
||||||
|
|
||||||
这两个密钥可以通过以下方式获取:
|
这两个密钥可以通过以下方式获取:
|
||||||
|
|
||||||
1. 从 WeFlow/Chatlog 设置中导出
|
1. 从 WeFlow/Chatlog 设置中导出
|
||||||
2. 使用内存扫描工具从微信进程中自动提取(待实现)
|
2. 使用内存扫描工具从微信进程中自动提取(待实现)
|
||||||
|
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 378 KiB After Width: | Height: | Size: 583 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 319 KiB After Width: | Height: | Size: 157 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 700 KiB |
+16
-3
@@ -109,14 +109,22 @@ app.whenReady().then(() => {
|
|||||||
try {
|
try {
|
||||||
const trimmedKey = String(key || '').trim()
|
const trimmedKey = String(key || '').trim()
|
||||||
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
console.log(`db:init build=${BUILD_MARK} keyLength=${trimmedKey.length}`)
|
||||||
wechatDb = new WechatDb(key)
|
const nextWechatDb = new WechatDb(key)
|
||||||
const wcdb4Client = wechatDb.getWcdb4Client()
|
wechatDb?.close()
|
||||||
|
wechatDb = nextWechatDb
|
||||||
|
const wcdb4Client = nextWechatDb.getWcdb4Client()
|
||||||
|
let monitoring = false
|
||||||
if (wcdb4Client) {
|
if (wcdb4Client) {
|
||||||
voiceService = new VoiceService(wcdb4Client)
|
voiceService = new VoiceService(wcdb4Client)
|
||||||
stickerService = new StickerService(wcdb4Client)
|
stickerService = new StickerService(wcdb4Client)
|
||||||
|
monitoring = wcdb4Client.startMonitor((type, json) => {
|
||||||
|
for (const window of BrowserWindow.getAllWindows()) {
|
||||||
|
if (!window.isDestroyed()) window.webContents.send('wcdb-change', { type, json })
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
imageDecryptService = null
|
imageDecryptService = null
|
||||||
return { success: true }
|
return { success: true, monitoring }
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('Failed to init DB:', error)
|
console.error('Failed to init DB:', error)
|
||||||
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
return { success: false, error: error instanceof Error ? error.message : String(error) }
|
||||||
@@ -453,3 +461,8 @@ app.on('window-all-closed', () => {
|
|||||||
app.quit()
|
app.quit()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
app.on('before-quit', () => {
|
||||||
|
wechatDb?.close()
|
||||||
|
wechatDb = null
|
||||||
|
})
|
||||||
|
|||||||
+161
-1
@@ -3,6 +3,7 @@ import path from 'path'
|
|||||||
import os from 'os'
|
import os from 'os'
|
||||||
import crypto from 'crypto'
|
import crypto from 'crypto'
|
||||||
import { createRequire } from 'module'
|
import { createRequire } from 'module'
|
||||||
|
import { createConnection, Socket } from 'net'
|
||||||
|
|
||||||
export interface Wcdb4Session {
|
export interface Wcdb4Session {
|
||||||
username: string
|
username: string
|
||||||
@@ -132,6 +133,15 @@ export class Wcdb4Client {
|
|||||||
private wcdbGetEmoticonCdnUrl:
|
private wcdbGetEmoticonCdnUrl:
|
||||||
| ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number)
|
| ((handle: number, dbPath: string, md5: string, outUrl: WcdbVoidOut) => number)
|
||||||
| null = null
|
| null = null
|
||||||
|
private wcdbStartMonitorPipe: (() => number) | null = null
|
||||||
|
private wcdbStopMonitorPipe: (() => void) | null = null
|
||||||
|
private wcdbGetMonitorPipeName: ((outName: WcdbVoidOut) => number) | null = null
|
||||||
|
private monitorPipeClient: Socket | null = null
|
||||||
|
private monitorCallback: ((type: string, json: string) => void) | null = null
|
||||||
|
private monitorConnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private monitorReconnectTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
private monitorPipePath = ''
|
||||||
|
private monitorStarted = false
|
||||||
|
|
||||||
constructor(key: string, accountRoot?: string) {
|
constructor(key: string, accountRoot?: string) {
|
||||||
this.key = key.replace(/^0x/i, '').trim()
|
this.key = key.replace(/^0x/i, '').trim()
|
||||||
@@ -225,6 +235,7 @@ export class Wcdb4Client {
|
|||||||
}
|
}
|
||||||
|
|
||||||
close(): void {
|
close(): void {
|
||||||
|
this.stopMonitor()
|
||||||
if (!this.initialized || !this.wcdbShutdown) return
|
if (!this.initialized || !this.wcdbShutdown) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
@@ -241,6 +252,142 @@ export class Wcdb4Client {
|
|||||||
this.groupNicknameCache.clear()
|
this.groupNicknameCache.clear()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
startMonitor(callback: (type: string, json: string) => void): boolean {
|
||||||
|
if (!this.wcdbStartMonitorPipe || !this.wcdbGetMonitorPipeName || !this.koffi) return false
|
||||||
|
|
||||||
|
this.stopMonitor()
|
||||||
|
this.monitorCallback = callback
|
||||||
|
|
||||||
|
try {
|
||||||
|
const startResult = this.wcdbStartMonitorPipe()
|
||||||
|
if (startResult !== 0) {
|
||||||
|
this.monitorCallback = null
|
||||||
|
console.warn(`[WCDB4] wcdb_start_monitor_pipe 失败,错误码: ${startResult}`)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
this.monitorStarted = true
|
||||||
|
|
||||||
|
const outName: WcdbVoidOut = [null]
|
||||||
|
const nameResult = this.wcdbGetMonitorPipeName(outName)
|
||||||
|
if (nameResult !== 0 || !outName[0]) {
|
||||||
|
console.warn(`[WCDB4] wcdb_get_monitor_pipe_name 失败,错误码: ${nameResult}`)
|
||||||
|
this.stopMonitor()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.monitorPipePath = this.koffi.decode(outName[0], 'char', -1).trim()
|
||||||
|
} finally {
|
||||||
|
this.wcdbFreeString?.(outName[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!this.monitorPipePath) {
|
||||||
|
this.stopMonitor()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
this.connectMonitorPipe()
|
||||||
|
return true
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[WCDB4] 启动数据库监听失败:', error)
|
||||||
|
this.stopMonitor()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
stopMonitor(): void {
|
||||||
|
this.monitorCallback = null
|
||||||
|
|
||||||
|
if (this.monitorConnectTimer) {
|
||||||
|
clearTimeout(this.monitorConnectTimer)
|
||||||
|
this.monitorConnectTimer = null
|
||||||
|
}
|
||||||
|
if (this.monitorReconnectTimer) {
|
||||||
|
clearTimeout(this.monitorReconnectTimer)
|
||||||
|
this.monitorReconnectTimer = null
|
||||||
|
}
|
||||||
|
if (this.monitorPipeClient) {
|
||||||
|
this.monitorPipeClient.destroy()
|
||||||
|
this.monitorPipeClient = null
|
||||||
|
}
|
||||||
|
if (this.monitorStarted && this.wcdbStopMonitorPipe) {
|
||||||
|
try {
|
||||||
|
this.wcdbStopMonitorPipe()
|
||||||
|
} catch {
|
||||||
|
// Native monitor cleanup is best-effort during reconnect or shutdown.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.monitorStarted = false
|
||||||
|
this.monitorPipePath = ''
|
||||||
|
}
|
||||||
|
|
||||||
|
private connectMonitorPipe(): void {
|
||||||
|
if (!this.monitorCallback || !this.monitorPipePath || this.monitorConnectTimer) return
|
||||||
|
|
||||||
|
this.monitorConnectTimer = setTimeout(() => {
|
||||||
|
this.monitorConnectTimer = null
|
||||||
|
if (!this.monitorCallback || !this.monitorPipePath || this.monitorPipeClient) return
|
||||||
|
|
||||||
|
const socket = createConnection(this.monitorPipePath)
|
||||||
|
this.monitorPipeClient = socket
|
||||||
|
let buffer = ''
|
||||||
|
|
||||||
|
socket.on('data', (data) => {
|
||||||
|
const normalizedChunk = data
|
||||||
|
.toString('utf8')
|
||||||
|
.split('\0')
|
||||||
|
.join('\n')
|
||||||
|
.replace(/}\s*{/g, '}\n{')
|
||||||
|
buffer += normalizedChunk
|
||||||
|
|
||||||
|
const lines = buffer.split(/\r?\n/)
|
||||||
|
buffer = lines.pop() || ''
|
||||||
|
for (const line of lines) this.emitMonitorPayload(line)
|
||||||
|
|
||||||
|
const tail = buffer.trim()
|
||||||
|
if (tail.startsWith('{') && tail.endsWith('}')) {
|
||||||
|
try {
|
||||||
|
JSON.parse(tail)
|
||||||
|
this.emitMonitorPayload(tail)
|
||||||
|
buffer = ''
|
||||||
|
} catch {
|
||||||
|
// Keep the partial payload until the next socket chunk arrives.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('error', (error) => {
|
||||||
|
console.warn('[WCDB4] 数据库监听管道异常:', error.message)
|
||||||
|
})
|
||||||
|
|
||||||
|
socket.on('close', () => {
|
||||||
|
if (this.monitorPipeClient === socket) this.monitorPipeClient = null
|
||||||
|
this.scheduleMonitorReconnect()
|
||||||
|
})
|
||||||
|
}, 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
private emitMonitorPayload(rawPayload: string): void {
|
||||||
|
const payload = rawPayload.trim()
|
||||||
|
if (!payload || !this.monitorCallback) return
|
||||||
|
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(payload) as { action?: string }
|
||||||
|
this.monitorCallback(parsed.action || 'update', payload)
|
||||||
|
} catch {
|
||||||
|
this.monitorCallback('update', payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private scheduleMonitorReconnect(): void {
|
||||||
|
if (this.monitorReconnectTimer || !this.monitorCallback || !this.monitorPipePath) return
|
||||||
|
this.monitorReconnectTimer = setTimeout(() => {
|
||||||
|
this.monitorReconnectTimer = null
|
||||||
|
this.connectMonitorPipe()
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
getSessions(): Wcdb4Session[] {
|
getSessions(): Wcdb4Session[] {
|
||||||
if (this.cachedSessions) return this.cachedSessions
|
if (this.cachedSessions) return this.cachedSessions
|
||||||
if (!this.wcdbGetSessions) return []
|
if (!this.wcdbGetSessions) return []
|
||||||
@@ -768,6 +915,19 @@ export class Wcdb4Client {
|
|||||||
console.warn('[WCDB4] wcdb_get_emoticon_cdn_url symbol unavailable')
|
console.warn('[WCDB4] wcdb_get_emoticon_cdn_url symbol unavailable')
|
||||||
this.wcdbGetEmoticonCdnUrl = null
|
this.wcdbGetEmoticonCdnUrl = null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
this.wcdbStartMonitorPipe = lib.func('int32 wcdb_start_monitor_pipe()') as () => number
|
||||||
|
this.wcdbStopMonitorPipe = lib.func('void wcdb_stop_monitor_pipe()') as () => void
|
||||||
|
this.wcdbGetMonitorPipeName = lib.func(
|
||||||
|
'int32 wcdb_get_monitor_pipe_name(_Out_ void** outName)'
|
||||||
|
) as (outName: WcdbVoidOut) => number
|
||||||
|
} catch {
|
||||||
|
console.warn('[WCDB4] monitor pipe symbols unavailable')
|
||||||
|
this.wcdbStartMonitorPipe = null
|
||||||
|
this.wcdbStopMonitorPipe = null
|
||||||
|
this.wcdbGetMonitorPipeName = null
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private initProtection(lib: KoffiLibrary, libDir: string): void {
|
private initProtection(lib: KoffiLibrary, libDir: string): void {
|
||||||
@@ -974,7 +1134,7 @@ export class Wcdb4Client {
|
|||||||
])
|
])
|
||||||
|
|
||||||
return {
|
return {
|
||||||
mesLocalID: localId || `${createTime}-${crypto.randomUUID()}`,
|
mesLocalID: localId || `${createTime}-${this.md5(JSON.stringify(row))}`,
|
||||||
mesDes: isSend ? 0 : 1,
|
mesDes: isSend ? 0 : 1,
|
||||||
messageType: messageType || '1',
|
messageType: messageType || '1',
|
||||||
msgCreateTime: String(createTime),
|
msgCreateTime: String(createTime),
|
||||||
|
|||||||
@@ -364,6 +364,11 @@ export class WechatDb {
|
|||||||
return this.wcdb4Client
|
return this.wcdb4Client
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public close(): void {
|
||||||
|
this.wcdb4Client?.close()
|
||||||
|
this.wcdb4Client = null
|
||||||
|
}
|
||||||
|
|
||||||
public getUserMessages(userMd5: string, startTime?: number, endTime?: number): WechatMessage[] {
|
public getUserMessages(userMd5: string, startTime?: number, endTime?: number): WechatMessage[] {
|
||||||
if (this.wcdb4Client) {
|
if (this.wcdb4Client) {
|
||||||
const username = this.chatMd5ToUsername.get(userMd5)
|
const username = this.chatMd5ToUsername.get(userMd5)
|
||||||
|
|||||||
Vendored
+4
-1
@@ -34,7 +34,9 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
electron: ElectronAPI
|
electron: ElectronAPI
|
||||||
api: {
|
api: {
|
||||||
initDb: (key: string) => Promise<boolean | { success: boolean; error?: string }>
|
initDb: (
|
||||||
|
key: string
|
||||||
|
) => Promise<boolean | { success: boolean; error?: string; monitoring?: boolean }>
|
||||||
getContacts: (filter?: string) => Promise<Contact[]>
|
getContacts: (filter?: string) => Promise<Contact[]>
|
||||||
getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise<Message[]>
|
getMessages: (userMd5: string, startTime?: number, endTime?: number) => Promise<Message[]>
|
||||||
search: (keyword: string) => Promise<string | null>
|
search: (keyword: string) => Promise<string | null>
|
||||||
@@ -72,6 +74,7 @@ declare global {
|
|||||||
}>
|
}>
|
||||||
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
pasteAndSaveDbKey: () => Promise<{ success: boolean; key?: string; error?: string }>
|
||||||
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
|
clearSavedDbKey: () => Promise<{ success: boolean; error?: string }>
|
||||||
|
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => () => void
|
||||||
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
onDbKeyStatus: (callback: (payload: { message: string }) => void) => () => void
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,14 @@ const api = {
|
|||||||
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
autoGetDbKey: () => ipcRenderer.invoke('key:autoGetDbKey'),
|
||||||
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
|
pasteAndSaveDbKey: () => ipcRenderer.invoke('key:pasteAndSaveDbKey'),
|
||||||
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
|
clearSavedDbKey: () => ipcRenderer.invoke('key:clearSavedDbKey'),
|
||||||
|
onWcdbChange: (callback: (payload: { type: string; json: string }) => void) => {
|
||||||
|
const listener = (
|
||||||
|
_event: Electron.IpcRendererEvent,
|
||||||
|
payload: { type: string; json: string }
|
||||||
|
): void => callback(payload)
|
||||||
|
ipcRenderer.on('wcdb-change', listener)
|
||||||
|
return () => ipcRenderer.removeListener('wcdb-change', listener)
|
||||||
|
},
|
||||||
onDbKeyStatus: (callback: (payload: { message: string }) => void) => {
|
onDbKeyStatus: (callback: (payload: { message: string }) => void) => {
|
||||||
const listener = (_event: Electron.IpcRendererEvent, payload: { message: string }): void =>
|
const listener = (_event: Electron.IpcRendererEvent, payload: { message: string }): void =>
|
||||||
callback(payload)
|
callback(payload)
|
||||||
|
|||||||
@@ -4,6 +4,22 @@ import ChatWindow from './components/ChatWindow'
|
|||||||
import { Contact, Message } from '../../shared/types'
|
import { Contact, Message } from '../../shared/types'
|
||||||
|
|
||||||
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
const MAC_KEY_FAQ_URL = 'https://github.com/hicccc77/WeFlow/blob/main/docs/MAC-KEY-FAQ.md'
|
||||||
|
const MESSAGE_MONITOR_DEBOUNCE_MS = 250
|
||||||
|
|
||||||
|
const getMessageIdentity = (message: Message): string => {
|
||||||
|
if (message.localId) return `local:${message.localId}`
|
||||||
|
if (message.id) return `id:${message.id}`
|
||||||
|
return `${message.createTime || 0}:${message.from}:${message.type}:${message.content}`
|
||||||
|
}
|
||||||
|
|
||||||
|
const areMessagesEquivalent = (left: Message[], right: Message[]): boolean => {
|
||||||
|
if (left === right) return true
|
||||||
|
if (left.length !== right.length) return false
|
||||||
|
for (let index = 0; index < left.length; index += 1) {
|
||||||
|
if (getMessageIdentity(left[index]) !== getMessageIdentity(right[index])) return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
function App(): React.ReactElement {
|
function App(): React.ReactElement {
|
||||||
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
const [isAuthenticated, setIsAuthenticated] = useState(false)
|
||||||
@@ -19,6 +35,7 @@ function App(): React.ReactElement {
|
|||||||
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
|
const [dbKeyStatusKind, setDbKeyStatusKind] = useState<'normal' | 'success' | 'error'>('normal')
|
||||||
const [showDbKey, setShowDbKey] = useState(false)
|
const [showDbKey, setShowDbKey] = useState(false)
|
||||||
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
|
const [showMacKeyFaq, setShowMacKeyFaq] = useState(false)
|
||||||
|
const [isNativeMonitorActive, setIsNativeMonitorActive] = useState(false)
|
||||||
|
|
||||||
React.useEffect(() => {
|
React.useEffect(() => {
|
||||||
let active = true
|
let active = true
|
||||||
@@ -54,6 +71,7 @@ function App(): React.ReactElement {
|
|||||||
const result = await window.api.initDb(keyToUse)
|
const result = await window.api.initDb(keyToUse)
|
||||||
const success = typeof result === 'boolean' ? result : result.success
|
const success = typeof result === 'boolean' ? result : result.success
|
||||||
if (success) {
|
if (success) {
|
||||||
|
setIsNativeMonitorActive(typeof result !== 'boolean' && result.monitoring === true)
|
||||||
setIsAuthenticated(true)
|
setIsAuthenticated(true)
|
||||||
loadContacts()
|
loadContacts()
|
||||||
} else {
|
} else {
|
||||||
@@ -162,19 +180,52 @@ function App(): React.ReactElement {
|
|||||||
|
|
||||||
const handleDateRangeChange = (range: string): void => {
|
const handleDateRangeChange = (range: string): void => {
|
||||||
setDateRange(range)
|
setDateRange(range)
|
||||||
// 如果选择了联系人,则使用新范围重新加载消息
|
|
||||||
if (selectedContact) {
|
if (selectedContact) {
|
||||||
// 需要调用 handleSelectContact,但它需要一个联系人对象。
|
|
||||||
// 由于状态更新是异步的,可能需要使用状态中的当前联系人,
|
|
||||||
// 此函数内部 'selectedContact' 可从闭包中获得。
|
|
||||||
// 但是,需要确保 'dateRange' 已更新。
|
|
||||||
// 实际上,就在这里使用新范围手动触发获取。
|
|
||||||
|
|
||||||
const { startTime, endTime } = getDateRangeParams(range)
|
const { startTime, endTime } = getDateRangeParams(range)
|
||||||
window.api.getMessages(selectedContact.md5, startTime, endTime).then(setMessages)
|
window.api.getMessages(selectedContact.md5, startTime, endTime).then(setMessages)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
React.useEffect(() => {
|
||||||
|
if (!isAuthenticated || !selectedContact || !isNativeMonitorActive) return
|
||||||
|
|
||||||
|
let disposed = false
|
||||||
|
let refreshTimer: number | null = null
|
||||||
|
const contactMd5 = selectedContact.md5
|
||||||
|
|
||||||
|
const refreshCurrentConversation = async (): Promise<void> => {
|
||||||
|
try {
|
||||||
|
const range = getDateRangeParams(dateRange)
|
||||||
|
const latestMessages = await window.api.getMessages(
|
||||||
|
contactMd5,
|
||||||
|
range.startTime,
|
||||||
|
range.endTime
|
||||||
|
)
|
||||||
|
if (!disposed) {
|
||||||
|
setMessages((current) =>
|
||||||
|
areMessagesEquivalent(current, latestMessages) ? current : latestMessages
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.warn('[MessageMonitor] 刷新当前会话失败:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsubscribe = window.api.onWcdbChange(() => {
|
||||||
|
if (refreshTimer) window.clearTimeout(refreshTimer)
|
||||||
|
refreshTimer = window.setTimeout(() => {
|
||||||
|
refreshTimer = null
|
||||||
|
void refreshCurrentConversation()
|
||||||
|
}, MESSAGE_MONITOR_DEBOUNCE_MS)
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
disposed = true
|
||||||
|
if (refreshTimer) window.clearTimeout(refreshTimer)
|
||||||
|
unsubscribe()
|
||||||
|
}
|
||||||
|
}, [dateRange, isAuthenticated, isNativeMonitorActive, selectedContact])
|
||||||
|
|
||||||
const handleSearchContacts = (keyword: string): void => {
|
const handleSearchContacts = (keyword: string): void => {
|
||||||
if (!keyword) {
|
if (!keyword) {
|
||||||
setFilteredContacts(contacts)
|
setFilteredContacts(contacts)
|
||||||
|
|||||||
Reference in New Issue
Block a user