diff --git a/Makefile b/Makefile
index 87be8d0..e4da6e1 100644
--- a/Makefile
+++ b/Makefile
@@ -6,10 +6,13 @@ endif
LDFLAGS := -ldflags '-X "github.com/sjzar/chatlog/pkg/version.Version=$(VERSION)" -w -s'
PLATFORMS := \
+ darwin/amd64 \
+ darwin/arm64 \
windows/amd64 \
windows/arm64
UPX_PLATFORMS := \
+ darwin/amd64 \
windows/386 \
windows/amd64
diff --git a/README.md b/README.md
index d28dc67..d8ad118 100644
--- a/README.md
+++ b/README.md
@@ -1,17 +1,38 @@
+
+
# Chatlog
-Chatlog 是一个聊天记录收集、分析的开源工具,旨在帮助用户更好地利用自己的聊天数据。
-目前支持微信聊天记录的解密和查询,提供 Terminal UI 界面和 HTTP API 服务,让您可以方便地访问和分析聊天数据。
+
-## 功能特点
+_聊天记录工具,帮助大家轻松使用自己的聊天数据_
-- **数据收集**:从本地数据库文件中获取聊天数据
-- **终端界面**:提供简洁的 Terminal UI,方便直接操作
-- **HTTP API**:提供 API 接口,支持查询聊天记录、联系人和群聊信息
-- **MCP 支持**:实现 Model Context Protocol,可与支持 MCP 的 AI 助手无缝集成
-- **多格式输出**:支持 JSON、CSV、纯文本等多种输出格式
+[](https://goreportcard.com/report/github.com/sjzar/chatlog)
+[](https://godoc.org/github.com/sjzar/chatlog)
+[](https://github.com/sjzar/chatlog/releases)
+[](https://github.com/sjzar/chatlog/blob/main/LICENSE)
-## 安装
+
+
+
+
+## Feature
+
+- 从本地数据库文件获取聊天数据
+- 支持 Windows / macOS 系统
+- 支持微信 3.x / 4.0 版本
+- 提供 Terminal UI 界面 & 命令行工具
+- 提供 HTTP API 服务,支持查询聊天记录、联系人、群聊、最近会话等信息
+- 支持 MCP SSE 协议,可与支持 MCP 的 AI 助手无缝集成
+
+
+## TODO
+
+- 支持多媒体数据
+- 聊天数据全文索引
+- 聊天数据统计 & Dashboard
+
+
+## Install
### 从源码安装
@@ -23,9 +44,39 @@ go install github.com/sjzar/chatlog@latest
访问 [Releases](https://github.com/sjzar/chatlog/releases) 页面下载适合您系统的预编译版本。
-## 快速开始
+## Quick Start
-### 终端 UI 模式
+### 操作流程
+
+1. 下载并安装微信客户端
+
+2. 手机微信上操作 `我 - 设置 - 通用 - 聊天记录迁移与备份 - 迁移 - 迁移到电脑`,这一步的目的是将手机中的聊天记录传输到电脑上。可以放心操作,不会影响到手机上的聊天记录。
+
+3. 下载 `chatlog` 预编译版本或从源码安装,推荐使用 go 进行安装。
+
+4. 运行 `chatlog`,按照提示进行操作,解密数据后开启 HTTP 服务后,即可通过浏览器或 AI 助手访问聊天记录。
+
+### macOS 版本提示
+
+1. macOS 用户在获取密钥前,需要确认已经关闭 SIP 并安装 Xcode。由于 macOS 的安全机制,在正常情况在无法读取微信进程的内存数据,所以需要临时关闭 SIP。关闭 SIP 的方法:
+
+```shell
+# 1. 进入恢复模式
+ Apple Intel Mac: 关机后,按住 Command + R 键开机,直到出现苹果标志和进度条。
+ Apple Silicon Mac: 关机后,按住开机键不松开,直到出现苹果标志和进度条。
+# 2. 打开终端
+ 选项 - 实用工具 - 终端
+# 3. 关闭 SIP
+ 输入以下命令关闭 SIP:
+ csrutil disable
+# 4. 重启系统
+```
+
+2. 目前的 macOS 版本方案依赖 `lldb` 工具,所以需要安装 Xcode,可以从 App Store 进行下载。
+
+3. 仅获取数据密钥步骤需要关闭 SIP;获取数据密钥后即可重新打开 SIP,不影响解密数据和 HTTP 服务的运行。
+
+### Terminal UI 模式
1. 启动程序:
@@ -35,13 +86,13 @@ go install github.com/sjzar/chatlog@latest
2. 使用界面操作:
- 使用方向键导航菜单
- - 按 Enter 选择菜单项
- - 按 Esc 返回上一级菜单
- - 按 Ctrl+C 退出程序
+ - 按 `Enter` 选择菜单项
+ - 按 `Esc` 返回上一级菜单
+ - 按 `Ctrl+C` 退出程序
### 命令行模式
-获取微信进程密钥:
+获取微信数据密钥:
```bash
./chatlog key
@@ -50,7 +101,7 @@ go install github.com/sjzar/chatlog@latest
解密数据库文件:
```bash
-./chatlog decrypt --data-dir "微信数据目录" --work-dir "输出目录" --key "密钥" --version 3
+./chatlog decrypt
```
## HTTP API
@@ -73,68 +124,63 @@ GET /api/v1/chatlog?time=2023-01-01&talker=wxid_xxx&limit=100&offset=0&format=js
### 联系人列表
```
-GET /api/v1/contact?format=json
+GET /api/v1/contact
```
### 群聊列表
```
-GET /api/v1/chatroom?format=json
+GET /api/v1/chatroom
```
### 会话列表
```
-GET /api/v1/session?limit=100&format=json
+GET /api/v1/session
```
-## MCP 集成
+## MCP
-Chatlog 实现了 Model Context Protocol (MCP),可以与支持 MCP 的 AI 助手集成。通过 MCP,AI 助手可以:
+支持 MCP SSE 协议,启动 HTTP 服务后,通过 SSE Endpoint 访问服务:
-1. 查询联系人信息
-2. 获取群聊列表和成员
-3. 检索最近的聊天记录
-4. 按时间和联系人搜索聊天记录
+```
+GET /sse
+```
-## 未来规划
+提供了 4 个 tool 用于与 AI 助手集成:
+- `chatlog`: 查询聊天记录
+- `query_contact`: 查询联系人
+- `query_chat_room`: 查询群聊
+- `query_recent_chat`: 查询最近会话
-Chatlog 希望成为最好用的聊天记录工具,帮助用户充分挖掘自己聊天数据的价值。我们的路线图包括:
+### 示例
-- **多平台支持**:计划支持 MacOS 平台的微信聊天记录解密
-- **全文索引**:实现聊天记录的全文检索,提供更快速的搜索体验
-- **统计与可视化**:提供聊天数据的统计分析和可视化 Dashboard
-- **CS 架构**:将数据收集和统计分析功能分离,支持将服务部署在 NAS 或家庭服务器上
-- **增量更新**:支持聊天记录的增量采集和更新,减少资源消耗
-- **关键词监控**:提供关键词监控和实时提醒功能
-- **更多聊天工具支持**:计划支持更多主流聊天工具的数据采集和分析
+以 [ChatWise](https://chatwise.app/) 工具为例,在 `设置 - 工具` 下新建工具,类型为 `sse`,ID 为 `chatlog`,URL 为 `http://127.0.0.1:5030/sse`,勾选自动执行工具,即可使用。
-## 数据安全声明
+部分 AI 聊天工具暂时不支持 MCP SSE 协议,可以通过 [`mcp-proxy`](https://github.com/sparfenyuk/mcp-proxy) 工具转发请求,以 [Claude Desktop](https://claude.ai/download) 为例,在安装好 `mcp-proxy` 后,将 `mcp-proxy` 配置到 `Claude Desktop` 的 `config.json` 文件中,即可使用:
-Chatlog 高度重视用户数据安全和隐私保护:
+```json
+{
+ "mcpServers": {
+ "mcp-proxy": {
+ "command": "/Users/sarv/.local/bin/mcp-proxy",
+ "args": [
+ "http://localhost:5030/sse"
+ ],
+ "env": {}
+ }
+ },
+ "globalShortcut": ""
+}
+```
-- 所有数据处理均在本地完成,不会上传到任何外部服务器
-- 解密后的数据存储在用户指定的工作目录中,用户对数据有完全控制权
-- 建议定期备份重要的聊天记录,并妥善保管解密后的数据
-- 请勿将本工具用于未经授权访问他人聊天记录等非法用途
+## License
-## 贡献
+`chatlog` 是在 Apache-2.0 许可下的开源软件。
-我们欢迎社区的贡献!无论是代码贡献、问题报告还是功能建议,都将帮助 Chatlog 变得更好:
+## Thanks
-1. Fork 本仓库
-2. 创建您的特性分支 (`git checkout -b feature/amazing-feature`)
-3. 提交您的更改 (`git commit -m 'Add some amazing feature'`)
-4. 推送到分支 (`git push origin feature/amazing-feature`)
-5. 打开一个 Pull Request
-
-## 许可证
-
-本项目采用 Apache-2.0 许可证 - 详见 [LICENSE](LICENSE) 文件。
-
-## 致谢
-
-- [tview](https://github.com/rivo/tview) - 终端 UI 库
-- [gin](https://github.com/gin-gonic/gin) - HTTP 框架
-- [Model Context Protocol](https://github.com/modelcontextprotocol) - AI 助手集成协议
-- 以及所有贡献者和用户的支持与反馈
\ No newline at end of file
+- [@0xlane](https://github.com/0xlane) 的 [wechat-dump-rs](https://github.com/0xlane/wechat-dump-rs) 项目
+- [@xaoyaoo](https://github.com/xaoyaoo) 的 [PyWxDump](https://github.com/xaoyaoo/PyWxDump) 项目
+- [Anthropic](https://www.anthropic.com/) 的 [MCP]((https://github.com/modelcontextprotocol) ) 协议
+- 各个 Go 开源库的贡献者们
\ No newline at end of file
diff --git a/cmd/chatlog/cmd_decrypt.go b/cmd/chatlog/cmd_decrypt.go
index c7100ac..5692a6b 100644
--- a/cmd/chatlog/cmd_decrypt.go
+++ b/cmd/chatlog/cmd_decrypt.go
@@ -2,6 +2,7 @@ package chatlog
import (
"fmt"
+ "runtime"
"github.com/sjzar/chatlog/internal/chatlog"
@@ -14,13 +15,17 @@ func init() {
decryptCmd.Flags().StringVarP(&dataDir, "data-dir", "d", "", "data dir")
decryptCmd.Flags().StringVarP(&workDir, "work-dir", "w", "", "work dir")
decryptCmd.Flags().StringVarP(&key, "key", "k", "", "key")
+ decryptCmd.Flags().StringVarP(&decryptPlatform, "platform", "p", runtime.GOOS, "platform")
decryptCmd.Flags().IntVarP(&decryptVer, "version", "v", 3, "version")
}
-var dataDir string
-var workDir string
-var key string
-var decryptVer int
+var (
+ dataDir string
+ workDir string
+ key string
+ decryptPlatform string
+ decryptVer int
+)
var decryptCmd = &cobra.Command{
Use: "decrypt",
@@ -31,7 +36,7 @@ var decryptCmd = &cobra.Command{
log.Error(err)
return
}
- if err := m.CommandDecrypt(dataDir, workDir, key, decryptVer); err != nil {
+ if err := m.CommandDecrypt(dataDir, workDir, key, decryptPlatform, decryptVer); err != nil {
log.Error(err)
return
}
diff --git a/go.mod b/go.mod
index 7b4cd80..4400eb6 100644
--- a/go.mod
+++ b/go.mod
@@ -5,68 +5,63 @@ go 1.24.0
require (
github.com/gdamore/tcell/v2 v2.8.1
github.com/gin-gonic/gin v1.10.0
- github.com/google/uuid v1.4.0
+ github.com/google/uuid v1.6.0
+ github.com/klauspost/compress v1.18.0
github.com/mattn/go-sqlite3 v1.14.24
- github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57
+ github.com/rivo/tview v0.0.0-20250322200051-73a5bd7d6839
github.com/shirou/gopsutil/v4 v4.25.2
github.com/sirupsen/logrus v1.9.3
github.com/spf13/cobra v1.9.1
- github.com/spf13/viper v1.19.0
+ github.com/spf13/viper v1.20.0
golang.org/x/crypto v0.36.0
golang.org/x/sys v0.31.0
google.golang.org/protobuf v1.36.5
+ howett.net/plist v1.0.1
)
require (
- github.com/bytedance/sonic v1.11.6 // indirect
- github.com/bytedance/sonic/loader v0.1.1 // indirect
- github.com/cloudwego/base64x v0.1.4 // indirect
- github.com/cloudwego/iasm v0.2.0 // indirect
+ github.com/bytedance/sonic v1.13.2 // indirect
+ github.com/bytedance/sonic/loader v0.2.4 // indirect
+ github.com/cloudwego/base64x v0.1.5 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
- github.com/fsnotify/fsnotify v1.7.0 // indirect
- github.com/gabriel-vasile/mimetype v1.4.3 // indirect
+ github.com/fsnotify/fsnotify v1.8.0 // indirect
+ github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
- github.com/gin-contrib/sse v0.1.0 // indirect
+ github.com/gin-contrib/sse v1.0.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
- github.com/go-playground/validator/v10 v10.20.0 // indirect
- github.com/goccy/go-json v0.10.2 // indirect
- github.com/hashicorp/hcl v1.0.0 // indirect
+ github.com/go-playground/validator/v10 v10.25.0 // indirect
+ github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
+ github.com/goccy/go-json v0.10.5 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
- github.com/klauspost/cpuid/v2 v2.2.7 // indirect
+ github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
- github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
- github.com/magiconair/properties v1.8.7 // indirect
+ github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
- github.com/mitchellh/mapstructure v1.5.0 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
- github.com/pelletier/go-toml/v2 v2.2.2 // indirect
- github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
+ github.com/pelletier/go-toml/v2 v2.2.3 // indirect
+ github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
- github.com/sagikazarmark/locafero v0.4.0 // indirect
- github.com/sagikazarmark/slog-shim v0.1.0 // indirect
+ github.com/sagikazarmark/locafero v0.8.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
- github.com/spf13/afero v1.11.0 // indirect
- github.com/spf13/cast v1.6.0 // indirect
+ github.com/spf13/afero v1.14.0 // indirect
+ github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
- github.com/tklauser/go-sysconf v0.3.12 // indirect
- github.com/tklauser/numcpus v0.6.1 // indirect
+ github.com/tklauser/go-sysconf v0.3.15 // indirect
+ github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
- go.uber.org/atomic v1.9.0 // indirect
- go.uber.org/multierr v1.9.0 // indirect
- golang.org/x/arch v0.8.0 // indirect
- golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
- golang.org/x/net v0.33.0 // indirect
+ go.uber.org/multierr v1.11.0 // indirect
+ golang.org/x/arch v0.15.0 // indirect
+ golang.org/x/net v0.37.0 // indirect
golang.org/x/term v0.30.0 // indirect
golang.org/x/text v0.23.0 // indirect
- gopkg.in/ini.v1 v1.67.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
diff --git a/go.sum b/go.sum
index 08b8e04..b7f0f51 100644
--- a/go.sum
+++ b/go.sum
@@ -1,30 +1,29 @@
-github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
-github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
-github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
+github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
+github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
-github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
-github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
-github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
+github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
+github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
+github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
+github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
+github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
-github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
-github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
-github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
-github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
-github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
-github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
+github.com/fsnotify/fsnotify v1.8.0 h1:dAwr6QBTBZIkG8roQaJjGof0pp0EeF+tNV7YBP3F/8M=
+github.com/fsnotify/fsnotify v1.8.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
+github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
+github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
-github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
-github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
+github.com/gin-contrib/sse v1.0.0 h1:y3bT1mUWUxDpW4JLQg/HnTqV4rozuW4tC9eFKTxYI9E=
+github.com/gin-contrib/sse v1.0.0/go.mod h1:zNuFdwarAygJBht0NTKiSi3jRf6RbqeILZ9Sp6Slhe0=
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
@@ -36,26 +35,28 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
-github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
-github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
-github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
-github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
-github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+github.com/go-playground/validator/v10 v10.25.0 h1:5Dh7cjvzR7BRZadnsVOzPhWsrwUr0nmsZJxEAnFLNO8=
+github.com/go-playground/validator/v10 v10.25.0/go.mod h1:GGzBIJMuE98Ic/kJsBXbz1x/7cByt++cQ+YOuDM5wus=
+github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
+github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
+github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
+github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
-github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
-github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
-github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
-github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
+github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
+github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
+github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
+github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
+github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
-github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
-github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
+github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
+github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
@@ -65,32 +66,27 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
-github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
-github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
-github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
-github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
+github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
+github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
-github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
-github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
-github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
-github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
+github.com/pelletier/go-toml/v2 v2.2.3 h1:YmeHyLY8mFWbdkNWwpr+qIL2bEqT0o95WSdkNHvL12M=
+github.com/pelletier/go-toml/v2 v2.2.3/go.mod h1:MfCQTFTvCcUyyvvwm1+G6H/jORL20Xlb6rzQu9GuUkc=
+github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
-github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
-github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
-github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
-github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 h1:LmsF7Fk5jyEDhJk0fYIqdWNuTxSyid2W42A0L2YWjGE=
-github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
+github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
+github.com/rivo/tview v0.0.0-20250322200051-73a5bd7d6839 h1:/v0ptNHBQaQCxlvS4QLxLKKGfsSA9hcZcNgqVgmPRro=
+github.com/rivo/tview v0.0.0-20250322200051-73a5bd7d6839/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
@@ -98,26 +94,24 @@ github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUc
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
-github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
-github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
-github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
-github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
+github.com/sagikazarmark/locafero v0.8.0 h1:mXaMVw7IqxNBxfv3LdWt9MDmcWDQ1fagDH918lOdVaQ=
+github.com/sagikazarmark/locafero v0.8.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/shirou/gopsutil/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk=
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
-github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
-github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
-github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
-github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
+github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
+github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
+github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
+github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
-github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
-github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
+github.com/spf13/viper v1.20.0 h1:zrxIyR3RQIOsarIrgL8+sAvALXul9jeEPa06Y0Ph6vY=
+github.com/spf13/viper v1.20.0/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
@@ -128,15 +122,14 @@ github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
-github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
-github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
-github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
-github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
-github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
+github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
+github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
+github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
+github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
@@ -144,13 +137,10 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
-go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
-go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
-go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
-go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
-golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
-golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
-golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
+go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
+go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
+golang.org/x/arch v0.15.0 h1:QtOrQd0bTUnhNVNndMpLHNWrDmYzZ2KDqSrEymqInZw=
+golang.org/x/arch v0.15.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
@@ -158,8 +148,6 @@ golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDf
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
-golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
-golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
@@ -173,8 +161,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
-golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
-golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
+golang.org/x/net v0.37.0 h1:1zLorHbz+LYj7MQlSf1+2tPIIgibq2eL5xkrGk6f+2c=
+golang.org/x/net v0.37.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
@@ -194,7 +182,6 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
-golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
@@ -230,16 +217,15 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
-golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
-gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
-gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
+gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
+howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
-rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
diff --git a/internal/chatlog/app.go b/internal/chatlog/app.go
index 6ca17e2..7a1085a 100644
--- a/internal/chatlog/app.go
+++ b/internal/chatlog/app.go
@@ -2,6 +2,7 @@ package chatlog
import (
"fmt"
+ "runtime"
"time"
"github.com/sjzar/chatlog/internal/chatlog/ctx"
@@ -109,7 +110,7 @@ func (a *App) refresh() {
return
case <-tick.C:
a.infoBar.UpdateAccount(a.ctx.Account)
- a.infoBar.UpdateBasicInfo(a.ctx.PID, a.ctx.Version, a.ctx.ExePath)
+ a.infoBar.UpdateBasicInfo(a.ctx.PID, a.ctx.FullVersion, a.ctx.ExePath)
a.infoBar.UpdateStatus(a.ctx.Status)
a.infoBar.UpdateDataKey(a.ctx.DataKey)
a.infoBar.UpdateDataUsageDir(a.ctx.DataUsage, a.ctx.DataDir)
@@ -159,11 +160,36 @@ func (a *App) initMenu() {
Name: "获取数据密钥",
Description: "从进程获取数据密钥",
Selected: func(i *menu.Item) {
- if err := a.m.GetDataKey(); err != nil {
- a.showError(err)
- return
+ modal := tview.NewModal()
+ if runtime.GOOS == "darwin" {
+ modal.SetText("获取数据密钥中...\n预计需要 20 秒左右的时间,期间微信会卡住,请耐心等待")
+ } else {
+ modal.SetText("获取数据密钥中...")
}
- a.showInfo("获取数据密钥成功")
+ a.mainPages.AddPage("modal", modal, true, true)
+ a.SetFocus(modal)
+
+ go func() {
+ err := a.m.GetDataKey()
+
+ // 在主线程中更新UI
+ a.QueueUpdateDraw(func() {
+ if err != nil {
+ // 解密失败
+ modal.SetText("获取数据密钥失败: " + err.Error())
+ } else {
+ // 解密成功
+ modal.SetText("获取数据密钥成功")
+ }
+
+ // 添加确认按钮
+ modal.AddButtons([]string{"OK"})
+ modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
+ a.mainPages.RemovePage("modal")
+ })
+ a.SetFocus(modal)
+ })
+ }()
},
}
@@ -264,7 +290,7 @@ func (a *App) initMenu() {
modal.SetText("已停止 HTTP 服务")
// 更改菜单项名称
i.Name = "启动 HTTP 服务"
- i.Description = "启动本地 HTTP 服务器"
+ i.Description = "启动本地 HTTP & MCP 服务器"
}
// 添加确认按钮
diff --git a/internal/chatlog/conf/config.go b/internal/chatlog/conf/config.go
index 79b2ae6..4e34455 100644
--- a/internal/chatlog/conf/config.go
+++ b/internal/chatlog/conf/config.go
@@ -9,17 +9,18 @@ type Config struct {
}
type ProcessConfig struct {
- Type string `mapstructure:"type" json:"type"`
- Version string `mapstructure:"version" json:"version"`
- MajorVersion int `mapstructure:"major_version" json:"major_version"`
- Account string `mapstructure:"account" json:"account"`
- DataKey string `mapstructure:"data_key" json:"data_key"`
- DataDir string `mapstructure:"data_dir" json:"data_dir"`
- WorkDir string `mapstructure:"work_dir" json:"work_dir"`
- HTTPEnabled bool `mapstructure:"http_enabled" json:"http_enabled"`
- HTTPAddr string `mapstructure:"http_addr" json:"http_addr"`
- LastTime int64 `mapstructure:"last_time" json:"last_time"`
- Files []File `mapstructure:"files" json:"files"`
+ Type string `mapstructure:"type" json:"type"`
+ Account string `mapstructure:"account" json:"account"`
+ Platform string `mapstructure:"platform" json:"platform"`
+ Version int `mapstructure:"version" json:"version"`
+ FullVersion string `mapstructure:"full_version" json:"full_version"`
+ DataDir string `mapstructure:"data_dir" json:"data_dir"`
+ DataKey string `mapstructure:"data_key" json:"data_key"`
+ WorkDir string `mapstructure:"work_dir" json:"work_dir"`
+ HTTPEnabled bool `mapstructure:"http_enabled" json:"http_enabled"`
+ HTTPAddr string `mapstructure:"http_addr" json:"http_addr"`
+ LastTime int64 `mapstructure:"last_time" json:"last_time"`
+ Files []File `mapstructure:"files" json:"files"`
}
type File struct {
diff --git a/internal/chatlog/ctx/context.go b/internal/chatlog/ctx/context.go
index 93ae773..11adb03 100644
--- a/internal/chatlog/ctx/context.go
+++ b/internal/chatlog/ctx/context.go
@@ -17,29 +17,30 @@ type Context struct {
History map[string]conf.ProcessConfig
// 微信账号相关状态
- Account string
- Version string
- MajorVersion int
- DataKey string
- DataUsage string
- DataDir string
+ Account string
+ Platform string
+ Version int
+ FullVersion string
+ DataDir string
+ DataKey string
+ DataUsage string
// 工作目录相关状态
- WorkUsage string
WorkDir string
+ WorkUsage string
// HTTP服务相关状态
HTTPEnabled bool
HTTPAddr string
// 当前选中的微信实例
- Current *wechat.Info
+ Current *wechat.Account
PID int
ExePath string
Status string
// 所有可用的微信实例
- WeChatInstances []*wechat.Info
+ WeChatInstances []*wechat.Account
}
func New(conf *conf.Service) *Context {
@@ -65,8 +66,9 @@ func (c *Context) SwitchHistory(account string) {
history, ok := c.History[account]
if ok {
c.Account = history.Account
+ c.Platform = history.Platform
c.Version = history.Version
- c.MajorVersion = history.MajorVersion
+ c.FullVersion = history.FullVersion
c.DataKey = history.DataKey
c.DataDir = history.DataDir
c.WorkDir = history.WorkDir
@@ -75,8 +77,8 @@ func (c *Context) SwitchHistory(account string) {
}
}
-func (c *Context) SwitchCurrent(info *wechat.Info) {
- c.SwitchHistory(info.AccountName)
+func (c *Context) SwitchCurrent(info *wechat.Account) {
+ c.SwitchHistory(info.Name)
c.mu.Lock()
defer c.mu.Unlock()
c.Current = info
@@ -85,9 +87,10 @@ func (c *Context) SwitchCurrent(info *wechat.Info) {
}
func (c *Context) Refresh() {
if c.Current != nil {
- c.Account = c.Current.AccountName
- c.Version = c.Current.Version.FileVersion
- c.MajorVersion = c.Current.Version.FileMajorVersion
+ c.Account = c.Current.Name
+ c.Platform = c.Current.Platform
+ c.Version = c.Current.Version
+ c.FullVersion = c.Current.FullVersion
c.PID = int(c.Current.PID)
c.ExePath = c.Current.ExePath
c.Status = c.Current.Status
@@ -143,15 +146,16 @@ func (c *Context) SetDataDir(dir string) {
// 更新配置
func (c *Context) UpdateConfig() {
pconf := conf.ProcessConfig{
- Type: "wechat",
- Version: c.Version,
- MajorVersion: c.MajorVersion,
- Account: c.Account,
- DataKey: c.DataKey,
- DataDir: c.DataDir,
- WorkDir: c.WorkDir,
- HTTPEnabled: c.HTTPEnabled,
- HTTPAddr: c.HTTPAddr,
+ Type: "wechat",
+ Account: c.Account,
+ Platform: c.Platform,
+ Version: c.Version,
+ FullVersion: c.FullVersion,
+ DataDir: c.DataDir,
+ DataKey: c.DataKey,
+ WorkDir: c.WorkDir,
+ HTTPEnabled: c.HTTPEnabled,
+ HTTPAddr: c.HTTPAddr,
}
conf := c.conf.GetConfig()
conf.UpdateHistory(c.Account, pconf)
diff --git a/internal/chatlog/database/service.go b/internal/chatlog/database/service.go
index 3b75207..5014119 100644
--- a/internal/chatlog/database/service.go
+++ b/internal/chatlog/database/service.go
@@ -4,8 +4,8 @@ import (
"time"
"github.com/sjzar/chatlog/internal/chatlog/ctx"
+ "github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb"
- "github.com/sjzar/chatlog/pkg/model"
)
type Service struct {
@@ -20,7 +20,7 @@ func NewService(ctx *ctx.Context) *Service {
}
func (s *Service) Start() error {
- db, err := wechatdb.New(s.ctx.WorkDir, s.ctx.MajorVersion)
+ db, err := wechatdb.New(s.ctx.WorkDir, s.ctx.Platform, s.ctx.Version)
if err != nil {
return err
}
@@ -36,42 +36,29 @@ func (s *Service) Stop() error {
return nil
}
-// GetDB returns the underlying database
func (s *Service) GetDB() *wechatdb.DB {
return s.db
}
-// GetMessages retrieves messages based on criteria
func (s *Service) GetMessages(start, end time.Time, talker string, limit, offset int) ([]*model.Message, error) {
return s.db.GetMessages(start, end, talker, limit, offset)
}
-// GetContact retrieves contact information
-func (s *Service) GetContact(userName string) *model.Contact {
- return s.db.GetContact(userName)
+func (s *Service) GetContacts(key string, limit, offset int) (*wechatdb.GetContactsResp, error) {
+ return s.db.GetContacts(key, limit, offset)
}
-// ListContact retrieves all contacts
-func (s *Service) ListContact() (*wechatdb.ListContactResp, error) {
- return s.db.ListContact()
-}
-
-// GetChatRoom retrieves chat room information
-func (s *Service) GetChatRoom(name string) *model.ChatRoom {
- return s.db.GetChatRoom(name)
-}
-
-// ListChatRoom retrieves all chat rooms
-func (s *Service) ListChatRoom() (*wechatdb.ListChatRoomResp, error) {
- return s.db.ListChatRoom()
+func (s *Service) GetChatRooms(key string, limit, offset int) (*wechatdb.GetChatRoomsResp, error) {
+ return s.db.GetChatRooms(key, limit, offset)
}
// GetSession retrieves session information
-func (s *Service) GetSession(limit int) (*wechatdb.GetSessionResp, error) {
- return s.db.GetSession(limit)
+func (s *Service) GetSessions(key string, limit, offset int) (*wechatdb.GetSessionsResp, error) {
+ return s.db.GetSessions(key, limit, offset)
}
// Close closes the database connection
func (s *Service) Close() {
// Add cleanup code if needed
+ s.db.Close()
}
diff --git a/internal/chatlog/http/route.go b/internal/chatlog/http/route.go
index c232be8..d25e948 100644
--- a/internal/chatlog/http/route.go
+++ b/internal/chatlog/http/route.go
@@ -5,7 +5,6 @@ import (
"fmt"
"io/fs"
"net/http"
- "strconv"
"strings"
"github.com/sjzar/chatlog/internal/errors"
@@ -19,8 +18,6 @@ import (
//go:embed static
var EFS embed.FS
-// initRouter sets up routes and static file servers for the web service.
-// It defines endpoints for API as well as serving static content.
func (s *Service) initRouter() {
router := s.GetRouter()
@@ -43,9 +40,9 @@ func (s *Service) initRouter() {
api := router.Group("/api/v1")
{
api.GET("/chatlog", s.GetChatlog)
- api.GET("/contact", s.ListContact)
- api.GET("/chatroom", s.ListChatRoom)
- api.GET("/session", s.GetSession)
+ api.GET("/contact", s.GetContacts)
+ api.GET("/chatroom", s.GetChatRooms)
+ api.GET("/session", s.GetSessions)
}
router.NoRoute(s.NoRoute)
@@ -66,47 +63,39 @@ func (s *Service) NoRoute(c *gin.Context) {
func (s *Service) GetChatlog(c *gin.Context) {
+ q := struct {
+ Time string `form:"time"`
+ Talker string `form:"talker"`
+ Limit int `form:"limit"`
+ Offset int `form:"offset"`
+ Format string `form:"format"`
+ }{}
+
+ if err := c.BindQuery(&q); err != nil {
+ errors.Err(c, err)
+ return
+ }
+
var err error
- start, end, ok := util.TimeRangeOf(c.Query("time"))
+ start, end, ok := util.TimeRangeOf(q.Time)
if !ok {
errors.Err(c, errors.ErrInvalidArg("time"))
}
-
- var limit int
- if _limit := c.Query("limit"); len(_limit) > 0 {
- limit, err = strconv.Atoi(_limit)
- if err != nil {
- errors.Err(c, errors.ErrInvalidArg("limit"))
- return
- }
+ if q.Limit < 0 {
+ q.Limit = 0
}
- var offset int
- if _offset := c.Query("offset"); len(_offset) > 0 {
- offset, err = strconv.Atoi(_offset)
- if err != nil {
- errors.Err(c, errors.ErrInvalidArg("offset"))
- return
- }
+ if q.Offset < 0 {
+ q.Offset = 0
}
- talker := c.Query("talker")
-
- if limit < 0 {
- limit = 0
- }
-
- if offset < 0 {
- offset = 0
- }
-
- messages, err := s.db.GetMessages(start, end, talker, limit, offset)
+ messages, err := s.db.GetMessages(start, end, q.Talker, q.Limit, q.Offset)
if err != nil {
errors.Err(c, err)
return
}
- switch strings.ToLower(c.Query("format")) {
+ switch strings.ToLower(q.Format) {
case "csv":
case "json":
// json
@@ -119,21 +108,34 @@ func (s *Service) GetChatlog(c *gin.Context) {
c.Writer.Flush()
for _, m := range messages {
- c.Writer.WriteString(m.PlainText(len(talker) == 0))
+ c.Writer.WriteString(m.PlainText(len(q.Talker) == 0))
c.Writer.WriteString("\n")
c.Writer.Flush()
}
}
}
-func (s *Service) ListContact(c *gin.Context) {
- list, err := s.db.ListContact()
+func (s *Service) GetContacts(c *gin.Context) {
+
+ q := struct {
+ Key string `form:"key"`
+ Limit int `form:"limit"`
+ Offset int `form:"offset"`
+ Format string `form:"format"`
+ }{}
+
+ if err := c.BindQuery(&q); err != nil {
+ errors.Err(c, err)
+ return
+ }
+
+ list, err := s.db.GetContacts(q.Key, q.Limit, q.Offset)
if err != nil {
errors.Err(c, err)
return
}
- format := strings.ToLower(c.Query("format"))
+ format := strings.ToLower(q.Format)
switch format {
case "json":
// json
@@ -158,22 +160,26 @@ func (s *Service) ListContact(c *gin.Context) {
}
}
-func (s *Service) ListChatRoom(c *gin.Context) {
+func (s *Service) GetChatRooms(c *gin.Context) {
- if query := c.Query("query"); len(query) > 0 {
- chatRoom := s.db.GetChatRoom(query)
- if chatRoom != nil {
- c.JSON(http.StatusOK, chatRoom)
- return
- }
+ q := struct {
+ Key string `form:"key"`
+ Limit int `form:"limit"`
+ Offset int `form:"offset"`
+ Format string `form:"format"`
+ }{}
+
+ if err := c.BindQuery(&q); err != nil {
+ errors.Err(c, err)
+ return
}
- list, err := s.db.ListChatRoom()
+ list, err := s.db.GetChatRooms(q.Key, q.Limit, q.Offset)
if err != nil {
errors.Err(c, err)
return
}
- format := strings.ToLower(c.Query("format"))
+ format := strings.ToLower(q.Format)
switch format {
case "json":
// json
@@ -190,32 +196,34 @@ func (s *Service) ListChatRoom(c *gin.Context) {
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
- c.Writer.WriteString("Name,Owner,UserCount\n")
+ c.Writer.WriteString("Name,Remark,NickName,Owner,UserCount\n")
for _, chatRoom := range list.Items {
- c.Writer.WriteString(fmt.Sprintf("%s,%s,%d\n", chatRoom.Name, chatRoom.Owner, len(chatRoom.Users)))
+ c.Writer.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
}
c.Writer.Flush()
}
}
-func (s *Service) GetSession(c *gin.Context) {
+func (s *Service) GetSessions(c *gin.Context) {
- var err error
- var limit int
- if _limit := c.Query("limit"); len(_limit) > 0 {
- limit, err = strconv.Atoi(_limit)
- if err != nil {
- errors.Err(c, errors.ErrInvalidArg("limit"))
- return
- }
+ q := struct {
+ Key string `form:"key"`
+ Limit int `form:"limit"`
+ Offset int `form:"offset"`
+ Format string `form:"format"`
+ }{}
+
+ if err := c.BindQuery(&q); err != nil {
+ errors.Err(c, err)
+ return
}
- sessions, err := s.db.GetSession(limit)
+ sessions, err := s.db.GetSessions(q.Key, q.Limit, q.Offset)
if err != nil {
errors.Err(c, err)
return
}
- format := strings.ToLower(c.Query("format"))
+ format := strings.ToLower(q.Format)
switch format {
case "csv":
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
diff --git a/internal/chatlog/http/service.go b/internal/chatlog/http/service.go
index 5ffe0cd..f027ec4 100644
--- a/internal/chatlog/http/service.go
+++ b/internal/chatlog/http/service.go
@@ -14,6 +14,10 @@ import (
log "github.com/sirupsen/logrus"
)
+const (
+ DefalutHTTPAddr = "127.0.0.1:5030"
+)
+
type Service struct {
ctx *ctx.Context
db *database.Service
@@ -34,7 +38,8 @@ func NewService(ctx *ctx.Context, db *database.Service, mcp *mcp.Service) *Servi
// Middleware
router.Use(
- gin.Recovery(),
+ errors.RecoveryMiddleware(),
+ errors.ErrorHandlerMiddleware(),
gin.LoggerWithWriter(log.StandardLogger().Out),
)
@@ -50,6 +55,11 @@ func NewService(ctx *ctx.Context, db *database.Service, mcp *mcp.Service) *Servi
}
func (s *Service) Start() error {
+
+ if s.ctx.HTTPAddr == "" {
+ s.ctx.HTTPAddr = DefalutHTTPAddr
+ }
+
s.server = &http.Server{
Addr: s.ctx.HTTPAddr,
Handler: s.router,
diff --git a/internal/chatlog/manager.go b/internal/chatlog/manager.go
index b55174f..7827ed9 100644
--- a/internal/chatlog/manager.go
+++ b/internal/chatlog/manager.go
@@ -1,6 +1,7 @@
package chatlog
import (
+ "context"
"fmt"
"path/filepath"
@@ -67,7 +68,7 @@ func (m *Manager) Run() error {
if m.ctx.HTTPEnabled {
// 启动HTTP服务
if err := m.StartService(); err != nil {
- return err
+ m.StopService()
}
}
// 启动终端UI
@@ -152,7 +153,7 @@ func (m *Manager) DecryptDBFiles() error {
m.ctx.WorkDir = util.DefaultWorkDir(m.ctx.Account)
}
- if err := m.wechat.DecryptDBFiles(m.ctx.DataDir, m.ctx.WorkDir, m.ctx.DataKey, m.ctx.MajorVersion); err != nil {
+ if err := m.wechat.DecryptDBFiles(m.ctx.DataDir, m.ctx.WorkDir, m.ctx.DataKey, m.ctx.Platform, m.ctx.Version); err != nil {
return err
}
m.ctx.Refresh()
@@ -166,24 +167,24 @@ func (m *Manager) CommandKey(pid int) (string, error) {
return "", fmt.Errorf("wechat process not found")
}
if len(instances) == 1 {
- return instances[0].GetKey()
+ return instances[0].GetKey(context.Background())
}
if pid == 0 {
str := "Select a process:\n"
for _, ins := range instances {
- str += fmt.Sprintf("PID: %d. %s[Version: %s Data Dir: %s ]\n", ins.PID, ins.AccountName, ins.Version.FileVersion, ins.DataDir)
+ str += fmt.Sprintf("PID: %d. %s[Version: %s Data Dir: %s ]\n", ins.PID, ins.Name, ins.FullVersion, ins.DataDir)
}
return str, nil
}
for _, ins := range instances {
if ins.PID == uint32(pid) {
- return ins.GetKey()
+ return ins.GetKey(context.Background())
}
}
return "", fmt.Errorf("wechat process not found")
}
-func (m *Manager) CommandDecrypt(dataDir string, workDir string, key string, version int) error {
+func (m *Manager) CommandDecrypt(dataDir string, workDir string, key string, platform string, version int) error {
if dataDir == "" {
return fmt.Errorf("dataDir is required")
}
@@ -194,7 +195,7 @@ func (m *Manager) CommandDecrypt(dataDir string, workDir string, key string, ver
workDir = util.DefaultWorkDir(filepath.Base(filepath.Dir(dataDir)))
}
- if err := m.wechat.DecryptDBFiles(dataDir, workDir, key, version); err != nil {
+ if err := m.wechat.DecryptDBFiles(dataDir, workDir, key, platform, version); err != nil {
return err
}
diff --git a/internal/chatlog/mcp/service.go b/internal/chatlog/mcp/service.go
index 6399286..2f2c95f 100644
--- a/internal/chatlog/mcp/service.go
+++ b/internal/chatlog/mcp/service.go
@@ -134,53 +134,39 @@ func (s *Service) toolsCall(session *mcp.Session, req *mcp.Request) error {
if v, ok := callReq.Arguments["query"]; ok {
query = v.(string)
}
- if len(query) == 0 {
- list, err := s.db.ListContact()
- if err != nil {
- return fmt.Errorf("无法获取联系人列表: %v", err)
- }
- buf.WriteString("UserName,Alias,Remark,NickName\n")
- for _, contact := range list.Items {
- buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
- }
- } else {
- contact := s.db.GetContact(query)
- if contact == nil {
- return fmt.Errorf("无法获取联系人: %s", query)
- }
- b, err := json.Marshal(contact)
- if err != nil {
- return fmt.Errorf("无法序列化联系人: %v", err)
- }
- buf.Write(b)
+ limit := util.MustAnyToInt(callReq.Arguments["limit"])
+ offset := util.MustAnyToInt(callReq.Arguments["offset"])
+ list, err := s.db.GetContacts(query, limit, offset)
+ if err != nil {
+ return fmt.Errorf("无法获取联系人列表: %v", err)
+ }
+ buf.WriteString("UserName,Alias,Remark,NickName\n")
+ for _, contact := range list.Items {
+ buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
}
case "query_chat_room":
query := ""
if v, ok := callReq.Arguments["query"]; ok {
query = v.(string)
}
- if len(query) == 0 {
- list, err := s.db.ListChatRoom()
- if err != nil {
- return fmt.Errorf("无法获取群聊列表: %v", err)
- }
- buf.WriteString("Name,Remark,NickName,Owner,UserCount\n")
- for _, chatRoom := range list.Items {
- buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
- }
- } else {
- chatRoom := s.db.GetChatRoom(query)
- if chatRoom == nil {
- return fmt.Errorf("无法获取群聊: %s", query)
- }
- b, err := json.Marshal(chatRoom)
- if err != nil {
- return fmt.Errorf("无法序列化群聊: %v", err)
- }
- buf.Write(b)
+ limit := util.MustAnyToInt(callReq.Arguments["limit"])
+ offset := util.MustAnyToInt(callReq.Arguments["offset"])
+ list, err := s.db.GetChatRooms(query, limit, offset)
+ if err != nil {
+ return fmt.Errorf("无法获取群聊列表: %v", err)
+ }
+ buf.WriteString("Name,Remark,NickName,Owner,UserCount\n")
+ for _, chatRoom := range list.Items {
+ buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
}
case "query_recent_chat":
- data, err := s.db.GetSession(0)
+ query := ""
+ if v, ok := callReq.Arguments["query"]; ok {
+ query = v.(string)
+ }
+ limit := util.MustAnyToInt(callReq.Arguments["limit"])
+ offset := util.MustAnyToInt(callReq.Arguments["offset"])
+ data, err := s.db.GetSessions(query, limit, offset)
if err != nil {
return fmt.Errorf("无法获取会话列表: %v", err)
}
@@ -245,49 +231,26 @@ func (s *Service) resourcesRead(session *mcp.Session, req *mcp.Request) error {
buf := &bytes.Buffer{}
switch u.Scheme {
case "contact":
- if len(u.Host) == 0 {
- list, err := s.db.ListContact()
- if err != nil {
- return fmt.Errorf("无法获取联系人列表: %v", err)
- }
- buf.WriteString("UserName,Alias,Remark,NickName\n")
- for _, contact := range list.Items {
- buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
- }
- } else {
- contact := s.db.GetContact(u.Host)
- if contact == nil {
- return fmt.Errorf("无法获取联系人: %s", u.Host)
- }
- b, err := json.Marshal(contact)
- if err != nil {
- return fmt.Errorf("无法序列化联系人: %v", err)
- }
- buf.Write(b)
+
+ list, err := s.db.GetContacts(u.Host, 0, 0)
+ if err != nil {
+ return fmt.Errorf("无法获取联系人列表: %v", err)
+ }
+ buf.WriteString("UserName,Alias,Remark,NickName\n")
+ for _, contact := range list.Items {
+ buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
}
case "chatroom":
- if len(u.Host) == 0 {
- list, err := s.db.ListChatRoom()
- if err != nil {
- return fmt.Errorf("无法获取群聊列表: %v", err)
- }
- buf.WriteString("Name,Remark,NickName,Owner,UserCount\n")
- for _, chatRoom := range list.Items {
- buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
- }
- } else {
- chatRoom := s.db.GetChatRoom(u.Host)
- if chatRoom == nil {
- return fmt.Errorf("无法获取群聊: %s", u.Host)
- }
- b, err := json.Marshal(chatRoom)
- if err != nil {
- return fmt.Errorf("无法序列化群聊: %v", err)
- }
- buf.Write(b)
+ list, err := s.db.GetChatRooms(u.Host, 0, 0)
+ if err != nil {
+ return fmt.Errorf("无法获取群聊列表: %v", err)
+ }
+ buf.WriteString("Name,Remark,NickName,Owner,UserCount\n")
+ for _, chatRoom := range list.Items {
+ buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
}
case "session":
- data, err := s.db.GetSession(0)
+ data, err := s.db.GetSessions("", 0, 0)
if err != nil {
return fmt.Errorf("无法获取会话列表: %v", err)
}
diff --git a/internal/chatlog/wechat/service.go b/internal/chatlog/wechat/service.go
index 6b737bb..584970b 100644
--- a/internal/chatlog/wechat/service.go
+++ b/internal/chatlog/wechat/service.go
@@ -1,14 +1,19 @@
package wechat
import (
+ "context"
"fmt"
"os"
"path/filepath"
"strings"
"github.com/sjzar/chatlog/internal/chatlog/ctx"
+ "github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/wechat"
+ "github.com/sjzar/chatlog/internal/wechat/decrypt"
"github.com/sjzar/chatlog/pkg/util"
+
+ log "github.com/sirupsen/logrus"
)
type Service struct {
@@ -22,18 +27,18 @@ func NewService(ctx *ctx.Context) *Service {
}
// GetWeChatInstances returns all running WeChat instances
-func (s *Service) GetWeChatInstances() []*wechat.Info {
+func (s *Service) GetWeChatInstances() []*wechat.Account {
wechat.Load()
- return wechat.Items
+ return wechat.GetAccounts()
}
// GetDataKey extracts the encryption key from a WeChat process
-func (s *Service) GetDataKey(info *wechat.Info) (string, error) {
+func (s *Service) GetDataKey(info *wechat.Account) (string, error) {
if info == nil {
return "", fmt.Errorf("no WeChat instance selected")
}
- key, err := info.GetKey()
+ key, err := info.GetKey(context.Background())
if err != nil {
return "", err
}
@@ -89,23 +94,42 @@ func (s *Service) FindDBFiles(rootDir string, recursive bool) ([]string, error)
return dbFiles, nil
}
-func (s *Service) DecryptDBFiles(dataDir string, workDir string, key string, version int) error {
+func (s *Service) DecryptDBFiles(dataDir string, workDir string, key string, platform string, version int) error {
+
+ ctx := context.Background()
dbfiles, err := s.FindDBFiles(dataDir, true)
if err != nil {
return err
}
+ decryptor, err := decrypt.NewDecryptor(platform, version)
+ if err != nil {
+ return err
+ }
+
for _, dbfile := range dbfiles {
output := filepath.Join(workDir, dbfile[len(dataDir):])
if err := util.PrepareDir(filepath.Dir(output)); err != nil {
return err
}
- if err := wechat.DecryptDBFileToFile(dbfile, output, key, version); err != nil {
- if err == wechat.ErrAlreadyDecrypted {
+
+ outputFile, err := os.Create(output)
+ if err != nil {
+ return fmt.Errorf("failed to create output file: %v", err)
+ }
+ defer outputFile.Close()
+
+ if err := decryptor.Decrypt(ctx, dbfile, key, outputFile); err != nil {
+ log.Debugf("failed to decrypt %s: %v", dbfile, err)
+ if err == errors.ErrAlreadyDecrypted {
+ if data, err := os.ReadFile(dbfile); err == nil {
+ outputFile.Write(data)
+ }
continue
}
- return err
+ continue
+ // return err
}
}
diff --git a/internal/errors/domain_errors.go b/internal/errors/domain_errors.go
new file mode 100644
index 0000000..457b303
--- /dev/null
+++ b/internal/errors/domain_errors.go
@@ -0,0 +1,151 @@
+package errors
+
+import (
+ "fmt"
+ "net/http"
+)
+
+// 微信相关错误
+
+// WeChatProcessNotFound 创建微信进程未找到错误
+func WeChatProcessNotFound() *AppError {
+ return New(ErrTypeWeChat, "wechat process not found", nil, http.StatusNotFound).WithStack()
+}
+
+// WeChatKeyExtractFailed 创建微信密钥提取失败错误
+func WeChatKeyExtractFailed(cause error) *AppError {
+ return New(ErrTypeWeChat, "failed to extract wechat key", cause, http.StatusInternalServerError).WithStack()
+}
+
+// WeChatDecryptFailed 创建微信解密失败错误
+func WeChatDecryptFailed(cause error) *AppError {
+ return New(ErrTypeWeChat, "failed to decrypt wechat database", cause, http.StatusInternalServerError).WithStack()
+}
+
+// WeChatAccountNotSelected 创建未选择微信账号错误
+func WeChatAccountNotSelected() *AppError {
+ return New(ErrTypeWeChat, "no wechat account selected", nil, http.StatusBadRequest).WithStack()
+}
+
+// 数据库相关错误
+
+// DBConnectionFailed 创建数据库连接失败错误
+func DBConnectionFailed(cause error) *AppError {
+ return New(ErrTypeDatabase, "database connection failed", cause, http.StatusInternalServerError).WithStack()
+}
+
+// DBQueryFailed 创建数据库查询失败错误
+func DBQueryFailed(operation string, cause error) *AppError {
+ return New(ErrTypeDatabase, fmt.Sprintf("database query failed: %s", operation), cause, http.StatusInternalServerError).WithStack()
+}
+
+// DBRecordNotFound 创建数据库记录未找到错误
+func DBRecordNotFound(resource string) *AppError {
+ return New(ErrTypeNotFound, fmt.Sprintf("record not found: %s", resource), nil, http.StatusNotFound).WithStack()
+}
+
+// 配置相关错误
+
+// ConfigInvalid 创建配置无效错误
+func ConfigInvalid(field string, cause error) *AppError {
+ return New(ErrTypeConfig, fmt.Sprintf("invalid configuration: %s", field), cause, http.StatusInternalServerError).WithStack()
+}
+
+// ConfigMissing 创建配置缺失错误
+func ConfigMissing(field string) *AppError {
+ return New(ErrTypeConfig, fmt.Sprintf("missing configuration: %s", field), nil, http.StatusBadRequest).WithStack()
+}
+
+// 平台相关错误
+
+// PlatformUnsupported 创建不支持的平台错误
+func PlatformUnsupported(platform string, version int) *AppError {
+ return New(ErrTypeInvalidArg, fmt.Sprintf("unsupported platform: %s v%d", platform, version), nil, http.StatusBadRequest).WithStack()
+}
+
+// 文件系统错误
+
+// FileNotFound 创建文件未找到错误
+func FileNotFound(path string) *AppError {
+ return New(ErrTypeNotFound, fmt.Sprintf("file not found: %s", path), nil, http.StatusNotFound).WithStack()
+}
+
+// FileReadFailed 创建文件读取失败错误
+func FileReadFailed(path string, cause error) *AppError {
+ return New(ErrTypeInternal, fmt.Sprintf("failed to read file: %s", path), cause, http.StatusInternalServerError).WithStack()
+}
+
+// FileWriteFailed 创建文件写入失败错误
+func FileWriteFailed(path string, cause error) *AppError {
+ return New(ErrTypeInternal, fmt.Sprintf("failed to write file: %s", path), cause, http.StatusInternalServerError).WithStack()
+}
+
+// 参数验证错误
+
+// RequiredParam 创建必需参数缺失错误
+func RequiredParam(param string) *AppError {
+ return New(ErrTypeInvalidArg, fmt.Sprintf("required parameter missing: %s", param), nil, http.StatusBadRequest).WithStack()
+}
+
+// InvalidParam 创建参数无效错误
+func InvalidParam(param string, reason string) *AppError {
+ message := fmt.Sprintf("invalid parameter: %s", param)
+ if reason != "" {
+ message = fmt.Sprintf("%s (%s)", message, reason)
+ }
+ return New(ErrTypeInvalidArg, message, nil, http.StatusBadRequest).WithStack()
+}
+
+// 解密相关错误
+
+// DecryptInvalidKey 创建无效密钥格式错误
+func DecryptInvalidKey(cause error) *AppError {
+ return New(ErrTypeWeChat, "invalid key format", cause, http.StatusBadRequest).
+ WithStack()
+}
+
+// DecryptCreateCipherFailed 创建无法创建加密器错误
+func DecryptCreateCipherFailed(cause error) *AppError {
+ return New(ErrTypeWeChat, "failed to create cipher", cause, http.StatusInternalServerError).
+ WithStack()
+}
+
+// DecryptDecodeKeyFailed 创建无法解码十六进制密钥错误
+func DecryptDecodeKeyFailed(cause error) *AppError {
+ return New(ErrTypeWeChat, "failed to decode hex key", cause, http.StatusBadRequest).
+ WithStack()
+}
+
+// DecryptWriteOutputFailed 创建无法写入输出错误
+func DecryptWriteOutputFailed(cause error) *AppError {
+ return New(ErrTypeWeChat, "failed to write decryption output", cause, http.StatusInternalServerError).
+ WithStack()
+}
+
+// DecryptOperationCanceled 创建解密操作被取消错误
+func DecryptOperationCanceled() *AppError {
+ return New(ErrTypeWeChat, "decryption operation was canceled", nil, http.StatusBadRequest).
+ WithStack()
+}
+
+// DecryptOpenFileFailed 创建无法打开数据库文件错误
+func DecryptOpenFileFailed(path string, cause error) *AppError {
+ return New(ErrTypeWeChat, fmt.Sprintf("failed to open database file: %s", path), cause, http.StatusInternalServerError).
+ WithStack()
+}
+
+// DecryptReadFileFailed 创建无法读取数据库文件错误
+func DecryptReadFileFailed(path string, cause error) *AppError {
+ return New(ErrTypeWeChat, fmt.Sprintf("failed to read database file: %s", path), cause, http.StatusInternalServerError).
+ WithStack()
+}
+
+// DecryptIncompleteRead 创建不完整的头部读取错误
+func DecryptIncompleteRead(cause error) *AppError {
+ return New(ErrTypeWeChat, "incomplete header read during decryption", cause, http.StatusInternalServerError).
+ WithStack()
+}
+
+var ErrAlreadyDecrypted = New(ErrTypeWeChat, "database file is already decrypted", nil, http.StatusBadRequest)
+var ErrDecryptHashVerificationFailed = New(ErrTypeWeChat, "hash verification failed during decryption", nil, http.StatusBadRequest)
+var ErrDecryptIncorrectKey = New(ErrTypeWeChat, "incorrect decryption key", nil, http.StatusBadRequest)
diff --git a/internal/errors/errors.go b/internal/errors/errors.go
index 12f7e65..d14b03f 100644
--- a/internal/errors/errors.go
+++ b/internal/errors/errors.go
@@ -1,8 +1,11 @@
package errors
import (
+ "errors"
"fmt"
"net/http"
+ "runtime"
+ "strings"
"github.com/gin-gonic/gin"
)
@@ -14,16 +17,25 @@ const (
ErrTypeHTTP = "http"
ErrTypeConfig = "config"
ErrTypeInvalidArg = "invalid_argument"
+ ErrTypeAuth = "authentication"
+ ErrTypePermission = "permission"
+ ErrTypeNotFound = "not_found"
+ ErrTypeValidation = "validation"
+ ErrTypeRateLimit = "rate_limit"
+ ErrTypeInternal = "internal"
)
// AppError 表示应用程序错误
type AppError struct {
- Type string `json:"type"` // 错误类型
- Message string `json:"message"` // 错误消息
- Cause error `json:"-"` // 原始错误
- Code int `json:"-"` // HTTP Code
+ Type string `json:"type"` // 错误类型
+ Message string `json:"message"` // 错误消息
+ Cause error `json:"-"` // 原始错误
+ Code int `json:"-"` // HTTP Code
+ Stack []string `json:"-"` // 错误堆栈
+ RequestID string `json:"request_id,omitempty"` // 请求ID,用于跟踪
}
+// Error 实现 error 接口
func (e *AppError) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %s: %v", e.Type, e.Message, e.Cause)
@@ -31,10 +43,44 @@ func (e *AppError) Error() string {
return fmt.Sprintf("%s: %s", e.Type, e.Message)
}
+// String 返回错误的字符串表示
func (e *AppError) String() string {
return e.Error()
}
+// Unwrap 实现 errors.Unwrap 接口,用于错误链
+func (e *AppError) Unwrap() error {
+ return e.Cause
+}
+
+// WithStack 添加堆栈信息到错误
+func (e *AppError) WithStack() *AppError {
+ const depth = 32
+ var pcs [depth]uintptr
+ n := runtime.Callers(2, pcs[:])
+ frames := runtime.CallersFrames(pcs[:n])
+
+ stack := make([]string, 0, n)
+ for {
+ frame, more := frames.Next()
+ if !strings.Contains(frame.File, "runtime/") {
+ stack = append(stack, fmt.Sprintf("%s:%d %s", frame.File, frame.Line, frame.Function))
+ }
+ if !more {
+ break
+ }
+ }
+
+ e.Stack = stack
+ return e
+}
+
+// WithRequestID 添加请求ID到错误
+func (e *AppError) WithRequestID(requestID string) *AppError {
+ e.RequestID = requestID
+ return e
+}
+
// New 创建新的应用错误
func New(errType, message string, cause error, code int) *AppError {
return &AppError{
@@ -45,41 +91,155 @@ func New(errType, message string, cause error, code int) *AppError {
}
}
+// Wrap 包装现有错误为 AppError
+func Wrap(err error, errType, message string, code int) *AppError {
+ if err == nil {
+ return nil
+ }
+
+ // 如果已经是 AppError,保留原始类型但更新消息
+ if appErr, ok := err.(*AppError); ok {
+ return &AppError{
+ Type: appErr.Type,
+ Message: message,
+ Cause: appErr.Cause,
+ Code: appErr.Code,
+ Stack: appErr.Stack,
+ }
+ }
+
+ return New(errType, message, err, code)
+}
+
+// Is 检查错误是否为特定类型
+func Is(err error, errType string) bool {
+ if err == nil {
+ return false
+ }
+
+ var appErr *AppError
+ if errors.As(err, &appErr) {
+ return appErr.Type == errType
+ }
+
+ return false
+}
+
+// GetType 获取错误类型
+func GetType(err error) string {
+ if err == nil {
+ return ""
+ }
+
+ var appErr *AppError
+ if errors.As(err, &appErr) {
+ return appErr.Type
+ }
+
+ return "unknown"
+}
+
+// GetCode 获取错误的 HTTP 状态码
+func GetCode(err error) int {
+ if err == nil {
+ return http.StatusOK
+ }
+
+ var appErr *AppError
+ if errors.As(err, &appErr) {
+ return appErr.Code
+ }
+
+ return http.StatusInternalServerError
+}
+
+// RootCause 获取错误链中的根本原因
+func RootCause(err error) error {
+ for err != nil {
+ unwrapped := errors.Unwrap(err)
+ if unwrapped == nil {
+ return err
+ }
+ err = unwrapped
+ }
+ return err
+}
+
// ErrInvalidArg 无效参数错误
func ErrInvalidArg(param string) *AppError {
- return New(ErrTypeInvalidArg, fmt.Sprintf("invalid arg: %s", param), nil, http.StatusBadRequest)
+ return New(ErrTypeInvalidArg, fmt.Sprintf("invalid arg: %s", param), nil, http.StatusBadRequest).WithStack()
}
// Database 创建数据库错误
func Database(message string, cause error) *AppError {
- return New(ErrTypeDatabase, message, cause, http.StatusInternalServerError)
+ return New(ErrTypeDatabase, message, cause, http.StatusInternalServerError).WithStack()
}
// WeChat 创建微信相关错误
func WeChat(message string, cause error) *AppError {
- return New(ErrTypeWeChat, message, cause, http.StatusInternalServerError)
+ return New(ErrTypeWeChat, message, cause, http.StatusInternalServerError).WithStack()
}
// HTTP 创建HTTP服务错误
func HTTP(message string, cause error) *AppError {
- return New(ErrTypeHTTP, message, cause, http.StatusInternalServerError)
+ return New(ErrTypeHTTP, message, cause, http.StatusInternalServerError).WithStack()
}
// Config 创建配置错误
func Config(message string, cause error) *AppError {
- return New(ErrTypeConfig, message, cause, http.StatusInternalServerError)
+ return New(ErrTypeConfig, message, cause, http.StatusInternalServerError).WithStack()
+}
+
+// NotFound 创建资源不存在错误
+func NotFound(resource string, cause error) *AppError {
+ message := fmt.Sprintf("resource not found: %s", resource)
+ return New(ErrTypeNotFound, message, cause, http.StatusNotFound).WithStack()
+}
+
+// Unauthorized 创建未授权错误
+func Unauthorized(message string, cause error) *AppError {
+ return New(ErrTypeAuth, message, cause, http.StatusUnauthorized).WithStack()
+}
+
+// Forbidden 创建权限不足错误
+func Forbidden(message string, cause error) *AppError {
+ return New(ErrTypePermission, message, cause, http.StatusForbidden).WithStack()
+}
+
+// Validation 创建数据验证错误
+func Validation(message string, cause error) *AppError {
+ return New(ErrTypeValidation, message, cause, http.StatusBadRequest).WithStack()
+}
+
+// RateLimit 创建请求频率限制错误
+func RateLimit(message string, cause error) *AppError {
+ return New(ErrTypeRateLimit, message, cause, http.StatusTooManyRequests).WithStack()
+}
+
+// Internal 创建内部服务器错误
+func Internal(message string, cause error) *AppError {
+ return New(ErrTypeInternal, message, cause, http.StatusInternalServerError).WithStack()
}
// Err 在HTTP响应中返回错误
func Err(c *gin.Context, err error) {
+ // 获取请求ID(如果有)
+ requestID := c.GetString("RequestID")
+
if appErr, ok := err.(*AppError); ok {
+ if requestID != "" {
+ appErr.RequestID = requestID
+ }
c.JSON(appErr.Code, appErr)
return
}
// 未知错误
- c.JSON(http.StatusInternalServerError, gin.H{
- "type": "unknown",
- "message": err.Error(),
- })
+ unknownErr := &AppError{
+ Type: "unknown",
+ Message: err.Error(),
+ Code: http.StatusInternalServerError,
+ RequestID: requestID,
+ }
+ c.JSON(http.StatusInternalServerError, unknownErr)
}
diff --git a/internal/errors/errors_test.go b/internal/errors/errors_test.go
new file mode 100644
index 0000000..e0a22a9
--- /dev/null
+++ b/internal/errors/errors_test.go
@@ -0,0 +1,165 @@
+package errors
+
+import (
+ "fmt"
+ "net/http"
+ "testing"
+)
+
+func TestErrorCreation(t *testing.T) {
+ // 测试创建基本错误
+ err := New("test", "test message", nil, http.StatusBadRequest)
+ if err.Type != "test" || err.Message != "test message" || err.Code != http.StatusBadRequest {
+ t.Errorf("New() created incorrect error: %v", err)
+ }
+
+ // 测试创建带原因的错误
+ cause := fmt.Errorf("original error")
+ err = New("test", "test with cause", cause, http.StatusInternalServerError)
+ if err.Cause != cause {
+ t.Errorf("New() did not set cause correctly: %v", err)
+ }
+
+ // 测试错误消息格式
+ expected := "test: test with cause: original error"
+ if err.Error() != expected {
+ t.Errorf("Error() = %q, want %q", err.Error(), expected)
+ }
+}
+
+func TestErrorWrapping(t *testing.T) {
+ // 测试包装普通错误
+ original := fmt.Errorf("original error")
+ wrapped := Wrap(original, "wrapped", "wrapped message", http.StatusBadRequest)
+
+ if wrapped.Type != "wrapped" || wrapped.Message != "wrapped message" {
+ t.Errorf("Wrap() created incorrect error: %v", wrapped)
+ }
+
+ if wrapped.Cause != original {
+ t.Errorf("Wrap() did not set cause correctly")
+ }
+
+ // 测试包装 AppError
+ appErr := New("app", "app error", nil, http.StatusNotFound)
+ rewrapped := Wrap(appErr, "ignored", "new message", http.StatusBadRequest)
+
+ if rewrapped.Type != "app" {
+ t.Errorf("Wrap() did not preserve original AppError type: got %s, want %s",
+ rewrapped.Type, appErr.Type)
+ }
+
+ if rewrapped.Message != "new message" {
+ t.Errorf("Wrap() did not update message: got %s, want %s",
+ rewrapped.Message, "new message")
+ }
+
+ if rewrapped.Code != appErr.Code {
+ t.Errorf("Wrap() did not preserve original status code: got %d, want %d",
+ rewrapped.Code, appErr.Code)
+ }
+}
+
+func TestErrorTypeChecking(t *testing.T) {
+ // 创建不同类型的错误
+ dbErr := Database("db error", nil)
+ httpErr := HTTP("http error", nil)
+
+ // 测试 Is 函数
+ if !Is(dbErr, ErrTypeDatabase) {
+ t.Errorf("Is() failed to identify database error")
+ }
+
+ if Is(dbErr, ErrTypeHTTP) {
+ t.Errorf("Is() incorrectly identified database error as HTTP error")
+ }
+
+ if !Is(httpErr, ErrTypeHTTP) {
+ t.Errorf("Is() failed to identify HTTP error")
+ }
+
+ // 测试 GetType 函数
+ if GetType(dbErr) != ErrTypeDatabase {
+ t.Errorf("GetType() returned incorrect type: got %s, want %s",
+ GetType(dbErr), ErrTypeDatabase)
+ }
+
+ if GetType(httpErr) != ErrTypeHTTP {
+ t.Errorf("GetType() returned incorrect type: got %s, want %s",
+ GetType(httpErr), ErrTypeHTTP)
+ }
+
+ // 测试普通错误
+ stdErr := fmt.Errorf("standard error")
+ if GetType(stdErr) != "unknown" {
+ t.Errorf("GetType() for standard error should return 'unknown', got %s",
+ GetType(stdErr))
+ }
+}
+
+func TestErrorUnwrapping(t *testing.T) {
+ // 创建嵌套错误
+ innermost := fmt.Errorf("innermost error")
+ inner := Wrap(innermost, "inner", "inner error", http.StatusBadRequest)
+ outer := Wrap(inner, "outer", "outer error", http.StatusInternalServerError)
+
+ // 测试 Unwrap
+ if unwrapped := outer.Unwrap(); unwrapped != inner.Cause {
+ t.Errorf("Unwrap() did not return correct inner error")
+ }
+
+ // 测试 RootCause
+ if root := RootCause(outer); root != innermost {
+ t.Errorf("RootCause() did not return innermost error")
+ }
+}
+
+func TestErrorHelperFunctions(t *testing.T) {
+ // 测试辅助函数
+ invalidArg := ErrInvalidArg("username")
+ if invalidArg.Type != ErrTypeInvalidArg {
+ t.Errorf("ErrInvalidArg() created error with wrong type: %s", invalidArg.Type)
+ }
+
+ dbErr := Database("query failed", nil)
+ if dbErr.Type != ErrTypeDatabase {
+ t.Errorf("Database() created error with wrong type: %s", dbErr.Type)
+ }
+
+ notFound := NotFound("user", nil)
+ if notFound.Type != ErrTypeNotFound || notFound.Code != http.StatusNotFound {
+ t.Errorf("NotFound() created error with wrong type or code: %s, %d",
+ notFound.Type, notFound.Code)
+ }
+}
+
+func TestErrorUtilityFunctions(t *testing.T) {
+ // 测试 JoinErrors
+ err1 := fmt.Errorf("error 1")
+ err2 := fmt.Errorf("error 2")
+
+ // 单个错误
+ if joined := JoinErrors(err1); joined != err1 {
+ t.Errorf("JoinErrors() with single error should return that error")
+ }
+
+ // 多个错误
+ joined := JoinErrors(err1, err2)
+ if joined == nil {
+ t.Errorf("JoinErrors() returned nil for multiple errors")
+ }
+
+ // nil 错误
+ if joined := JoinErrors(nil, nil); joined != nil {
+ t.Errorf("JoinErrors() with all nil should return nil")
+ }
+
+ // 测试 WrapIfErr
+ if wrapped := WrapIfErr(nil, "test", "message", http.StatusOK); wrapped != nil {
+ t.Errorf("WrapIfErr() with nil should return nil")
+ }
+
+ if wrapped := WrapIfErr(err1, "test", "message", http.StatusBadRequest); wrapped == nil {
+ t.Errorf("WrapIfErr() with non-nil error should return non-nil")
+ }
+}
diff --git a/internal/errors/middleware.go b/internal/errors/middleware.go
new file mode 100644
index 0000000..5afb763
--- /dev/null
+++ b/internal/errors/middleware.go
@@ -0,0 +1,66 @@
+package errors
+
+import (
+ "fmt"
+ "net/http"
+
+ "github.com/gin-gonic/gin"
+ "github.com/google/uuid"
+)
+
+// ErrorHandlerMiddleware 是一个 Gin 中间件,用于统一处理请求过程中的错误
+// 它会为每个请求生成一个唯一的请求 ID,并在错误发生时将其添加到错误响应中
+func ErrorHandlerMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ // 生成请求 ID
+ requestID := uuid.New().String()
+ c.Set("RequestID", requestID)
+ c.Header("X-Request-ID", requestID)
+
+ // 处理请求
+ c.Next()
+
+ // 检查是否有错误
+ if len(c.Errors) > 0 {
+ // 获取第一个错误
+ err := c.Errors[0].Err
+
+ // 使用 Err 函数处理错误响应
+ Err(c, err)
+
+ // 已经处理过错误,不需要继续
+ c.Abort()
+ }
+ }
+}
+
+// RecoveryMiddleware 是一个 Gin 中间件,用于从 panic 恢复并返回 500 错误
+func RecoveryMiddleware() gin.HandlerFunc {
+ return func(c *gin.Context) {
+ defer func() {
+ if r := recover(); r != nil {
+ // 获取请求 ID
+ requestID, _ := c.Get("RequestID")
+ requestIDStr, _ := requestID.(string)
+
+ // 创建内部服务器错误
+ var err *AppError
+ switch v := r.(type) {
+ case error:
+ err = Internal("panic recovered", v).WithRequestID(requestIDStr)
+ default:
+ err = Internal(fmt.Sprintf("panic recovered: %v", r), nil).WithRequestID(requestIDStr)
+ }
+
+ // 记录错误日志
+ fmt.Printf("PANIC RECOVERED: %v\n", err)
+
+ // 返回 500 错误
+ c.JSON(http.StatusInternalServerError, err)
+ c.Abort()
+ }
+ }()
+
+ c.Next()
+ }
+}
diff --git a/internal/errors/utils.go b/internal/errors/utils.go
new file mode 100644
index 0000000..8cef21e
--- /dev/null
+++ b/internal/errors/utils.go
@@ -0,0 +1,131 @@
+package errors
+
+import (
+ stderrors "errors"
+ "fmt"
+ "strings"
+)
+
+// WrapIfErr 如果 err 不为 nil,则包装错误并返回,否则返回 nil
+func WrapIfErr(err error, errType, message string, code int) error {
+ if err == nil {
+ return nil
+ }
+ return Wrap(err, errType, message, code)
+}
+
+// JoinErrors 将多个错误合并为一个错误
+// 如果只有一个错误不为 nil,则返回该错误
+// 如果有多个错误不为 nil,则创建一个包含所有错误信息的新错误
+func JoinErrors(errs ...error) error {
+ var nonNilErrs []error
+ for _, err := range errs {
+ if err != nil {
+ nonNilErrs = append(nonNilErrs, err)
+ }
+ }
+
+ if len(nonNilErrs) == 0 {
+ return nil
+ }
+
+ if len(nonNilErrs) == 1 {
+ return nonNilErrs[0]
+ }
+
+ // 合并多个错误
+ var messages []string
+ for _, err := range nonNilErrs {
+ messages = append(messages, err.Error())
+ }
+
+ return Internal(
+ fmt.Sprintf("multiple errors occurred: %s", strings.Join(messages, "; ")),
+ nonNilErrs[0],
+ )
+}
+
+// IsNil 检查错误是否为 nil
+func IsNil(err error) bool {
+ return err == nil
+}
+
+// IsNotNil 检查错误是否不为 nil
+func IsNotNil(err error) bool {
+ return err != nil
+}
+
+// IsType 检查错误是否为指定类型
+func IsType(err error, errType string) bool {
+ return Is(err, errType)
+}
+
+// HasCause 检查错误是否包含指定的原因
+func HasCause(err error, cause error) bool {
+ if err == nil || cause == nil {
+ return false
+ }
+
+ var appErr *AppError
+ if stderrors.As(err, &appErr) {
+ if appErr.Cause == cause {
+ return true
+ }
+ return HasCause(appErr.Cause, cause)
+ }
+
+ return err == cause
+}
+
+// AsAppError 将错误转换为 AppError 类型
+func AsAppError(err error) (*AppError, bool) {
+ var appErr *AppError
+ if stderrors.As(err, &appErr) {
+ return appErr, true
+ }
+ return nil, false
+}
+
+// FormatErrorChain 格式化错误链,便于调试
+func FormatErrorChain(err error) string {
+ if err == nil {
+ return ""
+ }
+
+ var result strings.Builder
+ result.WriteString(err.Error())
+
+ // 获取 AppError 类型的堆栈信息
+ var appErr *AppError
+ if stderrors.As(err, &appErr) && len(appErr.Stack) > 0 {
+ result.WriteString("\nStack Trace:\n")
+ for _, frame := range appErr.Stack {
+ result.WriteString(" ")
+ result.WriteString(frame)
+ result.WriteString("\n")
+ }
+ }
+
+ // 递归处理错误链
+ cause := stderrors.Unwrap(err)
+ if cause != nil {
+ result.WriteString("\nCaused by: ")
+ result.WriteString(FormatErrorChain(cause))
+ }
+
+ return result.String()
+}
+
+// GetErrorDetails 返回错误的详细信息,包括类型、消息、HTTP状态码和请求ID
+func GetErrorDetails(err error) (errType string, message string, code int, requestID string) {
+ if err == nil {
+ return "", "", 0, ""
+ }
+
+ var appErr *AppError
+ if stderrors.As(err, &appErr) {
+ return appErr.Type, appErr.Message, appErr.Code, appErr.RequestID
+ }
+
+ return "unknown", err.Error(), 500, ""
+}
diff --git a/pkg/model/chatroom.go b/internal/model/chatroom.go
similarity index 83%
rename from pkg/model/chatroom.go
rename to internal/model/chatroom.go
index d76bbc7..2c4874b 100644
--- a/pkg/model/chatroom.go
+++ b/internal/model/chatroom.go
@@ -1,7 +1,7 @@
package model
import (
- "github.com/sjzar/chatlog/pkg/model/wxproto"
+ "github.com/sjzar/chatlog/internal/model/wxproto"
"google.golang.org/protobuf/proto"
)
@@ -84,33 +84,6 @@ func (c *ChatRoomV3) Wrap() *ChatRoom {
}
}
-// CREATE TABLE chat_room(
-// id INTEGER PRIMARY KEY,
-// username TEXT,
-// owner TEXT,
-// ext_buffer BLOB
-// )
-type ChatRoomV4 struct {
- ID int `json:"id"`
- UserName string `json:"username"`
- Owner string `json:"owner"`
- ExtBuffer []byte `json:"ext_buffer"`
-}
-
-func (c *ChatRoomV4) Wrap() *ChatRoom {
-
- var users []ChatRoomUser
- if len(c.ExtBuffer) != 0 {
- users = ParseRoomData(c.ExtBuffer)
- }
-
- return &ChatRoom{
- Name: c.UserName,
- Owner: c.Owner,
- Users: users,
- }
-}
-
func ParseRoomData(b []byte) (users []ChatRoomUser) {
var pbMsg wxproto.RoomData
if err := proto.Unmarshal(b, &pbMsg); err != nil {
diff --git a/internal/model/chatroom_darwinv3.go b/internal/model/chatroom_darwinv3.go
new file mode 100644
index 0000000..9c89cf9
--- /dev/null
+++ b/internal/model/chatroom_darwinv3.go
@@ -0,0 +1,97 @@
+package model
+
+import "strings"
+
+// CREATE TABLE GroupContact(
+// m_nsUsrName TEXT PRIMARY KEY ASC,
+// m_uiConType INTEGER,
+// nickname TEXT,
+// m_nsFullPY TEXT,
+// m_nsShortPY TEXT,
+// m_nsRemark TEXT,
+// m_nsRemarkPYFull TEXT,
+// m_nsRemarkPYShort TEXT,
+// m_uiCertificationFlag INTEGER,
+// m_uiSex INTEGER,
+// m_uiType INTEGER,
+// m_nsImgStatus TEXT,
+// m_uiImgKey INTEGER,
+// m_nsHeadImgUrl TEXT,
+// m_nsHeadHDImgUrl TEXT,
+// m_nsHeadHDMd5 TEXT,
+// m_nsChatRoomMemList TEXT,
+// m_nsChatRoomAdminList TEXT,
+// m_uiChatRoomStatus INTEGER,
+// m_nsChatRoomDesc TEXT,
+// m_nsDraft TEXT,
+// m_nsBrandIconUrl TEXT,
+// m_nsGoogleContactName TEXT,
+// m_nsAliasName TEXT,
+// m_nsEncodeUserName TEXT,
+// m_uiChatRoomVersion INTEGER,
+// m_uiChatRoomMaxCount INTEGER,
+// m_uiChatRoomType INTEGER,
+// m_patSuffix TEXT,
+// richChatRoomDesc TEXT,
+// _packed_WCContactData BLOB,
+// openIMInfo BLOB
+// )
+type ChatRoomDarwinV3 struct {
+ M_nsUsrName string `json:"m_nsUsrName"`
+ Nickname string `json:"nickname"`
+ M_nsRemark string `json:"m_nsRemark"`
+ M_nsChatRoomMemList string `json:"m_nsChatRoomMemList"`
+ M_nsChatRoomAdminList string `json:"m_nsChatRoomAdminList"`
+
+ // M_uiConType int `json:"m_uiConType"`
+ // M_nsFullPY string `json:"m_nsFullPY"`
+ // M_nsShortPY string `json:"m_nsShortPY"`
+ // M_nsRemarkPYFull string `json:"m_nsRemarkPYFull"`
+ // M_nsRemarkPYShort string `json:"m_nsRemarkPYShort"`
+ // M_uiCertificationFlag int `json:"m_uiCertificationFlag"`
+ // M_uiSex int `json:"m_uiSex"`
+ // M_uiType int `json:"m_uiType"`
+ // M_nsImgStatus string `json:"m_nsImgStatus"`
+ // M_uiImgKey int `json:"m_uiImgKey"`
+ // M_nsHeadImgUrl string `json:"m_nsHeadImgUrl"`
+ // M_nsHeadHDImgUrl string `json:"m_nsHeadHDImgUrl"`
+ // M_nsHeadHDMd5 string `json:"m_nsHeadHDMd5"`
+ // M_uiChatRoomStatus int `json:"m_uiChatRoomStatus"`
+ // M_nsChatRoomDesc string `json:"m_nsChatRoomDesc"`
+ // M_nsDraft string `json:"m_nsDraft"`
+ // M_nsBrandIconUrl string `json:"m_nsBrandIconUrl"`
+ // M_nsGoogleContactName string `json:"m_nsGoogleContactName"`
+ // M_nsAliasName string `json:"m_nsAliasName"`
+ // M_nsEncodeUserName string `json:"m_nsEncodeUserName"`
+ // M_uiChatRoomVersion int `json:"m_uiChatRoomVersion"`
+ // M_uiChatRoomMaxCount int `json:"m_uiChatRoomMaxCount"`
+ // M_uiChatRoomType int `json:"m_uiChatRoomType"`
+ // M_patSuffix string `json:"m_patSuffix"`
+ // RichChatRoomDesc string `json:"richChatRoomDesc"`
+ // Packed_WCContactData []byte `json:"_packed_WCContactData"`
+ // OpenIMInfo []byte `json:"openIMInfo"`
+}
+
+func (c *ChatRoomDarwinV3) Wrap(user2DisplayName map[string]string) *ChatRoom {
+
+ split := strings.Split(c.M_nsChatRoomMemList, ";")
+ users := make([]ChatRoomUser, 0, len(split))
+ _user2DisplayName := make(map[string]string)
+ for _, v := range split {
+ users = append(users, ChatRoomUser{
+ UserName: v,
+ })
+ if name, ok := user2DisplayName[v]; ok {
+ _user2DisplayName[v] = name
+ }
+ }
+
+ return &ChatRoom{
+ Name: c.M_nsUsrName,
+ Owner: c.M_nsChatRoomAdminList,
+ Remark: c.M_nsRemark,
+ NickName: c.Nickname,
+ Users: users,
+ User2DisplayName: _user2DisplayName,
+ }
+}
diff --git a/internal/model/chatroom_v4.go b/internal/model/chatroom_v4.go
new file mode 100644
index 0000000..673ccec
--- /dev/null
+++ b/internal/model/chatroom_v4.go
@@ -0,0 +1,28 @@
+package model
+
+// CREATE TABLE chat_room(
+// id INTEGER PRIMARY KEY,
+// username TEXT,
+// owner TEXT,
+// ext_buffer BLOB
+// )
+type ChatRoomV4 struct {
+ ID int `json:"id"`
+ UserName string `json:"username"`
+ Owner string `json:"owner"`
+ ExtBuffer []byte `json:"ext_buffer"`
+}
+
+func (c *ChatRoomV4) Wrap() *ChatRoom {
+
+ var users []ChatRoomUser
+ if len(c.ExtBuffer) != 0 {
+ users = ParseRoomData(c.ExtBuffer)
+ }
+
+ return &ChatRoom{
+ Name: c.UserName,
+ Owner: c.Owner,
+ Users: users,
+ }
+}
diff --git a/pkg/model/contact.go b/internal/model/contact.go
similarity index 59%
rename from pkg/model/contact.go
rename to internal/model/contact.go
index 638e80a..6fe9b1c 100644
--- a/pkg/model/contact.go
+++ b/internal/model/contact.go
@@ -86,67 +86,6 @@ func (c *ContactV3) Wrap() *Contact {
}
}
-// CREATE TABLE contact(
-// id INTEGER PRIMARY KEY,
-// username TEXT,
-// local_type INTEGER,
-// alias TEXT,
-// encrypt_username TEXT,
-// flag INTEGER,
-// delete_flag INTEGER,
-// verify_flag INTEGER,
-// remark TEXT,
-// remark_quan_pin TEXT,
-// remark_pin_yin_initial TEXT,
-// nick_name TEXT,
-// pin_yin_initial TEXT,
-// quan_pin TEXT,
-// big_head_url TEXT,
-// small_head_url TEXT,
-// head_img_md5 TEXT,
-// chat_room_notify INTEGER,
-// is_in_chat_room INTEGER,
-// description TEXT,
-// extra_buffer BLOB,
-// chat_room_type INTEGER
-// )
-type ContactV4 struct {
- UserName string `json:"username"`
- Alias string `json:"alias"`
- Remark string `json:"remark"`
- NickName string `json:"nick_name"`
- LocalType int `json:"local_type"` // 2 群聊; 3 群聊成员(非好友); 5,6 企业微信;
-
- // ID int `json:"id"`
-
- // EncryptUserName string `json:"encrypt_username"`
- // Flag int `json:"flag"`
- // DeleteFlag int `json:"delete_flag"`
- // VerifyFlag int `json:"verify_flag"`
- // RemarkQuanPin string `json:"remark_quan_pin"`
- // RemarkPinYinInitial string `json:"remark_pin_yin_initial"`
- // PinYinInitial string `json:"pin_yin_initial"`
- // QuanPin string `json:"quan_pin"`
- // BigHeadUrl string `json:"big_head_url"`
- // SmallHeadUrl string `json:"small_head_url"`
- // HeadImgMd5 string `json:"head_img_md5"`
- // ChatRoomNotify int `json:"chat_room_notify"`
- // IsInChatRoom int `json:"is_in_chat_room"`
- // Description string `json:"description"`
- // ExtraBuffer []byte `json:"extra_buffer"`
- // ChatRoomType int `json:"chat_room_type"`
-}
-
-func (c *ContactV4) Wrap() *Contact {
- return &Contact{
- UserName: c.UserName,
- Alias: c.Alias,
- Remark: c.Remark,
- NickName: c.NickName,
- IsFriend: c.LocalType != 3,
- }
-}
-
func (c *Contact) DisplayName() string {
switch {
case c.Remark != "":
diff --git a/internal/model/contact_darwinv3.go b/internal/model/contact_darwinv3.go
new file mode 100644
index 0000000..df5157f
--- /dev/null
+++ b/internal/model/contact_darwinv3.go
@@ -0,0 +1,80 @@
+package model
+
+// CREATE TABLE WCContact(
+// m_nsUsrName TEXT PRIMARY KEY ASC,
+// m_uiConType INTEGER,
+// nickname TEXT,
+// m_nsFullPY TEXT,
+// m_nsShortPY TEXT,
+// m_nsRemark TEXT,
+// m_nsRemarkPYFull TEXT,
+// m_nsRemarkPYShort TEXT,
+// m_uiCertificationFlag INTEGER,
+// m_uiSex INTEGER,
+// m_uiType INTEGER,
+// m_nsImgStatus TEXT,
+// m_uiImgKey INTEGER,
+// m_nsHeadImgUrl TEXT,
+// m_nsHeadHDImgUrl TEXT,
+// m_nsHeadHDMd5 TEXT,
+// m_nsChatRoomMemList TEXT,
+// m_nsChatRoomAdminList TEXT,
+// m_uiChatRoomStatus INTEGER,
+// m_nsChatRoomDesc TEXT,
+// m_nsDraft TEXT,
+// m_nsBrandIconUrl TEXT,
+// m_nsGoogleContactName TEXT,
+// m_nsAliasName TEXT,
+// m_nsEncodeUserName TEXT,
+// m_uiChatRoomVersion INTEGER,
+// m_uiChatRoomMaxCount INTEGER,
+// m_uiChatRoomType INTEGER,
+// m_patSuffix TEXT,
+// richChatRoomDesc TEXT,
+// _packed_WCContactData BLOB,
+// openIMInfo BLOB
+// )
+type ContactDarwinV3 struct {
+ M_nsUsrName string `json:"m_nsUsrName"`
+ Nickname string `json:"nickname"`
+ M_nsRemark string `json:"m_nsRemark"`
+ M_uiSex int `json:"m_uiSex"`
+ M_nsAliasName string `json:"m_nsAliasName"`
+
+ // M_uiConType int `json:"m_uiConType"`
+ // M_nsShortPY string `json:"m_nsShortPY"`
+ // M_nsRemarkPYFull string `json:"m_nsRemarkPYFull"`
+ // M_nsRemarkPYShort string `json:"m_nsRemarkPYShort"`
+ // M_uiCertificationFlag int `json:"m_uiCertificationFlag"`
+ // M_uiType int `json:"m_uiType"` // 本来想拿这个字段来区分是否是好友,但是数据比较乱,好在 darwin v3 Contact 表中没有群聊成员
+ // M_nsImgStatus string `json:"m_nsImgStatus"`
+ // M_uiImgKey int `json:"m_uiImgKey"`
+ // M_nsHeadImgUrl string `json:"m_nsHeadImgUrl"`
+ // M_nsHeadHDImgUrl string `json:"m_nsHeadHDImgUrl"`
+ // M_nsHeadHDMd5 string `json:"m_nsHeadHDMd5"`
+ // M_nsChatRoomMemList string `json:"m_nsChatRoomMemList"`
+ // M_nsChatRoomAdminList string `json:"m_nsChatRoomAdminList"`
+ // M_uiChatRoomStatus int `json:"m_uiChatRoomStatus"`
+ // M_nsChatRoomDesc string `json:"m_nsChatRoomDesc"`
+ // M_nsDraft string `json:"m_nsDraft"`
+ // M_nsBrandIconUrl string `json:"m_nsBrandIconUrl"`
+ // M_nsGoogleContactName string `json:"m_nsGoogleContactName"`
+ // M_nsEncodeUserName string `json:"m_nsEncodeUserName"`
+ // M_uiChatRoomVersion int `json:"m_uiChatRoomVersion"`
+ // M_uiChatRoomMaxCount int `json:"m_uiChatRoomMaxCount"`
+ // M_uiChatRoomType int `json:"m_uiChatRoomType"`
+ // M_patSuffix string `json:"m_patSuffix"`
+ // RichChatRoomDesc string `json:"richChatRoomDesc"`
+ // Packed_WCContactData string `json:"_packed_WCContactData"`
+ // OpenIMInfo string `json:"openIMInfo"`
+}
+
+func (c *ContactDarwinV3) Wrap() *Contact {
+ return &Contact{
+ UserName: c.M_nsUsrName,
+ Alias: c.M_nsAliasName,
+ Remark: c.M_nsRemark,
+ NickName: c.Nickname,
+ IsFriend: true,
+ }
+}
diff --git a/internal/model/contact_v4.go b/internal/model/contact_v4.go
new file mode 100644
index 0000000..e713843
--- /dev/null
+++ b/internal/model/contact_v4.go
@@ -0,0 +1,62 @@
+package model
+
+// CREATE TABLE contact(
+// id INTEGER PRIMARY KEY,
+// username TEXT,
+// local_type INTEGER,
+// alias TEXT,
+// encrypt_username TEXT,
+// flag INTEGER,
+// delete_flag INTEGER,
+// verify_flag INTEGER,
+// remark TEXT,
+// remark_quan_pin TEXT,
+// remark_pin_yin_initial TEXT,
+// nick_name TEXT,
+// pin_yin_initial TEXT,
+// quan_pin TEXT,
+// big_head_url TEXT,
+// small_head_url TEXT,
+// head_img_md5 TEXT,
+// chat_room_notify INTEGER,
+// is_in_chat_room INTEGER,
+// description TEXT,
+// extra_buffer BLOB,
+// chat_room_type INTEGER
+// )
+type ContactV4 struct {
+ UserName string `json:"username"`
+ Alias string `json:"alias"`
+ Remark string `json:"remark"`
+ NickName string `json:"nick_name"`
+ LocalType int `json:"local_type"` // 2 群聊; 3 群聊成员(非好友); 5,6 企业微信;
+
+ // ID int `json:"id"`
+
+ // EncryptUserName string `json:"encrypt_username"`
+ // Flag int `json:"flag"`
+ // DeleteFlag int `json:"delete_flag"`
+ // VerifyFlag int `json:"verify_flag"`
+ // RemarkQuanPin string `json:"remark_quan_pin"`
+ // RemarkPinYinInitial string `json:"remark_pin_yin_initial"`
+ // PinYinInitial string `json:"pin_yin_initial"`
+ // QuanPin string `json:"quan_pin"`
+ // BigHeadUrl string `json:"big_head_url"`
+ // SmallHeadUrl string `json:"small_head_url"`
+ // HeadImgMd5 string `json:"head_img_md5"`
+ // ChatRoomNotify int `json:"chat_room_notify"`
+ // IsInChatRoom int `json:"is_in_chat_room"`
+ // Description string `json:"description"`
+ // ExtraBuffer []byte `json:"extra_buffer"`
+ // ChatRoomType int `json:"chat_room_type"`
+}
+
+func (c *ContactV4) Wrap() *Contact {
+ return &Contact{
+ UserName: c.UserName,
+ Alias: c.Alias,
+ Remark: c.Remark,
+ NickName: c.NickName,
+ IsFriend: c.LocalType != 3,
+ }
+}
diff --git a/pkg/model/message.go b/internal/model/message.go
similarity index 68%
rename from pkg/model/message.go
rename to internal/model/message.go
index 2de6662..f4eb802 100644
--- a/pkg/model/message.go
+++ b/internal/model/message.go
@@ -5,16 +5,16 @@ import (
"strings"
"time"
- "github.com/sjzar/chatlog/pkg/model/wxproto"
- "github.com/sjzar/chatlog/pkg/util"
+ "github.com/sjzar/chatlog/internal/model/wxproto"
"google.golang.org/protobuf/proto"
)
const (
// Source
- WeChatV3 = "wechatv3"
- WeChatV4 = "wechatv4"
+ WeChatV3 = "wechatv3"
+ WeChatV4 = "wechatv4"
+ WeChatDarwinV3 = "wechatdarwinv3"
)
type Message struct {
@@ -33,7 +33,7 @@ type Message struct {
// Fill Info
// 从联系人等信息中填充
DisplayName string `json:"-"` // 显示名称
- CharRoomName string `json:"-"` // 群聊名称
+ ChatRoomName string `json:"-"` // 群聊名称
Version string `json:"-"` // 消息版本,内部判断
}
@@ -122,83 +122,6 @@ func (m *MessageV3) Wrap() *Message {
}
}
-// CREATE TABLE Msg_xxxxxxxxxxxx(
-// local_id INTEGER PRIMARY KEY AUTOINCREMENT,
-// server_id INTEGER,
-// local_type INTEGER,
-// sort_seq INTEGER,
-// real_sender_id INTEGER,
-// create_time INTEGER,
-// status INTEGER,
-// upload_status INTEGER,
-// download_status INTEGER,
-// server_seq INTEGER,
-// origin_source INTEGER,
-// source TEXT,
-// message_content TEXT,
-// compress_content TEXT,
-// packed_info_data BLOB,
-// WCDB_CT_message_content INTEGER DEFAULT NULL,
-// WCDB_CT_source INTEGER DEFAULT NULL
-// )
-type MessageV4 struct {
- SortSeq int64 `json:"sort_seq"` // 消息序号,10位时间戳 + 3位序号
- LocalType int `json:"local_type"` // 消息类型
- RealSenderID int `json:"real_sender_id"` // 发送人 ID,对应 Name2Id 表序号
- CreateTime int64 `json:"create_time"` // 消息创建时间,10位时间戳
- MessageContent []byte `json:"message_content"` // 消息内容,文字聊天内容 或 zstd 压缩内容
- PackedInfoData []byte `json:"packed_info_data"` // 额外数据,类似 proto,格式与 v3 有差异
- Status int `json:"status"` // 消息状态,2 是已发送,4 是已接收,可以用于判断 IsSender(猜测)
-
- // 非关键信息,后续有需要再加入
- // LocalID int `json:"local_id"`
- // ServerID int64 `json:"server_id"`
-
- // UploadStatus int `json:"upload_status"`
- // DownloadStatus int `json:"download_status"`
- // ServerSeq int `json:"server_seq"`
- // OriginSource int `json:"origin_source"`
- // Source string `json:"source"`
- // CompressContent string `json:"compress_content"`
-}
-
-func (m *MessageV4) Wrap(id2Name map[int]string, isChatRoom bool) *Message {
-
- _m := &Message{
- Sequence: m.SortSeq,
- CreateTime: time.Unix(m.CreateTime, 0),
- TalkerID: m.RealSenderID, // 依赖 Name2Id 表进行转换为 StrTalker
- CompressContent: m.PackedInfoData,
- Type: m.LocalType,
- Version: WeChatV4,
- }
-
- if name, ok := id2Name[m.RealSenderID]; ok {
- _m.Talker = name
- }
-
- if m.Status == 2 {
- _m.IsSender = 1
- }
-
- if util.IsNormalString(m.MessageContent) {
- _m.Content = string(m.MessageContent)
- } else {
- _m.CompressContent = m.MessageContent
- }
-
- if isChatRoom {
- _m.IsChatRoom = true
- split := strings.Split(_m.Content, "\n")
- if len(split) > 1 {
- _m.Content = split[1]
- _m.ChatRoomSender = strings.TrimSuffix(split[0], ":")
- }
- }
-
- return _m
-}
-
// ParseBytesExtra 解析额外数据
// 按需解析
func ParseBytesExtra(b []byte) (chatRoomSender string) {
@@ -240,8 +163,8 @@ func (m *Message) PlainText(showChatRoom bool) string {
if m.IsChatRoom && showChatRoom {
buf.WriteString("[")
- if m.CharRoomName != "" {
- buf.WriteString(m.CharRoomName)
+ if m.ChatRoomName != "" {
+ buf.WriteString(m.ChatRoomName)
buf.WriteString("(")
buf.WriteString(m.Talker)
buf.WriteString(")")
@@ -293,7 +216,11 @@ func (m *Message) PlainText(showChatRoom bool) string {
case 10000:
buf.WriteString("[系统消息]")
default:
- buf.WriteString(fmt.Sprintf("Type: %d Content: %s", m.Type, m.Content))
+ content := m.Content
+ if len(content) > 120 {
+ content = content[:120] + "<...>"
+ }
+ buf.WriteString(fmt.Sprintf("Type: %d Content: %s", m.Type, content))
}
buf.WriteString("\n")
diff --git a/internal/model/message_darwinv3.go b/internal/model/message_darwinv3.go
new file mode 100644
index 0000000..8b73044
--- /dev/null
+++ b/internal/model/message_darwinv3.go
@@ -0,0 +1,69 @@
+package model
+
+import (
+ "strings"
+ "time"
+)
+
+// CREATE TABLE Chat_md5(talker)(
+// mesLocalID INTEGER PRIMARY KEY AUTOINCREMENT,
+// mesSvrID INTEGER,msgCreateTime INTEGER,
+// msgContent TEXT,msgStatus INTEGER,
+// msgImgStatus INTEGER,
+// messageType INTEGER,
+// mesDes INTEGER,
+// msgSource TEXT,
+// IntRes1 INTEGER,
+// IntRes2 INTEGER,
+// StrRes1 TEXT,
+// StrRes2 TEXT,
+// msgVoiceText TEXT,
+// msgSeq INTEGER,
+// CompressContent BLOB,
+// ConBlob BLOB
+// )
+type MessageDarwinV3 struct {
+ MesCreateTime int64 `json:"mesCreateTime"`
+ MesContent string `json:"mesContent"`
+ MesType int `json:"mesType"`
+ MesDes int `json:"mesDes"` // 0: 发送, 1: 接收
+ MesSource string `json:"mesSource"`
+
+ // MesLocalID int64 `json:"mesLocalID"`
+ // MesSvrID int64 `json:"mesSvrID"`
+ // MesStatus int `json:"mesStatus"`
+ // MesImgStatus int `json:"mesImgStatus"`
+ // IntRes1 int `json:"IntRes1"`
+ // IntRes2 int `json:"IntRes2"`
+ // StrRes1 string `json:"StrRes1"`
+ // StrRes2 string `json:"StrRes2"`
+ // MesVoiceText string `json:"mesVoiceText"`
+ // MesSeq int `json:"mesSeq"`
+ // CompressContent []byte `json:"CompressContent"`
+ // ConBlob []byte `json:"ConBlob"`
+}
+
+func (m *MessageDarwinV3) Wrap(talker string) *Message {
+ isChatRoom := strings.HasSuffix(talker, "@chatroom")
+
+ var chatRoomSender string
+ content := m.MesContent
+ if isChatRoom {
+ split := strings.SplitN(m.MesContent, ":\n", 2)
+ if len(split) == 2 {
+ chatRoomSender = split[0]
+ content = split[1]
+ }
+ }
+
+ return &Message{
+ CreateTime: time.Unix(m.MesCreateTime, 0),
+ Content: content,
+ Talker: talker,
+ Type: m.MesType,
+ IsSender: (m.MesDes + 1) % 2,
+ IsChatRoom: isChatRoom,
+ ChatRoomSender: chatRoomSender,
+ Version: WeChatDarwinV3,
+ }
+}
diff --git a/internal/model/message_v4.go b/internal/model/message_v4.go
new file mode 100644
index 0000000..4b04537
--- /dev/null
+++ b/internal/model/message_v4.go
@@ -0,0 +1,91 @@
+package model
+
+import (
+ "bytes"
+ "strings"
+ "time"
+
+ "github.com/sjzar/chatlog/pkg/util/zstd"
+)
+
+// CREATE TABLE Msg_md5(talker)(
+// local_id INTEGER PRIMARY KEY AUTOINCREMENT,
+// server_id INTEGER,
+// local_type INTEGER,
+// sort_seq INTEGER,
+// real_sender_id INTEGER,
+// create_time INTEGER,
+// status INTEGER,
+// upload_status INTEGER,
+// download_status INTEGER,
+// server_seq INTEGER,
+// origin_source INTEGER,
+// source TEXT,
+// message_content TEXT,
+// compress_content TEXT,
+// packed_info_data BLOB,
+// WCDB_CT_message_content INTEGER DEFAULT NULL,
+// WCDB_CT_source INTEGER DEFAULT NULL
+// )
+type MessageV4 struct {
+ SortSeq int64 `json:"sort_seq"` // 消息序号,10位时间戳 + 3位序号
+ LocalType int `json:"local_type"` // 消息类型
+ RealSenderID int `json:"real_sender_id"` // 发送人 ID,对应 Name2Id 表序号
+ CreateTime int64 `json:"create_time"` // 消息创建时间,10位时间戳
+ MessageContent []byte `json:"message_content"` // 消息内容,文字聊天内容 或 zstd 压缩内容
+ PackedInfoData []byte `json:"packed_info_data"` // 额外数据,类似 proto,格式与 v3 有差异
+ Status int `json:"status"` // 消息状态,2 是已发送,4 是已接收,可以用于判断 IsSender(猜测)
+
+ // 非关键信息,后续有需要再加入
+ // LocalID int `json:"local_id"`
+ // ServerID int64 `json:"server_id"`
+ // UploadStatus int `json:"upload_status"`
+ // DownloadStatus int `json:"download_status"`
+ // ServerSeq int `json:"server_seq"`
+ // OriginSource int `json:"origin_source"`
+ // Source string `json:"source"`
+ // CompressContent string `json:"compress_content"`
+}
+
+func (m *MessageV4) Wrap(id2Name map[int]string, isChatRoom bool) *Message {
+
+ _m := &Message{
+ Sequence: m.SortSeq,
+ CreateTime: time.Unix(m.CreateTime, 0),
+ TalkerID: m.RealSenderID, // 依赖 Name2Id 表进行转换为 StrTalker
+ CompressContent: m.PackedInfoData,
+ Type: m.LocalType,
+ Version: WeChatV4,
+ }
+
+ if name, ok := id2Name[m.RealSenderID]; ok {
+ _m.Talker = name
+ }
+
+ if m.Status == 2 {
+ _m.IsSender = 1
+ }
+
+ if _m.Type == 1 {
+ _m.Content = string(m.MessageContent)
+ } else {
+ if bytes.HasPrefix(m.MessageContent, []byte{0x28, 0xb5, 0x2f, 0xfd}) {
+ if b, err := zstd.Decompress(m.MessageContent); err == nil {
+ _m.Content = string(b)
+ }
+ } else {
+ _m.CompressContent = m.MessageContent
+ }
+ }
+
+ if isChatRoom {
+ _m.IsChatRoom = true
+ split := strings.SplitN(_m.Content, ":\n", 2)
+ if len(split) == 2 {
+ _m.ChatRoomSender = split[0]
+ _m.Content = split[1]
+ }
+ }
+
+ return _m
+}
diff --git a/pkg/model/session.go b/internal/model/session.go
similarity index 60%
rename from pkg/model/session.go
rename to internal/model/session.go
index 97c1f55..4522581 100644
--- a/pkg/model/session.go
+++ b/internal/model/session.go
@@ -71,47 +71,6 @@ func (s *SessionV3) Wrap() *Session {
}
}
-// 注意,v4 session 是独立数据库文件
-// CREATE TABLE SessionTable(
-// username TEXT PRIMARY KEY,
-// type INTEGER,
-// unread_count INTEGER,
-// unread_first_msg_srv_id INTEGER,
-// is_hidden INTEGER,
-// summary TEXT,
-// draft TEXT,
-// status INTEGER,
-// last_timestamp INTEGER,
-// sort_timestamp INTEGER,
-// last_clear_unread_timestamp INTEGER,
-// last_msg_locald_id INTEGER,
-// last_msg_type INTEGER,
-// last_msg_sub_type INTEGER,
-// last_msg_sender TEXT,
-// last_sender_display_name TEXT,
-// last_msg_ext_type INTEGER
-// )
-type SessionV4 struct {
- Username string `json:"username"`
- Summary string `json:"summary"`
- LastTimestamp int `json:"last_timestamp"`
- LastMsgSender string `json:"last_msg_sender"`
- LastSenderDisplayName string `json:"last_sender_display_name"`
-
- // Type int `json:"type"`
- // UnreadCount int `json:"unread_count"`
- // UnreadFirstMsgSrvID int `json:"unread_first_msg_srv_id"`
- // IsHidden int `json:"is_hidden"`
- // Draft string `json:"draft"`
- // Status int `json:"status"`
- // SortTimestamp int `json:"sort_timestamp"`
- // LastClearUnreadTimestamp int `json:"last_clear_unread_timestamp"`
- // LastMsgLocaldID int `json:"last_msg_locald_id"`
- // LastMsgType int `json:"last_msg_type"`
- // LastMsgSubType int `json:"last_msg_sub_type"`
- // LastMsgExtType int `json:"last_msg_ext_type"`
-}
-
func (s *Session) PlainText(limit int) string {
buf := strings.Builder{}
buf.WriteString(s.NickName)
diff --git a/internal/model/session_darwinv3.go b/internal/model/session_darwinv3.go
new file mode 100644
index 0000000..fee9476
--- /dev/null
+++ b/internal/model/session_darwinv3.go
@@ -0,0 +1,41 @@
+package model
+
+import "time"
+
+// CREATE TABLE SessionAbstract(
+// m_nsUserName TEXT PRIMARY KEY,
+// m_uUnReadCount INTEGER,
+// m_bShowUnReadAsRedDot INTEGER,
+// m_bMarkUnread INTEGER,
+// m_uLastTime INTEGER,
+// strRes1 TEXT,
+// strRes2 TEXT,
+// strRes3 TEXT,
+// intRes1 INTEGER,
+// intRes2 INTEGER,
+// intRes3 INTEGER,
+// _packed_MMSessionInfo BLOB
+// )
+type SessionDarwinV3 struct {
+ M_nsUserName string `json:"m_nsUserName"`
+ M_uLastTime int `json:"m_uLastTime"`
+
+ // M_uUnReadCount int `json:"m_uUnReadCount"`
+ // M_bShowUnReadAsRedDot int `json:"m_bShowUnReadAsRedDot"`
+ // M_bMarkUnread int `json:"m_bMarkUnread"`
+ // StrRes1 string `json:"strRes1"`
+ // StrRes2 string `json:"strRes2"`
+ // StrRes3 string `json:"strRes3"`
+ // IntRes1 int `json:"intRes1"`
+ // IntRes2 int `json:"intRes2"`
+ // IntRes3 int `json:"intRes3"`
+ // PackedMMSessionInfo string `json:"_packed_MMSessionInfo"` // TODO: decode
+}
+
+func (s *SessionDarwinV3) Wrap() *Session {
+ return &Session{
+ UserName: s.M_nsUserName,
+ NOrder: s.M_uLastTime,
+ NTime: time.Unix(int64(s.M_uLastTime), 0),
+ }
+}
diff --git a/internal/model/session_v4.go b/internal/model/session_v4.go
new file mode 100644
index 0000000..c4eb5b8
--- /dev/null
+++ b/internal/model/session_v4.go
@@ -0,0 +1,54 @@
+package model
+
+import "time"
+
+// 注意,v4 session 是独立数据库文件
+// CREATE TABLE SessionTable(
+// username TEXT PRIMARY KEY,
+// type INTEGER,
+// unread_count INTEGER,
+// unread_first_msg_srv_id INTEGER,
+// is_hidden INTEGER,
+// summary TEXT,
+// draft TEXT,
+// status INTEGER,
+// last_timestamp INTEGER,
+// sort_timestamp INTEGER,
+// last_clear_unread_timestamp INTEGER,
+// last_msg_locald_id INTEGER,
+// last_msg_type INTEGER,
+// last_msg_sub_type INTEGER,
+// last_msg_sender TEXT,
+// last_sender_display_name TEXT,
+// last_msg_ext_type INTEGER
+// )
+type SessionV4 struct {
+ Username string `json:"username"`
+ Summary string `json:"summary"`
+ LastTimestamp int `json:"last_timestamp"`
+ LastMsgSender string `json:"last_msg_sender"`
+ LastSenderDisplayName string `json:"last_sender_display_name"`
+
+ // Type int `json:"type"`
+ // UnreadCount int `json:"unread_count"`
+ // UnreadFirstMsgSrvID int `json:"unread_first_msg_srv_id"`
+ // IsHidden int `json:"is_hidden"`
+ // Draft string `json:"draft"`
+ // Status int `json:"status"`
+ // SortTimestamp int `json:"sort_timestamp"`
+ // LastClearUnreadTimestamp int `json:"last_clear_unread_timestamp"`
+ // LastMsgLocaldID int `json:"last_msg_locald_id"`
+ // LastMsgType int `json:"last_msg_type"`
+ // LastMsgSubType int `json:"last_msg_sub_type"`
+ // LastMsgExtType int `json:"last_msg_ext_type"`
+}
+
+func (s *SessionV4) Wrap() *Session {
+ return &Session{
+ UserName: s.Username,
+ NOrder: s.LastTimestamp,
+ NickName: s.LastSenderDisplayName,
+ Content: s.Summary,
+ NTime: time.Unix(int64(s.LastTimestamp), 0),
+ }
+}
diff --git a/pkg/model/wxproto/bytesextra.pb.go b/internal/model/wxproto/bytesextra.pb.go
similarity index 100%
rename from pkg/model/wxproto/bytesextra.pb.go
rename to internal/model/wxproto/bytesextra.pb.go
diff --git a/pkg/model/wxproto/bytesextra.proto b/internal/model/wxproto/bytesextra.proto
similarity index 100%
rename from pkg/model/wxproto/bytesextra.proto
rename to internal/model/wxproto/bytesextra.proto
diff --git a/pkg/model/wxproto/roomdata.pb.go b/internal/model/wxproto/roomdata.pb.go
similarity index 100%
rename from pkg/model/wxproto/roomdata.pb.go
rename to internal/model/wxproto/roomdata.pb.go
diff --git a/pkg/model/wxproto/roomdata.proto b/internal/model/wxproto/roomdata.proto
similarity index 100%
rename from pkg/model/wxproto/roomdata.proto
rename to internal/model/wxproto/roomdata.proto
diff --git a/internal/ui/footer/footer.go b/internal/ui/footer/footer.go
index d68f936..d06fd02 100644
--- a/internal/ui/footer/footer.go
+++ b/internal/ui/footer/footer.go
@@ -34,7 +34,7 @@ func New() *Footer {
SetTextAlign(tview.AlignLeft)
footer.copyRight.
SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
- footer.copyRight.SetText(fmt.Sprintf("[%s::b]%s[-:-:-]", style.GetColorHex(style.PageHeaderFgColor), fmt.Sprintf(" @ Sarv's Chatlog (%s)", version.Version)))
+ footer.copyRight.SetText(fmt.Sprintf("[%s::b]%s[-:-:-]", style.GetColorHex(style.PageHeaderFgColor), fmt.Sprintf(" @ Sarv's Chatlog %s", version.Version)))
footer.help.
SetDynamicColors(true).
diff --git a/internal/ui/help/help.go b/internal/ui/help/help.go
index a0def11..32f1d75 100644
--- a/internal/ui/help/help.go
+++ b/internal/ui/help/help.go
@@ -22,20 +22,22 @@ const (
[green]使用步骤:[white]
-[yellow]1. 获取数据密钥[white]
- 选择"获取数据密钥"菜单项,程序会自动从运行中的微信进程获取密钥。
- 如果有多个微信进程,会自动选择当前账号的进程。
- 确保微信正在运行,否则无法获取密钥。
+[yellow]1. 下载并安装微信客户端[white]
-[yellow]2. 解密数据[white]
- 选择"解密数据"菜单项,程序会使用获取的密钥解密微信数据库文件。
+[yellow]2. 迁移手机微信聊天记录[white]
+ 手机微信上操作 [yellow]我 - 设置 - 通用 - 聊天记录迁移与备份 - 迁移 - 迁移到电脑[white]。
+ 这一步的目的是将手机中的聊天记录传输到电脑上。
+ 可以放心操作,不会影响到手机上的聊天记录。
+
+[yellow]3. 解密数据[white]
+ 重新打开 chatlog,选择"解密数据"菜单项,程序会使用获取的密钥解密微信数据库文件。
解密后的文件会保存到工作目录中(可在设置中修改)。
-[yellow]3. 启动 HTTP 服务[white]
+[yellow]4. 启动 HTTP 服务[white]
选择"启动 HTTP 服务"菜单项,启动 HTTP 和 MCP 服务。
启动后可以通过浏览器访问 http://localhost:5030 查看聊天记录。
-[yellow]4. 设置选项[white]
+[yellow]5. 设置选项[white]
选择"设置"菜单项,可以配置:
• HTTP 服务端口 - 更改 HTTP 服务的监听端口
• 工作目录 - 更改解密数据的存储位置
diff --git a/internal/wechat/decrypt.go b/internal/wechat/decrypt.go
deleted file mode 100644
index 0184e20..0000000
--- a/internal/wechat/decrypt.go
+++ /dev/null
@@ -1,415 +0,0 @@
-package wechat
-
-import (
- "bytes"
- "context"
- "crypto/aes"
- "crypto/cipher"
- "crypto/hmac"
- "crypto/sha1"
- "crypto/sha512"
- "encoding/binary"
- "encoding/hex"
- "errors"
- "fmt"
- "hash"
- "io"
- "os"
-
- "golang.org/x/crypto/pbkdf2"
-)
-
-// Constants for WeChat database decryption
-const (
- // Common constants
- PageSize = 4096
- KeySize = 32
- SaltSize = 16
- AESBlockSize = 16
- SQLiteHeader = "SQLite format 3\x00"
-
- // Version specific constants
- V3IterCount = 64000
- V4IterCount = 256000
-
- IVSize = 16 // Same for both versions
- HmacSHA1Size = 20 // Used in V3
- HmacSHA512Size = 64 // Used in V4
-)
-
-// Error definitions
-var (
- ErrHashVerificationFailed = errors.New("hash verification failed")
- ErrInvalidVersion = errors.New("invalid version, must be 3 or 4")
- ErrInvalidKey = errors.New("invalid key format")
- ErrIncorrectKey = errors.New("incorrect decryption key")
- ErrReadFile = errors.New("failed to read database file")
- ErrOpenFile = errors.New("failed to open database file")
- ErrIncompleteRead = errors.New("incomplete header read")
- ErrCreateCipher = errors.New("failed to create cipher")
- ErrDecodeKey = errors.New("failed to decode hex key")
- ErrWriteOutput = errors.New("failed to write output")
- ErrSeekFile = errors.New("failed to seek in file")
- ErrOperationCanceled = errors.New("operation was canceled")
- ErrAlreadyDecrypted = errors.New("file is already decrypted")
-)
-
-// Decryptor handles the decryption of WeChat database files
-type Decryptor struct {
- // Database file path
- dbPath string
-
- // Database properties
- version int
- salt []byte
- page1 []byte
- reserve int
-
- // Calculated fields
- hashFunc func() hash.Hash
- hmacSize int
- currentPage int64
- totalPages int64
-}
-
-// NewDecryptor creates a new Decryptor for the specified database file and version
-func NewDecryptor(dbPath string, version int) (*Decryptor, error) {
- // Validate version
- if version != 3 && version != 4 {
- return nil, ErrInvalidVersion
- }
-
- // Open database file
- fp, err := os.Open(dbPath)
- if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrOpenFile, err)
- }
- defer fp.Close()
-
- // Get file size
- fileInfo, err := fp.Stat()
- if err != nil {
- return nil, fmt.Errorf("failed to get file info: %v", err)
- }
-
- // Calculate total pages
- fileSize := fileInfo.Size()
- totalPages := fileSize / PageSize
- if fileSize%PageSize > 0 {
- totalPages++
- }
-
- // Read first page
- buffer := make([]byte, PageSize)
- n, err := io.ReadFull(fp, buffer)
- if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrReadFile, err)
- }
- if n != PageSize {
- return nil, fmt.Errorf("%w: expected %d bytes, got %d", ErrIncompleteRead, PageSize, n)
- }
-
- // Check if file is already decrypted
- if bytes.Equal(buffer[:len(SQLiteHeader)-1], []byte(SQLiteHeader[:len(SQLiteHeader)-1])) {
- return nil, ErrAlreadyDecrypted
- }
-
- // Initialize hash function and HMAC size based on version
- var hashFunc func() hash.Hash
- var hmacSize int
-
- if version == 4 {
- hashFunc = sha512.New
- hmacSize = HmacSHA512Size
- } else {
- hashFunc = sha1.New
- hmacSize = HmacSHA1Size
- }
-
- // Calculate reserve size and MAC offset
- reserve := IVSize + hmacSize
- if reserve%AESBlockSize != 0 {
- reserve = ((reserve / AESBlockSize) + 1) * AESBlockSize
- }
-
- return &Decryptor{
- dbPath: dbPath,
- version: version,
- salt: buffer[:SaltSize],
- page1: buffer,
- reserve: reserve,
- hashFunc: hashFunc,
- hmacSize: hmacSize,
- totalPages: totalPages,
- }, nil
-}
-
-// GetTotalPages returns the total number of pages in the database
-func (d *Decryptor) GetTotalPages() int64 {
- return d.totalPages
-}
-
-// Validate checks if the provided key is valid for this database
-func (d *Decryptor) Validate(key []byte) bool {
- if len(key) != KeySize {
- return false
- }
- _, macKey := d.calcPBKDF2Key(key)
- return d.validate(macKey)
-}
-
-func (d *Decryptor) calcPBKDF2Key(key []byte) ([]byte, []byte) {
- // Generate encryption key from password
- var encKey []byte
- if d.version == 4 {
- encKey = pbkdf2.Key(key, d.salt, V4IterCount, KeySize, sha512.New)
- } else {
- encKey = pbkdf2.Key(key, d.salt, V3IterCount, KeySize, sha1.New)
- }
-
- // Generate MAC key
- macSalt := xorBytes(d.salt, 0x3a)
- macKey := pbkdf2.Key(encKey, macSalt, 2, KeySize, d.hashFunc)
- return encKey, macKey
-}
-
-func (d *Decryptor) validate(macKey []byte) bool {
- // Calculate HMAC
- hashMac := hmac.New(d.hashFunc, macKey)
-
- dataEnd := PageSize - d.reserve + IVSize
- hashMac.Write(d.page1[SaltSize:dataEnd])
-
- // Page number is fixed as 1
- pageNoBytes := make([]byte, 4)
- binary.LittleEndian.PutUint32(pageNoBytes, 1)
- hashMac.Write(pageNoBytes)
-
- calculatedMAC := hashMac.Sum(nil)
- storedMAC := d.page1[dataEnd : dataEnd+d.hmacSize]
-
- return hmac.Equal(calculatedMAC, storedMAC)
-}
-
-// Decrypt decrypts the database using the provided key and writes the result to the writer
-func (d *Decryptor) Decrypt(ctx context.Context, hexKey string, w io.Writer) error {
- // Decode key
- key, err := hex.DecodeString(hexKey)
- if err != nil {
- return fmt.Errorf("%w: %v", ErrDecodeKey, err)
- }
-
- encKey, macKey := d.calcPBKDF2Key(key)
-
- // Validate key first
- if !d.validate(macKey) {
- return ErrIncorrectKey
- }
-
- // Open input file
- dbFile, err := os.Open(d.dbPath)
- if err != nil {
- return fmt.Errorf("%w: %v", ErrOpenFile, err)
- }
- defer dbFile.Close()
-
- // Write SQLite header to output
- _, err = w.Write([]byte(SQLiteHeader))
- if err != nil {
- return fmt.Errorf("%w: %v", ErrWriteOutput, err)
- }
-
- // Process each page
- pageBuf := make([]byte, PageSize)
- d.currentPage = 0
-
- for curPage := int64(0); curPage < d.totalPages; curPage++ {
- // Check for cancellation before processing each page
- select {
- case <-ctx.Done():
- return ErrOperationCanceled
- default:
- // Continue processing
- }
-
- // For the first page, we need to skip the salt
- if curPage == 0 {
- // Read the first page
- _, err = io.ReadFull(dbFile, pageBuf)
- if err != nil {
- return fmt.Errorf("%w: %v", ErrReadFile, err)
- }
- } else {
- // Read a full page
- n, err := io.ReadFull(dbFile, pageBuf)
- if err != nil {
- if err == io.EOF || err == io.ErrUnexpectedEOF {
- // Handle last partial page
- if n > 0 {
- // Process partial page
- // For simplicity, we'll just break here
- break
- }
- }
- return fmt.Errorf("%w: %v", ErrReadFile, err)
- }
- }
-
- // check if page contains only zeros (v3 & v4 both have this behavior)
- allZeros := true
- for _, b := range pageBuf {
- if b != 0 {
- allZeros = false
- break
- }
- }
-
- if allZeros {
- // Write the zeros page to output
- _, err = w.Write(pageBuf)
- if err != nil {
- return fmt.Errorf("%w: %v", ErrWriteOutput, err)
- }
-
- // Update progress
- d.currentPage = curPage + 1
- continue
-
- // // Set current page to total pages to indicate completion
- // d.currentPage = d.totalPages
- // return nil
- }
-
- // Decrypt the page
- decryptedPage, err := d.decryptPage(encKey, macKey, pageBuf, curPage)
- if err != nil {
- return err
- }
-
- // Write decrypted page to output
- _, err = w.Write(decryptedPage)
- if err != nil {
- return fmt.Errorf("%w: %v", ErrWriteOutput, err)
- }
-
- // Update progress
- d.currentPage = curPage + 1
- }
-
- return nil
-}
-
-// decryptPage decrypts a single page of the database
-func (d *Decryptor) decryptPage(key, macKey []byte, pageBuf []byte, pageNum int64) ([]byte, error) {
- offset := 0
- if pageNum == 0 {
- offset = SaltSize
- }
-
- // Verify HMAC
- mac := hmac.New(d.hashFunc, macKey)
- mac.Write(pageBuf[offset : PageSize-d.reserve+IVSize])
-
- // Convert page number and update HMAC
- pageNumBytes := make([]byte, 4)
- binary.LittleEndian.PutUint32(pageNumBytes, uint32(pageNum+1))
- mac.Write(pageNumBytes)
-
- hashMac := mac.Sum(nil)
-
- hashMacStartOffset := PageSize - d.reserve + IVSize
- hashMacEndOffset := hashMacStartOffset + len(hashMac)
-
- if !bytes.Equal(hashMac, pageBuf[hashMacStartOffset:hashMacEndOffset]) {
- return nil, ErrHashVerificationFailed
- }
-
- // Decrypt content using AES-256-CBC
- iv := pageBuf[PageSize-d.reserve : PageSize-d.reserve+IVSize]
- block, err := aes.NewCipher(key)
- if err != nil {
- return nil, fmt.Errorf("%w: %v", ErrCreateCipher, err)
- }
-
- mode := cipher.NewCBCDecrypter(block, iv)
-
- // Create a copy of encrypted data for decryption
- encrypted := make([]byte, PageSize-d.reserve-offset)
- copy(encrypted, pageBuf[offset:PageSize-d.reserve])
-
- // Decrypt in place
- mode.CryptBlocks(encrypted, encrypted)
-
- // Combine decrypted data with reserve part
- decryptedPage := append(encrypted, pageBuf[PageSize-d.reserve:PageSize]...)
-
- return decryptedPage, nil
-}
-
-// xorBytes performs XOR operation on each byte of the array with the specified byte
-func xorBytes(a []byte, b byte) []byte {
- result := make([]byte, len(a))
- for i := range a {
- result[i] = a[i] ^ b
- }
- return result
-}
-
-// Utility functions for backward compatibility
-
-// DecryptDBFile decrypts a WeChat database file and returns the decrypted content
-func DecryptDBFile(dbPath string, hexKey string, version int) ([]byte, error) {
- // Create a buffer to store the decrypted content
- var buf bytes.Buffer
-
- // Create a decryptor
- d, err := NewDecryptor(dbPath, version)
- if err != nil {
- return nil, err
- }
-
- // Decrypt the database
- err = d.Decrypt(context.Background(), hexKey, &buf)
- if err != nil {
- return nil, err
- }
-
- return buf.Bytes(), nil
-}
-
-// DecryptDBFileToFile decrypts a WeChat database file and saves the result to the specified output file
-func DecryptDBFileToFile(dbPath, outputPath, hexKey string, version int) error {
- // Create output file
- outputFile, err := os.Create(outputPath)
- if err != nil {
- return fmt.Errorf("failed to create output file: %v", err)
- }
- defer outputFile.Close()
-
- // Create a decryptor
- d, err := NewDecryptor(dbPath, version)
- if err != nil {
- return err
- }
-
- // Decrypt the database
- return d.Decrypt(context.Background(), hexKey, outputFile)
-}
-
-// ValidateDBKey validates if the provided key is correct for the database
-func ValidateDBKey(dbPath string, hexKey string, version int) bool {
- // Create a decryptor
- d, err := NewDecryptor(dbPath, version)
- if err != nil {
- return false
- }
-
- // Decode key
- key, err := hex.DecodeString(hexKey)
- if err != nil {
- return false
- }
-
- // Validate the key
- return d.Validate(key)
-}
diff --git a/internal/wechat/decrypt/common/common.go b/internal/wechat/decrypt/common/common.go
new file mode 100644
index 0000000..03b173d
--- /dev/null
+++ b/internal/wechat/decrypt/common/common.go
@@ -0,0 +1,138 @@
+package common
+
+import (
+ "bytes"
+ "crypto/aes"
+ "crypto/cipher"
+ "crypto/hmac"
+ "encoding/binary"
+ "fmt"
+ "hash"
+ "io"
+ "os"
+
+ "github.com/sjzar/chatlog/internal/errors"
+)
+
+const (
+ KeySize = 32
+ SaltSize = 16
+ AESBlockSize = 16
+ SQLiteHeader = "SQLite format 3\x00"
+ IVSize = 16
+)
+
+type DBFile struct {
+ Path string
+ Salt []byte
+ TotalPages int64
+ FirstPage []byte
+}
+
+func OpenDBFile(dbPath string, pageSize int) (*DBFile, error) {
+ fp, err := os.Open(dbPath)
+ if err != nil {
+ return nil, errors.DecryptOpenFileFailed(dbPath, err)
+ }
+ defer fp.Close()
+
+ fileInfo, err := fp.Stat()
+ if err != nil {
+ return nil, errors.WeChatDecryptFailed(err)
+ }
+
+ fileSize := fileInfo.Size()
+ totalPages := fileSize / int64(pageSize)
+ if fileSize%int64(pageSize) > 0 {
+ totalPages++
+ }
+
+ buffer := make([]byte, pageSize)
+ n, err := io.ReadFull(fp, buffer)
+ if err != nil {
+ return nil, errors.DecryptReadFileFailed(dbPath, err)
+ }
+ if n != pageSize {
+ return nil, errors.DecryptIncompleteRead(fmt.Errorf("read %d bytes, expected %d", n, pageSize))
+ }
+
+ if bytes.Equal(buffer[:len(SQLiteHeader)-1], []byte(SQLiteHeader[:len(SQLiteHeader)-1])) {
+ return nil, errors.ErrAlreadyDecrypted
+ }
+
+ return &DBFile{
+ Path: dbPath,
+ Salt: buffer[:SaltSize],
+ FirstPage: buffer,
+ TotalPages: totalPages,
+ }, nil
+}
+
+func XorBytes(a []byte, b byte) []byte {
+ result := make([]byte, len(a))
+ for i := range a {
+ result[i] = a[i] ^ b
+ }
+ return result
+}
+
+func ValidateKey(page1 []byte, key []byte, salt []byte, hashFunc func() hash.Hash, hmacSize int, reserve int, pageSize int, deriveKeys func([]byte, []byte) ([]byte, []byte)) bool {
+ if len(key) != KeySize {
+ return false
+ }
+
+ _, macKey := deriveKeys(key, salt)
+
+ mac := hmac.New(hashFunc, macKey)
+ dataEnd := pageSize - reserve + IVSize
+ mac.Write(page1[SaltSize:dataEnd])
+
+ pageNoBytes := make([]byte, 4)
+ binary.LittleEndian.PutUint32(pageNoBytes, 1)
+ mac.Write(pageNoBytes)
+
+ calculatedMAC := mac.Sum(nil)
+ storedMAC := page1[dataEnd : dataEnd+hmacSize]
+
+ return hmac.Equal(calculatedMAC, storedMAC)
+}
+
+func DecryptPage(pageBuf []byte, encKey []byte, macKey []byte, pageNum int64, hashFunc func() hash.Hash, hmacSize int, reserve int, pageSize int) ([]byte, error) {
+ offset := 0
+ if pageNum == 0 {
+ offset = SaltSize
+ }
+
+ mac := hmac.New(hashFunc, macKey)
+ mac.Write(pageBuf[offset : pageSize-reserve+IVSize])
+
+ pageNoBytes := make([]byte, 4)
+ binary.LittleEndian.PutUint32(pageNoBytes, uint32(pageNum+1))
+ mac.Write(pageNoBytes)
+
+ hashMac := mac.Sum(nil)
+
+ hashMacStartOffset := pageSize - reserve + IVSize
+ hashMacEndOffset := hashMacStartOffset + hmacSize
+
+ if !bytes.Equal(hashMac, pageBuf[hashMacStartOffset:hashMacEndOffset]) {
+ return nil, errors.ErrDecryptHashVerificationFailed
+ }
+
+ iv := pageBuf[pageSize-reserve : pageSize-reserve+IVSize]
+ block, err := aes.NewCipher(encKey)
+ if err != nil {
+ return nil, errors.DecryptCreateCipherFailed(err)
+ }
+
+ mode := cipher.NewCBCDecrypter(block, iv)
+
+ encrypted := make([]byte, pageSize-reserve-offset)
+ copy(encrypted, pageBuf[offset:pageSize-reserve])
+
+ mode.CryptBlocks(encrypted, encrypted)
+
+ decryptedPage := append(encrypted, pageBuf[pageSize-reserve:pageSize]...)
+
+ return decryptedPage, nil
+}
diff --git a/internal/wechat/decrypt/darwin/v3.go b/internal/wechat/decrypt/darwin/v3.go
new file mode 100644
index 0000000..67aaa6c
--- /dev/null
+++ b/internal/wechat/decrypt/darwin/v3.go
@@ -0,0 +1,184 @@
+package darwin
+
+import (
+ "context"
+ "crypto/sha1"
+ "encoding/hex"
+ "hash"
+ "io"
+ "os"
+
+ "github.com/sjzar/chatlog/internal/errors"
+ "github.com/sjzar/chatlog/internal/wechat/decrypt/common"
+ "golang.org/x/crypto/pbkdf2"
+)
+
+// 常量定义
+const (
+ V3PageSize = 1024
+ HmacSHA1Size = 20
+)
+
+// V3Decryptor 实现 macOS V3 版本的解密器
+type V3Decryptor struct {
+ // macOS V3 特定参数
+ hmacSize int
+ hashFunc func() hash.Hash
+ reserve int
+ pageSize int
+ version string
+}
+
+// NewV3Decryptor 创建 macOS V3 解密器
+func NewV3Decryptor() *V3Decryptor {
+ hashFunc := sha1.New
+ hmacSize := HmacSHA1Size
+ reserve := common.IVSize + hmacSize
+ if reserve%common.AESBlockSize != 0 {
+ reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize
+ }
+
+ return &V3Decryptor{
+ hmacSize: hmacSize,
+ hashFunc: hashFunc,
+ reserve: reserve,
+ pageSize: V3PageSize,
+ version: "macOS v3",
+ }
+}
+
+// deriveKeys 派生 MAC 密钥
+// 注意:macOS V3 版本直接使用提供的密钥作为加密密钥,不进行 PBKDF2 派生
+func (d *V3Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) {
+ // 对于 macOS V3,直接使用密钥作为加密密钥
+ encKey := key
+
+ // 生成 MAC 密钥
+ macSalt := common.XorBytes(salt, 0x3a)
+ macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc)
+
+ return encKey, macKey
+}
+
+// Validate 验证密钥是否有效
+func (d *V3Decryptor) Validate(page1 []byte, key []byte) bool {
+ if len(page1) < d.pageSize || len(key) != common.KeySize {
+ return false
+ }
+
+ salt := page1[:common.SaltSize]
+ return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys)
+}
+
+// Decrypt 解密数据库
+func (d *V3Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error {
+ // 解码密钥
+ key, err := hex.DecodeString(hexKey)
+ if err != nil {
+ return errors.DecryptDecodeKeyFailed(err)
+ }
+
+ // 打开数据库文件并读取基本信息
+ dbInfo, err := common.OpenDBFile(dbfile, d.pageSize)
+ if err != nil {
+ return err
+ }
+
+ // 验证密钥
+ if !d.Validate(dbInfo.FirstPage, key) {
+ return errors.ErrDecryptIncorrectKey
+ }
+
+ // 计算密钥
+ encKey, macKey := d.deriveKeys(key, dbInfo.Salt)
+
+ // 打开数据库文件
+ dbFile, err := os.Open(dbfile)
+ if err != nil {
+ return errors.DecryptOpenFileFailed(dbfile, err)
+ }
+ defer dbFile.Close()
+
+ // 写入 SQLite 头
+ _, err = output.Write([]byte(common.SQLiteHeader))
+ if err != nil {
+ return errors.DecryptWriteOutputFailed(err)
+ }
+
+ // 处理每一页
+ pageBuf := make([]byte, d.pageSize)
+
+ for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ {
+ // 检查是否取消
+ select {
+ case <-ctx.Done():
+ return errors.DecryptOperationCanceled()
+ default:
+ // 继续处理
+ }
+
+ // 读取一页
+ n, err := io.ReadFull(dbFile, pageBuf)
+ if err != nil {
+ if err == io.EOF || err == io.ErrUnexpectedEOF {
+ // 处理最后一部分页面
+ if n > 0 {
+ break
+ }
+ }
+ return errors.DecryptReadFileFailed(dbfile, err)
+ }
+
+ // 检查页面是否全为零
+ allZeros := true
+ for _, b := range pageBuf {
+ if b != 0 {
+ allZeros = false
+ break
+ }
+ }
+
+ if allZeros {
+ // 写入零页面
+ _, err = output.Write(pageBuf)
+ if err != nil {
+ return errors.DecryptWriteOutputFailed(err)
+ }
+ continue
+ }
+
+ // 解密页面
+ decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize)
+ if err != nil {
+ return err
+ }
+
+ // 写入解密后的页面
+ _, err = output.Write(decryptedData)
+ if err != nil {
+ return errors.DecryptWriteOutputFailed(err)
+ }
+ }
+
+ return nil
+}
+
+// GetPageSize 返回页面大小
+func (d *V3Decryptor) GetPageSize() int {
+ return d.pageSize
+}
+
+// GetReserve 返回保留字节数
+func (d *V3Decryptor) GetReserve() int {
+ return d.reserve
+}
+
+// GetHMACSize 返回HMAC大小
+func (d *V3Decryptor) GetHMACSize() int {
+ return d.hmacSize
+}
+
+// GetVersion 返回解密器版本
+func (d *V3Decryptor) GetVersion() string {
+ return d.version
+}
diff --git a/internal/wechat/decrypt/darwin/v4.go b/internal/wechat/decrypt/darwin/v4.go
new file mode 100644
index 0000000..13d7380
--- /dev/null
+++ b/internal/wechat/decrypt/darwin/v4.go
@@ -0,0 +1,194 @@
+package darwin
+
+import (
+ "context"
+ "crypto/sha512"
+ "encoding/hex"
+ "hash"
+ "io"
+ "os"
+
+ "github.com/sjzar/chatlog/internal/errors"
+ "github.com/sjzar/chatlog/internal/wechat/decrypt/common"
+
+ "golang.org/x/crypto/pbkdf2"
+)
+
+// Darwin Version 4 same as WIndows Version 4
+
+// V4 版本特定常量
+const (
+ V4PageSize = 4096
+ V4IterCount = 256000
+ HmacSHA512Size = 64
+)
+
+// V4Decryptor 实现Windows V4版本的解密器
+type V4Decryptor struct {
+ // V4 特定参数
+ iterCount int
+ hmacSize int
+ hashFunc func() hash.Hash
+ reserve int
+ pageSize int
+ version string
+}
+
+// NewV4Decryptor 创建Windows V4解密器
+func NewV4Decryptor() *V4Decryptor {
+ hashFunc := sha512.New
+ hmacSize := HmacSHA512Size
+ reserve := common.IVSize + hmacSize
+ if reserve%common.AESBlockSize != 0 {
+ reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize
+ }
+
+ return &V4Decryptor{
+ iterCount: V4IterCount,
+ hmacSize: hmacSize,
+ hashFunc: hashFunc,
+ reserve: reserve,
+ pageSize: V4PageSize,
+ version: "macOS v4",
+ }
+}
+
+// deriveKeys 派生加密密钥和MAC密钥
+func (d *V4Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) {
+ // 生成加密密钥
+ encKey := pbkdf2.Key(key, salt, d.iterCount, common.KeySize, d.hashFunc)
+
+ // 生成MAC密钥
+ macSalt := common.XorBytes(salt, 0x3a)
+ macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc)
+
+ return encKey, macKey
+}
+
+// Validate 验证密钥是否有效
+func (d *V4Decryptor) Validate(page1 []byte, key []byte) bool {
+ if len(page1) < d.pageSize || len(key) != common.KeySize {
+ return false
+ }
+
+ salt := page1[:common.SaltSize]
+ return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys)
+}
+
+// Decrypt 解密数据库
+func (d *V4Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error {
+ // 解码密钥
+ key, err := hex.DecodeString(hexKey)
+ if err != nil {
+ return errors.DecryptDecodeKeyFailed(err)
+ }
+
+ // 打开数据库文件并读取基本信息
+ dbInfo, err := common.OpenDBFile(dbfile, d.pageSize)
+ if err != nil {
+ return err
+ }
+
+ // 验证密钥
+ if !d.Validate(dbInfo.FirstPage, key) {
+ return errors.ErrDecryptIncorrectKey
+ }
+
+ // 计算密钥
+ encKey, macKey := d.deriveKeys(key, dbInfo.Salt)
+
+ // 打开数据库文件
+ dbFile, err := os.Open(dbfile)
+ if err != nil {
+ return errors.DecryptOpenFileFailed(dbfile, err)
+ }
+ defer dbFile.Close()
+
+ // 写入SQLite头
+ _, err = output.Write([]byte(common.SQLiteHeader))
+ if err != nil {
+ return errors.DecryptWriteOutputFailed(err)
+ }
+
+ // 处理每一页
+ pageBuf := make([]byte, d.pageSize)
+
+ for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ {
+ // 检查是否取消
+ select {
+ case <-ctx.Done():
+ return errors.DecryptOperationCanceled()
+ default:
+ // 继续处理
+ }
+
+ // 读取一页
+ n, err := io.ReadFull(dbFile, pageBuf)
+ if err != nil {
+ if err == io.EOF || err == io.ErrUnexpectedEOF {
+ // 处理最后一部分页面
+ if n > 0 {
+ break
+ }
+ }
+ return errors.DecryptReadFileFailed(dbfile, err)
+ }
+
+ // 检查页面是否全为零
+ allZeros := true
+ for _, b := range pageBuf {
+ if b != 0 {
+ allZeros = false
+ break
+ }
+ }
+
+ if allZeros {
+ // 写入零页面
+ _, err = output.Write(pageBuf)
+ if err != nil {
+ return errors.DecryptWriteOutputFailed(err)
+ }
+ continue
+ }
+
+ // 解密页面
+ decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize)
+ if err != nil {
+ return err
+ }
+
+ // 写入解密后的页面
+ _, err = output.Write(decryptedData)
+ if err != nil {
+ return errors.DecryptWriteOutputFailed(err)
+ }
+ }
+
+ return nil
+}
+
+// GetPageSize 返回页面大小
+func (d *V4Decryptor) GetPageSize() int {
+ return d.pageSize
+}
+
+// GetReserve 返回保留字节数
+func (d *V4Decryptor) GetReserve() int {
+ return d.reserve
+}
+
+// GetHMACSize 返回HMAC大小
+func (d *V4Decryptor) GetHMACSize() int {
+ return d.hmacSize
+}
+
+// GetVersion 返回解密器版本
+func (d *V4Decryptor) GetVersion() string {
+ return d.version
+}
+
+// GetIterCount 返回迭代次数(Windows特有)
+func (d *V4Decryptor) GetIterCount() int {
+ return d.iterCount
+}
diff --git a/internal/wechat/decrypt/decryptor.go b/internal/wechat/decrypt/decryptor.go
new file mode 100644
index 0000000..a48eb2f
--- /dev/null
+++ b/internal/wechat/decrypt/decryptor.go
@@ -0,0 +1,55 @@
+package decrypt
+
+import (
+ "context"
+ "fmt"
+ "io"
+
+ "github.com/sjzar/chatlog/internal/errors"
+ "github.com/sjzar/chatlog/internal/wechat/decrypt/darwin"
+ "github.com/sjzar/chatlog/internal/wechat/decrypt/windows"
+)
+
+// 错误定义
+var (
+ ErrInvalidVersion = fmt.Errorf("invalid version, must be 3 or 4")
+ ErrUnsupportedPlatform = fmt.Errorf("unsupported platform")
+)
+
+// Decryptor 定义数据库解密的接口
+type Decryptor interface {
+ // Decrypt 解密数据库
+ Decrypt(ctx context.Context, dbfile string, key string, output io.Writer) error
+
+ // Validate 验证密钥是否有效
+ Validate(page1 []byte, key []byte) bool
+
+ // GetPageSize 返回页面大小
+ GetPageSize() int
+
+ // GetReserve 返回保留字节数
+ GetReserve() int
+
+ // GetHMACSize 返回HMAC大小
+ GetHMACSize() int
+
+ // GetVersion 返回解密器版本
+ GetVersion() string
+}
+
+// NewDecryptor 创建一个新的解密器
+func NewDecryptor(platform string, version int) (Decryptor, error) {
+ // 根据平台返回对应的实现
+ switch {
+ case platform == "windows" && version == 3:
+ return windows.NewV3Decryptor(), nil
+ case platform == "windows" && version == 4:
+ return windows.NewV4Decryptor(), nil
+ case platform == "darwin" && version == 3:
+ return darwin.NewV3Decryptor(), nil
+ case platform == "darwin" && version == 4:
+ return darwin.NewV4Decryptor(), nil
+ default:
+ return nil, errors.PlatformUnsupported(platform, version)
+ }
+}
diff --git a/internal/wechat/decrypt/validator.go b/internal/wechat/decrypt/validator.go
new file mode 100644
index 0000000..9567d64
--- /dev/null
+++ b/internal/wechat/decrypt/validator.go
@@ -0,0 +1,56 @@
+package decrypt
+
+import (
+ "path/filepath"
+
+ "github.com/sjzar/chatlog/internal/wechat/decrypt/common"
+)
+
+type Validator struct {
+ platform string
+ version int
+ dbPath string
+ decryptor Decryptor
+ dbFile *common.DBFile
+}
+
+// NewValidator 创建一个仅用于验证的验证器
+func NewValidator(dataDir string, platform string, version int) (*Validator, error) {
+ decryptor, err := NewDecryptor(platform, version)
+ if err != nil {
+ return nil, err
+ }
+ dbFile := GetSimpleDBFile(platform, version)
+ dbPath := filepath.Join(dataDir + "/" + dbFile)
+ d, err := common.OpenDBFile(dbPath, decryptor.GetPageSize())
+ if err != nil {
+ return nil, err
+ }
+
+ return &Validator{
+ platform: platform,
+ version: version,
+ dbPath: dbPath,
+ decryptor: decryptor,
+ dbFile: d,
+ }, nil
+}
+
+func (v *Validator) Validate(key []byte) bool {
+ return v.decryptor.Validate(v.dbFile.FirstPage, key)
+}
+
+func GetSimpleDBFile(platform string, version int) string {
+ switch {
+ case platform == "windows" && version == 3:
+ return "Msg\\Misc.db"
+ case platform == "windows" && version == 4:
+ return "db_storage\\message\\message_0.db"
+ case platform == "darwin" && version == 3:
+ return "Message/msg_0.db"
+ case platform == "darwin" && version == 4:
+ return "db_storage/message/message_0.db"
+ }
+ return ""
+
+}
diff --git a/internal/wechat/decrypt/windows/v3.go b/internal/wechat/decrypt/windows/v3.go
new file mode 100644
index 0000000..07f76ef
--- /dev/null
+++ b/internal/wechat/decrypt/windows/v3.go
@@ -0,0 +1,192 @@
+package windows
+
+import (
+ "context"
+ "crypto/sha1"
+ "encoding/hex"
+ "hash"
+ "io"
+ "os"
+
+ "github.com/sjzar/chatlog/internal/errors"
+ "github.com/sjzar/chatlog/internal/wechat/decrypt/common"
+
+ "golang.org/x/crypto/pbkdf2"
+)
+
+// V3 版本特定常量
+const (
+ PageSize = 4096
+ V3IterCount = 64000
+ HmacSHA1Size = 20
+)
+
+// V3Decryptor 实现Windows V3版本的解密器
+type V3Decryptor struct {
+ // V3 特定参数
+ iterCount int
+ hmacSize int
+ hashFunc func() hash.Hash
+ reserve int
+ pageSize int
+ version string
+}
+
+// NewV3Decryptor 创建Windows V3解密器
+func NewV3Decryptor() *V3Decryptor {
+ hashFunc := sha1.New
+ hmacSize := HmacSHA1Size
+ reserve := common.IVSize + hmacSize
+ if reserve%common.AESBlockSize != 0 {
+ reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize
+ }
+
+ return &V3Decryptor{
+ iterCount: V3IterCount,
+ hmacSize: hmacSize,
+ hashFunc: hashFunc,
+ reserve: reserve,
+ pageSize: PageSize,
+ version: "Windows v3",
+ }
+}
+
+// deriveKeys 派生加密密钥和MAC密钥
+func (d *V3Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) {
+ // 生成加密密钥
+ encKey := pbkdf2.Key(key, salt, d.iterCount, common.KeySize, d.hashFunc)
+
+ // 生成MAC密钥
+ macSalt := common.XorBytes(salt, 0x3a)
+ macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc)
+
+ return encKey, macKey
+}
+
+// Validate 验证密钥是否有效
+func (d *V3Decryptor) Validate(page1 []byte, key []byte) bool {
+ if len(page1) < d.pageSize || len(key) != common.KeySize {
+ return false
+ }
+
+ salt := page1[:common.SaltSize]
+ return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys)
+}
+
+// Decrypt 解密数据库
+func (d *V3Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error {
+ // 解码密钥
+ key, err := hex.DecodeString(hexKey)
+ if err != nil {
+ return errors.DecryptDecodeKeyFailed(err)
+ }
+
+ // 打开数据库文件并读取基本信息
+ dbInfo, err := common.OpenDBFile(dbfile, d.pageSize)
+ if err != nil {
+ return err
+ }
+
+ // 验证密钥
+ if !d.Validate(dbInfo.FirstPage, key) {
+ return errors.ErrDecryptIncorrectKey
+ }
+
+ // 计算密钥
+ encKey, macKey := d.deriveKeys(key, dbInfo.Salt)
+
+ // 打开数据库文件
+ dbFile, err := os.Open(dbfile)
+ if err != nil {
+ return errors.DecryptOpenFileFailed(dbfile, err)
+ }
+ defer dbFile.Close()
+
+ // 写入SQLite头
+ _, err = output.Write([]byte(common.SQLiteHeader))
+ if err != nil {
+ return errors.DecryptWriteOutputFailed(err)
+ }
+
+ // 处理每一页
+ pageBuf := make([]byte, d.pageSize)
+
+ for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ {
+ // 检查是否取消
+ select {
+ case <-ctx.Done():
+ return errors.DecryptOperationCanceled()
+ default:
+ // 继续处理
+ }
+
+ // 读取一页
+ n, err := io.ReadFull(dbFile, pageBuf)
+ if err != nil {
+ if err == io.EOF || err == io.ErrUnexpectedEOF {
+ // 处理最后一部分页面
+ if n > 0 {
+ break
+ }
+ }
+ return errors.DecryptReadFileFailed(dbfile, err)
+ }
+
+ // 检查页面是否全为零
+ allZeros := true
+ for _, b := range pageBuf {
+ if b != 0 {
+ allZeros = false
+ break
+ }
+ }
+
+ if allZeros {
+ // 写入零页面
+ _, err = output.Write(pageBuf)
+ if err != nil {
+ return errors.DecryptWriteOutputFailed(err)
+ }
+ continue
+ }
+
+ // 解密页面
+ decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize)
+ if err != nil {
+ return err
+ }
+
+ // 写入解密后的页面
+ _, err = output.Write(decryptedData)
+ if err != nil {
+ return errors.DecryptWriteOutputFailed(err)
+ }
+ }
+
+ return nil
+}
+
+// GetPageSize 返回页面大小
+func (d *V3Decryptor) GetPageSize() int {
+ return d.pageSize
+}
+
+// GetReserve 返回保留字节数
+func (d *V3Decryptor) GetReserve() int {
+ return d.reserve
+}
+
+// GetHMACSize 返回HMAC大小
+func (d *V3Decryptor) GetHMACSize() int {
+ return d.hmacSize
+}
+
+// GetVersion 返回解密器版本
+func (d *V3Decryptor) GetVersion() string {
+ return d.version
+}
+
+// GetIterCount 返回迭代次数(Windows特有)
+func (d *V3Decryptor) GetIterCount() int {
+ return d.iterCount
+}
diff --git a/internal/wechat/decrypt/windows/v4.go b/internal/wechat/decrypt/windows/v4.go
new file mode 100644
index 0000000..bd2e519
--- /dev/null
+++ b/internal/wechat/decrypt/windows/v4.go
@@ -0,0 +1,190 @@
+package windows
+
+import (
+ "context"
+ "crypto/sha512"
+ "encoding/hex"
+ "hash"
+ "io"
+ "os"
+
+ "github.com/sjzar/chatlog/internal/errors"
+ "github.com/sjzar/chatlog/internal/wechat/decrypt/common"
+ "golang.org/x/crypto/pbkdf2"
+)
+
+// V4 版本特定常量
+const (
+ V4IterCount = 256000
+ HmacSHA512Size = 64
+)
+
+// V4Decryptor 实现Windows V4版本的解密器
+type V4Decryptor struct {
+ // V4 特定参数
+ iterCount int
+ hmacSize int
+ hashFunc func() hash.Hash
+ reserve int
+ pageSize int
+ version string
+}
+
+// NewV4Decryptor 创建Windows V4解密器
+func NewV4Decryptor() *V4Decryptor {
+ hashFunc := sha512.New
+ hmacSize := HmacSHA512Size
+ reserve := common.IVSize + hmacSize
+ if reserve%common.AESBlockSize != 0 {
+ reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize
+ }
+
+ return &V4Decryptor{
+ iterCount: V4IterCount,
+ hmacSize: hmacSize,
+ hashFunc: hashFunc,
+ reserve: reserve,
+ pageSize: PageSize,
+ version: "Windows v4",
+ }
+}
+
+// deriveKeys 派生加密密钥和MAC密钥
+func (d *V4Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) {
+ // 生成加密密钥
+ encKey := pbkdf2.Key(key, salt, d.iterCount, common.KeySize, d.hashFunc)
+
+ // 生成MAC密钥
+ macSalt := common.XorBytes(salt, 0x3a)
+ macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc)
+
+ return encKey, macKey
+}
+
+// Validate 验证密钥是否有效
+func (d *V4Decryptor) Validate(page1 []byte, key []byte) bool {
+ if len(page1) < d.pageSize || len(key) != common.KeySize {
+ return false
+ }
+
+ salt := page1[:common.SaltSize]
+ return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys)
+}
+
+// Decrypt 解密数据库
+func (d *V4Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error {
+ // 解码密钥
+ key, err := hex.DecodeString(hexKey)
+ if err != nil {
+ return errors.DecryptDecodeKeyFailed(err)
+ }
+
+ // 打开数据库文件并读取基本信息
+ dbInfo, err := common.OpenDBFile(dbfile, d.pageSize)
+ if err != nil {
+ return err
+ }
+
+ // 验证密钥
+ if !d.Validate(dbInfo.FirstPage, key) {
+ return errors.ErrDecryptIncorrectKey
+ }
+
+ // 计算密钥
+ encKey, macKey := d.deriveKeys(key, dbInfo.Salt)
+
+ // 打开数据库文件
+ dbFile, err := os.Open(dbfile)
+ if err != nil {
+ return errors.DecryptOpenFileFailed(dbfile, err)
+ }
+ defer dbFile.Close()
+
+ // 写入SQLite头
+ _, err = output.Write([]byte(common.SQLiteHeader))
+ if err != nil {
+ return errors.DecryptWriteOutputFailed(err)
+ }
+
+ // 处理每一页
+ pageBuf := make([]byte, d.pageSize)
+
+ for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ {
+ // 检查是否取消
+ select {
+ case <-ctx.Done():
+ return errors.DecryptOperationCanceled()
+ default:
+ // 继续处理
+ }
+
+ // 读取一页
+ n, err := io.ReadFull(dbFile, pageBuf)
+ if err != nil {
+ if err == io.EOF || err == io.ErrUnexpectedEOF {
+ // 处理最后一部分页面
+ if n > 0 {
+ break
+ }
+ }
+ return errors.DecryptReadFileFailed(dbfile, err)
+ }
+
+ // 检查页面是否全为零
+ allZeros := true
+ for _, b := range pageBuf {
+ if b != 0 {
+ allZeros = false
+ break
+ }
+ }
+
+ if allZeros {
+ // 写入零页面
+ _, err = output.Write(pageBuf)
+ if err != nil {
+ return errors.DecryptWriteOutputFailed(err)
+ }
+ continue
+ }
+
+ // 解密页面
+ decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize)
+ if err != nil {
+ return err
+ }
+
+ // 写入解密后的页面
+ _, err = output.Write(decryptedData)
+ if err != nil {
+ return errors.DecryptWriteOutputFailed(err)
+ }
+ }
+
+ return nil
+}
+
+// GetPageSize 返回页面大小
+func (d *V4Decryptor) GetPageSize() int {
+ return d.pageSize
+}
+
+// GetReserve 返回保留字节数
+func (d *V4Decryptor) GetReserve() int {
+ return d.reserve
+}
+
+// GetHMACSize 返回HMAC大小
+func (d *V4Decryptor) GetHMACSize() int {
+ return d.hmacSize
+}
+
+// GetVersion 返回解密器版本
+func (d *V4Decryptor) GetVersion() string {
+ return d.version
+}
+
+// GetIterCount 返回迭代次数(Windows特有)
+func (d *V4Decryptor) GetIterCount() int {
+ return d.iterCount
+}
diff --git a/internal/wechat/info.go b/internal/wechat/info.go
deleted file mode 100644
index be5859f..0000000
--- a/internal/wechat/info.go
+++ /dev/null
@@ -1,50 +0,0 @@
-package wechat
-
-import (
- "github.com/sjzar/chatlog/pkg/dllver"
-
- "github.com/shirou/gopsutil/v4/process"
- log "github.com/sirupsen/logrus"
-)
-
-const (
- StatusInit = ""
- StatusOffline = "offline"
- StatusOnline = "online"
-)
-
-type Info struct {
- PID uint32
- ExePath string
- Version *dllver.Info
- Status string
- DataDir string
- AccountName string
- Key string
-}
-
-func NewInfo(p *process.Process) (*Info, error) {
- info := &Info{
- PID: uint32(p.Pid),
- Status: StatusOffline,
- }
-
- var err error
- info.ExePath, err = p.Exe()
- if err != nil {
- log.Error(err)
- return nil, err
- }
-
- info.Version, err = dllver.New(info.ExePath)
- if err != nil {
- log.Error(err)
- return nil, err
- }
-
- if err := info.initialize(p); err != nil {
- return nil, err
- }
-
- return info, nil
-}
diff --git a/internal/wechat/info_others.go b/internal/wechat/info_others.go
deleted file mode 100644
index b8c3e3d..0000000
--- a/internal/wechat/info_others.go
+++ /dev/null
@@ -1,16 +0,0 @@
-//go:build !windows
-
-package wechat
-
-import "github.com/shirou/gopsutil/v4/process"
-
-// Giao~
-// 还没来得及写,Mac 版本打算通过 vmmap 检查内存区域,再用 lldb 读取内存来检查 Key,需要关 SIP 或自签名应用,稍晚再填坑
-
-func (i *Info) initialize(p *process.Process) error {
- return nil
-}
-
-func (i *Info) GetKey() (string, error) {
- return "mock-key", nil
-}
diff --git a/internal/wechat/info_windows.go b/internal/wechat/info_windows.go
deleted file mode 100644
index 6820c44..0000000
--- a/internal/wechat/info_windows.go
+++ /dev/null
@@ -1,507 +0,0 @@
-package wechat
-
-import (
- "bytes"
- "context"
- "encoding/binary"
- "encoding/hex"
- "errors"
- "fmt"
- "path/filepath"
- "runtime"
- "strings"
- "sync"
- "unsafe"
-
- "github.com/sjzar/chatlog/pkg/util"
-
- "github.com/shirou/gopsutil/v4/process"
- log "github.com/sirupsen/logrus"
- "golang.org/x/sys/windows"
-)
-
-const (
- V3ModuleName = "WeChatWin.dll"
- V3DBFile = "Msg\\Misc.db"
- V4DBFile = "db_storage\\message\\message_0.db"
-
- MaxWorkers = 16
-
- // Windows memory protection constants
- MEM_PRIVATE = 0x20000
-)
-
-// Common error definitions
-var (
- ErrWeChatOffline = errors.New("wechat is not logged in")
- ErrOpenProcess = errors.New("failed to open process")
- ErrReaddecryptor = errors.New("failed to read database header")
- ErrCheckProcessBits = errors.New("failed to check process architecture")
- ErrFindWeChatDLL = errors.New("WeChatWin.dll module not found")
- ErrNoValidKey = errors.New("no valid key found")
- ErrInvalidFilePath = errors.New("invalid file path format")
-)
-
-// GetKey is the entry point for retrieving the WeChat database key
-func (i *Info) GetKey() (string, error) {
- if i.Status == StatusOffline {
- return "", ErrWeChatOffline
- }
-
- // Choose key retrieval method based on WeChat version
- if i.Version.FileMajorVersion == 4 {
- return i.getKeyV4()
- }
- return i.getKeyV3()
-}
-
-// initialize initializes WeChat information
-func (i *Info) initialize(p *process.Process) error {
- files, err := p.OpenFiles()
- if err != nil {
- log.Error("Failed to get open file list: ", err)
- return err
- }
-
- dbPath := V3DBFile
- if i.Version.FileMajorVersion == 4 {
- dbPath = V4DBFile
- }
-
- for _, f := range files {
- if strings.HasSuffix(f.Path, dbPath) {
- filePath := f.Path[4:] // Remove "\\?\" prefix
- parts := strings.Split(filePath, string(filepath.Separator))
- if len(parts) < 4 {
- log.Debug("Invalid file path format: " + filePath)
- continue
- }
-
- i.Status = StatusOnline
- if i.Version.FileMajorVersion == 4 {
- i.DataDir = strings.Join(parts[:len(parts)-3], string(filepath.Separator))
- i.AccountName = parts[len(parts)-4]
- } else {
- i.DataDir = strings.Join(parts[:len(parts)-2], string(filepath.Separator))
- i.AccountName = parts[len(parts)-3]
- }
- }
- }
-
- return nil
-}
-
-// getKeyV3 retrieves the database key for WeChat V3 version
-func (i *Info) getKeyV3() (string, error) {
- // Read database header for key validation
- dbPath := filepath.Join(i.DataDir, V3DBFile)
- decryptor, err := NewDecryptor(dbPath, i.Version.FileMajorVersion)
- if err != nil {
- return "", fmt.Errorf("%w: %v", ErrReaddecryptor, err)
- }
- log.Debug("V3 database path: ", dbPath)
-
- // Open WeChat process
- handle, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, i.PID)
- if err != nil {
- return "", fmt.Errorf("%w: %v", ErrOpenProcess, err)
- }
- defer windows.CloseHandle(handle)
-
- // Check process architecture
- is64Bit, err := util.Is64Bit(handle)
- if err != nil {
- return "", fmt.Errorf("%w: %v", ErrCheckProcessBits, err)
- }
-
- // Create context to control all goroutines
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- // Create channels for memory data and results
- memoryChannel := make(chan []byte, 100)
- resultChannel := make(chan string, 1)
-
- // Determine number of worker goroutines
- workerCount := runtime.NumCPU()
- if workerCount < 2 {
- workerCount = 2
- }
- if workerCount > MaxWorkers {
- workerCount = MaxWorkers
- }
- log.Debug("Starting ", workerCount, " workers for V3 key search")
-
- // Start consumer goroutines
- var workerWaitGroup sync.WaitGroup
- workerWaitGroup.Add(workerCount)
- for index := 0; index < workerCount; index++ {
- go func() {
- defer workerWaitGroup.Done()
- workerV3(ctx, handle, decryptor, is64Bit, memoryChannel, resultChannel)
- }()
- }
-
- // Start producer goroutine
- var producerWaitGroup sync.WaitGroup
- producerWaitGroup.Add(1)
- go func() {
- defer producerWaitGroup.Done()
- defer close(memoryChannel) // Close channel when producer is done
- err := i.findMemoryV3(ctx, handle, memoryChannel)
- if err != nil {
- log.Error(err)
- }
- }()
-
- // Wait for producer and consumers to complete
- go func() {
- producerWaitGroup.Wait()
- workerWaitGroup.Wait()
- close(resultChannel)
- }()
-
- // Wait for result
- result, ok := <-resultChannel
- if ok && result != "" {
- i.Key = result
- return result, nil
- }
-
- return "", ErrNoValidKey
-}
-
-// getKeyV4 retrieves the database key for WeChat V4 version
-func (i *Info) getKeyV4() (string, error) {
- // Read database header for key validation
- dbPath := filepath.Join(i.DataDir, V4DBFile)
- decryptor, err := NewDecryptor(dbPath, i.Version.FileMajorVersion)
- if err != nil {
- return "", fmt.Errorf("%w: %v", ErrReaddecryptor, err)
- }
- log.Debug("V4 database path: ", dbPath)
-
- // Open process handle
- handle, err := windows.OpenProcess(windows.PROCESS_VM_READ|windows.PROCESS_QUERY_INFORMATION, false, i.PID)
- if err != nil {
- return "", fmt.Errorf("%w: %v", ErrOpenProcess, err)
- }
- defer windows.CloseHandle(handle)
-
- // Create context to control all goroutines
- ctx, cancel := context.WithCancel(context.Background())
- defer cancel()
-
- // Create channels for memory data and results
- memoryChannel := make(chan []byte, 100)
- resultChannel := make(chan string, 1)
-
- // Determine number of worker goroutines
- workerCount := runtime.NumCPU()
- if workerCount < 2 {
- workerCount = 2
- }
- if workerCount > MaxWorkers {
- workerCount = MaxWorkers
- }
- log.Debug("Starting ", workerCount, " workers for V4 key search")
-
- // Start consumer goroutines
- var workerWaitGroup sync.WaitGroup
- workerWaitGroup.Add(workerCount)
- for index := 0; index < workerCount; index++ {
- go func() {
- defer workerWaitGroup.Done()
- workerV4(ctx, handle, decryptor, memoryChannel, resultChannel)
- }()
- }
-
- // Start producer goroutine
- var producerWaitGroup sync.WaitGroup
- producerWaitGroup.Add(1)
- go func() {
- defer producerWaitGroup.Done()
- defer close(memoryChannel) // Close channel when producer is done
- err := i.findMemoryV4(ctx, handle, memoryChannel)
- if err != nil {
- log.Error(err)
- }
- }()
-
- // Wait for producer and consumers to complete
- go func() {
- producerWaitGroup.Wait()
- workerWaitGroup.Wait()
- close(resultChannel)
- }()
-
- // Wait for result
- result, ok := <-resultChannel
- if ok && result != "" {
- i.Key = result
- return result, nil
- }
-
- return "", ErrNoValidKey
-}
-
-// findMemoryV3 searches for writable memory regions in WeChatWin.dll for V3 version
-func (i *Info) findMemoryV3(ctx context.Context, handle windows.Handle, memoryChannel chan<- []byte) error {
- // Find WeChatWin.dll module
- module, isFound := FindModule(i.PID, V3ModuleName)
- if !isFound {
- return ErrFindWeChatDLL
- }
- log.Debug("Found WeChatWin.dll module at base address: 0x", fmt.Sprintf("%X", module.ModBaseAddr))
-
- // Read writable memory regions
- baseAddr := uintptr(module.ModBaseAddr)
- endAddr := baseAddr + uintptr(module.ModBaseSize)
- currentAddr := baseAddr
-
- for currentAddr < endAddr {
- var mbi windows.MemoryBasicInformation
- err := windows.VirtualQueryEx(handle, currentAddr, &mbi, unsafe.Sizeof(mbi))
- if err != nil {
- break
- }
-
- // Skip small memory regions
- if mbi.RegionSize < 100*1024 {
- currentAddr += uintptr(mbi.RegionSize)
- continue
- }
-
- // Check if memory region is writable
- isWritable := (mbi.Protect & (windows.PAGE_READWRITE | windows.PAGE_WRITECOPY | windows.PAGE_EXECUTE_READWRITE | windows.PAGE_EXECUTE_WRITECOPY)) > 0
- if isWritable && uint32(mbi.State) == windows.MEM_COMMIT {
- // Calculate region size, ensure it doesn't exceed DLL bounds
- regionSize := uintptr(mbi.RegionSize)
- if currentAddr+regionSize > endAddr {
- regionSize = endAddr - currentAddr
- }
-
- // Read writable memory region
- memory := make([]byte, regionSize)
- if err = windows.ReadProcessMemory(handle, currentAddr, &memory[0], regionSize, nil); err == nil {
- select {
- case memoryChannel <- memory:
- log.Debug("Sent memory region for analysis, size: ", regionSize, " bytes")
- case <-ctx.Done():
- return nil
- }
- }
- }
-
- // Move to next memory region
- currentAddr = uintptr(mbi.BaseAddress) + uintptr(mbi.RegionSize)
- }
-
- return nil
-}
-
-// workerV3 processes memory regions to find V3 version key
-func workerV3(ctx context.Context, handle windows.Handle, decryptor *Decryptor, is64Bit bool, memoryChannel <-chan []byte, resultChannel chan<- string) {
-
- // Define search pattern
- keyPattern := []byte{0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
- ptrSize := 8
- littleEndianFunc := binary.LittleEndian.Uint64
-
- // Adjust for 32-bit process
- if !is64Bit {
- keyPattern = keyPattern[:4]
- ptrSize = 4
- littleEndianFunc = func(b []byte) uint64 { return uint64(binary.LittleEndian.Uint32(b)) }
- }
-
- for {
- select {
- case <-ctx.Done():
- return
- case memory, ok := <-memoryChannel:
- if !ok {
- return
- }
-
- index := len(memory)
- for {
- select {
- case <-ctx.Done():
- return // Exit if result found
- default:
- }
-
- // Find pattern from end to beginning
- index = bytes.LastIndex(memory[:index], keyPattern)
- if index == -1 || index-ptrSize < 0 {
- break
- }
-
- // Extract and validate pointer value
- ptrValue := littleEndianFunc(memory[index-ptrSize : index])
- if ptrValue > 0x10000 && ptrValue < 0x7FFFFFFFFFFF {
- if key := validateKey(handle, decryptor, ptrValue); key != "" {
- select {
- case resultChannel <- key:
- log.Debug("Valid key found for V3 database")
- default:
- }
- return
- }
- }
- index -= 1 // Continue searching from previous position
- }
- }
- }
-}
-
-// findMemoryV4 searches for writable memory regions for V4 version
-func (i *Info) findMemoryV4(ctx context.Context, handle windows.Handle, memoryChannel chan<- []byte) error {
- // Define search range
- minAddr := uintptr(0x10000) // Process space usually starts from 0x10000
- maxAddr := uintptr(0x7FFFFFFF) // 32-bit process space limit
-
- if runtime.GOARCH == "amd64" {
- maxAddr = uintptr(0x7FFFFFFFFFFF) // 64-bit process space limit
- }
- log.Debug("Scanning memory regions from 0x", fmt.Sprintf("%X", minAddr), " to 0x", fmt.Sprintf("%X", maxAddr))
-
- currentAddr := minAddr
-
- for currentAddr < maxAddr {
- var memInfo windows.MemoryBasicInformation
- err := windows.VirtualQueryEx(handle, currentAddr, &memInfo, unsafe.Sizeof(memInfo))
- if err != nil {
- break
- }
-
- // Skip small memory regions
- if memInfo.RegionSize < 1024*1024 {
- currentAddr += uintptr(memInfo.RegionSize)
- continue
- }
-
- // Check if memory region is readable and private
- if memInfo.State == windows.MEM_COMMIT && (memInfo.Protect&windows.PAGE_READWRITE) != 0 && memInfo.Type == MEM_PRIVATE {
- // Calculate region size, ensure it doesn't exceed limit
- regionSize := uintptr(memInfo.RegionSize)
- if currentAddr+regionSize > maxAddr {
- regionSize = maxAddr - currentAddr
- }
-
- // Read memory region
- memory := make([]byte, regionSize)
- if err = windows.ReadProcessMemory(handle, currentAddr, &memory[0], regionSize, nil); err == nil {
- select {
- case memoryChannel <- memory:
- log.Debug("Sent memory region for analysis, size: ", regionSize, " bytes")
- case <-ctx.Done():
- return nil
- }
- }
- }
-
- // Move to next memory region
- currentAddr = uintptr(memInfo.BaseAddress) + uintptr(memInfo.RegionSize)
- }
-
- return nil
-}
-
-// workerV4 processes memory regions to find V4 version key
-func workerV4(ctx context.Context, handle windows.Handle, decryptor *Decryptor, memoryChannel <-chan []byte, resultChannel chan<- string) {
-
- // Define search pattern for V4
- keyPattern := []byte{
- 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- 0x2F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
- }
- ptrSize := 8
- littleEndianFunc := binary.LittleEndian.Uint64
-
- for {
- select {
- case <-ctx.Done():
- return
- case memory, ok := <-memoryChannel:
- if !ok {
- return
- }
-
- index := len(memory)
- for {
- select {
- case <-ctx.Done():
- return // Exit if result found
- default:
- }
-
- // Find pattern from end to beginning
- index = bytes.LastIndex(memory[:index], keyPattern)
- if index == -1 || index-ptrSize < 0 {
- break
- }
-
- // Extract and validate pointer value
- ptrValue := littleEndianFunc(memory[index-ptrSize : index])
- if ptrValue > 0x10000 && ptrValue < 0x7FFFFFFFFFFF {
- if key := validateKey(handle, decryptor, ptrValue); key != "" {
- select {
- case resultChannel <- key:
- log.Debug("Valid key found for V4 database")
- default:
- }
- return
- }
- }
- index -= 1 // Continue searching from previous position
- }
- }
- }
-}
-
-// validateKey validates a single key candidate
-func validateKey(handle windows.Handle, decryptor *Decryptor, addr uint64) string {
- keyData := make([]byte, 0x20) // 32-byte key
- if err := windows.ReadProcessMemory(handle, uintptr(addr), &keyData[0], uintptr(len(keyData)), nil); err != nil {
- return ""
- }
-
- // Validate key against database header
- if decryptor.Validate(keyData) {
- return hex.EncodeToString(keyData)
- }
-
- return ""
-}
-
-// FindModule searches for a specified module in the process
-// Used to find WeChatWin.dll module for V3 version
-func FindModule(pid uint32, name string) (module windows.ModuleEntry32, isFound bool) {
- // Create module snapshot
- snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPMODULE|windows.TH32CS_SNAPMODULE32, pid)
- if err != nil {
- log.Debug("Failed to create module snapshot: ", err)
- return module, false
- }
- defer windows.CloseHandle(snapshot)
-
- // Initialize module entry structure
- module.Size = uint32(windows.SizeofModuleEntry32)
-
- // Get the first module
- if err := windows.Module32First(snapshot, &module); err != nil {
- log.Debug("Failed to get first module: ", err)
- return module, false
- }
-
- // Iterate through all modules to find WeChatWin.dll
- for ; err == nil; err = windows.Module32Next(snapshot, &module) {
- if windows.UTF16ToString(module.Module[:]) == name {
- return module, true
- }
- }
- return module, false
-}
diff --git a/internal/wechat/key/darwin/glance/glance.go b/internal/wechat/key/darwin/glance/glance.go
new file mode 100644
index 0000000..7bf0c6d
--- /dev/null
+++ b/internal/wechat/key/darwin/glance/glance.go
@@ -0,0 +1,118 @@
+package glance
+
+import (
+ "bufio"
+ "fmt"
+ "io"
+ "os"
+ "os/exec"
+ "path/filepath"
+ "time"
+)
+
+// FIXME 按照 region 读取效率较低,512MB 内存读取耗时约 18s
+
+type Glance struct {
+ PID uint32
+ MemRegions []MemRegion
+ pipePath string
+ data []byte
+}
+
+func NewGlance(pid uint32) *Glance {
+ return &Glance{
+ PID: pid,
+ pipePath: filepath.Join(os.TempDir(), fmt.Sprintf("chatlog_pipe_%d", time.Now().UnixNano())),
+ }
+}
+
+func (g *Glance) Read() ([]byte, error) {
+ if g.data != nil {
+ return g.data, nil
+ }
+
+ regions, err := GetVmmap(g.PID)
+ if err != nil {
+ return nil, err
+ }
+ g.MemRegions = MemRegionsFilter(regions)
+
+ if len(g.MemRegions) == 0 {
+ return nil, fmt.Errorf("no memory regions found")
+ }
+
+ region := g.MemRegions[0]
+
+ // 1. Create pipe file
+ if err := exec.Command("mkfifo", g.pipePath).Run(); err != nil {
+ return nil, fmt.Errorf("failed to create pipe file: %w", err)
+ }
+ defer os.Remove(g.pipePath)
+
+ // Start a goroutine to read from the pipe
+ dataCh := make(chan []byte, 1)
+ errCh := make(chan error, 1)
+ go func() {
+ // Open pipe for reading
+ file, err := os.OpenFile(g.pipePath, os.O_RDONLY, 0600)
+ if err != nil {
+ errCh <- fmt.Errorf("failed to open pipe for reading: %w", err)
+ return
+ }
+ defer file.Close()
+
+ // Read all data from pipe
+ data, err := io.ReadAll(file)
+ if err != nil {
+ errCh <- fmt.Errorf("failed to read from pipe: %w", err)
+ return
+ }
+ dataCh <- data
+ }()
+
+ // 2 & 3. Execute lldb command to read memory directly with all parameters
+ size := region.End - region.Start
+ lldbCmd := fmt.Sprintf("lldb -p %d -o \"memory read --binary --force --outfile %s --count %d 0x%x\" -o \"quit\"",
+ g.PID, g.pipePath, size, region.Start)
+
+ cmd := exec.Command("bash", "-c", lldbCmd)
+
+ // Set up stdout pipe for monitoring (optional)
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return nil, fmt.Errorf("failed to create stdout pipe: %w", err)
+ }
+
+ // Start the command
+ if err := cmd.Start(); err != nil {
+ return nil, fmt.Errorf("failed to start lldb: %w", err)
+ }
+
+ // Monitor lldb output (optional)
+ go func() {
+ scanner := bufio.NewScanner(stdout)
+ for scanner.Scan() {
+ // Uncomment for debugging:
+ // fmt.Println(scanner.Text())
+ }
+ }()
+
+ // Wait for data with timeout
+ select {
+ case data := <-dataCh:
+ g.data = data
+ case err := <-errCh:
+ return nil, fmt.Errorf("failed to read memory: %w", err)
+ case <-time.After(30 * time.Second):
+ cmd.Process.Kill()
+ return nil, fmt.Errorf("timeout waiting for memory data")
+ }
+
+ // Wait for the command to finish
+ if err := cmd.Wait(); err != nil {
+ // We already have the data, so just log the error
+ fmt.Printf("Warning: lldb process exited with error: %v\n", err)
+ }
+
+ return g.data, nil
+}
diff --git a/internal/wechat/key/darwin/glance/sip.go b/internal/wechat/key/darwin/glance/sip.go
new file mode 100644
index 0000000..601fbc6
--- /dev/null
+++ b/internal/wechat/key/darwin/glance/sip.go
@@ -0,0 +1,37 @@
+package glance
+
+import (
+ "os/exec"
+ "strings"
+)
+
+// IsSIPDisabled checks if System Integrity Protection (SIP) is disabled on macOS.
+// Returns true if SIP is disabled, false if it's enabled or if the status cannot be determined.
+func IsSIPDisabled() bool {
+ // Run the csrutil status command to check SIP status
+ cmd := exec.Command("csrutil", "status")
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ // If there's an error running the command, assume SIP is enabled
+ return false
+ }
+
+ // Convert output to string and check if SIP is disabled
+ outputStr := strings.ToLower(string(output))
+
+ // $ csrutil status
+ // System Integrity Protection status: disabled.
+
+ // If the output contains "disabled", SIP is disabled
+ if strings.Contains(outputStr, "system integrity protection status: disabled") {
+ return true
+ }
+
+ // Check for partial SIP disabling - some configurations might have specific protections disabled
+ if strings.Contains(outputStr, "disabled") && strings.Contains(outputStr, "debugging") {
+ return true
+ }
+
+ // By default, assume SIP is enabled
+ return false
+}
diff --git a/internal/wechat/key/darwin/glance/vmmap.go b/internal/wechat/key/darwin/glance/vmmap.go
new file mode 100644
index 0000000..d87b18b
--- /dev/null
+++ b/internal/wechat/key/darwin/glance/vmmap.go
@@ -0,0 +1,158 @@
+package glance
+
+import (
+ "bufio"
+ "fmt"
+ "os/exec"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+const (
+ FilterRegionType = "MALLOC_NANO"
+ FilterSHRMOD = "SM=PRV"
+ CommandVmmap = "vmmap"
+)
+
+type MemRegion struct {
+ RegionType string
+ Start uint64
+ End uint64
+ VSize uint64 // Size in bytes
+ RSDNT uint64 // Resident memory size in bytes (new field)
+ SHRMOD string
+ Permissions string
+ RegionDetail string
+}
+
+func GetVmmap(pid uint32) ([]MemRegion, error) {
+ // Execute vmmap command
+ cmd := exec.Command(CommandVmmap, "-wide", fmt.Sprintf("%d", pid))
+ output, err := cmd.CombinedOutput()
+ if err != nil {
+ return nil, fmt.Errorf("error executing vmmap command: %w", err)
+ }
+
+ // Parse the output using the existing LoadVmmap function
+ return LoadVmmap(string(output))
+}
+
+func LoadVmmap(output string) ([]MemRegion, error) {
+ var regions []MemRegion
+
+ scanner := bufio.NewScanner(strings.NewReader(output))
+
+ // Skip lines until we find the header
+ foundHeader := false
+ for scanner.Scan() {
+ line := scanner.Text()
+ if strings.HasPrefix(line, "==== Writable regions for") {
+ foundHeader = true
+ // Skip the column headers line
+ scanner.Scan()
+ break
+ }
+ }
+
+ if !foundHeader {
+ return nil, nil // No vmmap data found
+ }
+
+ // Regular expression to parse the vmmap output lines
+ // Format: REGION TYPE START - END [ VSIZE RSDNT DIRTY SWAP] PRT/MAX SHRMOD PURGE REGION DETAIL
+ // Updated regex to capture RSDNT value (second value in brackets)
+ re := regexp.MustCompile(`^(\S+)\s+([0-9a-f]+)-([0-9a-f]+)\s+\[\s*(\S+)\s+(\S+)(?:\s+\S+){2}\]\s+(\S+)\s+(\S+)(?:\s+\S+)?\s+(.*)$`)
+
+ // Parse each line
+ for scanner.Scan() {
+ line := scanner.Text()
+ if line == "" {
+ continue
+ }
+
+ matches := re.FindStringSubmatch(line)
+ if len(matches) >= 9 { // Updated to check for at least 9 matches
+
+ // Parse start and end addresses
+ start, _ := strconv.ParseUint(matches[2], 16, 64)
+ end, _ := strconv.ParseUint(matches[3], 16, 64)
+
+ // Parse VSize as numeric value
+ vsize := parseSize(matches[4])
+
+ // Parse RSDNT as numeric value (new)
+ rsdnt := parseSize(matches[5])
+
+ region := MemRegion{
+ RegionType: strings.TrimSpace(matches[1]),
+ Start: start,
+ End: end,
+ VSize: vsize,
+ RSDNT: rsdnt, // Add the new RSDNT field
+ Permissions: matches[6], // Shifted index
+ SHRMOD: matches[7], // Shifted index
+ RegionDetail: strings.TrimSpace(matches[8]), // Shifted index
+ }
+
+ regions = append(regions, region)
+ }
+ }
+
+ return regions, nil
+}
+
+func MemRegionsFilter(regions []MemRegion) []MemRegion {
+ var filteredRegions []MemRegion
+ for _, region := range regions {
+ if region.RegionType == FilterRegionType {
+ filteredRegions = append(filteredRegions, region)
+ }
+ }
+ return filteredRegions
+}
+
+// parseSize converts size strings like "5616K" or "128.0M" to bytes (uint64)
+func parseSize(sizeStr string) uint64 {
+ // Remove any whitespace
+ sizeStr = strings.TrimSpace(sizeStr)
+
+ // Define multipliers for different units
+ multipliers := map[string]uint64{
+ "B": 1,
+ "K": 1024,
+ "KB": 1024,
+ "M": 1024 * 1024,
+ "MB": 1024 * 1024,
+ "G": 1024 * 1024 * 1024,
+ "GB": 1024 * 1024 * 1024,
+ }
+
+ // Regular expression to match numbers with optional decimal point and unit
+ // This will match formats like: "5616K", "128.0M", "1.5G", etc.
+ re := regexp.MustCompile(`^(\d+(?:\.\d+)?)([KMGB]+)?$`)
+ matches := re.FindStringSubmatch(sizeStr)
+
+ if len(matches) < 2 {
+ return 0 // No match found
+ }
+
+ // Parse the numeric part (which may include a decimal point)
+ numStr := matches[1]
+ numVal, err := strconv.ParseFloat(numStr, 64)
+ if err != nil {
+ return 0
+ }
+
+ // Determine the multiplier based on the unit
+ multiplier := uint64(1) // Default if no unit specified
+ if len(matches) >= 3 && matches[2] != "" {
+ unit := matches[2]
+ if m, ok := multipliers[unit]; ok {
+ multiplier = m
+ }
+ }
+
+ // Calculate final size in bytes (rounding to nearest integer)
+ return uint64(numVal*float64(multiplier) + 0.5)
+}
diff --git a/internal/wechat/key/darwin/v3.go b/internal/wechat/key/darwin/v3.go
new file mode 100644
index 0000000..46e0120
--- /dev/null
+++ b/internal/wechat/key/darwin/v3.go
@@ -0,0 +1,187 @@
+package darwin
+
+import (
+ "bytes"
+ "context"
+ "encoding/hex"
+ "fmt"
+ "runtime"
+ "sync"
+
+ "github.com/sirupsen/logrus"
+
+ "github.com/sjzar/chatlog/internal/wechat/decrypt"
+ "github.com/sjzar/chatlog/internal/wechat/key/darwin/glance"
+ "github.com/sjzar/chatlog/internal/wechat/model"
+)
+
+const (
+ MaxWorkersV3 = 8
+)
+
+type V3Extractor struct {
+ validator *decrypt.Validator
+}
+
+func NewV3Extractor() *V3Extractor {
+ return &V3Extractor{}
+}
+
+func (e *V3Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
+ if proc.Status == model.StatusOffline {
+ return "", fmt.Errorf("WeChat is offline")
+ }
+
+ // Check if SIP is disabled, as it's required for memory reading on macOS
+ if !glance.IsSIPDisabled() {
+ return "", fmt.Errorf("System Integrity Protection (SIP) is enabled, cannot read process memory")
+ }
+
+ if e.validator == nil {
+ return "", fmt.Errorf("validator not set")
+ }
+
+ // Create context to control all goroutines
+ searchCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ // Create channels for memory data and results
+ memoryChannel := make(chan []byte, 100)
+ resultChannel := make(chan string, 1)
+
+ // Determine number of worker goroutines
+ workerCount := runtime.NumCPU()
+ if workerCount < 2 {
+ workerCount = 2
+ }
+ if workerCount > MaxWorkersV3 {
+ workerCount = MaxWorkersV3
+ }
+ logrus.Debug("Starting ", workerCount, " workers for V3 key search")
+
+ // Start consumer goroutines
+ var workerWaitGroup sync.WaitGroup
+ workerWaitGroup.Add(workerCount)
+ for index := 0; index < workerCount; index++ {
+ go func() {
+ defer workerWaitGroup.Done()
+ e.worker(searchCtx, memoryChannel, resultChannel)
+ }()
+ }
+
+ // Start producer goroutine
+ var producerWaitGroup sync.WaitGroup
+ producerWaitGroup.Add(1)
+ go func() {
+ defer producerWaitGroup.Done()
+ defer close(memoryChannel) // Close channel when producer is done
+ err := e.findMemory(searchCtx, uint32(proc.PID), memoryChannel)
+ if err != nil {
+ logrus.Error(err)
+ }
+ }()
+
+ // Wait for producer and consumers to complete
+ go func() {
+ producerWaitGroup.Wait()
+ workerWaitGroup.Wait()
+ close(resultChannel)
+ }()
+
+ // Wait for result
+ select {
+ case <-ctx.Done():
+ return "", ctx.Err()
+ case result, ok := <-resultChannel:
+ if ok && result != "" {
+ return result, nil
+ }
+ }
+
+ return "", fmt.Errorf("no valid key found")
+}
+
+// findMemory searches for memory regions using Glance
+func (e *V3Extractor) findMemory(ctx context.Context, pid uint32, memoryChannel chan<- []byte) error {
+ // Initialize a Glance instance to read process memory
+ g := glance.NewGlance(pid)
+
+ // Read memory data
+ memory, err := g.Read()
+ if err != nil {
+ return fmt.Errorf("failed to read process memory: %w", err)
+ }
+
+ logrus.Debug("Read memory region, size: ", len(memory), " bytes")
+
+ // Send memory data to channel for processing
+ select {
+ case memoryChannel <- memory:
+ logrus.Debug("Sent memory region for analysis")
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+
+ return nil
+}
+
+// worker processes memory regions to find V3 version key
+func (e *V3Extractor) worker(ctx context.Context, memoryChannel <-chan []byte, resultChannel chan<- string) {
+ keyPattern := []byte{0x72, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x69, 0x33, 0x32}
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case memory, ok := <-memoryChannel:
+ if !ok {
+ return
+ }
+
+ index := len(memory)
+
+ for {
+ select {
+ case <-ctx.Done():
+ return // Exit if context cancelled
+ default:
+ }
+
+ logrus.Debugf("Searching for V3 key in memory region, size: %d bytes", len(memory))
+
+ // Find pattern from end to beginning
+ index = bytes.LastIndex(memory[:index], keyPattern)
+ if index == -1 {
+ break // No more matches found
+ }
+
+ logrus.Debugf("Found potential V3 key pattern in memory region, index: %d", index)
+
+ // For V3, the key is 32 bytes and starts right after the pattern
+ if index+24+32 > len(memory) {
+ index -= 1
+ continue
+ }
+
+ // Extract the key data, which is right after the pattern and 32 bytes long
+ keyOffset := index + 24
+ keyData := memory[keyOffset : keyOffset+32]
+
+ // Validate key against database header
+ if e.validator.Validate(keyData) {
+ select {
+ case resultChannel <- hex.EncodeToString(keyData):
+ logrus.Debug("Valid key found for V3 database")
+ return
+ default:
+ }
+ }
+
+ index -= 1
+ }
+ }
+ }
+}
+
+func (e *V3Extractor) SetValidate(validator *decrypt.Validator) {
+ e.validator = validator
+}
diff --git a/internal/wechat/key/darwin/v4.go b/internal/wechat/key/darwin/v4.go
new file mode 100644
index 0000000..b1f8534
--- /dev/null
+++ b/internal/wechat/key/darwin/v4.go
@@ -0,0 +1,184 @@
+package darwin
+
+import (
+ "bytes"
+ "context"
+ "encoding/hex"
+ "fmt"
+ "runtime"
+ "sync"
+
+ "github.com/sirupsen/logrus"
+
+ "github.com/sjzar/chatlog/internal/wechat/decrypt"
+ "github.com/sjzar/chatlog/internal/wechat/key/darwin/glance"
+ "github.com/sjzar/chatlog/internal/wechat/model"
+)
+
+const (
+ MaxWorkers = 8
+)
+
+type V4Extractor struct {
+ validator *decrypt.Validator
+}
+
+func NewV4Extractor() *V4Extractor {
+ return &V4Extractor{}
+}
+
+func (e *V4Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
+ if proc.Status == model.StatusOffline {
+ return "", fmt.Errorf("WeChat is offline")
+ }
+
+ // Check if SIP is disabled, as it's required for memory reading on macOS
+ if !glance.IsSIPDisabled() {
+ return "", fmt.Errorf("System Integrity Protection (SIP) is enabled, cannot read process memory")
+ }
+
+ if e.validator == nil {
+ return "", fmt.Errorf("validator not set")
+ }
+
+ // Create context to control all goroutines
+ searchCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ // Create channels for memory data and results
+ memoryChannel := make(chan []byte, 100)
+ resultChannel := make(chan string, 1)
+
+ // Determine number of worker goroutines
+ workerCount := runtime.NumCPU()
+ if workerCount < 2 {
+ workerCount = 2
+ }
+ if workerCount > MaxWorkers {
+ workerCount = MaxWorkers
+ }
+ logrus.Debug("Starting ", workerCount, " workers for V4 key search")
+
+ // Start consumer goroutines
+ var workerWaitGroup sync.WaitGroup
+ workerWaitGroup.Add(workerCount)
+ for index := 0; index < workerCount; index++ {
+ go func() {
+ defer workerWaitGroup.Done()
+ e.worker(searchCtx, memoryChannel, resultChannel)
+ }()
+ }
+
+ // Start producer goroutine
+ var producerWaitGroup sync.WaitGroup
+ producerWaitGroup.Add(1)
+ go func() {
+ defer producerWaitGroup.Done()
+ defer close(memoryChannel) // Close channel when producer is done
+ err := e.findMemory(searchCtx, uint32(proc.PID), memoryChannel)
+ if err != nil {
+ logrus.Error(err)
+ }
+ }()
+
+ // Wait for producer and consumers to complete
+ go func() {
+ producerWaitGroup.Wait()
+ workerWaitGroup.Wait()
+ close(resultChannel)
+ }()
+
+ // Wait for result
+ select {
+ case <-ctx.Done():
+ return "", ctx.Err()
+ case result, ok := <-resultChannel:
+ if ok && result != "" {
+ return result, nil
+ }
+ }
+
+ return "", fmt.Errorf("no valid key found")
+}
+
+// findMemory searches for memory regions using Glance
+func (e *V4Extractor) findMemory(ctx context.Context, pid uint32, memoryChannel chan<- []byte) error {
+ // Initialize a Glance instance to read process memory
+ g := glance.NewGlance(pid)
+
+ // Read memory data
+ memory, err := g.Read()
+ if err != nil {
+ return fmt.Errorf("failed to read process memory: %w", err)
+ }
+
+ logrus.Debug("Read memory region, size: ", len(memory), " bytes")
+
+ // Send memory data to channel for processing
+ select {
+ case memoryChannel <- memory:
+ logrus.Debug("Sent memory region for analysis")
+ case <-ctx.Done():
+ return ctx.Err()
+ }
+
+ return nil
+}
+
+// worker processes memory regions to find V4 version key
+func (e *V4Extractor) worker(ctx context.Context, memoryChannel <-chan []byte, resultChannel chan<- string) {
+ keyPattern := []byte{0x20, 0x66, 0x74, 0x73, 0x35, 0x28, 0x25, 0x00}
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case memory, ok := <-memoryChannel:
+ if !ok {
+ return
+ }
+
+ index := len(memory)
+
+ for {
+ select {
+ case <-ctx.Done():
+ return // Exit if context cancelled
+ default:
+ }
+
+ // Find pattern from end to beginning
+ index = bytes.LastIndex(memory[:index], keyPattern)
+ if index == -1 {
+ break // No more matches found
+ }
+
+ // Check if we have enough space for the key
+ if index+16+32 > len(memory) {
+ index -= 1
+ continue
+ }
+
+ // Extract the key data, which is 16 bytes after the pattern and 32 bytes long
+ keyOffset := index + 16
+ keyData := memory[keyOffset : keyOffset+32]
+
+ // Validate key against database header
+ if e.validator.Validate(keyData) {
+ select {
+ case resultChannel <- hex.EncodeToString(keyData):
+ logrus.Debug("Valid key found for V4 database")
+ return
+ default:
+ }
+ }
+
+ index -= 1
+ }
+ }
+ }
+}
+
+func (e *V4Extractor) SetValidate(validator *decrypt.Validator) {
+ e.validator = validator
+}
diff --git a/internal/wechat/key/extractor.go b/internal/wechat/key/extractor.go
new file mode 100644
index 0000000..7586658
--- /dev/null
+++ b/internal/wechat/key/extractor.go
@@ -0,0 +1,41 @@
+package key
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/sjzar/chatlog/internal/wechat/decrypt"
+ "github.com/sjzar/chatlog/internal/wechat/key/darwin"
+ "github.com/sjzar/chatlog/internal/wechat/key/windows"
+ "github.com/sjzar/chatlog/internal/wechat/model"
+)
+
+// 错误定义
+var (
+ ErrInvalidVersion = fmt.Errorf("invalid version, must be 3 or 4")
+ ErrUnsupportedPlatform = fmt.Errorf("unsupported platform")
+)
+
+// Extractor 定义密钥提取器接口
+type Extractor interface {
+ // Extract 从进程中提取密钥
+ Extract(ctx context.Context, proc *model.Process) (string, error)
+
+ SetValidate(validator *decrypt.Validator)
+}
+
+// NewExtractor 创建适合当前平台的密钥提取器
+func NewExtractor(platform string, version int) (Extractor, error) {
+ switch {
+ case platform == "windows" && version == 3:
+ return windows.NewV3Extractor(), nil
+ case platform == "windows" && version == 4:
+ return windows.NewV4Extractor(), nil
+ case platform == "darwin" && version == 3:
+ return darwin.NewV3Extractor(), nil
+ case platform == "darwin" && version == 4:
+ return darwin.NewV4Extractor(), nil
+ default:
+ return nil, fmt.Errorf("%w: %s v%d", ErrUnsupportedPlatform, platform, version)
+ }
+}
diff --git a/internal/wechat/key/windows/v3.go b/internal/wechat/key/windows/v3.go
new file mode 100644
index 0000000..0034a24
--- /dev/null
+++ b/internal/wechat/key/windows/v3.go
@@ -0,0 +1,28 @@
+package windows
+
+import (
+ "errors"
+
+ "github.com/sjzar/chatlog/internal/wechat/decrypt"
+)
+
+// Common error definitions
+var (
+ ErrWeChatOffline = errors.New("wechat is not logged in")
+ ErrOpenProcess = errors.New("failed to open process")
+ ErrCheckProcessBits = errors.New("failed to check process architecture")
+ ErrFindWeChatDLL = errors.New("WeChatWin.dll module not found")
+ ErrNoValidKey = errors.New("no valid key found")
+)
+
+type V3Extractor struct {
+ validator *decrypt.Validator
+}
+
+func NewV3Extractor() *V3Extractor {
+ return &V3Extractor{}
+}
+
+func (e *V3Extractor) SetValidate(validator *decrypt.Validator) {
+ e.validator = validator
+}
diff --git a/internal/wechat/key/windows/v3_others.go b/internal/wechat/key/windows/v3_others.go
new file mode 100644
index 0000000..6ee2ed1
--- /dev/null
+++ b/internal/wechat/key/windows/v3_others.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package windows
+
+import (
+ "context"
+
+ "github.com/sjzar/chatlog/internal/wechat/model"
+)
+
+func (e *V3Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
+ return "", nil
+}
diff --git a/internal/wechat/key/windows/v3_windows.go b/internal/wechat/key/windows/v3_windows.go
new file mode 100644
index 0000000..6f56616
--- /dev/null
+++ b/internal/wechat/key/windows/v3_windows.go
@@ -0,0 +1,254 @@
+package windows
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "encoding/hex"
+ "fmt"
+ "runtime"
+ "sync"
+ "unsafe"
+
+ "github.com/sirupsen/logrus"
+ "golang.org/x/sys/windows"
+
+ "github.com/sjzar/chatlog/internal/wechat/model"
+ "github.com/sjzar/chatlog/pkg/util"
+)
+
+const (
+ V3ModuleName = "WeChatWin.dll"
+ MaxWorkers = 16
+)
+
+func (e *V3Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
+ if proc.Status == model.StatusOffline {
+ return "", ErrWeChatOffline
+ }
+
+ // Open WeChat process
+ handle, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, proc.PID)
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", ErrOpenProcess, err)
+ }
+ defer windows.CloseHandle(handle)
+
+ // Check process architecture
+ is64Bit, err := util.Is64Bit(handle)
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", ErrCheckProcessBits, err)
+ }
+
+ // Create context to control all goroutines
+ searchCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ // Create channels for memory data and results
+ memoryChannel := make(chan []byte, 100)
+ resultChannel := make(chan string, 1)
+
+ // Determine number of worker goroutines
+ workerCount := runtime.NumCPU()
+ if workerCount < 2 {
+ workerCount = 2
+ }
+ if workerCount > MaxWorkers {
+ workerCount = MaxWorkers
+ }
+ logrus.Debug("Starting ", workerCount, " workers for V3 key search")
+
+ // Start consumer goroutines
+ var workerWaitGroup sync.WaitGroup
+ workerWaitGroup.Add(workerCount)
+ for index := 0; index < workerCount; index++ {
+ go func() {
+ defer workerWaitGroup.Done()
+ e.worker(searchCtx, handle, is64Bit, memoryChannel, resultChannel)
+ }()
+ }
+
+ // Start producer goroutine
+ var producerWaitGroup sync.WaitGroup
+ producerWaitGroup.Add(1)
+ go func() {
+ defer producerWaitGroup.Done()
+ defer close(memoryChannel) // Close channel when producer is done
+ err := e.findMemory(searchCtx, handle, proc.PID, memoryChannel)
+ if err != nil {
+ logrus.Error(err)
+ }
+ }()
+
+ // Wait for producer and consumers to complete
+ go func() {
+ producerWaitGroup.Wait()
+ workerWaitGroup.Wait()
+ close(resultChannel)
+ }()
+
+ // Wait for result
+ select {
+ case <-ctx.Done():
+ return "", ctx.Err()
+ case result, ok := <-resultChannel:
+ if ok && result != "" {
+ return result, nil
+ }
+ }
+
+ return "", ErrNoValidKey
+}
+
+// findMemoryV3 searches for writable memory regions in WeChatWin.dll for V3 version
+func (e *V3Extractor) findMemory(ctx context.Context, handle windows.Handle, pid uint32, memoryChannel chan<- []byte) error {
+ // Find WeChatWin.dll module
+ module, isFound := FindModule(pid, V3ModuleName)
+ if !isFound {
+ return ErrFindWeChatDLL
+ }
+ logrus.Debug("Found WeChatWin.dll module at base address: 0x", fmt.Sprintf("%X", module.ModBaseAddr))
+
+ // Read writable memory regions
+ baseAddr := uintptr(module.ModBaseAddr)
+ endAddr := baseAddr + uintptr(module.ModBaseSize)
+ currentAddr := baseAddr
+
+ for currentAddr < endAddr {
+ var mbi windows.MemoryBasicInformation
+ err := windows.VirtualQueryEx(handle, currentAddr, &mbi, unsafe.Sizeof(mbi))
+ if err != nil {
+ break
+ }
+
+ // Skip small memory regions
+ if mbi.RegionSize < 100*1024 {
+ currentAddr += uintptr(mbi.RegionSize)
+ continue
+ }
+
+ // Check if memory region is writable
+ isWritable := (mbi.Protect & (windows.PAGE_READWRITE | windows.PAGE_WRITECOPY | windows.PAGE_EXECUTE_READWRITE | windows.PAGE_EXECUTE_WRITECOPY)) > 0
+ if isWritable && uint32(mbi.State) == windows.MEM_COMMIT {
+ // Calculate region size, ensure it doesn't exceed DLL bounds
+ regionSize := uintptr(mbi.RegionSize)
+ if currentAddr+regionSize > endAddr {
+ regionSize = endAddr - currentAddr
+ }
+
+ // Read writable memory region
+ memory := make([]byte, regionSize)
+ if err = windows.ReadProcessMemory(handle, currentAddr, &memory[0], regionSize, nil); err == nil {
+ select {
+ case memoryChannel <- memory:
+ logrus.Debug("Sent memory region for analysis, size: ", regionSize, " bytes")
+ case <-ctx.Done():
+ return nil
+ }
+ }
+ }
+
+ // Move to next memory region
+ currentAddr = uintptr(mbi.BaseAddress) + uintptr(mbi.RegionSize)
+ }
+
+ return nil
+}
+
+// workerV3 processes memory regions to find V3 version key
+func (e *V3Extractor) worker(ctx context.Context, handle windows.Handle, is64Bit bool, memoryChannel <-chan []byte, resultChannel chan<- string) {
+ // Define search pattern
+ keyPattern := []byte{0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
+ ptrSize := 8
+ littleEndianFunc := binary.LittleEndian.Uint64
+
+ // Adjust for 32-bit process
+ if !is64Bit {
+ keyPattern = keyPattern[:4]
+ ptrSize = 4
+ littleEndianFunc = func(b []byte) uint64 { return uint64(binary.LittleEndian.Uint32(b)) }
+ }
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case memory, ok := <-memoryChannel:
+ if !ok {
+ return
+ }
+
+ index := len(memory)
+ for {
+ select {
+ case <-ctx.Done():
+ return // Exit if context cancelled
+ default:
+ }
+
+ // Find pattern from end to beginning
+ index = bytes.LastIndex(memory[:index], keyPattern)
+ if index == -1 || index-ptrSize < 0 {
+ break
+ }
+
+ // Extract and validate pointer value
+ ptrValue := littleEndianFunc(memory[index-ptrSize : index])
+ if ptrValue > 0x10000 && ptrValue < 0x7FFFFFFFFFFF {
+ if key := e.validateKey(handle, ptrValue); key != "" {
+ select {
+ case resultChannel <- key:
+ logrus.Debug("Valid key found for V3 database")
+ return
+ default:
+ }
+ }
+ }
+ index -= 1 // Continue searching from previous position
+ }
+ }
+ }
+}
+
+// validateKey validates a single key candidate
+func (e *V3Extractor) validateKey(handle windows.Handle, addr uint64) string {
+ keyData := make([]byte, 0x20) // 32-byte key
+ if err := windows.ReadProcessMemory(handle, uintptr(addr), &keyData[0], uintptr(len(keyData)), nil); err != nil {
+ return ""
+ }
+
+ // Validate key against database header
+ if e.validator.Validate(keyData) {
+ return hex.EncodeToString(keyData)
+ }
+
+ return ""
+}
+
+// FindModule searches for a specified module in the process
+func FindModule(pid uint32, name string) (module windows.ModuleEntry32, isFound bool) {
+ // Create module snapshot
+ snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPMODULE|windows.TH32CS_SNAPMODULE32, pid)
+ if err != nil {
+ logrus.Debug("Failed to create module snapshot: ", err)
+ return module, false
+ }
+ defer windows.CloseHandle(snapshot)
+
+ // Initialize module entry structure
+ module.Size = uint32(windows.SizeofModuleEntry32)
+
+ // Get the first module
+ if err := windows.Module32First(snapshot, &module); err != nil {
+ logrus.Debug("Failed to get first module: ", err)
+ return module, false
+ }
+
+ // Iterate through all modules to find WeChatWin.dll
+ for ; err == nil; err = windows.Module32Next(snapshot, &module) {
+ if windows.UTF16ToString(module.Module[:]) == name {
+ return module, true
+ }
+ }
+ return module, false
+}
diff --git a/internal/wechat/key/windows/v4.go b/internal/wechat/key/windows/v4.go
new file mode 100644
index 0000000..5e21563
--- /dev/null
+++ b/internal/wechat/key/windows/v4.go
@@ -0,0 +1,17 @@
+package windows
+
+import (
+ "github.com/sjzar/chatlog/internal/wechat/decrypt"
+)
+
+type V4Extractor struct {
+ validator *decrypt.Validator
+}
+
+func NewV4Extractor() *V4Extractor {
+ return &V4Extractor{}
+}
+
+func (e *V4Extractor) SetValidate(validator *decrypt.Validator) {
+ e.validator = validator
+}
diff --git a/internal/wechat/key/windows/v4_others.go b/internal/wechat/key/windows/v4_others.go
new file mode 100644
index 0000000..d242d6a
--- /dev/null
+++ b/internal/wechat/key/windows/v4_others.go
@@ -0,0 +1,13 @@
+//go:build !windows
+
+package windows
+
+import (
+ "context"
+
+ "github.com/sjzar/chatlog/internal/wechat/model"
+)
+
+func (e *V4Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
+ return "", nil
+}
diff --git a/internal/wechat/key/windows/v4_windows.go b/internal/wechat/key/windows/v4_windows.go
new file mode 100644
index 0000000..5eafaa4
--- /dev/null
+++ b/internal/wechat/key/windows/v4_windows.go
@@ -0,0 +1,213 @@
+package windows
+
+import (
+ "bytes"
+ "context"
+ "encoding/binary"
+ "encoding/hex"
+ "fmt"
+ "runtime"
+ "sync"
+ "unsafe"
+
+ "github.com/sirupsen/logrus"
+ "golang.org/x/sys/windows"
+
+ "github.com/sjzar/chatlog/internal/wechat/model"
+)
+
+const (
+ MEM_PRIVATE = 0x20000
+)
+
+func (e *V4Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
+ if proc.Status == model.StatusOffline {
+ return "", ErrWeChatOffline
+ }
+
+ // Open process handle
+ handle, err := windows.OpenProcess(windows.PROCESS_VM_READ|windows.PROCESS_QUERY_INFORMATION, false, proc.PID)
+ if err != nil {
+ return "", fmt.Errorf("%w: %v", ErrOpenProcess, err)
+ }
+ defer windows.CloseHandle(handle)
+
+ // Create context to control all goroutines
+ searchCtx, cancel := context.WithCancel(ctx)
+ defer cancel()
+
+ // Create channels for memory data and results
+ memoryChannel := make(chan []byte, 100)
+ resultChannel := make(chan string, 1)
+
+ // Determine number of worker goroutines
+ workerCount := runtime.NumCPU()
+ if workerCount < 2 {
+ workerCount = 2
+ }
+ if workerCount > MaxWorkers {
+ workerCount = MaxWorkers
+ }
+ logrus.Debug("Starting ", workerCount, " workers for V4 key search")
+
+ // Start consumer goroutines
+ var workerWaitGroup sync.WaitGroup
+ workerWaitGroup.Add(workerCount)
+ for index := 0; index < workerCount; index++ {
+ go func() {
+ defer workerWaitGroup.Done()
+ e.worker(searchCtx, handle, memoryChannel, resultChannel)
+ }()
+ }
+
+ // Start producer goroutine
+ var producerWaitGroup sync.WaitGroup
+ producerWaitGroup.Add(1)
+ go func() {
+ defer producerWaitGroup.Done()
+ defer close(memoryChannel) // Close channel when producer is done
+ err := e.findMemory(searchCtx, handle, memoryChannel)
+ if err != nil {
+ logrus.Error(err)
+ }
+ }()
+
+ // Wait for producer and consumers to complete
+ go func() {
+ producerWaitGroup.Wait()
+ workerWaitGroup.Wait()
+ close(resultChannel)
+ }()
+
+ // Wait for result
+ select {
+ case <-ctx.Done():
+ return "", ctx.Err()
+ case result, ok := <-resultChannel:
+ if ok && result != "" {
+ return result, nil
+ }
+ }
+
+ return "", ErrNoValidKey
+}
+
+// findMemoryV4 searches for writable memory regions for V4 version
+func (e *V4Extractor) findMemory(ctx context.Context, handle windows.Handle, memoryChannel chan<- []byte) error {
+ // Define search range
+ minAddr := uintptr(0x10000) // Process space usually starts from 0x10000
+ maxAddr := uintptr(0x7FFFFFFF) // 32-bit process space limit
+
+ if runtime.GOARCH == "amd64" {
+ maxAddr = uintptr(0x7FFFFFFFFFFF) // 64-bit process space limit
+ }
+ logrus.Debug("Scanning memory regions from 0x", fmt.Sprintf("%X", minAddr), " to 0x", fmt.Sprintf("%X", maxAddr))
+
+ currentAddr := minAddr
+
+ for currentAddr < maxAddr {
+ var memInfo windows.MemoryBasicInformation
+ err := windows.VirtualQueryEx(handle, currentAddr, &memInfo, unsafe.Sizeof(memInfo))
+ if err != nil {
+ break
+ }
+
+ // Skip small memory regions
+ if memInfo.RegionSize < 1024*1024 {
+ currentAddr += uintptr(memInfo.RegionSize)
+ continue
+ }
+
+ // Check if memory region is readable and private
+ if memInfo.State == windows.MEM_COMMIT && (memInfo.Protect&windows.PAGE_READWRITE) != 0 && memInfo.Type == MEM_PRIVATE {
+ // Calculate region size, ensure it doesn't exceed limit
+ regionSize := uintptr(memInfo.RegionSize)
+ if currentAddr+regionSize > maxAddr {
+ regionSize = maxAddr - currentAddr
+ }
+
+ // Read memory region
+ memory := make([]byte, regionSize)
+ if err = windows.ReadProcessMemory(handle, currentAddr, &memory[0], regionSize, nil); err == nil {
+ select {
+ case memoryChannel <- memory:
+ logrus.Debug("Sent memory region for analysis, size: ", regionSize, " bytes")
+ case <-ctx.Done():
+ return nil
+ }
+ }
+ }
+
+ // Move to next memory region
+ currentAddr = uintptr(memInfo.BaseAddress) + uintptr(memInfo.RegionSize)
+ }
+
+ return nil
+}
+
+// workerV4 processes memory regions to find V4 version key
+func (e *V4Extractor) worker(ctx context.Context, handle windows.Handle, memoryChannel <-chan []byte, resultChannel chan<- string) {
+ // Define search pattern for V4
+ keyPattern := []byte{
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ 0x2F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
+ }
+ ptrSize := 8
+ littleEndianFunc := binary.LittleEndian.Uint64
+
+ for {
+ select {
+ case <-ctx.Done():
+ return
+ case memory, ok := <-memoryChannel:
+ if !ok {
+ return
+ }
+
+ index := len(memory)
+ for {
+ select {
+ case <-ctx.Done():
+ return // Exit if context cancelled
+ default:
+ }
+
+ // Find pattern from end to beginning
+ index = bytes.LastIndex(memory[:index], keyPattern)
+ if index == -1 || index-ptrSize < 0 {
+ break
+ }
+
+ // Extract and validate pointer value
+ ptrValue := littleEndianFunc(memory[index-ptrSize : index])
+ if ptrValue > 0x10000 && ptrValue < 0x7FFFFFFFFFFF {
+ if key := e.validateKey(handle, ptrValue); key != "" {
+ select {
+ case resultChannel <- key:
+ logrus.Debug("Valid key found for V4 database")
+ return
+ default:
+ }
+ }
+ }
+ index -= 1 // Continue searching from previous position
+ }
+ }
+ }
+}
+
+// validateKey validates a single key candidate
+func (e *V4Extractor) validateKey(handle windows.Handle, addr uint64) string {
+ keyData := make([]byte, 0x20) // 32-byte key
+ if err := windows.ReadProcessMemory(handle, uintptr(addr), &keyData[0], uintptr(len(keyData)), nil); err != nil {
+ return ""
+ }
+
+ // Validate key against database header
+ if e.validator.Validate(keyData) {
+ return hex.EncodeToString(keyData)
+ }
+
+ return ""
+}
diff --git a/internal/wechat/manager.go b/internal/wechat/manager.go
index d812345..dd6bd42 100644
--- a/internal/wechat/manager.go
+++ b/internal/wechat/manager.go
@@ -1,57 +1,110 @@
package wechat
import (
- "strings"
+ "context"
+ "fmt"
+ "runtime"
- "github.com/shirou/gopsutil/v4/process"
- log "github.com/sirupsen/logrus"
+ "github.com/sjzar/chatlog/internal/wechat/model"
+ "github.com/sjzar/chatlog/internal/wechat/process"
)
-const (
- V3ProcessName = "WeChat"
- V4ProcessName = "Weixin"
-)
+var DefaultManager *Manager
-var (
- Items []*Info
- ItemMap map[string]*Info
-)
+func init() {
+ DefaultManager = NewManager()
+ DefaultManager.Load()
+}
-func Load() {
- Items = make([]*Info, 0, 2)
- ItemMap = make(map[string]*Info)
+func Load() error {
+ return DefaultManager.Load()
+}
- processes, err := process.Processes()
- if err != nil {
- log.Println("获取进程列表失败:", err)
- return
- }
+func GetAccount(name string) (*Account, error) {
+ return DefaultManager.GetAccount(name)
+}
- for _, p := range processes {
- name, err := p.Name()
- name = strings.TrimSuffix(name, ".exe")
- if err != nil || name != V3ProcessName && name != V4ProcessName {
- continue
- }
+func GetProcess(name string) (*model.Process, error) {
+ return DefaultManager.GetProcess(name)
+}
- // v4 存在同名进程,需要继续判断 cmdline
- if name == V4ProcessName {
- cmdline, err := p.Cmdline()
- if err != nil {
- log.Error(err)
- continue
- }
- if strings.Contains(cmdline, "--") {
- continue
- }
- }
+func GetAccounts() []*Account {
+ return DefaultManager.GetAccounts()
+}
- info, err := NewInfo(p)
- if err != nil {
- continue
- }
+// Manager 微信管理器
+type Manager struct {
+ detector process.Detector
+ accounts []*Account
+ processMap map[string]*model.Process
+}
- Items = append(Items, info)
- ItemMap[info.AccountName] = info
+// NewManager 创建新的微信管理器
+func NewManager() *Manager {
+ return &Manager{
+ detector: process.NewDetector(runtime.GOOS),
+ accounts: make([]*Account, 0),
+ processMap: make(map[string]*model.Process),
}
}
+
+// Load 加载微信进程信息
+func (m *Manager) Load() error {
+ // 查找微信进程
+ processes, err := m.detector.FindProcesses()
+ if err != nil {
+ return err
+ }
+
+ // 转换为账号信息
+ accounts := make([]*Account, 0, len(processes))
+ processMap := make(map[string]*model.Process, len(processes))
+
+ for _, p := range processes {
+ account := NewAccount(p)
+
+ accounts = append(accounts, account)
+ if account.Name != "" {
+ processMap[account.Name] = p
+ }
+ }
+
+ m.accounts = accounts
+ m.processMap = processMap
+
+ return nil
+}
+
+// GetAccount 获取指定名称的账号
+func (m *Manager) GetAccount(name string) (*Account, error) {
+ p, err := m.GetProcess(name)
+ if err != nil {
+ return nil, err
+ }
+ return NewAccount(p), nil
+}
+
+func (m *Manager) GetProcess(name string) (*model.Process, error) {
+ p, ok := m.processMap[name]
+ if !ok {
+ return nil, fmt.Errorf("account not found: %s", name)
+ }
+ return p, nil
+}
+
+// GetAccounts 获取所有账号
+func (m *Manager) GetAccounts() []*Account {
+ return m.accounts
+}
+
+// DecryptDatabase 便捷方法:通过账号名解密数据库
+func (m *Manager) DecryptDatabase(ctx context.Context, accountName, dbPath, outputPath string) error {
+ // 获取账号
+ account, err := m.GetAccount(accountName)
+ if err != nil {
+ return err
+ }
+
+ // 使用账号解密数据库
+ return account.DecryptDatabase(ctx, dbPath, outputPath)
+}
diff --git a/internal/wechat/model/process.go b/internal/wechat/model/process.go
new file mode 100644
index 0000000..bc6f1c9
--- /dev/null
+++ b/internal/wechat/model/process.go
@@ -0,0 +1,24 @@
+package model
+
+type Process struct {
+ PID uint32
+ ExePath string
+ Platform string
+ Version int
+ FullVersion string
+ Status string
+ DataDir string
+ AccountName string
+}
+
+// 平台常量定义
+const (
+ PlatformWindows = "windows"
+ PlatformMacOS = "darwin"
+)
+
+const (
+ StatusInit = ""
+ StatusOffline = "offline"
+ StatusOnline = "online"
+)
diff --git a/internal/wechat/process/darwin/detector.go b/internal/wechat/process/darwin/detector.go
new file mode 100644
index 0000000..68a3294
--- /dev/null
+++ b/internal/wechat/process/darwin/detector.go
@@ -0,0 +1,164 @@
+package darwin
+
+import (
+ "fmt"
+ "os/exec"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "github.com/shirou/gopsutil/v4/process"
+ log "github.com/sirupsen/logrus"
+
+ "github.com/sjzar/chatlog/internal/wechat/model"
+ "github.com/sjzar/chatlog/pkg/appver"
+)
+
+const (
+ V3ProcessName = "WeChat"
+ V4ProcessName = "Weixin"
+ V3DBFile = "Message/msg_0.db"
+ V4DBFile = "db_storage/message/message_0.db"
+)
+
+// Detector 实现 macOS 平台的进程检测器
+type Detector struct{}
+
+// NewDetector 创建一个新的 macOS 检测器
+func NewDetector() *Detector {
+ return &Detector{}
+}
+
+// FindProcesses 查找所有微信进程并返回它们的信息
+func (d *Detector) FindProcesses() ([]*model.Process, error) {
+ processes, err := process.Processes()
+ if err != nil {
+ log.Errorf("获取进程列表失败: %v", err)
+ return nil, err
+ }
+
+ var result []*model.Process
+ for _, p := range processes {
+ name, err := p.Name()
+ if err != nil || (name != V3ProcessName && name != V4ProcessName) {
+ continue
+ }
+
+ // 获取进程信息
+ procInfo, err := d.getProcessInfo(p)
+ if err != nil {
+ log.Errorf("获取进程 %d 的信息失败: %v", p.Pid, err)
+ continue
+ }
+
+ result = append(result, procInfo)
+ }
+
+ return result, nil
+}
+
+// getProcessInfo 获取微信进程的详细信息
+func (d *Detector) getProcessInfo(p *process.Process) (*model.Process, error) {
+ procInfo := &model.Process{
+ PID: uint32(p.Pid),
+ Status: model.StatusOffline,
+ Platform: model.PlatformMacOS,
+ }
+
+ // 获取可执行文件路径
+ exePath, err := p.Exe()
+ if err != nil {
+ log.Error(err)
+ return nil, err
+ }
+ procInfo.ExePath = exePath
+
+ // 获取版本信息
+ // 注意:macOS 的版本获取方式可能与 Windows 不同
+ versionInfo, err := appver.New(exePath)
+ if err != nil {
+ log.Error(err)
+ procInfo.Version = 3
+ procInfo.FullVersion = "3.0.0"
+ } else {
+ procInfo.Version = versionInfo.Version
+ procInfo.FullVersion = versionInfo.FullVersion
+ }
+
+ // 初始化附加信息(数据目录、账户名)
+ if err := d.initializeProcessInfo(p, procInfo); err != nil {
+ log.Errorf("初始化进程信息失败: %v", err)
+ // 即使初始化失败也返回部分信息
+ }
+
+ return procInfo, nil
+}
+
+// initializeProcessInfo 获取进程的数据目录和账户名
+func (d *Detector) initializeProcessInfo(p *process.Process, info *model.Process) error {
+ // 使用 lsof 命令获取进程打开的文件
+ files, err := d.getOpenFiles(int(p.Pid))
+ if err != nil {
+ log.Error("获取打开文件列表失败: ", err)
+ return err
+ }
+
+ dbPath := V3DBFile
+ if info.Version == 4 {
+ dbPath = V4DBFile
+ }
+
+ for _, filePath := range files {
+ if strings.Contains(filePath, dbPath) {
+ parts := strings.Split(filePath, string(filepath.Separator))
+ if len(parts) < 4 {
+ log.Debug("无效的文件路径格式: " + filePath)
+ continue
+ }
+
+ // v3:
+ // /Users/sarv/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9//Message/msg_0.db
+ // v4:
+ // /Users/sarv/Library/Containers/com.tencent.xWeChat/Data/Documents/xwechat_files//db_storage/message/message_0.db
+
+ info.Status = model.StatusOnline
+ if info.Version == 4 {
+ info.DataDir = strings.Join(parts[:len(parts)-3], string(filepath.Separator))
+ info.AccountName = parts[len(parts)-4]
+ } else {
+ info.DataDir = strings.Join(parts[:len(parts)-2], string(filepath.Separator))
+ info.AccountName = parts[len(parts)-3]
+ }
+ return nil
+ }
+ }
+
+ return nil
+}
+
+// getOpenFiles 使用 lsof 命令获取进程打开的文件列表
+func (d *Detector) getOpenFiles(pid int) ([]string, error) {
+ // 执行 lsof -p 命令,使用 -F n 选项只输出文件名
+ cmd := exec.Command("lsof", "-p", strconv.Itoa(pid), "-F", "n")
+ output, err := cmd.Output()
+ if err != nil {
+ return nil, fmt.Errorf("执行 lsof 命令失败: %v", err)
+ }
+
+ // 解析 lsof -F n 输出
+ // 格式为: n/path/to/file
+ lines := strings.Split(string(output), "\n")
+ var files []string
+
+ for _, line := range lines {
+ if strings.HasPrefix(line, "n") {
+ // 移除前缀 'n' 获取文件路径
+ filePath := line[1:]
+ if filePath != "" {
+ files = append(files, filePath)
+ }
+ }
+ }
+
+ return files, nil
+}
diff --git a/internal/wechat/process/detector.go b/internal/wechat/process/detector.go
new file mode 100644
index 0000000..8cafd6f
--- /dev/null
+++ b/internal/wechat/process/detector.go
@@ -0,0 +1,36 @@
+package process
+
+import (
+ "github.com/sjzar/chatlog/internal/wechat/model"
+ "github.com/sjzar/chatlog/internal/wechat/process/darwin"
+ "github.com/sjzar/chatlog/internal/wechat/process/windows"
+)
+
+type Detector interface {
+ FindProcesses() ([]*model.Process, error)
+}
+
+// NewDetector 创建适合当前平台的检测器
+func NewDetector(platform string) Detector {
+ // 根据平台返回对应的实现
+ switch platform {
+ case "windows":
+ return windows.NewDetector()
+ case "darwin":
+ return darwin.NewDetector()
+ default:
+ // 默认返回一个空实现
+ return &nullDetector{}
+ }
+}
+
+// nullDetector 空实现
+type nullDetector struct{}
+
+func (d *nullDetector) FindProcesses() ([]*model.Process, error) {
+ return nil, nil
+}
+
+func (d *nullDetector) GetProcessInfo(pid uint32) (*model.Process, error) {
+ return nil, nil
+}
diff --git a/internal/wechat/process/windows/detector.go b/internal/wechat/process/windows/detector.go
new file mode 100644
index 0000000..88b304a
--- /dev/null
+++ b/internal/wechat/process/windows/detector.go
@@ -0,0 +1,101 @@
+package windows
+
+import (
+ "strings"
+
+ "github.com/shirou/gopsutil/v4/process"
+ log "github.com/sirupsen/logrus"
+
+ "github.com/sjzar/chatlog/internal/wechat/model"
+ "github.com/sjzar/chatlog/pkg/appver"
+)
+
+const (
+ V3ProcessName = "WeChat"
+ V4ProcessName = "Weixin"
+ V3DBFile = "Msg\\Misc.db"
+ V4DBFile = "db_storage\\message\\message_0.db"
+)
+
+// Detector 实现 Windows 平台的进程检测器
+type Detector struct{}
+
+// NewDetector 创建一个新的 Windows 检测器
+func NewDetector() *Detector {
+ return &Detector{}
+}
+
+// FindProcesses 查找所有微信进程并返回它们的信息
+func (d *Detector) FindProcesses() ([]*model.Process, error) {
+ processes, err := process.Processes()
+ if err != nil {
+ log.Errorf("获取进程列表失败: %v", err)
+ return nil, err
+ }
+
+ var result []*model.Process
+ for _, p := range processes {
+ name, err := p.Name()
+ name = strings.TrimSuffix(name, ".exe")
+ if err != nil || (name != V3ProcessName && name != V4ProcessName) {
+ continue
+ }
+
+ // v4 存在同名进程,需要继续判断 cmdline
+ if name == V4ProcessName {
+ cmdline, err := p.Cmdline()
+ if err != nil {
+ log.Error(err)
+ continue
+ }
+ if strings.Contains(cmdline, "--") {
+ continue
+ }
+ }
+
+ // 获取进程信息
+ procInfo, err := d.getProcessInfo(p)
+ if err != nil {
+ log.Errorf("获取进程 %d 的信息失败: %v", p.Pid, err)
+ continue
+ }
+
+ result = append(result, procInfo)
+ }
+
+ return result, nil
+}
+
+// getProcessInfo 获取微信进程的详细信息
+func (d *Detector) getProcessInfo(p *process.Process) (*model.Process, error) {
+ procInfo := &model.Process{
+ PID: uint32(p.Pid),
+ Status: model.StatusOffline,
+ Platform: model.PlatformWindows,
+ }
+
+ // 获取可执行文件路径
+ exePath, err := p.Exe()
+ if err != nil {
+ log.Error(err)
+ return nil, err
+ }
+ procInfo.ExePath = exePath
+
+ // 获取版本信息
+ versionInfo, err := appver.New(exePath)
+ if err != nil {
+ log.Error(err)
+ return nil, err
+ }
+ procInfo.Version = versionInfo.Version
+ procInfo.FullVersion = versionInfo.FullVersion
+
+ // 初始化附加信息(数据目录、账户名)
+ if err := initializeProcessInfo(p, procInfo); err != nil {
+ log.Errorf("初始化进程信息失败: %v", err)
+ // 即使初始化失败也返回部分信息
+ }
+
+ return procInfo, nil
+}
diff --git a/internal/wechat/process/windows/detector_others.go b/internal/wechat/process/windows/detector_others.go
new file mode 100644
index 0000000..800f4a6
--- /dev/null
+++ b/internal/wechat/process/windows/detector_others.go
@@ -0,0 +1,12 @@
+//go:build !windows
+
+package windows
+
+import (
+ "github.com/shirou/gopsutil/v4/process"
+ "github.com/sjzar/chatlog/internal/wechat/model"
+)
+
+func initializeProcessInfo(p *process.Process, info *model.Process) error {
+ return nil
+}
diff --git a/internal/wechat/process/windows/detector_windows.go b/internal/wechat/process/windows/detector_windows.go
new file mode 100644
index 0000000..80bf9a3
--- /dev/null
+++ b/internal/wechat/process/windows/detector_windows.go
@@ -0,0 +1,48 @@
+package windows
+
+import (
+ "path/filepath"
+ "strings"
+
+ "github.com/shirou/gopsutil/v4/process"
+ log "github.com/sirupsen/logrus"
+
+ "github.com/sjzar/chatlog/internal/wechat/model"
+)
+
+// initializeProcessInfo 获取进程的数据目录和账户名
+func initializeProcessInfo(p *process.Process, info *model.Process) error {
+ files, err := p.OpenFiles()
+ if err != nil {
+ log.Error("获取打开文件列表失败: ", err)
+ return err
+ }
+
+ dbPath := V3DBFile
+ if info.Version == 4 {
+ dbPath = V4DBFile
+ }
+
+ for _, f := range files {
+ if strings.HasSuffix(f.Path, dbPath) {
+ filePath := f.Path[4:] // 移除 "\\?\" 前缀
+ parts := strings.Split(filePath, string(filepath.Separator))
+ if len(parts) < 4 {
+ log.Debug("无效的文件路径格式: " + filePath)
+ continue
+ }
+
+ info.Status = model.StatusOnline
+ if info.Version == 4 {
+ info.DataDir = strings.Join(parts[:len(parts)-3], string(filepath.Separator))
+ info.AccountName = parts[len(parts)-4]
+ } else {
+ info.DataDir = strings.Join(parts[:len(parts)-2], string(filepath.Separator))
+ info.AccountName = parts[len(parts)-3]
+ }
+ return nil
+ }
+ }
+
+ return nil
+}
diff --git a/internal/wechat/wechat.go b/internal/wechat/wechat.go
new file mode 100644
index 0000000..9b22333
--- /dev/null
+++ b/internal/wechat/wechat.go
@@ -0,0 +1,134 @@
+package wechat
+
+import (
+ "context"
+ "fmt"
+ "os"
+
+ "github.com/sjzar/chatlog/internal/wechat/decrypt"
+ "github.com/sjzar/chatlog/internal/wechat/key"
+ "github.com/sjzar/chatlog/internal/wechat/model"
+)
+
+// Account 表示一个微信账号
+type Account struct {
+ Name string
+ Platform string
+ Version int
+ FullVersion string
+ DataDir string
+ Key string
+ PID uint32
+ ExePath string
+ Status string
+}
+
+// NewAccount 创建新的账号对象
+func NewAccount(proc *model.Process) *Account {
+ return &Account{
+ Name: proc.AccountName,
+ Platform: proc.Platform,
+ Version: proc.Version,
+ FullVersion: proc.FullVersion,
+ DataDir: proc.DataDir,
+ PID: proc.PID,
+ ExePath: proc.ExePath,
+ Status: proc.Status,
+ }
+}
+
+// RefreshStatus 刷新账号的进程状态
+func (a *Account) RefreshStatus() error {
+ // 查找所有微信进程
+ Load()
+
+ process, err := GetProcess(a.Name)
+ if err != nil {
+ a.Status = model.StatusOffline
+ return nil
+ }
+
+ if process.AccountName == a.Name {
+ // 更新进程信息
+ a.PID = process.PID
+ a.ExePath = process.ExePath
+ a.Platform = process.Platform
+ a.Version = process.Version
+ a.FullVersion = process.FullVersion
+ a.Status = process.Status
+ a.DataDir = process.DataDir
+ }
+
+ return nil
+}
+
+// GetKey 获取账号的密钥
+func (a *Account) GetKey(ctx context.Context) (string, error) {
+ // 如果已经有密钥,直接返回
+ if a.Key != "" {
+ return a.Key, nil
+ }
+
+ // 刷新进程状态
+ if err := a.RefreshStatus(); err != nil {
+ return "", fmt.Errorf("failed to refresh process status: %w", err)
+ }
+
+ // 检查账号状态
+ if a.Status != model.StatusOnline {
+ return "", fmt.Errorf("account %s is not online", a.Name)
+ }
+
+ // 创建密钥提取器 - 使用新的接口,传入平台和版本信息
+ extractor, err := key.NewExtractor(a.Platform, a.Version)
+ if err != nil {
+ return "", fmt.Errorf("failed to create key extractor: %w", err)
+ }
+
+ process, err := GetProcess(a.Name)
+ if err != nil {
+ return "", fmt.Errorf("failed to get process: %w", err)
+ }
+
+ validator, err := decrypt.NewValidator(process.DataDir, process.Platform, process.Version)
+ if err != nil {
+ return "", fmt.Errorf("failed to create validator: %w", err)
+ }
+
+ extractor.SetValidate(validator)
+
+ // 提取密钥
+ key, err := extractor.Extract(ctx, process)
+ if err != nil {
+ return "", err
+ }
+
+ // 保存密钥
+ a.Key = key
+ return key, nil
+}
+
+// DecryptDatabase 解密数据库
+func (a *Account) DecryptDatabase(ctx context.Context, dbPath, outputPath string) error {
+ // 获取密钥
+ hexKey, err := a.GetKey(ctx)
+ if err != nil {
+ return err
+ }
+
+ // 创建解密器 - 传入平台信息和版本
+ decryptor, err := decrypt.NewDecryptor(a.Platform, a.Version)
+ if err != nil {
+ return err
+ }
+
+ // 创建输出文件
+ output, err := os.Create(outputPath)
+ if err != nil {
+ return err
+ }
+ defer output.Close()
+
+ // 解密数据库
+ return decryptor.Decrypt(ctx, dbPath, hexKey, output)
+}
diff --git a/internal/wechatdb/contact.go b/internal/wechatdb/contact.go
deleted file mode 100644
index 19d1d4d..0000000
--- a/internal/wechatdb/contact.go
+++ /dev/null
@@ -1,269 +0,0 @@
-package wechatdb
-
-import (
- "database/sql"
- "fmt"
-
- "github.com/sjzar/chatlog/pkg/model"
- "github.com/sjzar/chatlog/pkg/util"
-
- log "github.com/sirupsen/logrus"
-)
-
-var (
- ContactFileV3 = "^MicroMsg.db$"
- ContactFileV4 = "contact.db$"
-)
-
-type Contact struct {
- version int
- dbFile string
- db *sql.DB
-
- Contact map[string]*model.Contact // 好友和群聊信息,Key UserName
- ChatRoom map[string]*model.ChatRoom // 群聊信息,Key UserName
- Sessions []*model.Session // 历史会话,按时间倒序
-
- // Quick Search
- ChatRoomUsers map[string]*model.Contact // 群聊成员信息,Key UserName
- Alias2Contack map[string]*model.Contact // 别名到联系人的映射
- Remark2Contack map[string]*model.Contact // 备注名到联系人的映射
- NickName2Contack map[string]*model.Contact // 昵称到联系人的映射
-}
-
-func NewContact(path string, version int) (*Contact, error) {
- c := &Contact{
- version: version,
- }
-
- files, err := util.FindFilesWithPatterns(path, ContactFileV3, true)
- if err != nil {
- return nil, fmt.Errorf("查找数据库文件失败: %v", err)
- }
-
- if len(files) == 0 {
- return nil, fmt.Errorf("未找到任何数据库文件: %s", path)
- }
-
- c.dbFile = files[0]
-
- c.db, err = sql.Open("sqlite3", c.dbFile)
- if err != nil {
- log.Printf("警告: 连接数据库 %s 失败: %v", c.dbFile, err)
- return nil, fmt.Errorf("连接数据库失败: %v", err)
- }
-
- c.loadContact()
- c.loadChatRoom()
- c.loadSession()
- c.fillChatRoomInfo()
-
- return c, nil
-}
-
-func (c *Contact) loadContact() {
- contactMap := make(map[string]*model.Contact)
- chatRoomUserMap := make(map[string]*model.Contact)
- aliasMap := make(map[string]*model.Contact)
- remarkMap := make(map[string]*model.Contact)
- nickNameMap := make(map[string]*model.Contact)
- rows, err := c.db.Query("SELECT UserName, Alias, Remark, NickName, Reserved1 FROM Contact")
- if err != nil {
- log.Errorf("查询联系人失败: %v", err)
- return
- }
-
- for rows.Next() {
- var contactv3 model.ContactV3
-
- if err := rows.Scan(
- &contactv3.UserName,
- &contactv3.Alias,
- &contactv3.Remark,
- &contactv3.NickName,
- &contactv3.Reserved1,
- ); err != nil {
- log.Printf("警告: 扫描联系人行失败: %v", err)
- continue
- }
- contact := contactv3.Wrap()
-
- if contact.IsFriend {
- contactMap[contact.UserName] = contact
- if contact.Alias != "" {
- aliasMap[contact.Alias] = contact
- }
- if contact.Remark != "" {
- remarkMap[contact.Remark] = contact
- }
- if contact.NickName != "" {
- nickNameMap[contact.NickName] = contact
- }
- } else {
- chatRoomUserMap[contact.UserName] = contact
- }
-
- }
- rows.Close()
-
- c.Contact = contactMap
- c.ChatRoomUsers = chatRoomUserMap
- c.Alias2Contack = aliasMap
- c.Remark2Contack = remarkMap
- c.NickName2Contack = nickNameMap
-}
-
-func (c *Contact) loadChatRoom() {
-
- chatRoomMap := make(map[string]*model.ChatRoom)
- rows, err := c.db.Query("SELECT ChatRoomName, Reserved2, RoomData FROM ChatRoom")
- if err != nil {
- log.Errorf("查询群聊失败: %v", err)
- return
- }
- for rows.Next() {
- var chatRoom model.ChatRoomV3
- if err := rows.Scan(
- &chatRoom.ChatRoomName,
- &chatRoom.Reserved2,
- &chatRoom.RoomData,
- ); err != nil {
- log.Printf("警告: 扫描群聊行失败: %v", err)
- continue
- }
- chatRoomMap[chatRoom.ChatRoomName] = chatRoom.Wrap()
- }
- rows.Close()
- c.ChatRoom = chatRoomMap
-}
-
-func (c *Contact) loadSession() {
-
- sessions := make([]*model.Session, 0)
- rows, err := c.db.Query("SELECT strUsrName, nOrder, strNickName, strContent, nTime FROM Session ORDER BY nOrder DESC")
- if err != nil {
- log.Errorf("查询群聊失败: %v", err)
- return
- }
- for rows.Next() {
- var sessionV3 model.SessionV3
- if err := rows.Scan(
- &sessionV3.StrUsrName,
- &sessionV3.NOrder,
- &sessionV3.StrNickName,
- &sessionV3.StrContent,
- &sessionV3.NTime,
- ); err != nil {
- log.Printf("警告: 扫描历史会话失败: %v", err)
- continue
- }
- session := sessionV3.Wrap()
- sessions = append(sessions, session)
-
- }
- rows.Close()
- c.Sessions = sessions
-}
-
-func (c *Contact) ListContact() ([]*model.Contact, error) {
- contacts := make([]*model.Contact, 0, len(c.Contact))
- for _, contact := range c.Contact {
- contacts = append(contacts, contact)
- }
- return contacts, nil
-}
-
-func (c *Contact) ListChatRoom() ([]*model.ChatRoom, error) {
- chatRooms := make([]*model.ChatRoom, 0, len(c.ChatRoom))
- for _, chatRoom := range c.ChatRoom {
- chatRooms = append(chatRooms, chatRoom)
- }
- return chatRooms, nil
-}
-
-func (c *Contact) GetContact(key string) *model.Contact {
- if contact, ok := c.Contact[key]; ok {
- return contact
- }
- if contact, ok := c.Alias2Contack[key]; ok {
- return contact
- }
- if contact, ok := c.Remark2Contack[key]; ok {
- return contact
- }
- if contact, ok := c.NickName2Contack[key]; ok {
- return contact
- }
- return nil
-}
-
-func (c *Contact) GetChatRoom(name string) *model.ChatRoom {
- if chatRoom, ok := c.ChatRoom[name]; ok {
- return chatRoom
- }
-
- if contact := c.GetContact(name); contact != nil {
- if chatRoom, ok := c.ChatRoom[contact.UserName]; ok {
- return chatRoom
- } else {
- // 被删除的群聊,在 ChatRoom 记录中没有了,但是能找到 Contact,做下 Mock
- return &model.ChatRoom{
- Name: contact.UserName,
- Remark: contact.Remark,
- NickName: contact.NickName,
- Users: make([]model.ChatRoomUser, 0),
- User2DisplayName: make(map[string]string),
- }
- }
- }
-
- return nil
-}
-
-func (c *Contact) GetSession(limit int) []*model.Session {
- if limit <= 0 {
- limit = len(c.Sessions)
- }
-
- if len(c.Sessions) < limit {
- limit = len(c.Sessions)
- }
- return c.Sessions[:limit]
-}
-
-func (c *Contact) getFullContact(userName string) *model.Contact {
- if contact := c.GetContact(userName); contact != nil {
- return contact
- }
- if contact, ok := c.ChatRoomUsers[userName]; ok {
- return contact
- }
- return nil
-}
-
-func (c *Contact) fillChatRoomInfo() {
- for i := range c.ChatRoom {
- if contact := c.GetContact(c.ChatRoom[i].Name); contact != nil {
- c.ChatRoom[i].Remark = contact.Remark
- c.ChatRoom[i].NickName = contact.NickName
- }
- }
-}
-
-func (c *Contact) MessageFillInfo(msg *model.Message) {
- talker := msg.Talker
- if msg.IsChatRoom {
- talker = msg.ChatRoomSender
- if chatRoom := c.GetChatRoom(msg.Talker); chatRoom != nil {
- msg.CharRoomName = chatRoom.DisplayName()
- if displayName, ok := chatRoom.User2DisplayName[talker]; ok {
- msg.DisplayName = displayName
- }
- }
- }
- if msg.DisplayName == "" && msg.IsSender != 1 {
- if contact := c.getFullContact(talker); contact != nil {
- msg.DisplayName = contact.DisplayName()
- }
- }
-}
diff --git a/internal/wechatdb/datasource/darwinv3/datasource.go b/internal/wechatdb/datasource/darwinv3/datasource.go
new file mode 100644
index 0000000..856ce9c
--- /dev/null
+++ b/internal/wechatdb/datasource/darwinv3/datasource.go
@@ -0,0 +1,510 @@
+package darwinv3
+
+import (
+ "context"
+ "crypto/md5"
+ "database/sql"
+ "encoding/hex"
+ "fmt"
+ "log"
+ "strings"
+ "time"
+
+ "github.com/sjzar/chatlog/internal/model"
+ "github.com/sjzar/chatlog/pkg/util"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+const (
+ MessageFilePattern = "^msg_([0-9]?[0-9])?\\.db$"
+ ContactFilePattern = "^wccontact_new2\\.db$"
+ ChatRoomFilePattern = "^group_new\\.db$"
+ SessionFilePattern = "^session_new\\.db$"
+)
+
+type DataSource struct {
+ path string
+ messageDbs []*sql.DB
+ contactDb *sql.DB
+ chatRoomDb *sql.DB
+ sessionDb *sql.DB
+
+ talkerDBMap map[string]*sql.DB
+ user2DisplayName map[string]string
+}
+
+func New(path string) (*DataSource, error) {
+ ds := &DataSource{
+ path: path,
+ messageDbs: make([]*sql.DB, 0),
+ talkerDBMap: make(map[string]*sql.DB),
+ user2DisplayName: make(map[string]string),
+ }
+
+ if err := ds.initMessageDbs(path); err != nil {
+ return nil, fmt.Errorf("初始化消息数据库失败: %w", err)
+ }
+ if err := ds.initContactDb(path); err != nil {
+ return nil, fmt.Errorf("初始化联系人数据库失败: %w", err)
+ }
+ if err := ds.initChatRoomDb(path); err != nil {
+ return nil, fmt.Errorf("初始化群聊数据库失败: %w", err)
+ }
+ if err := ds.initSessionDb(path); err != nil {
+ return nil, fmt.Errorf("初始化会话数据库失败: %w", err)
+ }
+
+ return ds, nil
+}
+
+func (ds *DataSource) initMessageDbs(path string) error {
+
+ files, err := util.FindFilesWithPatterns(path, MessageFilePattern, true)
+ if err != nil {
+ return fmt.Errorf("查找消息数据库文件失败: %w", err)
+ }
+
+ if len(files) == 0 {
+ return fmt.Errorf("未找到任何消息数据库文件: %s", path)
+ }
+
+ // 处理每个数据库文件
+ for _, filePath := range files {
+ // 连接数据库
+ db, err := sql.Open("sqlite3", filePath)
+ if err != nil {
+ log.Printf("警告: 连接数据库 %s 失败: %v", filePath, err)
+ continue
+ }
+ ds.messageDbs = append(ds.messageDbs, db)
+
+ // 获取所有表名
+ rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'")
+ if err != nil {
+ log.Printf("警告: 获取表名失败: %v", err)
+ continue
+ }
+
+ for rows.Next() {
+ var tableName string
+ if err := rows.Scan(&tableName); err != nil {
+ log.Printf("警告: 扫描表名失败: %v", err)
+ continue
+ }
+
+ // 从表名中提取可能的talker信息
+ talkerMd5 := extractTalkerFromTableName(tableName)
+ if talkerMd5 == "" {
+ continue
+ }
+ ds.talkerDBMap[talkerMd5] = db
+ }
+ rows.Close()
+
+ }
+ return nil
+}
+
+func (ds *DataSource) initContactDb(path string) error {
+
+ files, err := util.FindFilesWithPatterns(path, ContactFilePattern, true)
+ if err != nil {
+ return fmt.Errorf("查找联系人数据库文件失败: %w", err)
+ }
+
+ if len(files) == 0 {
+ return fmt.Errorf("未找到联系人数据库文件: %s", path)
+ }
+
+ ds.contactDb, err = sql.Open("sqlite3", files[0])
+ if err != nil {
+ return fmt.Errorf("连接联系人数据库失败: %w", err)
+ }
+
+ return nil
+}
+
+func (ds *DataSource) initChatRoomDb(path string) error {
+ files, err := util.FindFilesWithPatterns(path, ChatRoomFilePattern, true)
+ if err != nil {
+ return fmt.Errorf("查找群聊数据库文件失败: %w", err)
+ }
+ if len(files) == 0 {
+ return fmt.Errorf("未找到群聊数据库文件: %s", path)
+ }
+ ds.chatRoomDb, err = sql.Open("sqlite3", files[0])
+ if err != nil {
+ return fmt.Errorf("连接群聊数据库失败: %w", err)
+ }
+
+ rows, err := ds.chatRoomDb.Query("SELECT m_nsUsrName, nickname FROM GroupMember")
+ if err != nil {
+ log.Printf("警告: 获取群聊成员失败: %v", err)
+ return nil
+ }
+
+ for rows.Next() {
+ var user string
+ var nickName string
+ if err := rows.Scan(&user, &nickName); err != nil {
+ log.Printf("警告: 扫描表名失败: %v", err)
+ continue
+ }
+ ds.user2DisplayName[user] = nickName
+ }
+ rows.Close()
+
+ return nil
+}
+
+func (ds *DataSource) initSessionDb(path string) error {
+ files, err := util.FindFilesWithPatterns(path, SessionFilePattern, true)
+ if err != nil {
+ return fmt.Errorf("查找最近会话数据库文件失败: %w", err)
+ }
+ if len(files) == 0 {
+ return fmt.Errorf("未找到最近会话数据库文件: %s", path)
+ }
+ ds.sessionDb, err = sql.Open("sqlite3", files[0])
+ if err != nil {
+ return fmt.Errorf("连接最近会话数据库失败: %w", err)
+ }
+ return nil
+}
+
+// GetMessages 实现获取消息的方法
+func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
+ // 在 darwinv3 中,每个联系人/群聊的消息存储在单独的表中,表名为 Chat_md5(talker)
+ // 首先需要找到对应的表名
+ if talker == "" {
+ return nil, fmt.Errorf("talker 不能为空")
+ }
+
+ _talkerMd5Bytes := md5.Sum([]byte(talker))
+ talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
+ db, ok := ds.talkerDBMap[talkerMd5]
+ if !ok {
+ return nil, fmt.Errorf("未找到 talker %s 的消息数据库", talker)
+ }
+ tableName := fmt.Sprintf("Chat_%s", talkerMd5)
+
+ // 构建查询条件
+ query := fmt.Sprintf(`
+ SELECT msgCreateTime, msgContent, messageType, mesDes, msgSource, CompressContent, ConBlob
+ FROM %s
+ WHERE msgCreateTime >= ? AND msgCreateTime <= ?
+ ORDER BY msgCreateTime ASC
+ `, tableName)
+
+ if limit > 0 {
+ query += fmt.Sprintf(" LIMIT %d", limit)
+
+ if offset > 0 {
+ query += fmt.Sprintf(" OFFSET %d", offset)
+ }
+ }
+
+ // 执行查询
+ rows, err := db.QueryContext(ctx, query, startTime.Unix(), endTime.Unix())
+ if err != nil {
+ return nil, fmt.Errorf("查询表 %s 失败: %w", tableName, err)
+ }
+ defer rows.Close()
+
+ // 处理查询结果
+ messages := []*model.Message{}
+ for rows.Next() {
+ var msg model.MessageDarwinV3
+ var compressContent, conBlob []byte
+ err := rows.Scan(
+ &msg.MesCreateTime,
+ &msg.MesContent,
+ &msg.MesType,
+ &msg.MesDes,
+ &msg.MesSource,
+ &compressContent,
+ &conBlob,
+ )
+ if err != nil {
+ log.Printf("警告: 扫描消息行失败: %v", err)
+ continue
+ }
+
+ // 将消息包装为通用模型
+ message := msg.Wrap(talker)
+ messages = append(messages, message)
+ }
+
+ return messages, nil
+}
+
+// 从表名中提取 talker
+func extractTalkerFromTableName(tableName string) string {
+
+ if !strings.HasPrefix(tableName, "Chat_") {
+ return ""
+ }
+
+ if strings.HasSuffix(tableName, "_dels") {
+ return ""
+ }
+
+ return strings.TrimPrefix(tableName, "Chat_")
+}
+
+// GetContacts 实现获取联系人信息的方法
+func (ds *DataSource) GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error) {
+ var query string
+ var args []interface{}
+
+ if key != "" {
+ // 按照关键字查询
+ query = `SELECT m_nsUsrName, nickname, IFNULL(m_nsRemark,""), m_uiSex, IFNULL(m_nsAliasName,"")
+ FROM WCContact
+ WHERE m_nsUsrName = ? OR nickname = ? OR m_nsRemark = ? OR m_nsAliasName = ?`
+ args = []interface{}{key, key, key, key}
+ } else {
+ // 查询所有联系人
+ query = `SELECT m_nsUsrName, nickname, IFNULL(m_nsRemark,""), m_uiSex, IFNULL(m_nsAliasName,"")
+ FROM WCContact`
+ }
+
+ // 添加排序、分页
+ query += ` ORDER BY m_nsUsrName`
+ if limit > 0 {
+ query += fmt.Sprintf(" LIMIT %d", limit)
+ if offset > 0 {
+ query += fmt.Sprintf(" OFFSET %d", offset)
+ }
+ }
+
+ // 执行查询
+ rows, err := ds.contactDb.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询联系人失败: %w", err)
+ }
+ defer rows.Close()
+
+ contacts := []*model.Contact{}
+ for rows.Next() {
+ var contactDarwinV3 model.ContactDarwinV3
+ err := rows.Scan(
+ &contactDarwinV3.M_nsUsrName,
+ &contactDarwinV3.Nickname,
+ &contactDarwinV3.M_nsRemark,
+ &contactDarwinV3.M_uiSex,
+ &contactDarwinV3.M_nsAliasName,
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("扫描联系人行失败: %w", err)
+ }
+
+ contacts = append(contacts, contactDarwinV3.Wrap())
+ }
+
+ return contacts, nil
+}
+
+// GetChatRooms 实现获取群聊信息的方法
+func (ds *DataSource) GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error) {
+ var query string
+ var args []interface{}
+
+ if key != "" {
+ // 按照关键字查询
+ query = `SELECT m_nsUsrName, nickname, IFNULL(m_nsRemark,""), IFNULL(m_nsChatRoomMemList,""), IFNULL(m_nsChatRoomAdminList,"")
+ FROM GroupContact
+ WHERE m_nsUsrName = ? OR nickname = ? OR m_nsRemark = ?`
+ args = []interface{}{key, key, key}
+ } else {
+ // 查询所有群聊
+ query = `SELECT m_nsUsrName, nickname, IFNULL(m_nsRemark,""), IFNULL(m_nsChatRoomMemList,""), IFNULL(m_nsChatRoomAdminList,"")
+ FROM GroupContact`
+ }
+
+ // 添加排序、分页
+ query += ` ORDER BY m_nsUsrName`
+ if limit > 0 {
+ query += fmt.Sprintf(" LIMIT %d", limit)
+ if offset > 0 {
+ query += fmt.Sprintf(" OFFSET %d", offset)
+ }
+ }
+
+ // 执行查询
+ rows, err := ds.chatRoomDb.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询群聊失败: %w", err)
+ }
+ defer rows.Close()
+
+ chatRooms := []*model.ChatRoom{}
+ for rows.Next() {
+ var chatRoomDarwinV3 model.ChatRoomDarwinV3
+ err := rows.Scan(
+ &chatRoomDarwinV3.M_nsUsrName,
+ &chatRoomDarwinV3.Nickname,
+ &chatRoomDarwinV3.M_nsRemark,
+ &chatRoomDarwinV3.M_nsChatRoomMemList,
+ &chatRoomDarwinV3.M_nsChatRoomAdminList,
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("扫描群聊行失败: %w", err)
+ }
+
+ chatRooms = append(chatRooms, chatRoomDarwinV3.Wrap(ds.user2DisplayName))
+ }
+
+ // 如果没有找到群聊,尝试通过联系人查找
+ if len(chatRooms) == 0 && key != "" {
+ contacts, err := ds.GetContacts(ctx, key, 1, 0)
+ if err == nil && len(contacts) > 0 && strings.HasSuffix(contacts[0].UserName, "@chatroom") {
+ // 再次尝试通过用户名查找群聊
+ rows, err := ds.chatRoomDb.QueryContext(ctx,
+ `SELECT m_nsUsrName, nickname, m_nsRemark, m_nsChatRoomMemList, m_nsChatRoomAdminList
+ FROM GroupContact
+ WHERE m_nsUsrName = ?`,
+ contacts[0].UserName)
+
+ if err != nil {
+ return nil, fmt.Errorf("查询群聊失败: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var chatRoomDarwinV3 model.ChatRoomDarwinV3
+ err := rows.Scan(
+ &chatRoomDarwinV3.M_nsUsrName,
+ &chatRoomDarwinV3.Nickname,
+ &chatRoomDarwinV3.M_nsRemark,
+ &chatRoomDarwinV3.M_nsChatRoomMemList,
+ &chatRoomDarwinV3.M_nsChatRoomAdminList,
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("扫描群聊行失败: %w", err)
+ }
+
+ chatRooms = append(chatRooms, chatRoomDarwinV3.Wrap(ds.user2DisplayName))
+ }
+
+ // 如果群聊记录不存在,但联系人记录存在,创建一个模拟的群聊对象
+ if len(chatRooms) == 0 {
+ chatRooms = append(chatRooms, &model.ChatRoom{
+ Name: contacts[0].UserName,
+ Users: make([]model.ChatRoomUser, 0),
+ })
+ }
+ }
+ }
+
+ return chatRooms, nil
+}
+
+// GetSessions 实现获取会话信息的方法
+func (ds *DataSource) GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error) {
+ var query string
+ var args []interface{}
+
+ if key != "" {
+ // 按照关键字查询
+ query = `SELECT m_nsUserName, m_uLastTime
+ FROM SessionAbstract
+ WHERE m_nsUserName = ?`
+ args = []interface{}{key}
+ } else {
+ // 查询所有会话
+ query = `SELECT m_nsUserName, m_uLastTime
+ FROM SessionAbstract`
+ }
+
+ // 添加排序、分页
+ query += ` ORDER BY m_uLastTime DESC`
+ if limit > 0 {
+ query += fmt.Sprintf(" LIMIT %d", limit)
+ if offset > 0 {
+ query += fmt.Sprintf(" OFFSET %d", offset)
+ }
+ }
+
+ // 执行查询
+ rows, err := ds.sessionDb.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询会话失败: %w", err)
+ }
+ defer rows.Close()
+
+ sessions := []*model.Session{}
+ for rows.Next() {
+ var sessionDarwinV3 model.SessionDarwinV3
+ err := rows.Scan(
+ &sessionDarwinV3.M_nsUserName,
+ &sessionDarwinV3.M_uLastTime,
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("扫描会话行失败: %w", err)
+ }
+
+ // 包装成通用模型
+ session := sessionDarwinV3.Wrap()
+
+ // 尝试获取联系人信息以补充会话信息
+ contacts, err := ds.GetContacts(ctx, session.UserName, 1, 0)
+ if err == nil && len(contacts) > 0 {
+ session.NickName = contacts[0].DisplayName()
+ } else {
+ // 尝试获取群聊信息
+ chatRooms, err := ds.GetChatRooms(ctx, session.UserName, 1, 0)
+ if err == nil && len(chatRooms) > 0 {
+ session.NickName = chatRooms[0].DisplayName()
+ }
+ }
+
+ sessions = append(sessions, session)
+ }
+
+ return sessions, nil
+}
+
+// Close 实现关闭数据库连接的方法
+func (ds *DataSource) Close() error {
+ var errs []error
+
+ // 关闭消息数据库连接
+ for i, db := range ds.messageDbs {
+ if err := db.Close(); err != nil {
+ errs = append(errs, fmt.Errorf("关闭消息数据库 %d 失败: %w", i, err))
+ }
+ }
+
+ // 关闭联系人数据库连接
+ if ds.contactDb != nil {
+ if err := ds.contactDb.Close(); err != nil {
+ errs = append(errs, fmt.Errorf("关闭联系人数据库失败: %w", err))
+ }
+ }
+
+ // 关闭群聊数据库连接
+ if ds.chatRoomDb != nil {
+ if err := ds.chatRoomDb.Close(); err != nil {
+ errs = append(errs, fmt.Errorf("关闭群聊数据库失败: %w", err))
+ }
+ }
+
+ // 关闭会话数据库连接
+ if ds.sessionDb != nil {
+ if err := ds.sessionDb.Close(); err != nil {
+ errs = append(errs, fmt.Errorf("关闭会话数据库失败: %w", err))
+ }
+ }
+
+ if len(errs) > 0 {
+ return fmt.Errorf("关闭数据库连接时发生错误: %v", errs)
+ }
+
+ return nil
+}
diff --git a/internal/wechatdb/datasource/datasource.go b/internal/wechatdb/datasource/datasource.go
new file mode 100644
index 0000000..be7f84b
--- /dev/null
+++ b/internal/wechatdb/datasource/datasource.go
@@ -0,0 +1,49 @@
+package datasource
+
+import (
+ "context"
+ "fmt"
+ "time"
+
+ "github.com/sjzar/chatlog/internal/model"
+ "github.com/sjzar/chatlog/internal/wechatdb/datasource/darwinv3"
+ v4 "github.com/sjzar/chatlog/internal/wechatdb/datasource/v4"
+ "github.com/sjzar/chatlog/internal/wechatdb/datasource/windowsv3"
+)
+
+// 错误定义
+var (
+ ErrUnsupportedPlatform = fmt.Errorf("unsupported platform")
+)
+
+type DataSource interface {
+
+ // 消息
+ GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error)
+
+ // 联系人
+ GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error)
+
+ // 群聊
+ GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error)
+
+ // 最近会话
+ GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error)
+
+ Close() error
+}
+
+func NewDataSource(path string, platform string, version int) (DataSource, error) {
+ switch {
+ case platform == "windows" && version == 3:
+ return windowsv3.New(path)
+ case platform == "windows" && version == 4:
+ return v4.New(path)
+ case platform == "darwin" && version == 3:
+ return darwinv3.New(path)
+ case platform == "darwin" && version == 4:
+ return v4.New(path)
+ default:
+ return nil, fmt.Errorf("%w: %s v%d", ErrUnsupportedPlatform, platform, version)
+ }
+}
diff --git a/internal/wechatdb/datasource/v4/datasource.go b/internal/wechatdb/datasource/v4/datasource.go
new file mode 100644
index 0000000..11fa4a6
--- /dev/null
+++ b/internal/wechatdb/datasource/v4/datasource.go
@@ -0,0 +1,634 @@
+package v4
+
+import (
+ "context"
+ "crypto/md5"
+ "database/sql"
+ "encoding/hex"
+ "fmt"
+ "log"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/sjzar/chatlog/internal/model"
+ "github.com/sjzar/chatlog/pkg/util"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+const (
+ MessageFilePattern = "^message_([0-9]?[0-9])?\\.db$"
+ ContactFilePattern = "^contact\\.db$"
+ SessionFilePattern = "^session\\.db$"
+)
+
+// MessageDBInfo 存储消息数据库的信息
+type MessageDBInfo struct {
+ FilePath string
+ StartTime time.Time
+ EndTime time.Time
+ ID2Name map[int]string
+}
+
+type DataSource struct {
+ path string
+ messageDbs map[string]*sql.DB
+ contactDb *sql.DB
+ sessionDb *sql.DB
+
+ // 消息数据库信息
+ messageFiles []MessageDBInfo
+}
+
+func New(path string) (*DataSource, error) {
+ ds := &DataSource{
+ path: path,
+ messageDbs: make(map[string]*sql.DB),
+ messageFiles: make([]MessageDBInfo, 0),
+ }
+
+ if err := ds.initMessageDbs(path); err != nil {
+ return nil, fmt.Errorf("初始化消息数据库失败: %w", err)
+ }
+ if err := ds.initContactDb(path); err != nil {
+ return nil, fmt.Errorf("初始化联系人数据库失败: %w", err)
+ }
+ if err := ds.initSessionDb(path); err != nil {
+ return nil, fmt.Errorf("初始化会话数据库失败: %w", err)
+ }
+
+ return ds, nil
+}
+
+func (ds *DataSource) initMessageDbs(path string) error {
+ // 查找所有消息数据库文件
+ files, err := util.FindFilesWithPatterns(path, MessageFilePattern, true)
+ if err != nil {
+ return fmt.Errorf("查找消息数据库文件失败: %w", err)
+ }
+
+ if len(files) == 0 {
+ return fmt.Errorf("未找到任何消息数据库文件: %s", path)
+ }
+
+ // 处理每个数据库文件
+ for _, filePath := range files {
+ // 连接数据库
+ db, err := sql.Open("sqlite3", filePath)
+ if err != nil {
+ log.Printf("警告: 连接数据库 %s 失败: %v", filePath, err)
+ continue
+ }
+
+ // 获取 Timestamp 表中的开始时间
+ var startTime time.Time
+ var timestamp int64
+
+ row := db.QueryRow("SELECT timestamp FROM Timestamp LIMIT 1")
+ if err := row.Scan(×tamp); err != nil {
+ log.Printf("警告: 获取数据库 %s 的时间戳失败: %v", filePath, err)
+ db.Close()
+ continue
+ }
+ startTime = time.Unix(timestamp, 0)
+
+ // 获取 ID2Name 映射
+ id2Name := make(map[int]string)
+ rows, err := db.Query("SELECT user_name FROM Name2Id")
+ if err != nil {
+ log.Printf("警告: 获取数据库 %s 的 Name2Id 表失败: %v", filePath, err)
+ db.Close()
+ continue
+ }
+
+ i := 1
+ for rows.Next() {
+ var name string
+ if err := rows.Scan(&name); err != nil {
+ log.Printf("警告: 扫描 Name2Id 行失败: %v", err)
+ continue
+ }
+ id2Name[i] = name
+ i++
+ }
+ rows.Close()
+
+ // 保存数据库信息
+ ds.messageFiles = append(ds.messageFiles, MessageDBInfo{
+ FilePath: filePath,
+ StartTime: startTime,
+ ID2Name: id2Name,
+ })
+
+ // 保存数据库连接
+ ds.messageDbs[filePath] = db
+ }
+
+ // 按照 StartTime 排序数据库文件
+ sort.Slice(ds.messageFiles, func(i, j int) bool {
+ return ds.messageFiles[i].StartTime.Before(ds.messageFiles[j].StartTime)
+ })
+
+ // 设置结束时间
+ for i := range ds.messageFiles {
+ if i == len(ds.messageFiles)-1 {
+ ds.messageFiles[i].EndTime = time.Now()
+ } else {
+ ds.messageFiles[i].EndTime = ds.messageFiles[i+1].StartTime
+ }
+ }
+
+ return nil
+}
+
+func (ds *DataSource) initContactDb(path string) error {
+ files, err := util.FindFilesWithPatterns(path, ContactFilePattern, true)
+ if err != nil {
+ return fmt.Errorf("查找联系人数据库文件失败: %w", err)
+ }
+
+ if len(files) == 0 {
+ return fmt.Errorf("未找到联系人数据库文件: %s", path)
+ }
+
+ ds.contactDb, err = sql.Open("sqlite3", files[0])
+ if err != nil {
+ return fmt.Errorf("连接联系人数据库失败: %w", err)
+ }
+
+ return nil
+}
+
+func (ds *DataSource) initSessionDb(path string) error {
+ files, err := util.FindFilesWithPatterns(path, SessionFilePattern, true)
+ if err != nil {
+ return fmt.Errorf("查找最近会话数据库文件失败: %w", err)
+ }
+ if len(files) == 0 {
+ return fmt.Errorf("未找到最近会话数据库文件: %s", path)
+ }
+ ds.sessionDb, err = sql.Open("sqlite3", files[0])
+ if err != nil {
+ return fmt.Errorf("连接最近会话数据库失败: %w", err)
+ }
+ return nil
+}
+
+// getDBInfosForTimeRange 获取时间范围内的数据库信息
+func (ds *DataSource) getDBInfosForTimeRange(startTime, endTime time.Time) []MessageDBInfo {
+ var dbs []MessageDBInfo
+ for _, info := range ds.messageFiles {
+ if info.StartTime.Before(endTime) && info.EndTime.After(startTime) {
+ dbs = append(dbs, info)
+ }
+ }
+ return dbs
+}
+
+func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
+ if talker == "" {
+ return nil, fmt.Errorf("必须指定 talker 参数")
+ }
+
+ // 找到时间范围内的数据库文件
+ dbInfos := ds.getDBInfosForTimeRange(startTime, endTime)
+ if len(dbInfos) == 0 {
+ return nil, fmt.Errorf("未找到时间范围 %v 到 %v 内的数据库文件", startTime, endTime)
+ }
+
+ if len(dbInfos) == 1 {
+ // LIMIT 和 OFFSET 逻辑在单文件情况下可以直接在 SQL 里处理
+ return ds.getMessagesSingleFile(ctx, dbInfos[0], startTime, endTime, talker, limit, offset)
+ }
+
+ // 从每个相关数据库中查询消息
+ totalMessages := []*model.Message{}
+
+ for _, dbInfo := range dbInfos {
+ // 检查上下文是否已取消
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ db, ok := ds.messageDbs[dbInfo.FilePath]
+ if !ok {
+ log.Printf("警告: 数据库 %s 未打开", dbInfo.FilePath)
+ continue
+ }
+
+ messages, err := ds.getMessagesFromDB(ctx, db, dbInfo, startTime, endTime, talker)
+ if err != nil {
+ log.Printf("警告: 从数据库 %s 获取消息失败: %v", dbInfo.FilePath, err)
+ continue
+ }
+
+ totalMessages = append(totalMessages, messages...)
+
+ if limit+offset > 0 && len(totalMessages) >= limit+offset {
+ break
+ }
+ }
+
+ // 对所有消息按时间排序
+ sort.Slice(totalMessages, func(i, j int) bool {
+ return totalMessages[i].Sequence < totalMessages[j].Sequence
+ })
+
+ // 处理分页
+ if limit > 0 {
+ if offset >= len(totalMessages) {
+ return []*model.Message{}, nil
+ }
+ end := offset + limit
+ if end > len(totalMessages) {
+ end = len(totalMessages)
+ }
+ return totalMessages[offset:end], nil
+ }
+
+ return totalMessages, nil
+}
+
+// getMessagesSingleFile 从单个数据库文件获取消息
+func (ds *DataSource) getMessagesSingleFile(ctx context.Context, dbInfo MessageDBInfo, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
+ db, ok := ds.messageDbs[dbInfo.FilePath]
+ if !ok {
+ return nil, fmt.Errorf("数据库 %s 未打开", dbInfo.FilePath)
+ }
+
+ // 构建表名
+ _talkerMd5Bytes := md5.Sum([]byte(talker))
+ talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
+ tableName := "Msg_" + talkerMd5
+
+ // 构建查询条件
+ conditions := []string{"create_time >= ? AND create_time <= ?"}
+ args := []interface{}{startTime.Unix(), endTime.Unix()}
+
+ query := fmt.Sprintf(`
+ SELECT sort_seq, local_type, real_sender_id, create_time, message_content, packed_info_data, status
+ FROM %s
+ WHERE %s
+ ORDER BY sort_seq ASC
+ `, tableName, strings.Join(conditions, " AND "))
+
+ if limit > 0 {
+ query += fmt.Sprintf(" LIMIT %d", limit)
+ if offset > 0 {
+ query += fmt.Sprintf(" OFFSET %d", offset)
+ }
+ }
+
+ // 执行查询
+ rows, err := db.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询数据库 %s 失败: %w", dbInfo.FilePath, err)
+ }
+ defer rows.Close()
+
+ // 处理查询结果
+ messages := []*model.Message{}
+ isChatRoom := strings.HasSuffix(talker, "@chatroom")
+
+ for rows.Next() {
+ var msg model.MessageV4
+ err := rows.Scan(
+ &msg.SortSeq,
+ &msg.LocalType,
+ &msg.RealSenderID,
+ &msg.CreateTime,
+ &msg.MessageContent,
+ &msg.PackedInfoData,
+ &msg.Status,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("扫描消息行失败: %w", err)
+ }
+
+ messages = append(messages, msg.Wrap(dbInfo.ID2Name, isChatRoom))
+ }
+
+ return messages, nil
+}
+
+// getMessagesFromDB 从数据库获取消息
+func (ds *DataSource) getMessagesFromDB(ctx context.Context, db *sql.DB, dbInfo MessageDBInfo, startTime, endTime time.Time, talker string) ([]*model.Message, error) {
+ // 构建表名
+ _talkerMd5Bytes := md5.Sum([]byte(talker))
+ talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
+ tableName := "Msg_" + talkerMd5
+
+ // 检查表是否存在
+ var exists bool
+ err := db.QueryRowContext(ctx,
+ "SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
+ tableName).Scan(&exists)
+
+ if err != nil {
+ if err == sql.ErrNoRows {
+ // 表不存在,返回空结果
+ return []*model.Message{}, nil
+ }
+ return nil, fmt.Errorf("检查表 %s 是否存在失败: %w", tableName, err)
+ }
+
+ // 构建查询条件
+ conditions := []string{"create_time >= ? AND create_time <= ?"}
+ args := []interface{}{startTime.Unix(), endTime.Unix()}
+
+ query := fmt.Sprintf(`
+ SELECT sort_seq, local_type, real_sender_id, create_time, message_content, packed_info_data, status
+ FROM %s
+ WHERE %s
+ ORDER BY sort_seq ASC
+ `, tableName, strings.Join(conditions, " AND "))
+
+ // 执行查询
+ rows, err := db.QueryContext(ctx, query, args...)
+ if err != nil {
+ // 如果表不存在,SQLite 会返回错误
+ if strings.Contains(err.Error(), "no such table") {
+ return []*model.Message{}, nil
+ }
+ return nil, fmt.Errorf("查询数据库失败: %w", err)
+ }
+ defer rows.Close()
+
+ // 处理查询结果
+ messages := []*model.Message{}
+ isChatRoom := strings.HasSuffix(talker, "@chatroom")
+
+ for rows.Next() {
+ var msg model.MessageV4
+ err := rows.Scan(
+ &msg.SortSeq,
+ &msg.LocalType,
+ &msg.RealSenderID,
+ &msg.CreateTime,
+ &msg.MessageContent,
+ &msg.PackedInfoData,
+ &msg.Status,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("扫描消息行失败: %w", err)
+ }
+
+ messages = append(messages, msg.Wrap(dbInfo.ID2Name, isChatRoom))
+ }
+
+ return messages, nil
+}
+
+// 联系人
+func (ds *DataSource) GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error) {
+ var query string
+ var args []interface{}
+
+ if key != "" {
+ // 按照关键字查询
+ query = `SELECT username, local_type, alias, remark, nick_name
+ FROM contact
+ WHERE username = ? OR alias = ? OR remark = ? OR nick_name = ?`
+ args = []interface{}{key, key, key, key}
+ } else {
+ // 查询所有联系人
+ query = `SELECT username, local_type, alias, remark, nick_name FROM contact`
+ }
+
+ // 添加排序、分页
+ query += ` ORDER BY username`
+ if limit > 0 {
+ query += fmt.Sprintf(" LIMIT %d", limit)
+ if offset > 0 {
+ query += fmt.Sprintf(" OFFSET %d", offset)
+ }
+ }
+
+ // 执行查询
+ rows, err := ds.contactDb.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询联系人失败: %w", err)
+ }
+ defer rows.Close()
+
+ contacts := []*model.Contact{}
+ for rows.Next() {
+ var contactV4 model.ContactV4
+ err := rows.Scan(
+ &contactV4.UserName,
+ &contactV4.LocalType,
+ &contactV4.Alias,
+ &contactV4.Remark,
+ &contactV4.NickName,
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("扫描联系人行失败: %w", err)
+ }
+
+ contacts = append(contacts, contactV4.Wrap())
+ }
+
+ return contacts, nil
+}
+
+// 群聊
+func (ds *DataSource) GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error) {
+ var query string
+ var args []interface{}
+
+ if key != "" {
+ // 按照关键字查询
+ query = `SELECT username, owner, ext_buffer FROM chat_room WHERE username = ?`
+ args = []interface{}{key}
+
+ // 执行查询
+ rows, err := ds.contactDb.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询群聊失败: %w", err)
+ }
+ defer rows.Close()
+
+ chatRooms := []*model.ChatRoom{}
+ for rows.Next() {
+ var chatRoomV4 model.ChatRoomV4
+ err := rows.Scan(
+ &chatRoomV4.UserName,
+ &chatRoomV4.Owner,
+ &chatRoomV4.ExtBuffer,
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("扫描群聊行失败: %w", err)
+ }
+
+ chatRooms = append(chatRooms, chatRoomV4.Wrap())
+ }
+
+ // 如果没有找到群聊,尝试通过联系人查找
+ if len(chatRooms) == 0 {
+ contacts, err := ds.GetContacts(ctx, key, 1, 0)
+ if err == nil && len(contacts) > 0 && strings.HasSuffix(contacts[0].UserName, "@chatroom") {
+ // 再次尝试通过用户名查找群聊
+ rows, err := ds.contactDb.QueryContext(ctx,
+ `SELECT username, owner, ext_buffer FROM chat_room WHERE username = ?`,
+ contacts[0].UserName)
+
+ if err != nil {
+ return nil, fmt.Errorf("查询群聊失败: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var chatRoomV4 model.ChatRoomV4
+ err := rows.Scan(
+ &chatRoomV4.UserName,
+ &chatRoomV4.Owner,
+ &chatRoomV4.ExtBuffer,
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("扫描群聊行失败: %w", err)
+ }
+
+ chatRooms = append(chatRooms, chatRoomV4.Wrap())
+ }
+
+ // 如果群聊记录不存在,但联系人记录存在,创建一个模拟的群聊对象
+ if len(chatRooms) == 0 {
+ chatRooms = append(chatRooms, &model.ChatRoom{
+ Name: contacts[0].UserName,
+ Users: make([]model.ChatRoomUser, 0),
+ User2DisplayName: make(map[string]string),
+ })
+ }
+ }
+ }
+
+ return chatRooms, nil
+ } else {
+ // 查询所有群聊
+ query = `SELECT username, owner, ext_buffer FROM chat_room`
+
+ // 添加排序、分页
+ query += ` ORDER BY username`
+ if limit > 0 {
+ query += fmt.Sprintf(" LIMIT %d", limit)
+ if offset > 0 {
+ query += fmt.Sprintf(" OFFSET %d", offset)
+ }
+ }
+
+ // 执行查询
+ rows, err := ds.contactDb.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询群聊失败: %w", err)
+ }
+ defer rows.Close()
+
+ chatRooms := []*model.ChatRoom{}
+ for rows.Next() {
+ var chatRoomV4 model.ChatRoomV4
+ err := rows.Scan(
+ &chatRoomV4.UserName,
+ &chatRoomV4.Owner,
+ &chatRoomV4.ExtBuffer,
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("扫描群聊行失败: %w", err)
+ }
+
+ chatRooms = append(chatRooms, chatRoomV4.Wrap())
+ }
+
+ return chatRooms, nil
+ }
+}
+
+// 最近会话
+func (ds *DataSource) GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error) {
+ var query string
+ var args []interface{}
+
+ if key != "" {
+ // 按照关键字查询
+ query = `SELECT username, summary, last_timestamp, last_msg_sender, last_sender_display_name
+ FROM SessionTable
+ WHERE username = ? OR last_sender_display_name = ?
+ ORDER BY sort_timestamp DESC`
+ args = []interface{}{key, key}
+ } else {
+ // 查询所有会话
+ query = `SELECT username, summary, last_timestamp, last_msg_sender, last_sender_display_name
+ FROM SessionTable
+ ORDER BY sort_timestamp DESC`
+ }
+
+ // 添加分页
+ if limit > 0 {
+ query += fmt.Sprintf(" LIMIT %d", limit)
+ if offset > 0 {
+ query += fmt.Sprintf(" OFFSET %d", offset)
+ }
+ }
+
+ // 执行查询
+ rows, err := ds.sessionDb.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询会话失败: %w", err)
+ }
+ defer rows.Close()
+
+ sessions := []*model.Session{}
+ for rows.Next() {
+ var sessionV4 model.SessionV4
+ err := rows.Scan(
+ &sessionV4.Username,
+ &sessionV4.Summary,
+ &sessionV4.LastTimestamp,
+ &sessionV4.LastMsgSender,
+ &sessionV4.LastSenderDisplayName,
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("扫描会话行失败: %w", err)
+ }
+
+ sessions = append(sessions, sessionV4.Wrap())
+ }
+
+ return sessions, nil
+}
+
+func (ds *DataSource) Close() error {
+ var errs []error
+
+ // 关闭消息数据库连接
+ for path, db := range ds.messageDbs {
+ if err := db.Close(); err != nil {
+ errs = append(errs, fmt.Errorf("关闭消息数据库 %s 失败: %w", path, err))
+ }
+ }
+
+ // 关闭联系人数据库连接
+ if ds.contactDb != nil {
+ if err := ds.contactDb.Close(); err != nil {
+ errs = append(errs, fmt.Errorf("关闭联系人数据库失败: %w", err))
+ }
+ }
+
+ // 关闭会话数据库连接
+ if ds.sessionDb != nil {
+ if err := ds.sessionDb.Close(); err != nil {
+ errs = append(errs, fmt.Errorf("关闭会话数据库失败: %w", err))
+ }
+ }
+
+ if len(errs) > 0 {
+ return fmt.Errorf("关闭数据库连接时发生错误: %v", errs)
+ }
+
+ return nil
+}
diff --git a/internal/wechatdb/datasource/windowsv3/datasource.go b/internal/wechatdb/datasource/windowsv3/datasource.go
new file mode 100644
index 0000000..eafe119
--- /dev/null
+++ b/internal/wechatdb/datasource/windowsv3/datasource.go
@@ -0,0 +1,615 @@
+package windowsv3
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "log"
+ "sort"
+ "strings"
+ "time"
+
+ "github.com/sjzar/chatlog/internal/model"
+ "github.com/sjzar/chatlog/pkg/util"
+
+ _ "github.com/mattn/go-sqlite3"
+)
+
+const (
+ MessageFilePattern = "^MSG([0-9]?[0-9])?\\.db$"
+ ContactFilePattern = "^MicroMsg.db$"
+)
+
+// MessageDBInfo 保存消息数据库的信息
+type MessageDBInfo struct {
+ FilePath string
+ StartTime time.Time
+ EndTime time.Time
+ TalkerMap map[string]int
+}
+
+// DataSource 实现了 DataSource 接口
+type DataSource struct {
+ // 消息数据库
+ messageFiles []MessageDBInfo
+ messageDbs map[string]*sql.DB
+
+ // 联系人数据库
+ contactDbFile string
+ contactDb *sql.DB
+}
+
+// New 创建一个新的 WindowsV3DataSource
+func New(path string) (*DataSource, error) {
+ ds := &DataSource{
+ messageFiles: make([]MessageDBInfo, 0),
+ messageDbs: make(map[string]*sql.DB),
+ }
+
+ // 初始化消息数据库
+ if err := ds.initMessageDbs(path); err != nil {
+ return nil, fmt.Errorf("初始化消息数据库失败: %w", err)
+ }
+
+ // 初始化联系人数据库
+ if err := ds.initContactDb(path); err != nil {
+ return nil, fmt.Errorf("初始化联系人数据库失败: %w", err)
+ }
+
+ return ds, nil
+}
+
+// initMessageDbs 初始化消息数据库
+func (ds *DataSource) initMessageDbs(path string) error {
+ // 查找所有消息数据库文件
+ files, err := util.FindFilesWithPatterns(path, MessageFilePattern, true)
+ if err != nil {
+ return fmt.Errorf("查找消息数据库文件失败: %w", err)
+ }
+
+ if len(files) == 0 {
+ return fmt.Errorf("未找到任何消息数据库文件: %s", path)
+ }
+
+ // 处理每个数据库文件
+ for _, filePath := range files {
+ // 连接数据库
+ db, err := sql.Open("sqlite3", filePath)
+ if err != nil {
+ log.Printf("警告: 连接数据库 %s 失败: %v", filePath, err)
+ continue
+ }
+
+ // 获取 DBInfo 表中的开始时间
+ var startTime time.Time
+
+ rows, err := db.Query("SELECT tableIndex, tableVersion, tableDesc FROM DBInfo")
+ if err != nil {
+ log.Printf("警告: 查询数据库 %s 的 DBInfo 表失败: %v", filePath, err)
+ db.Close()
+ continue
+ }
+
+ for rows.Next() {
+ var tableIndex int
+ var tableVersion int64
+ var tableDesc string
+
+ if err := rows.Scan(&tableIndex, &tableVersion, &tableDesc); err != nil {
+ log.Printf("警告: 扫描 DBInfo 行失败: %v", err)
+ continue
+ }
+
+ // 查找描述为 "Start Time" 的记录
+ if strings.Contains(tableDesc, "Start Time") {
+ startTime = time.Unix(tableVersion/1000, (tableVersion%1000)*1000000)
+ break
+ }
+ }
+ rows.Close()
+
+ // 组织 TalkerMap
+ talkerMap := make(map[string]int)
+ rows, err = db.Query("SELECT UsrName FROM Name2ID")
+ if err != nil {
+ log.Printf("警告: 查询数据库 %s 的 Name2ID 表失败: %v", filePath, err)
+ db.Close()
+ continue
+ }
+
+ i := 1
+ for rows.Next() {
+ var userName string
+ if err := rows.Scan(&userName); err != nil {
+ log.Printf("警告: 扫描 Name2ID 行失败: %v", err)
+ continue
+ }
+ talkerMap[userName] = i
+ i++
+ }
+ rows.Close()
+
+ // 保存数据库信息
+ ds.messageFiles = append(ds.messageFiles, MessageDBInfo{
+ FilePath: filePath,
+ StartTime: startTime,
+ TalkerMap: talkerMap,
+ })
+
+ // 保存数据库连接
+ ds.messageDbs[filePath] = db
+ }
+
+ // 按照 StartTime 排序数据库文件
+ sort.Slice(ds.messageFiles, func(i, j int) bool {
+ return ds.messageFiles[i].StartTime.Before(ds.messageFiles[j].StartTime)
+ })
+
+ // 设置结束时间
+ for i := range ds.messageFiles {
+ if i == len(ds.messageFiles)-1 {
+ ds.messageFiles[i].EndTime = time.Now()
+ } else {
+ ds.messageFiles[i].EndTime = ds.messageFiles[i+1].StartTime
+ }
+ }
+
+ return nil
+}
+
+// initContactDb 初始化联系人数据库
+func (ds *DataSource) initContactDb(path string) error {
+ files, err := util.FindFilesWithPatterns(path, ContactFilePattern, true)
+ if err != nil {
+ return fmt.Errorf("查找联系人数据库文件失败: %w", err)
+ }
+
+ if len(files) == 0 {
+ return fmt.Errorf("未找到联系人数据库文件: %s", path)
+ }
+
+ ds.contactDbFile = files[0]
+
+ ds.contactDb, err = sql.Open("sqlite3", ds.contactDbFile)
+ if err != nil {
+ return fmt.Errorf("连接联系人数据库失败: %w", err)
+ }
+
+ return nil
+}
+
+// getDBInfosForTimeRange 获取时间范围内的数据库信息
+func (ds *DataSource) getDBInfosForTimeRange(startTime, endTime time.Time) []MessageDBInfo {
+ var dbs []MessageDBInfo
+ for _, info := range ds.messageFiles {
+ if info.StartTime.Before(endTime) && info.EndTime.After(startTime) {
+ dbs = append(dbs, info)
+ }
+ }
+ return dbs
+}
+
+// GetMessages 实现 DataSource 接口的 GetMessages 方法
+func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
+ // 找到时间范围内的数据库文件
+ dbInfos := ds.getDBInfosForTimeRange(startTime, endTime)
+ if len(dbInfos) == 0 {
+ return nil, fmt.Errorf("未找到时间范围 %v 到 %v 内的数据库文件", startTime, endTime)
+ }
+
+ if len(dbInfos) == 1 {
+ // LIMIT 和 OFFSET 逻辑在单文件情况下可以直接在 SQL 里处理
+ return ds.getMessagesSingleFile(ctx, dbInfos[0], startTime, endTime, talker, limit, offset)
+ }
+
+ // 从每个相关数据库中查询消息
+ totalMessages := []*model.Message{}
+
+ for _, dbInfo := range dbInfos {
+ // 检查上下文是否已取消
+ if err := ctx.Err(); err != nil {
+ return nil, err
+ }
+
+ db, ok := ds.messageDbs[dbInfo.FilePath]
+ if !ok {
+ log.Printf("警告: 数据库 %s 未打开", dbInfo.FilePath)
+ continue
+ }
+
+ // 构建查询条件
+ conditions := []string{"Sequence >= ? AND Sequence <= ?"}
+ args := []interface{}{startTime.Unix() * 1000, endTime.Unix() * 1000}
+
+ if len(talker) > 0 {
+ talkerID, ok := dbInfo.TalkerMap[talker]
+ if ok {
+ conditions = append(conditions, "TalkerId = ?")
+ args = append(args, talkerID)
+ } else {
+ conditions = append(conditions, "StrTalker = ?")
+ args = append(args, talker)
+ }
+ }
+
+ query := fmt.Sprintf(`
+ SELECT Sequence, CreateTime, TalkerId, StrTalker, IsSender,
+ Type, SubType, StrContent, CompressContent, BytesExtra
+ FROM MSG
+ WHERE %s
+ ORDER BY Sequence ASC
+ `, strings.Join(conditions, " AND "))
+
+ // 执行查询
+ rows, err := db.QueryContext(ctx, query, args...)
+ if err != nil {
+ log.Printf("警告: 查询数据库 %s 失败: %v", dbInfo.FilePath, err)
+ continue
+ }
+
+ // 处理查询结果
+ for rows.Next() {
+ var msg model.MessageV3
+ var compressContent []byte
+ var bytesExtra []byte
+
+ err := rows.Scan(
+ &msg.Sequence,
+ &msg.CreateTime,
+ &msg.TalkerID,
+ &msg.StrTalker,
+ &msg.IsSender,
+ &msg.Type,
+ &msg.SubType,
+ &msg.StrContent,
+ &compressContent,
+ &bytesExtra,
+ )
+ if err != nil {
+ log.Printf("警告: 扫描消息行失败: %v", err)
+ continue
+ }
+ msg.CompressContent = compressContent
+ msg.BytesExtra = bytesExtra
+
+ totalMessages = append(totalMessages, msg.Wrap())
+ }
+ rows.Close()
+
+ if limit+offset > 0 && len(totalMessages) >= limit+offset {
+ break
+ }
+ }
+
+ // 对所有消息按时间排序
+ sort.Slice(totalMessages, func(i, j int) bool {
+ return totalMessages[i].Sequence < totalMessages[j].Sequence
+ })
+
+ // 处理分页
+ if limit > 0 {
+ if offset >= len(totalMessages) {
+ return []*model.Message{}, nil
+ }
+ end := offset + limit
+ if end > len(totalMessages) {
+ end = len(totalMessages)
+ }
+ return totalMessages[offset:end], nil
+ }
+
+ return totalMessages, nil
+}
+
+// getMessagesSingleFile 从单个数据库文件获取消息
+func (ds *DataSource) getMessagesSingleFile(ctx context.Context, dbInfo MessageDBInfo, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
+ // 构建查询条件
+ conditions := []string{"Sequence >= ? AND Sequence <= ?"}
+ args := []interface{}{startTime.Unix() * 1000, endTime.Unix() * 1000}
+ if len(talker) > 0 {
+ // TalkerId 有索引,优先使用
+ talkerID, ok := dbInfo.TalkerMap[talker]
+ if ok {
+ conditions = append(conditions, "TalkerId = ?")
+ args = append(args, talkerID)
+ } else {
+ conditions = append(conditions, "StrTalker = ?")
+ args = append(args, talker)
+ }
+ }
+ query := fmt.Sprintf(`
+ SELECT Sequence, CreateTime, TalkerId, StrTalker, IsSender,
+ Type, SubType, StrContent, CompressContent, BytesExtra
+ FROM MSG
+ WHERE %s
+ ORDER BY Sequence ASC
+ `, strings.Join(conditions, " AND "))
+
+ if limit > 0 {
+ query += fmt.Sprintf(" LIMIT %d", limit)
+
+ if offset > 0 {
+ query += fmt.Sprintf(" OFFSET %d", offset)
+ }
+ }
+
+ // 执行查询
+ rows, err := ds.messageDbs[dbInfo.FilePath].QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询数据库 %s 失败: %w", dbInfo.FilePath, err)
+ }
+ defer rows.Close()
+
+ // 处理查询结果
+ totalMessages := []*model.Message{}
+ for rows.Next() {
+ var msg model.MessageV3
+ var compressContent []byte
+ var bytesExtra []byte
+ err := rows.Scan(
+ &msg.Sequence,
+ &msg.CreateTime,
+ &msg.TalkerID,
+ &msg.StrTalker,
+ &msg.IsSender,
+ &msg.Type,
+ &msg.SubType,
+ &msg.StrContent,
+ &compressContent,
+ &bytesExtra,
+ )
+ if err != nil {
+ return nil, fmt.Errorf("扫描消息行失败: %w", err)
+ }
+ msg.CompressContent = compressContent
+ msg.BytesExtra = bytesExtra
+ totalMessages = append(totalMessages, msg.Wrap())
+ }
+ return totalMessages, nil
+}
+
+// GetContacts 实现获取联系人信息的方法
+func (ds *DataSource) GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error) {
+ var query string
+ var args []interface{}
+
+ if key != "" {
+ // 按照关键字查询
+ query = `SELECT UserName, Alias, Remark, NickName, Reserved1 FROM Contact
+ WHERE UserName = ? OR Alias = ? OR Remark = ? OR NickName = ?`
+ args = []interface{}{key, key, key, key}
+ } else {
+ // 查询所有联系人
+ query = `SELECT UserName, Alias, Remark, NickName, Reserved1 FROM Contact`
+ }
+
+ // 添加排序、分页
+ query += ` ORDER BY UserName`
+ if limit > 0 {
+ query += fmt.Sprintf(" LIMIT %d", limit)
+ if offset > 0 {
+ query += fmt.Sprintf(" OFFSET %d", offset)
+ }
+ }
+
+ // 执行查询
+ rows, err := ds.contactDb.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询联系人失败: %w", err)
+ }
+ defer rows.Close()
+
+ contacts := []*model.Contact{}
+ for rows.Next() {
+ var contactV3 model.ContactV3
+ err := rows.Scan(
+ &contactV3.UserName,
+ &contactV3.Alias,
+ &contactV3.Remark,
+ &contactV3.NickName,
+ &contactV3.Reserved1,
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("扫描联系人行失败: %w", err)
+ }
+
+ contacts = append(contacts, contactV3.Wrap())
+ }
+
+ return contacts, nil
+}
+
+// GetChatRooms 实现获取群聊信息的方法
+func (ds *DataSource) GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error) {
+ var query string
+ var args []interface{}
+
+ if key != "" {
+ // 按照关键字查询
+ query = `SELECT ChatRoomName, Reserved2, RoomData FROM ChatRoom WHERE ChatRoomName = ?`
+ args = []interface{}{key}
+
+ // 执行查询
+ rows, err := ds.contactDb.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询群聊失败: %w", err)
+ }
+ defer rows.Close()
+
+ chatRooms := []*model.ChatRoom{}
+ for rows.Next() {
+ var chatRoomV3 model.ChatRoomV3
+ err := rows.Scan(
+ &chatRoomV3.ChatRoomName,
+ &chatRoomV3.Reserved2,
+ &chatRoomV3.RoomData,
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("扫描群聊行失败: %w", err)
+ }
+
+ chatRooms = append(chatRooms, chatRoomV3.Wrap())
+ }
+
+ // 如果没有找到群聊,尝试通过联系人查找
+ if len(chatRooms) == 0 {
+ contacts, err := ds.GetContacts(ctx, key, 1, 0)
+ if err == nil && len(contacts) > 0 && strings.HasSuffix(contacts[0].UserName, "@chatroom") {
+ // 再次尝试通过用户名查找群聊
+ rows, err := ds.contactDb.QueryContext(ctx,
+ `SELECT ChatRoomName, Reserved2, RoomData FROM ChatRoom WHERE ChatRoomName = ?`,
+ contacts[0].UserName)
+
+ if err != nil {
+ return nil, fmt.Errorf("查询群聊失败: %w", err)
+ }
+ defer rows.Close()
+
+ for rows.Next() {
+ var chatRoomV3 model.ChatRoomV3
+ err := rows.Scan(
+ &chatRoomV3.ChatRoomName,
+ &chatRoomV3.Reserved2,
+ &chatRoomV3.RoomData,
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("扫描群聊行失败: %w", err)
+ }
+
+ chatRooms = append(chatRooms, chatRoomV3.Wrap())
+ }
+
+ // 如果群聊记录不存在,但联系人记录存在,创建一个模拟的群聊对象
+ if len(chatRooms) == 0 {
+ chatRooms = append(chatRooms, &model.ChatRoom{
+ Name: contacts[0].UserName,
+ Users: make([]model.ChatRoomUser, 0),
+ User2DisplayName: make(map[string]string),
+ })
+ }
+ }
+ }
+
+ return chatRooms, nil
+ } else {
+ // 查询所有群聊
+ query = `SELECT ChatRoomName, Reserved2, RoomData FROM ChatRoom`
+
+ // 添加排序、分页
+ query += ` ORDER BY ChatRoomName`
+ if limit > 0 {
+ query += fmt.Sprintf(" LIMIT %d", limit)
+ if offset > 0 {
+ query += fmt.Sprintf(" OFFSET %d", offset)
+ }
+ }
+
+ // 执行查询
+ rows, err := ds.contactDb.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询群聊失败: %w", err)
+ }
+ defer rows.Close()
+
+ chatRooms := []*model.ChatRoom{}
+ for rows.Next() {
+ var chatRoomV3 model.ChatRoomV3
+ err := rows.Scan(
+ &chatRoomV3.ChatRoomName,
+ &chatRoomV3.Reserved2,
+ &chatRoomV3.RoomData,
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("扫描群聊行失败: %w", err)
+ }
+
+ chatRooms = append(chatRooms, chatRoomV3.Wrap())
+ }
+
+ return chatRooms, nil
+ }
+}
+
+// GetSessions 实现获取会话信息的方法
+func (ds *DataSource) GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error) {
+ var query string
+ var args []interface{}
+
+ if key != "" {
+ // 按照关键字查询
+ query = `SELECT strUsrName, nOrder, strNickName, strContent, nTime
+ FROM Session
+ WHERE strUsrName = ? OR strNickName = ?
+ ORDER BY nOrder DESC`
+ args = []interface{}{key, key}
+ } else {
+ // 查询所有会话
+ query = `SELECT strUsrName, nOrder, strNickName, strContent, nTime
+ FROM Session
+ ORDER BY nOrder DESC`
+ }
+
+ // 添加分页
+ if limit > 0 {
+ query += fmt.Sprintf(" LIMIT %d", limit)
+ if offset > 0 {
+ query += fmt.Sprintf(" OFFSET %d", offset)
+ }
+ }
+
+ // 执行查询
+ rows, err := ds.contactDb.QueryContext(ctx, query, args...)
+ if err != nil {
+ return nil, fmt.Errorf("查询会话失败: %w", err)
+ }
+ defer rows.Close()
+
+ sessions := []*model.Session{}
+ for rows.Next() {
+ var sessionV3 model.SessionV3
+ err := rows.Scan(
+ &sessionV3.StrUsrName,
+ &sessionV3.NOrder,
+ &sessionV3.StrNickName,
+ &sessionV3.StrContent,
+ &sessionV3.NTime,
+ )
+
+ if err != nil {
+ return nil, fmt.Errorf("扫描会话行失败: %w", err)
+ }
+
+ sessions = append(sessions, sessionV3.Wrap())
+ }
+
+ return sessions, nil
+}
+
+// Close 实现 DataSource 接口的 Close 方法
+func (ds *DataSource) Close() error {
+ var errs []error
+
+ // 关闭消息数据库连接
+ for path, db := range ds.messageDbs {
+ if err := db.Close(); err != nil {
+ errs = append(errs, fmt.Errorf("关闭消息数据库 %s 失败: %w", path, err))
+ }
+ }
+
+ // 关闭联系人数据库连接
+ if ds.contactDb != nil {
+ if err := ds.contactDb.Close(); err != nil {
+ errs = append(errs, fmt.Errorf("关闭联系人数据库失败: %w", err))
+ }
+ }
+
+ if len(errs) > 0 {
+ return fmt.Errorf("关闭数据库连接时发生错误: %v", errs)
+ }
+
+ return nil
+}
diff --git a/internal/wechatdb/message.go b/internal/wechatdb/message.go
deleted file mode 100644
index d2ba934..0000000
--- a/internal/wechatdb/message.go
+++ /dev/null
@@ -1,321 +0,0 @@
-package wechatdb
-
-import (
- "database/sql"
- "fmt"
- "log"
- "sort"
- "strings"
- "time"
-
- "github.com/sjzar/chatlog/pkg/model"
- "github.com/sjzar/chatlog/pkg/util"
-
- _ "github.com/mattn/go-sqlite3"
-)
-
-const (
- MessageFileV3 = "^MSG([0-9]?[0-9])?\\.db$"
- MessageFileV4 = "^messages_([0-9]?[0-9])+\\.db$"
-)
-
-type Message struct {
- version int
- files []MsgDBInfo
- dbs map[string]*sql.DB
-}
-
-type MsgDBInfo struct {
- FilePath string
- StartTime time.Time
- EndTime time.Time
- TalkerMap map[string]int
-}
-
-func NewMessage(path string, version int) (*Message, error) {
- m := &Message{
- version: version,
- files: make([]MsgDBInfo, 0),
- dbs: make(map[string]*sql.DB),
- }
-
- // 查找所有 MSG[0-13].db 文件
- files, err := util.FindFilesWithPatterns(path, MessageFileV3, true)
- if err != nil {
- return nil, fmt.Errorf("查找数据库文件失败: %v", err)
- }
-
- if len(files) == 0 {
- return nil, fmt.Errorf("未找到任何数据库文件: %s", path)
- }
-
- // 处理每个数据库文件
- for _, filePath := range files {
- // 连接数据库
- db, err := sql.Open("sqlite3", filePath)
- if err != nil {
- log.Printf("警告: 连接数据库 %s 失败: %v", filePath, err)
- continue
- }
-
- // 获取 DBInfo 表中的开始时间
- // 首先检查表结构
- var startTime time.Time
-
- // 尝试从 DBInfo 表中查找 Start Time 对应的记录
- rows, err := db.Query("SELECT tableIndex, tableVersion, tableDesc FROM DBInfo")
- if err != nil {
- log.Printf("警告: 查询数据库 %s 的 DBInfo 表失败: %v", filePath, err)
- db.Close()
- continue
- }
-
- for rows.Next() {
- var tableIndex int
- var tableVersion int64
- var tableDesc string
-
- if err := rows.Scan(&tableIndex, &tableVersion, &tableDesc); err != nil {
- log.Printf("警告: 扫描 DBInfo 行失败: %v", err)
- continue
- }
-
- // 查找描述为 "Start Time" 的记录
- if strings.Contains(tableDesc, "Start Time") {
- startTime = time.Unix(tableVersion/1000, (tableVersion%1000)*1000000)
- break
- }
- }
- rows.Close()
-
- // 组织 TalkerMap
- talkerMap := make(map[string]int)
- rows, err = db.Query("SELECT UsrName FROM Name2ID")
- if err != nil {
- log.Printf("警告: 查询数据库 %s 的 Name2ID 表失败: %v", filePath, err)
- db.Close()
- continue
- }
-
- i := 1
- for rows.Next() {
- var userName string
- if err := rows.Scan(&userName); err != nil {
- log.Printf("警告: 扫描 Name2ID 行失败: %v", err)
- continue
- }
- talkerMap[userName] = i
- i++
- }
-
- // 保存数据库信息
- m.files = append(m.files, MsgDBInfo{
- FilePath: filePath,
- StartTime: startTime,
- TalkerMap: talkerMap,
- })
-
- // 保存数据库连接
- m.dbs[filePath] = db
- }
-
- // 按照 StartTime 排序数据库文件
- sort.Slice(m.files, func(i, j int) bool {
- return m.files[i].StartTime.Before(m.files[j].StartTime)
- })
-
- for i := range m.files {
- if i == len(m.files)-1 {
- m.files[i].EndTime = time.Now()
- } else {
- m.files[i].EndTime = m.files[i+1].StartTime
- }
- }
-
- return m, nil
-}
-
-// GetMessages 根据时间段和 talker 查询聊天记录
-func (m *Message) GetMessages(startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
- // 找到时间范围内的数据库文件
- dbInfos := m.getDBInfosForTimeRange(startTime, endTime)
- if len(dbInfos) == 0 {
- return nil, fmt.Errorf("未找到时间范围 %v 到 %v 内的数据库文件", startTime, endTime)
- }
-
- if len(dbInfos) == 1 {
- // LIMIT 和 OFFSET 逻辑在单文件情况下可以直接在 SQL 里处理
- return m.getMessagesSingleFile(dbInfos[0], startTime, endTime, talker, limit, offset)
- }
-
- // 从每个相关数据库中查询消息
- totalMessages := []*model.Message{}
-
- for _, dbInfo := range dbInfos {
- db, ok := m.dbs[dbInfo.FilePath]
- if !ok {
- log.Printf("警告: 数据库 %s 未打开", dbInfo.FilePath)
- continue
- }
-
- // 构建查询条件
- // 使用 Sequence 查询,有索引
- conditions := []string{"Sequence >= ? AND Sequence <= ?"}
- args := []interface{}{startTime.Unix() * 1000, endTime.Unix() * 1000}
-
- if len(talker) > 0 {
- talkerID, ok := dbInfo.TalkerMap[talker]
- if ok {
- conditions = append(conditions, "TalkerId = ?")
- args = append(args, talkerID)
- } else {
- conditions = append(conditions, "StrTalker = ?")
- args = append(args, talker)
- }
- }
-
- query := fmt.Sprintf(`
- SELECT Sequence, CreateTime, TalkerId, StrTalker, IsSender,
- Type, SubType, StrContent, CompressContent, BytesExtra
- FROM MSG
- WHERE %s
- ORDER BY Sequence ASC
- `, strings.Join(conditions, " AND "))
-
- // 执行查询
- rows, err := db.Query(query, args...)
- if err != nil {
- log.Printf("警告: 查询数据库 %s 失败: %v", dbInfo.FilePath, err)
- continue
- }
-
- // 处理查询结果
- for rows.Next() {
- var msg model.MessageV3
- var compressContent []byte
- var bytesExtra []byte
-
- err := rows.Scan(
- &msg.Sequence,
- &msg.CreateTime,
- &msg.TalkerID,
- &msg.StrTalker,
- &msg.IsSender,
- &msg.Type,
- &msg.SubType,
- &msg.StrContent,
- &compressContent,
- &bytesExtra,
- )
- if err != nil {
- log.Printf("警告: 扫描消息行失败: %v", err)
- continue
- }
- msg.CompressContent = compressContent
- msg.BytesExtra = bytesExtra
-
- totalMessages = append(totalMessages, msg.Wrap())
- }
- rows.Close()
-
- if limit+offset > 0 && len(totalMessages) >= limit+offset {
- break
- }
- }
-
- // 对所有消息按时间排序
- sort.Slice(totalMessages, func(i, j int) bool {
- return totalMessages[i].Sequence < totalMessages[j].Sequence
- })
-
- // FIXME limit 和 offset 逻辑,在多文件边界条件下不好处理,直接查询全量数据后在进程里处理
- if limit > 0 {
- if offset >= len(totalMessages) {
- return []*model.Message{}, nil
- }
- end := offset + limit
- if end > len(totalMessages) || limit == 0 {
- end = len(totalMessages)
- }
- return totalMessages[offset:end], nil
- }
-
- return totalMessages, nil
-
-}
-
-func (m *Message) getMessagesSingleFile(dbInfo MsgDBInfo, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
- // 构建查询条件
- // 使用 Sequence 查询,有索引
- conditions := []string{"Sequence >= ? AND Sequence <= ?"}
- args := []interface{}{startTime.Unix() * 1000, endTime.Unix() * 1000}
- if len(talker) > 0 {
- // TalkerId 有索引,优先使用
- talkerID, ok := dbInfo.TalkerMap[talker]
- if ok {
- conditions = append(conditions, "TalkerId = ?")
- args = append(args, talkerID)
- } else {
- conditions = append(conditions, "StrTalker = ?")
- args = append(args, talker)
- }
- }
- query := fmt.Sprintf(`
- SELECT Sequence, CreateTime, TalkerId, StrTalker, IsSender,
- Type, SubType, StrContent, CompressContent, BytesExtra
- FROM MSG
- WHERE %s
- ORDER BY Sequence ASC
- `, strings.Join(conditions, " AND "))
-
- if limit > 0 {
- query += fmt.Sprintf(" LIMIT %d", limit)
-
- if offset > 0 {
- query += fmt.Sprintf(" OFFSET %d", offset)
- }
- }
-
- // 执行查询
- rows, err := m.dbs[dbInfo.FilePath].Query(query, args...)
- if err != nil {
- return nil, fmt.Errorf("查询数据库 %s 失败: %v", dbInfo.FilePath, err)
- }
- defer rows.Close()
- // 处理查询结果
- totalMessages := []*model.Message{}
- for rows.Next() {
- var msg model.MessageV3
- var compressContent []byte
- var bytesExtra []byte
- err := rows.Scan(
- &msg.Sequence,
- &msg.CreateTime,
- &msg.TalkerID,
- &msg.StrTalker,
- &msg.IsSender,
- &msg.Type,
- &msg.SubType,
- &msg.StrContent,
- &compressContent,
- &bytesExtra,
- )
- if err != nil {
- return nil, fmt.Errorf("扫描消息行失败: %v", err)
- }
- msg.CompressContent = compressContent
- msg.BytesExtra = bytesExtra
- totalMessages = append(totalMessages, msg.Wrap())
- }
- return totalMessages, nil
-}
-
-func (m *Message) getDBInfosForTimeRange(startTime, endTime time.Time) []MsgDBInfo {
- var dbs []MsgDBInfo
- for _, info := range m.files {
- if info.StartTime.Before(endTime) && info.EndTime.After(startTime) {
- dbs = append(dbs, info)
- }
- }
- return dbs
-}
diff --git a/internal/wechatdb/repository/chatroom.go b/internal/wechatdb/repository/chatroom.go
new file mode 100644
index 0000000..fd2e399
--- /dev/null
+++ b/internal/wechatdb/repository/chatroom.go
@@ -0,0 +1,184 @@
+package repository
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/sjzar/chatlog/internal/model"
+)
+
+// initChatRoomCache 初始化群聊缓存
+func (r *Repository) initChatRoomCache(ctx context.Context) error {
+ // 加载所有群聊到缓存
+ chatRooms, err := r.ds.GetChatRooms(ctx, "", 0, 0)
+ if err != nil {
+ return fmt.Errorf("加载群聊失败: %w", err)
+ }
+
+ chatRoomMap := make(map[string]*model.ChatRoom)
+ remarkToChatRoom := make(map[string]*model.ChatRoom)
+ nickNameToChatRoom := make(map[string]*model.ChatRoom)
+ chatRoomList := make([]string, 0)
+ chatRoomRemark := make([]string, 0)
+ chatRoomNickName := make([]string, 0)
+
+ for _, chatRoom := range chatRooms {
+ // 补充群聊信息(从联系人中获取 Remark 和 NickName)
+ r.enrichChatRoom(chatRoom)
+ chatRoomMap[chatRoom.Name] = chatRoom
+ chatRoomList = append(chatRoomList, chatRoom.Name)
+ if chatRoom.Remark != "" {
+ remarkToChatRoom[chatRoom.Remark] = chatRoom
+ chatRoomRemark = append(chatRoomRemark, chatRoom.Remark)
+ }
+ if chatRoom.NickName != "" {
+ nickNameToChatRoom[chatRoom.NickName] = chatRoom
+ chatRoomNickName = append(chatRoomNickName, chatRoom.NickName)
+ }
+ }
+
+ for _, contact := range r.chatRoomInContact {
+ if _, ok := chatRoomMap[contact.UserName]; !ok {
+ chatRoom := &model.ChatRoom{
+ Name: contact.UserName,
+ Remark: contact.Remark,
+ NickName: contact.NickName,
+ }
+ chatRoomMap[contact.UserName] = chatRoom
+ chatRoomList = append(chatRoomList, contact.UserName)
+ if contact.Remark != "" {
+ remarkToChatRoom[contact.Remark] = chatRoom
+ chatRoomRemark = append(chatRoomRemark, contact.Remark)
+ }
+ if contact.NickName != "" {
+ nickNameToChatRoom[contact.NickName] = chatRoom
+ chatRoomNickName = append(chatRoomNickName, contact.NickName)
+ }
+ }
+ }
+ sort.Strings(chatRoomList)
+ sort.Strings(chatRoomRemark)
+ sort.Strings(chatRoomNickName)
+
+ r.chatRoomCache = chatRoomMap
+ r.chatRoomList = chatRoomList
+ r.remarkToChatRoom = remarkToChatRoom
+ r.nickNameToChatRoom = nickNameToChatRoom
+ return nil
+}
+
+func (r *Repository) GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error) {
+
+ ret := make([]*model.ChatRoom, 0)
+ if key != "" {
+ ret = r.findChatRooms(key)
+ if len(ret) == 0 {
+ return nil, fmt.Errorf("未找到群聊: %s", key)
+ }
+
+ if limit > 0 {
+ end := offset + limit
+ if end > len(ret) {
+ end = len(ret)
+ }
+ if offset >= len(ret) {
+ return []*model.ChatRoom{}, nil
+ }
+ return ret[offset:end], nil
+ }
+ } else {
+ list := r.chatRoomList
+ if limit > 0 {
+ end := offset + limit
+ if end > len(list) {
+ end = len(list)
+ }
+ if offset >= len(list) {
+ return []*model.ChatRoom{}, nil
+ }
+ list = list[offset:end]
+ }
+ for _, name := range list {
+ ret = append(ret, r.chatRoomCache[name])
+ }
+ }
+
+ return ret, nil
+}
+
+func (r *Repository) GetChatRoom(ctx context.Context, key string) (*model.ChatRoom, error) {
+ chatRoom := r.findChatRoom(key)
+ if chatRoom == nil {
+ return nil, fmt.Errorf("未找到群聊: %s", key)
+ }
+ return chatRoom, nil
+}
+
+// enrichChatRoom 从联系人信息中补充群聊信息
+func (r *Repository) enrichChatRoom(chatRoom *model.ChatRoom) {
+ if contact, ok := r.contactCache[chatRoom.Name]; ok {
+ chatRoom.Remark = contact.Remark
+ chatRoom.NickName = contact.NickName
+ }
+}
+
+func (r *Repository) findChatRoom(key string) *model.ChatRoom {
+ if chatRoom, ok := r.chatRoomCache[key]; ok {
+ return chatRoom
+ }
+ if chatRoom, ok := r.remarkToChatRoom[key]; ok {
+ return chatRoom
+ }
+ if chatRoom, ok := r.nickNameToChatRoom[key]; ok {
+ return chatRoom
+ }
+
+ // Contain
+ for _, remark := range r.chatRoomRemark {
+ if strings.Contains(remark, key) {
+ return r.remarkToChatRoom[remark]
+ }
+ }
+ for _, nickName := range r.chatRoomNickName {
+ if strings.Contains(nickName, key) {
+ return r.nickNameToChatRoom[nickName]
+ }
+ }
+
+ return nil
+}
+
+func (r *Repository) findChatRooms(key string) []*model.ChatRoom {
+ ret := make([]*model.ChatRoom, 0)
+ distinct := make(map[string]bool)
+ if chatRoom, ok := r.chatRoomCache[key]; ok {
+ ret = append(ret, chatRoom)
+ distinct[chatRoom.Name] = true
+ }
+ if chatRoom, ok := r.remarkToChatRoom[key]; ok && !distinct[chatRoom.Name] {
+ ret = append(ret, chatRoom)
+ distinct[chatRoom.Name] = true
+ }
+ if chatRoom, ok := r.nickNameToChatRoom[key]; ok && !distinct[chatRoom.Name] {
+ ret = append(ret, chatRoom)
+ distinct[chatRoom.Name] = true
+ }
+
+ // Contain
+ for _, remark := range r.chatRoomRemark {
+ if strings.Contains(remark, key) && !distinct[r.remarkToChatRoom[remark].Name] {
+ ret = append(ret, r.remarkToChatRoom[remark])
+ distinct[r.remarkToChatRoom[remark].Name] = true
+ }
+ }
+ for _, nickName := range r.chatRoomNickName {
+ if strings.Contains(nickName, key) && !distinct[r.nickNameToChatRoom[nickName].Name] {
+ ret = append(ret, r.nickNameToChatRoom[nickName])
+ distinct[r.nickNameToChatRoom[nickName].Name] = true
+ }
+ }
+
+ return ret
+}
diff --git a/internal/wechatdb/repository/contact.go b/internal/wechatdb/repository/contact.go
new file mode 100644
index 0000000..ca40c8d
--- /dev/null
+++ b/internal/wechatdb/repository/contact.go
@@ -0,0 +1,211 @@
+package repository
+
+import (
+ "context"
+ "fmt"
+ "sort"
+ "strings"
+
+ "github.com/sjzar/chatlog/internal/model"
+)
+
+// initContactCache 初始化联系人缓存
+func (r *Repository) initContactCache(ctx context.Context) error {
+ // 加载所有联系人到缓存
+ contacts, err := r.ds.GetContacts(ctx, "", 0, 0)
+ if err != nil {
+ return fmt.Errorf("加载联系人失败: %w", err)
+ }
+
+ contactMap := make(map[string]*model.Contact)
+ aliasMap := make(map[string]*model.Contact)
+ remarkMap := make(map[string]*model.Contact)
+ nickNameMap := make(map[string]*model.Contact)
+ chatRoomUserMap := make(map[string]*model.Contact)
+ chatRoomInContactMap := make(map[string]*model.Contact)
+ contactList := make([]string, 0)
+ aliasList := make([]string, 0)
+ remarkList := make([]string, 0)
+ nickNameList := make([]string, 0)
+
+ for _, contact := range contacts {
+ contactMap[contact.UserName] = contact
+ contactList = append(contactList, contact.UserName)
+
+ // 建立快速查找索引
+ if contact.Alias != "" {
+ aliasMap[contact.Alias] = contact
+ aliasList = append(aliasList, contact.Alias)
+ }
+ if contact.Remark != "" {
+ remarkMap[contact.Remark] = contact
+ remarkList = append(remarkList, contact.Remark)
+ }
+ if contact.NickName != "" {
+ nickNameMap[contact.NickName] = contact
+ nickNameList = append(nickNameList, contact.NickName)
+ }
+
+ // 如果是群聊成员(非好友),添加到群聊成员索引
+ if !contact.IsFriend {
+ chatRoomUserMap[contact.UserName] = contact
+ }
+
+ if strings.HasSuffix(contact.UserName, "@chatroom") {
+ chatRoomInContactMap[contact.UserName] = contact
+ }
+ }
+
+ sort.Strings(contactList)
+ sort.Strings(aliasList)
+ sort.Strings(remarkList)
+ sort.Strings(nickNameList)
+
+ r.contactCache = contactMap
+ r.aliasToContact = aliasMap
+ r.remarkToContact = remarkMap
+ r.nickNameToContact = nickNameMap
+ r.chatRoomUserToInfo = chatRoomUserMap
+ r.chatRoomInContact = chatRoomInContactMap
+ r.contactList = contactList
+ r.aliasList = aliasList
+ r.remarkList = remarkList
+ r.nickNameList = nickNameList
+ return nil
+}
+
+func (r *Repository) GetContact(ctx context.Context, key string) (*model.Contact, error) {
+ // 先尝试从缓存中获取
+ contact := r.findContact(key)
+ if contact == nil {
+ return nil, fmt.Errorf("未找到联系人: %s", key)
+ }
+ return contact, nil
+}
+
+func (r *Repository) GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error) {
+ ret := make([]*model.Contact, 0)
+ if key != "" {
+ ret = r.findContacts(key)
+ if len(ret) == 0 {
+ return nil, fmt.Errorf("未找到联系人: %s", key)
+ }
+ if limit > 0 {
+ end := offset + limit
+ if end > len(ret) {
+ end = len(ret)
+ }
+ if offset >= len(ret) {
+ return []*model.Contact{}, nil
+ }
+ return ret[offset:end], nil
+ }
+ } else {
+ list := r.contactList
+ if limit > 0 {
+ end := offset + limit
+ if end > len(list) {
+ end = len(list)
+ }
+ if offset >= len(list) {
+ return []*model.Contact{}, nil
+ }
+ list = list[offset:end]
+ }
+ for _, name := range list {
+ ret = append(ret, r.contactCache[name])
+ }
+ }
+ return ret, nil
+}
+
+func (r *Repository) findContact(key string) *model.Contact {
+ if contact, ok := r.contactCache[key]; ok {
+ return contact
+ }
+ if contact, ok := r.aliasToContact[key]; ok {
+ return contact
+ }
+ if contact, ok := r.remarkToContact[key]; ok {
+ return contact
+ }
+ if contact, ok := r.nickNameToContact[key]; ok {
+ return contact
+ }
+
+ // Contain
+ for _, alias := range r.aliasList {
+ if strings.Contains(alias, key) {
+ return r.aliasToContact[alias]
+ }
+ }
+ for _, remark := range r.remarkList {
+ if strings.Contains(remark, key) {
+ return r.remarkToContact[remark]
+ }
+ }
+ for _, nickName := range r.nickNameList {
+ if strings.Contains(nickName, key) {
+ return r.nickNameToContact[nickName]
+ }
+ }
+ return nil
+}
+
+func (r *Repository) findContacts(key string) []*model.Contact {
+ ret := make([]*model.Contact, 0)
+ distinct := make(map[string]bool)
+ if contact, ok := r.contactCache[key]; ok {
+ ret = append(ret, contact)
+ distinct[contact.UserName] = true
+ }
+ if contact, ok := r.aliasToContact[key]; ok && !distinct[contact.UserName] {
+ ret = append(ret, contact)
+ distinct[contact.UserName] = true
+ }
+ if contact, ok := r.remarkToContact[key]; ok && !distinct[contact.UserName] {
+ ret = append(ret, contact)
+ distinct[contact.UserName] = true
+ }
+ if contact, ok := r.nickNameToContact[key]; ok && !distinct[contact.UserName] {
+ ret = append(ret, contact)
+ distinct[contact.UserName] = true
+ }
+ // Contain
+ for _, alias := range r.aliasList {
+ if strings.Contains(alias, key) && !distinct[r.aliasToContact[alias].UserName] {
+ ret = append(ret, r.aliasToContact[alias])
+ distinct[r.aliasToContact[alias].UserName] = true
+ }
+ }
+ for _, remark := range r.remarkList {
+ if strings.Contains(remark, key) && !distinct[r.remarkToContact[remark].UserName] {
+ ret = append(ret, r.remarkToContact[remark])
+ distinct[r.remarkToContact[remark].UserName] = true
+ }
+ }
+ for _, nickName := range r.nickNameList {
+ if strings.Contains(nickName, key) && !distinct[r.nickNameToContact[nickName].UserName] {
+ ret = append(ret, r.nickNameToContact[nickName])
+ distinct[r.nickNameToContact[nickName].UserName] = true
+ }
+ }
+ return ret
+}
+
+// getFullContact 获取联系人信息,包括群聊成员
+func (r *Repository) getFullContact(userName string) *model.Contact {
+ // 先查找联系人缓存
+ if contact, ok := r.contactCache[userName]; ok {
+ return contact
+ }
+
+ // 再查找群聊成员缓存
+ contact, ok := r.chatRoomUserToInfo[userName]
+
+ if ok {
+ return contact
+ }
+
+ return nil
+}
diff --git a/internal/wechatdb/repository/message.go b/internal/wechatdb/repository/message.go
new file mode 100644
index 0000000..597f809
--- /dev/null
+++ b/internal/wechatdb/repository/message.go
@@ -0,0 +1,68 @@
+package repository
+
+import (
+ "context"
+ "time"
+
+ "github.com/sjzar/chatlog/internal/model"
+
+ log "github.com/sirupsen/logrus"
+)
+
+// GetMessages 实现 Repository 接口的 GetMessages 方法
+func (r *Repository) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, limit, offset int) ([]*model.Message, error) {
+
+ if contact, _ := r.GetContact(ctx, talker); contact != nil {
+ talker = contact.UserName
+ } else if chatRoom, _ := r.GetChatRoom(ctx, talker); chatRoom != nil {
+ talker = chatRoom.Name
+ }
+
+ messages, err := r.ds.GetMessages(ctx, startTime, endTime, talker, limit, offset)
+ if err != nil {
+ return nil, err
+ }
+
+ // 补充消息信息
+ if err := r.EnrichMessages(ctx, messages); err != nil {
+ log.Debugf("EnrichMessages failed: %v", err)
+ }
+
+ return messages, nil
+}
+
+// EnrichMessages 补充消息的额外信息
+func (r *Repository) EnrichMessages(ctx context.Context, messages []*model.Message) error {
+ for _, msg := range messages {
+ r.enrichMessage(msg)
+ }
+ return nil
+}
+
+// enrichMessage 补充单条消息的额外信息
+func (r *Repository) enrichMessage(msg *model.Message) {
+ talker := msg.Talker
+
+ // 处理群聊消息
+ if msg.IsChatRoom {
+ talker = msg.ChatRoomSender
+
+ // 补充群聊名称
+ if chatRoom, ok := r.chatRoomCache[msg.Talker]; ok {
+ msg.ChatRoomName = chatRoom.DisplayName()
+
+ // 补充发送者在群里的显示名称
+ if displayName, ok := chatRoom.User2DisplayName[talker]; ok {
+ msg.DisplayName = displayName
+ }
+ }
+ }
+
+ // 如果不是自己发送的消息且还没有显示名称,尝试补充发送者信息
+ if msg.DisplayName == "" && msg.IsSender != 1 {
+ contact := r.getFullContact(talker)
+ if contact != nil {
+ msg.DisplayName = contact.DisplayName()
+ }
+ }
+}
diff --git a/internal/wechatdb/repository/repository.go b/internal/wechatdb/repository/repository.go
new file mode 100644
index 0000000..aefe0d5
--- /dev/null
+++ b/internal/wechatdb/repository/repository.go
@@ -0,0 +1,85 @@
+package repository
+
+import (
+ "context"
+ "fmt"
+
+ "github.com/sjzar/chatlog/internal/model"
+ "github.com/sjzar/chatlog/internal/wechatdb/datasource"
+)
+
+// Repository 实现了 repository.Repository 接口
+type Repository struct {
+ ds datasource.DataSource
+
+ // Cache for contact
+ contactCache map[string]*model.Contact
+ aliasToContact map[string]*model.Contact
+ remarkToContact map[string]*model.Contact
+ nickNameToContact map[string]*model.Contact
+ chatRoomInContact map[string]*model.Contact
+ contactList []string
+ aliasList []string
+ remarkList []string
+ nickNameList []string
+
+ // Cache for chat room
+ chatRoomCache map[string]*model.ChatRoom
+ remarkToChatRoom map[string]*model.ChatRoom
+ nickNameToChatRoom map[string]*model.ChatRoom
+ chatRoomList []string
+ chatRoomRemark []string
+ chatRoomNickName []string
+
+ // 快速查找索引
+ chatRoomUserToInfo map[string]*model.Contact
+}
+
+// New 创建一个新的 Repository
+func New(ds datasource.DataSource) (*Repository, error) {
+ r := &Repository{
+ ds: ds,
+ contactCache: make(map[string]*model.Contact),
+ aliasToContact: make(map[string]*model.Contact),
+ remarkToContact: make(map[string]*model.Contact),
+ nickNameToContact: make(map[string]*model.Contact),
+ chatRoomUserToInfo: make(map[string]*model.Contact),
+ contactList: make([]string, 0),
+ aliasList: make([]string, 0),
+ remarkList: make([]string, 0),
+ nickNameList: make([]string, 0),
+ chatRoomCache: make(map[string]*model.ChatRoom),
+ remarkToChatRoom: make(map[string]*model.ChatRoom),
+ nickNameToChatRoom: make(map[string]*model.ChatRoom),
+ chatRoomList: make([]string, 0),
+ chatRoomRemark: make([]string, 0),
+ chatRoomNickName: make([]string, 0),
+ }
+
+ // 初始化缓存
+ if err := r.initCache(context.Background()); err != nil {
+ return nil, fmt.Errorf("初始化缓存失败: %w", err)
+ }
+
+ return r, nil
+}
+
+// initCache 初始化缓存
+func (r *Repository) initCache(ctx context.Context) error {
+ // 初始化联系人缓存
+ if err := r.initContactCache(ctx); err != nil {
+ return err
+ }
+
+ // 初始化群聊缓存
+ if err := r.initChatRoomCache(ctx); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// Close 实现 Repository 接口的 Close 方法
+func (r *Repository) Close() error {
+ return r.ds.Close()
+}
diff --git a/internal/wechatdb/repository/session.go b/internal/wechatdb/repository/session.go
new file mode 100644
index 0000000..36754e0
--- /dev/null
+++ b/internal/wechatdb/repository/session.go
@@ -0,0 +1,11 @@
+package repository
+
+import (
+ "context"
+
+ "github.com/sjzar/chatlog/internal/model"
+)
+
+func (r *Repository) GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error) {
+ return r.ds.GetSessions(ctx, key, limit, offset)
+}
diff --git a/internal/wechatdb/wechatdb.go b/internal/wechatdb/wechatdb.go
index dfdf176..d909eb0 100644
--- a/internal/wechatdb/wechatdb.go
+++ b/internal/wechatdb/wechatdb.go
@@ -1,25 +1,31 @@
package wechatdb
import (
+ "context"
+ "fmt"
"time"
- "github.com/sjzar/chatlog/pkg/model"
+ "github.com/sjzar/chatlog/internal/model"
+ "github.com/sjzar/chatlog/internal/wechatdb/datasource"
+ "github.com/sjzar/chatlog/internal/wechatdb/repository"
_ "github.com/mattn/go-sqlite3"
)
type DB struct {
- BasePath string
- Version int
-
- contact *Contact
- message *Message
+ path string
+ platform string
+ version int
+ ds datasource.DataSource
+ repo *repository.Repository
}
-func New(path string, version int) (*DB, error) {
+func New(path string, platform string, version int) (*DB, error) {
+
w := &DB{
- BasePath: path,
- Version: version,
+ path: path,
+ platform: platform,
+ version: version,
}
// 初始化,加载数据库文件信息
@@ -31,87 +37,87 @@ func New(path string, version int) (*DB, error) {
}
func (w *DB) Close() error {
+ if w.repo != nil {
+ return w.repo.Close()
+ }
return nil
}
func (w *DB) Initialize() error {
-
var err error
- w.message, err = NewMessage(w.BasePath, w.Version)
+ w.ds, err = datasource.NewDataSource(w.path, w.platform, w.version)
if err != nil {
- return err
+ return fmt.Errorf("初始化数据源失败: %w", err)
}
- w.contact, err = NewContact(w.BasePath, w.Version)
+ w.repo, err = repository.New(w.ds)
if err != nil {
- return err
+ return fmt.Errorf("初始化仓库失败: %w", err)
}
return nil
}
func (w *DB) GetMessages(start, end time.Time, talker string, limit, offset int) ([]*model.Message, error) {
+ ctx := context.Background()
- if talker != "" {
- if contact := w.contact.GetContact(talker); contact != nil {
- talker = contact.UserName
- }
- }
-
- messages, err := w.message.GetMessages(start, end, talker, limit, offset)
+ // 使用 repository 获取消息
+ messages, err := w.repo.GetMessages(ctx, start, end, talker, limit, offset)
if err != nil {
- return nil, err
- }
- for i := range messages {
- w.contact.MessageFillInfo(messages[i])
+ return nil, fmt.Errorf("获取消息失败: %w", err)
}
return messages, nil
}
-type ListContactResp struct {
+type GetContactsResp struct {
Items []*model.Contact `json:"items"`
}
-func (w *DB) ListContact() (*ListContactResp, error) {
- list, err := w.contact.ListContact()
+func (w *DB) GetContacts(key string, limit, offset int) (*GetContactsResp, error) {
+ ctx := context.Background()
+
+ contacts, err := w.repo.GetContacts(ctx, key, limit, offset)
if err != nil {
return nil, err
}
- return &ListContactResp{
- Items: list,
+
+ return &GetContactsResp{
+ Items: contacts,
}, nil
}
-func (w *DB) GetContact(userName string) *model.Contact {
- return w.contact.GetContact(userName)
-}
-
-type ListChatRoomResp struct {
+type GetChatRoomsResp struct {
Items []*model.ChatRoom `json:"items"`
}
-func (w *DB) ListChatRoom() (*ListChatRoomResp, error) {
- list, err := w.contact.ListChatRoom()
+func (w *DB) GetChatRooms(key string, limit, offset int) (*GetChatRoomsResp, error) {
+ ctx := context.Background()
+
+ chatRooms, err := w.repo.GetChatRooms(ctx, key, limit, offset)
if err != nil {
return nil, err
}
- return &ListChatRoomResp{
- Items: list,
+
+ return &GetChatRoomsResp{
+ Items: chatRooms,
}, nil
}
-func (w *DB) GetChatRoom(userName string) *model.ChatRoom {
- return w.contact.GetChatRoom(userName)
-}
-
-type GetSessionResp struct {
+type GetSessionsResp struct {
Items []*model.Session `json:"items"`
}
-func (w *DB) GetSession(limit int) (*GetSessionResp, error) {
- sessions := w.contact.GetSession(limit)
- return &GetSessionResp{
+func (w *DB) GetSessions(key string, limit, offset int) (*GetSessionsResp, error) {
+ ctx := context.Background()
+
+ // 使用 repository 获取会话列表
+ sessions, err := w.repo.GetSessions(ctx, key, limit, offset)
+ if err != nil {
+ return nil, fmt.Errorf("获取会话列表失败: %w", err)
+ }
+
+ return &GetSessionsResp{
Items: sessions,
}, nil
}
diff --git a/pkg/appver/version.go b/pkg/appver/version.go
new file mode 100644
index 0000000..b52d6da
--- /dev/null
+++ b/pkg/appver/version.go
@@ -0,0 +1,25 @@
+package appver
+
+type Info struct {
+ FilePath string `json:"file_path"`
+ CompanyName string `json:"company_name"`
+ FileDescription string `json:"file_description"`
+ Version int `json:"version"`
+ FullVersion string `json:"full_version"`
+ LegalCopyright string `json:"legal_copyright"`
+ ProductName string `json:"product_name"`
+ ProductVersion string `json:"product_version"`
+}
+
+func New(filePath string) (*Info, error) {
+ i := &Info{
+ FilePath: filePath,
+ }
+
+ err := i.initialize()
+ if err != nil {
+ return nil, err
+ }
+
+ return i, nil
+}
diff --git a/pkg/appver/version_darwin.go b/pkg/appver/version_darwin.go
new file mode 100644
index 0000000..1947e85
--- /dev/null
+++ b/pkg/appver/version_darwin.go
@@ -0,0 +1,41 @@
+package appver
+
+import (
+ "os"
+ "path/filepath"
+ "strconv"
+ "strings"
+
+ "howett.net/plist"
+)
+
+const (
+ InfoFile = "Info.plist"
+)
+
+type Plist struct {
+ CFBundleShortVersionString string `plist:"CFBundleShortVersionString"`
+ NSHumanReadableCopyright string `plist:"NSHumanReadableCopyright"`
+}
+
+func (i *Info) initialize() error {
+
+ parts := strings.Split(i.FilePath, string(filepath.Separator))
+ file := filepath.Join(append(parts[:len(parts)-2], InfoFile)...)
+ b, err := os.ReadFile("/" + file)
+ if err != nil {
+ return err
+ }
+
+ p := Plist{}
+ _, err = plist.Unmarshal(b, &p)
+ if err != nil {
+ return err
+ }
+
+ i.FullVersion = p.CFBundleShortVersionString
+ i.Version, _ = strconv.Atoi(strings.Split(i.FullVersion, ".")[0])
+ i.CompanyName = p.NSHumanReadableCopyright
+
+ return nil
+}
diff --git a/pkg/dllver/version_others.go b/pkg/appver/version_others.go
similarity index 53%
rename from pkg/dllver/version_others.go
rename to pkg/appver/version_others.go
index 86f69f8..bc45747 100644
--- a/pkg/dllver/version_others.go
+++ b/pkg/appver/version_others.go
@@ -1,6 +1,6 @@
-//go:build !windows
+//go:build !windows && !darwin
-package dllver
+package appver
func (i *Info) initialize() error {
return nil
diff --git a/pkg/dllver/version_windows.go b/pkg/appver/version_windows.go
similarity index 95%
rename from pkg/dllver/version_windows.go
rename to pkg/appver/version_windows.go
index 8bcbc62..a87ab1c 100644
--- a/pkg/dllver/version_windows.go
+++ b/pkg/appver/version_windows.go
@@ -1,4 +1,4 @@
-package dllver
+package appver
import (
"fmt"
@@ -75,13 +75,13 @@ func (i *Info) initialize() error {
}
// 解析文件版本
- i.FileVersion = fmt.Sprintf("%d.%d.%d.%d",
+ i.FullVersion = fmt.Sprintf("%d.%d.%d.%d",
(fixedFileInfo.FileVersionMS>>16)&0xffff,
(fixedFileInfo.FileVersionMS>>0)&0xffff,
(fixedFileInfo.FileVersionLS>>16)&0xffff,
(fixedFileInfo.FileVersionLS>>0)&0xffff,
)
- i.FileMajorVersion = int((fixedFileInfo.FileVersionMS >> 16) & 0xffff)
+ i.Version = int((fixedFileInfo.FileVersionMS >> 16) & 0xffff)
i.ProductVersion = fmt.Sprintf("%d.%d.%d.%d",
(fixedFileInfo.ProductVersionMS>>16)&0xffff,
@@ -111,7 +111,7 @@ func (i *Info) initialize() error {
stringInfos := map[string]*string{
"CompanyName": &i.CompanyName,
"FileDescription": &i.FileDescription,
- "FileVersion": &i.FileVersion,
+ "FileVersion": &i.FullVersion,
"LegalCopyright": &i.LegalCopyright,
"ProductName": &i.ProductName,
"ProductVersion": &i.ProductVersion,
diff --git a/pkg/dllver/version.go b/pkg/dllver/version.go
deleted file mode 100644
index c28cf57..0000000
--- a/pkg/dllver/version.go
+++ /dev/null
@@ -1,25 +0,0 @@
-package dllver
-
-type Info struct {
- FilePath string `json:"file_path"`
- CompanyName string `json:"company_name"`
- FileDescription string `json:"file_description"`
- FileVersion string `json:"file_version"`
- FileMajorVersion int `json:"file_major_version"`
- LegalCopyright string `json:"legal_copyright"`
- ProductName string `json:"product_name"`
- ProductVersion string `json:"product_version"`
-}
-
-func New(filePath string) (*Info, error) {
- i := &Info{
- FilePath: filePath,
- }
-
- err := i.initialize()
- if err != nil {
- return nil, err
- }
-
- return i, nil
-}
diff --git a/pkg/util/zstd/zstd.go b/pkg/util/zstd/zstd.go
new file mode 100644
index 0000000..9179164
--- /dev/null
+++ b/pkg/util/zstd/zstd.go
@@ -0,0 +1,11 @@
+package zstd
+
+import (
+ "github.com/klauspost/compress/zstd"
+)
+
+var decoder, _ = zstd.NewReader(nil, zstd.WithDecoderConcurrency(0))
+
+func Decompress(src []byte) ([]byte, error) {
+ return decoder.DecodeAll(src, nil)
+}