From 80c7e67106c72783ca9a90f7b856f9966c941761 Mon Sep 17 00:00:00 2001 From: Shen Junzheng Date: Fri, 21 Mar 2025 21:45:08 +0800 Subject: [PATCH] x --- Makefile | 3 + README.md | 162 +++-- cmd/chatlog/cmd_decrypt.go | 15 +- go.mod | 57 +- go.sum | 126 ++-- internal/chatlog/app.go | 38 +- internal/chatlog/conf/config.go | 23 +- internal/chatlog/ctx/context.go | 52 +- internal/chatlog/database/service.go | 31 +- internal/chatlog/http/route.go | 128 ++-- internal/chatlog/http/service.go | 12 +- internal/chatlog/manager.go | 15 +- internal/chatlog/mcp/service.go | 119 ++-- internal/chatlog/wechat/service.go | 40 +- internal/errors/domain_errors.go | 151 +++++ internal/errors/errors.go | 186 ++++- internal/errors/errors_test.go | 165 +++++ internal/errors/middleware.go | 66 ++ internal/errors/utils.go | 131 ++++ {pkg => internal}/model/chatroom.go | 29 +- internal/model/chatroom_darwinv3.go | 97 +++ internal/model/chatroom_v4.go | 28 + {pkg => internal}/model/contact.go | 61 -- internal/model/contact_darwinv3.go | 80 +++ internal/model/contact_v4.go | 62 ++ {pkg => internal}/model/message.go | 97 +-- internal/model/message_darwinv3.go | 69 ++ internal/model/message_v4.go | 91 +++ {pkg => internal}/model/session.go | 41 -- internal/model/session_darwinv3.go | 41 ++ internal/model/session_v4.go | 54 ++ .../model/wxproto/bytesextra.pb.go | 0 .../model/wxproto/bytesextra.proto | 0 .../model/wxproto/roomdata.pb.go | 0 .../model/wxproto/roomdata.proto | 0 internal/ui/footer/footer.go | 2 +- internal/ui/help/help.go | 18 +- internal/wechat/decrypt.go | 415 ------------ internal/wechat/decrypt/common/common.go | 138 ++++ internal/wechat/decrypt/darwin/v3.go | 184 +++++ internal/wechat/decrypt/darwin/v4.go | 194 ++++++ internal/wechat/decrypt/decryptor.go | 55 ++ internal/wechat/decrypt/validator.go | 56 ++ internal/wechat/decrypt/windows/v3.go | 192 ++++++ internal/wechat/decrypt/windows/v4.go | 190 ++++++ internal/wechat/info.go | 50 -- internal/wechat/info_others.go | 16 - internal/wechat/info_windows.go | 507 -------------- internal/wechat/key/darwin/glance/glance.go | 118 ++++ internal/wechat/key/darwin/glance/sip.go | 37 + internal/wechat/key/darwin/glance/vmmap.go | 158 +++++ internal/wechat/key/darwin/v3.go | 187 ++++++ internal/wechat/key/darwin/v4.go | 184 +++++ internal/wechat/key/extractor.go | 41 ++ internal/wechat/key/windows/v3.go | 28 + internal/wechat/key/windows/v3_others.go | 13 + internal/wechat/key/windows/v3_windows.go | 254 +++++++ internal/wechat/key/windows/v4.go | 17 + internal/wechat/key/windows/v4_others.go | 13 + internal/wechat/key/windows/v4_windows.go | 213 ++++++ internal/wechat/manager.go | 137 ++-- internal/wechat/model/process.go | 24 + internal/wechat/process/darwin/detector.go | 164 +++++ internal/wechat/process/detector.go | 36 + internal/wechat/process/windows/detector.go | 101 +++ .../wechat/process/windows/detector_others.go | 12 + .../process/windows/detector_windows.go | 48 ++ internal/wechat/wechat.go | 134 ++++ internal/wechatdb/contact.go | 269 -------- .../datasource/darwinv3/datasource.go | 510 ++++++++++++++ internal/wechatdb/datasource/datasource.go | 49 ++ internal/wechatdb/datasource/v4/datasource.go | 634 ++++++++++++++++++ .../datasource/windowsv3/datasource.go | 615 +++++++++++++++++ internal/wechatdb/message.go | 321 --------- internal/wechatdb/repository/chatroom.go | 184 +++++ internal/wechatdb/repository/contact.go | 211 ++++++ internal/wechatdb/repository/message.go | 68 ++ internal/wechatdb/repository/repository.go | 85 +++ internal/wechatdb/repository/session.go | 11 + internal/wechatdb/wechatdb.go | 100 +-- pkg/appver/version.go | 25 + pkg/appver/version_darwin.go | 41 ++ pkg/{dllver => appver}/version_others.go | 4 +- pkg/{dllver => appver}/version_windows.go | 8 +- pkg/dllver/version.go | 25 - pkg/util/zstd/zstd.go | 11 + 86 files changed, 7061 insertions(+), 2316 deletions(-) create mode 100644 internal/errors/domain_errors.go create mode 100644 internal/errors/errors_test.go create mode 100644 internal/errors/middleware.go create mode 100644 internal/errors/utils.go rename {pkg => internal}/model/chatroom.go (83%) create mode 100644 internal/model/chatroom_darwinv3.go create mode 100644 internal/model/chatroom_v4.go rename {pkg => internal}/model/contact.go (59%) create mode 100644 internal/model/contact_darwinv3.go create mode 100644 internal/model/contact_v4.go rename {pkg => internal}/model/message.go (68%) create mode 100644 internal/model/message_darwinv3.go create mode 100644 internal/model/message_v4.go rename {pkg => internal}/model/session.go (60%) create mode 100644 internal/model/session_darwinv3.go create mode 100644 internal/model/session_v4.go rename {pkg => internal}/model/wxproto/bytesextra.pb.go (100%) rename {pkg => internal}/model/wxproto/bytesextra.proto (100%) rename {pkg => internal}/model/wxproto/roomdata.pb.go (100%) rename {pkg => internal}/model/wxproto/roomdata.proto (100%) delete mode 100644 internal/wechat/decrypt.go create mode 100644 internal/wechat/decrypt/common/common.go create mode 100644 internal/wechat/decrypt/darwin/v3.go create mode 100644 internal/wechat/decrypt/darwin/v4.go create mode 100644 internal/wechat/decrypt/decryptor.go create mode 100644 internal/wechat/decrypt/validator.go create mode 100644 internal/wechat/decrypt/windows/v3.go create mode 100644 internal/wechat/decrypt/windows/v4.go delete mode 100644 internal/wechat/info.go delete mode 100644 internal/wechat/info_others.go delete mode 100644 internal/wechat/info_windows.go create mode 100644 internal/wechat/key/darwin/glance/glance.go create mode 100644 internal/wechat/key/darwin/glance/sip.go create mode 100644 internal/wechat/key/darwin/glance/vmmap.go create mode 100644 internal/wechat/key/darwin/v3.go create mode 100644 internal/wechat/key/darwin/v4.go create mode 100644 internal/wechat/key/extractor.go create mode 100644 internal/wechat/key/windows/v3.go create mode 100644 internal/wechat/key/windows/v3_others.go create mode 100644 internal/wechat/key/windows/v3_windows.go create mode 100644 internal/wechat/key/windows/v4.go create mode 100644 internal/wechat/key/windows/v4_others.go create mode 100644 internal/wechat/key/windows/v4_windows.go create mode 100644 internal/wechat/model/process.go create mode 100644 internal/wechat/process/darwin/detector.go create mode 100644 internal/wechat/process/detector.go create mode 100644 internal/wechat/process/windows/detector.go create mode 100644 internal/wechat/process/windows/detector_others.go create mode 100644 internal/wechat/process/windows/detector_windows.go create mode 100644 internal/wechat/wechat.go delete mode 100644 internal/wechatdb/contact.go create mode 100644 internal/wechatdb/datasource/darwinv3/datasource.go create mode 100644 internal/wechatdb/datasource/datasource.go create mode 100644 internal/wechatdb/datasource/v4/datasource.go create mode 100644 internal/wechatdb/datasource/windowsv3/datasource.go delete mode 100644 internal/wechatdb/message.go create mode 100644 internal/wechatdb/repository/chatroom.go create mode 100644 internal/wechatdb/repository/contact.go create mode 100644 internal/wechatdb/repository/message.go create mode 100644 internal/wechatdb/repository/repository.go create mode 100644 internal/wechatdb/repository/session.go create mode 100644 pkg/appver/version.go create mode 100644 pkg/appver/version_darwin.go rename pkg/{dllver => appver}/version_others.go (53%) rename pkg/{dllver => appver}/version_windows.go (95%) delete mode 100644 pkg/dllver/version.go create mode 100644 pkg/util/zstd/zstd.go 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 服务,让您可以方便地访问和分析聊天数据。 +![chatlog](https://socialify.git.ci/sjzar/chatlog/image?font=Rokkitt&name=1&pattern=Diagonal+Stripes&theme=Auto) -## 功能特点 +_聊天记录工具,帮助大家轻松使用自己的聊天数据_ -- **数据收集**:从本地数据库文件中获取聊天数据 -- **终端界面**:提供简洁的 Terminal UI,方便直接操作 -- **HTTP API**:提供 API 接口,支持查询聊天记录、联系人和群聊信息 -- **MCP 支持**:实现 Model Context Protocol,可与支持 MCP 的 AI 助手无缝集成 -- **多格式输出**:支持 JSON、CSV、纯文本等多种输出格式 +[![Go Report Card](https://goreportcard.com/badge/github.com/sjzar/chatlog)](https://goreportcard.com/report/github.com/sjzar/chatlog) +[![GoDoc](https://godoc.org/github.com/sjzar/chatlog?status.svg)](https://godoc.org/github.com/sjzar/chatlog) +[![GitHub release](https://img.shields.io/github/release/sjzar/chatlog.svg)](https://github.com/sjzar/chatlog/releases) +[![GitHub license](https://img.shields.io/github/license/sjzar/chatlog.svg)](https://github.com/sjzar/chatlog/blob/main/LICENSE) -## 安装 +
+ +![chatlog](https://github.com/user-attachments/assets/746717b8-9b39-4a45-97f3-f0ae8fc5a344) + +## 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) +}